Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.15% covered (success)
96.15%
125 / 130
96.15% covered (success)
96.15%
25 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
Users
96.15% covered (success)
96.15%
125 / 130
96.15% covered (success)
96.15%
25 / 26
34
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 byLogin
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 byAuthToken
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 byOneTimeToken
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 bySession
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 byUid
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 byId
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 list
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 count
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 find
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 create
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 update
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 setPassword
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 delete
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 taken
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 isLastSuperuser
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 savePanelLocale
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 remember
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 forget
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 createOneTimeToken
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 removeOneTimeToken
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 filter
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 row
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 syncReferences
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserOrNull
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 user
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray;
6
7use Celema\Quma\Database;
8use Cosray\Exception\RuntimeException;
9use Cosray\References\Scanner;
10use Cosray\References\Sync;
11use Cosray\User\Types;
12
13/**
14 * @phpstan-type UserValues = array{
15 *     username: ?string,
16 *     email: string,
17 *     name: ?string,
18 *     roles: list<string>,
19 *     active: bool,
20 *     panelLocale: ?string,
21 *     content: array<string, mixed>,
22 * }
23 */
24class Users
25{
26    public function __construct(
27        protected Database $db,
28        protected Types $types = new Types(),
29    ) {}
30
31    public function byLogin(string $login): ?User
32    {
33        return $this->getUserOrNull(
34            $this->db->users->get([
35                'login' => $login,
36            ])->first(),
37        );
38    }
39
40    /** The user an unexpired API token belongs to; finding it records the token's use. */
41    public function byAuthToken(#[\SensitiveParameter] string $token): ?User
42    {
43        $hash = hash('sha256', $token);
44        $user = $this->getUserOrNull(
45            $this->db->users->get([
46                'token' => $hash,
47            ])->first(),
48        );
49
50        if ($user) {
51            $this->db->users->touchToken(['token' => $hash])->run();
52        }
53
54        return $user;
55    }
56
57    public function byOneTimeToken(#[\SensitiveParameter] string $token): ?User
58    {
59        $hashedToken = hash('sha256', $token);
60
61        $user = $this->getUserOrNull(
62            $this->db->users->get([
63                'onetimetoken' => $hashedToken,
64            ])->first(),
65        );
66
67        if ($user) {
68            $this->db->users->removeOneTimeToken([
69                'token' => $hashedToken,
70            ])->run();
71        }
72
73        return $user;
74    }
75
76    public function bySession(string $hash): ?User
77    {
78        return $this->getUserOrNull(
79            $this->db->users->get([
80                'sessionhash' => $hash,
81            ])->first(),
82        );
83    }
84
85    public function byUid(string $uid): ?User
86    {
87        return $this->getUserOrNull(
88            $this->db->users->get([
89                'uid' => $uid,
90            ])->first(),
91        );
92    }
93
94    public function byId(int $id): ?User
95    {
96        return $this->getUserOrNull(
97            $this->db->users->get([
98                'usr' => $id,
99            ])->first(),
100        );
101    }
102
103    /** @return list<User> live users, inactive ones included */
104    public function list(?string $type, string $search, int $limit, int $offset): array
105    {
106        return array_map(
107            $this->user(...),
108            $this->db->users->list([...$this->filter($type, $search), 'limit' => $limit, 'offset' => $offset])->all(),
109        );
110    }
111
112    public function count(?string $type, string $search): int
113    {
114        return (int) $this->db->users->count($this->filter($type, $search))->one()['total'];
115    }
116
117    /** Like `byUid()`, but finds a deactivated user too. */
118    public function find(string $uid): ?User
119    {
120        return $this->getUserOrNull(
121            $this->db->users->get(['uid' => $uid, 'inactiveAlso' => true])->first(),
122        );
123    }
124
125    /** @param UserValues $values */
126    public function create(
127        string $type,
128        array $values,
129        #[\SensitiveParameter]
130        string $password,
131        Actor $actor,
132    ): User {
133        $uid = new Uid(Uid::ALPHABET_LOWERCASE_WORD_SAFE, 13)->generate();
134
135        $this->db->users->create([
136            'uid' => $uid,
137            'type' => $type,
138            'password' => password_hash($password, PASSWORD_ARGON2ID),
139            'creator' => $actor->id,
140            'editor' => $actor->id,
141            ...$this->row($values, []),
142        ])->run();
143        $this->syncReferences($uid, $values['content']);
144
145        return $this->find($uid) ?? throw new RuntimeException("User '{$uid}' was not created");
146    }
147
148    /** @param UserValues $values */
149    public function update(User $user, array $values, Actor $actor): void
150    {
151        $this->db->users->update([
152            'usr' => $user->id,
153            'editor' => $actor->id,
154            ...$this->row($values, $user->data()),
155        ])->run();
156        $this->syncReferences($user->uid, $values['content']);
157
158        if (!$values['active']) {
159            $this->db->users->forgetAll(['usr' => $user->id])->run();
160        }
161    }
162
163    public function setPassword(
164        User $user,
165        #[\SensitiveParameter]
166        string $password,
167        Actor $actor,
168    ): void {
169        $this->db->users->setPassword([
170            'usr' => $user->id,
171            'password' => password_hash($password, PASSWORD_ARGON2ID),
172            'editor' => $actor->id,
173        ])->run();
174        $this->db->users->forgetAll(['usr' => $user->id])->run();
175    }
176
177    public function delete(User $user, Actor $actor): void
178    {
179        $this->db->users->delete(['usr' => $user->id, 'editor' => $actor->id])->run();
180        $this->db->users->forgetAll(['usr' => $user->id])->run();
181        new Sync($this->db)->remove('user', $user->uid);
182    }
183
184    /** @return array{email: bool, username: bool} which logins another live user already holds */
185    public function taken(string $email, ?string $username, ?User $except = null): array
186    {
187        $row = $this->db->users->taken([
188            'email' => $email,
189            'username' => $username,
190            'usr' => $except->id ?? 0,
191        ])->one();
192
193        return ['email' => (bool) $row['email'], 'username' => (bool) $row['username']];
194    }
195
196    public function isLastSuperuser(User $user): bool
197    {
198        return (
199            $user->active
200                && in_array('superuser', $user->roles, true)
201                && (int) $this->db->users->otherSuperusers(['usr' => $user->id])->one()['total'] === 0
202        );
203    }
204
205    public function savePanelLocale(int $userId, ?string $locale): bool
206    {
207        return $this->db->users->savePanelLocale([
208            'usr' => $userId,
209            'locale' => $locale,
210        ])->run();
211    }
212
213    public function remember(string $hash, int $userId, string $expires): bool
214    {
215        return $this->db->users->remember([
216            'hash' => $hash,
217            'user' => $userId,
218            'expires' => $expires,
219        ])->run();
220    }
221
222    public function forget(string $hash): bool
223    {
224        return $this->db->users->forget([
225            'hash' => $hash,
226        ])->run();
227    }
228
229    /** A token valid for five minutes and one use, to hand a user over into a panel session. */
230    public function createOneTimeToken(int $userId): string
231    {
232        $this->db->users->cleanUpOneTimeTokens()->run();
233        $token = bin2hex(random_bytes(32));
234
235        $this->db->users->saveOneTimeToken([
236            'token' => hash('sha256', $token),
237            'usr' => $userId,
238        ])->run();
239
240        return $token;
241    }
242
243    public function removeOneTimeToken(#[\SensitiveParameter] string $token): void
244    {
245        $this->db->users->removeOneTimeToken([
246            'token' => hash('sha256', $token),
247        ])->run();
248    }
249
250    /** @return array<string, string> */
251    private function filter(?string $type, string $search): array
252    {
253        $filter = [];
254
255        if ($type !== null) {
256            $filter['type'] = $type;
257        }
258
259        if ($search !== '') {
260            $filter['search'] = '%' . addcslashes($search, '%_\\') . '%';
261        }
262
263        return $filter;
264    }
265
266    /**
267     * @param array<string, mixed> $values
268     * @param array<string, mixed> $stored keys of `users.data` the panel does not own survive a save
269     * @return array<string, mixed>
270     */
271    private function row(array $values, array $stored): array
272    {
273        return [
274            'username' => $values['username'],
275            'email' => $values['email'],
276            'roles' => json_encode(array_values($values['roles']), JSON_THROW_ON_ERROR),
277            'active' => $values['active'],
278            'panel_locale' => $values['panelLocale'],
279            'data' => json_encode(
280                [...$stored, 'name' => $values['name'], 'content' => (object) $values['content']],
281                JSON_THROW_ON_ERROR,
282            ),
283        ];
284    }
285
286    /** @param array<string, mixed> $content */
287    private function syncReferences(string $uid, array $content): void
288    {
289        new Sync($this->db)->replace('user', $uid, new Scanner()->scan($content));
290    }
291
292    /** @param array<string, mixed>|null $data */
293    protected function getUserOrNull(?array $data): ?User
294    {
295        return $data ? $this->user($data) : null;
296    }
297
298    /** @param array<string, mixed> $data */
299    private function user(array $data): User
300    {
301        return new ($this->types->class($data['type'] ?? Types::DEFAULT))($data);
302    }
303}