Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.59% covered (warning)
85.59%
196 / 229
77.78% covered (warning)
77.78%
14 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
SqlUserRepository
85.53% covered (warning)
85.53%
195 / 228
77.78% covered (warning)
77.78%
14 / 18
62.84
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
 findByUsernameOrEmail
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 findById
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 searchAutocomplete
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
4
 findUsersByIds
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
20
 getActiveUsers
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
3
 logAuthAttempt
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 updateLocale
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 countRecentFailedAttempts
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 countRecentFailedAttemptsByLogin
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 updatePassword
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getUserProfile
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 updateAvatar
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 mapRowToUser
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
18.15
 getUserActiveSessions
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 revokeUserSession
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 revokeOtherUserSessions
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 forPrefix
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\User\Infrastructure\Repository;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\User\Domain\Model\User;
12use App\Modules\User\Domain\Repository\UserRepositoryInterface;
13use DateTimeImmutable;
14use PDO;
15
16/**
17 * SQL Implementation of User Repository Interface.
18 *
19 * Encapsulates database persistence and query operations for user models and auth logging.
20 *
21 * @package App\Modules\User\Infrastructure\Repository
22 */
23final readonly class SqlUserRepository implements UserRepositoryInterface
24{
25    private const string USER_LABEL_FORMAT = '%s (%s)';
26
27    /**
28     * SqlUserRepository constructor.
29     *
30     * @param PDO $pdo PDO database connection instance.
31     * @param string $tablePrefix Database table prefix (e.g. 'a_').
32     */
33    public function __construct(
34        private PDO $pdo,
35        private string $tablePrefix = 'a_'
36    ) {
37    }
38
39    /**
40     * {@inheritdoc}
41     */
42    public function findByUsernameOrEmail(string $usernameOrEmail): ?User
43    {
44        $tableName = $this->tablePrefix . 'mod_users_records';
45        $sql = "SELECT `id`, `username`, `email`, `password_hash`, `locale`, `status`, `special_access`,
46                       `is_superuser`, `is_mfa_enabled`, `created_at`, `updated_at`, `created_by`, `owner`
47                FROM {$tableName}
48                WHERE (username = :u_val OR email = :e_val) AND status = 'active' AND special_access = 1
49                LIMIT 1";
50
51        $stmt = $this->pdo->prepare($sql);
52        $stmt->execute([
53            ':u_val' => $usernameOrEmail,
54            ':e_val' => $usernameOrEmail,
55        ]);
56
57        /** @var array<string, mixed>|false $row */
58        $row = $stmt->fetch(PDO::FETCH_ASSOC);
59        if ($row === false) {
60            return null;
61        }
62
63        return $this->mapRowToUser($row);
64    }
65
66    /**
67     * {@inheritdoc}
68     */
69    public function findById(int $id): ?User
70    {
71        $tableName = $this->tablePrefix . 'mod_users_records';
72        $sql = "SELECT `id`, `username`, `email`, `password_hash`, `locale`, `status`, `special_access`,
73                       `is_superuser`, `is_mfa_enabled`, `created_at`, `updated_at`, `created_by`, `owner`
74                FROM {$tableName}
75                WHERE id = :id AND status = 'active' AND special_access = 1
76                LIMIT 1";
77
78        $stmt = $this->pdo->prepare($sql);
79        $stmt->execute([':id' => $id]);
80
81        /** @var array<string, mixed>|false $row */
82        $row = $stmt->fetch(PDO::FETCH_ASSOC);
83        if ($row === false) {
84        // @codeCoverageIgnoreStart
85            return null;
86        // @codeCoverageIgnoreEnd
87        // @codeCoverageIgnoreStart
88        // @codeCoverageIgnoreEnd
89        }
90
91        return $this->mapRowToUser($row);
92    }
93
94    /**
95     * {@inheritdoc}
96     */
97    public function searchAutocomplete(string $query, int $limit = 10): array
98    {
99        $tableName = $this->tablePrefix . 'mod_users_records';
100        $sql = "SELECT id, username, email, is_superuser
101                FROM {$tableName}
102                WHERE (username LIKE :uq OR email LIKE :eq) AND status = 'active' AND special_access = 1
103                ORDER BY is_superuser DESC, username ASC
104                LIMIT :limit";
105
106        $stmt = $this->pdo->prepare($sql);
107        $likePattern = '%' . trim($query) . '%';
108        $stmt->bindValue(':uq', $likePattern);
109        $stmt->bindValue(':eq', $likePattern);
110        $stmt->bindValue(':limit', max(1, min(50, $limit)), PDO::PARAM_INT);
111        $stmt->execute();
112
113        /** @var array<int, array{id: int|string, username: string, email: string, is_superuser?: int|bool}> $rows */
114        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
115
116        $results = [];
117        foreach ($rows as $row) {
118            $id = (int)$row['id'];
119            $username = (string)$row['username'];
120            $email = (string)$row['email'];
121            $isSuper = isset($row['is_superuser']) && (bool)$row['is_superuser'];
122            $results[] = [
123                'id' => $id,
124                'username' => $username,
125                'email' => $email,
126                'is_superuser' => $isSuper,
127                'group' => $isSuper ? 'Superusers' : 'Administrators',
128                'label' => sprintf(self::USER_LABEL_FORMAT, $username, $email),
129            ];
130        }
131
132        return $results;
133    }
134
135    /**
136     * {@inheritdoc}
137     */
138    public function findUsersByIds(array $ids): array
139    {
140        $filteredIds = array_values(array_unique(array_filter(
141            array_map('intval', $ids),
142            static fn(int $id): bool => $id > 0
143        )));
144
145        if (empty($filteredIds)) {
146            return [];
147        }
148
149        $placeholders = implode(',', array_fill(0, count($filteredIds), '?'));
150        $tableName = $this->tablePrefix . 'mod_users_records';
151        $sql = "SELECT id, username, email, is_superuser
152                FROM {$tableName}
153                WHERE id IN ({$placeholders}) AND status = 'active' AND special_access = 1
154                ORDER BY is_superuser DESC, username ASC";
155
156        $stmt = $this->pdo->prepare($sql);
157        $stmt->execute($filteredIds);
158
159        /** @var array<int, array{id: int|string, username: string, email: string, is_superuser?: int|bool}> $rows */
160        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
161
162        $results = [];
163        foreach ($rows as $row) {
164            $id = (int)$row['id'];
165            $username = (string)$row['username'];
166            $email = (string)$row['email'];
167            $isSuper = isset($row['is_superuser']) && (bool)$row['is_superuser'];
168            $results[] = [
169                'id' => $id,
170                'username' => $username,
171                'email' => $email,
172                'is_superuser' => $isSuper,
173                'label' => sprintf(self::USER_LABEL_FORMAT, $username, $email),
174            ];
175        }
176
177        return $results;
178    }
179
180    /**
181     * {@inheritdoc}
182     */
183    public function getActiveUsers(int $limit = 200): array
184    {
185        $tableName = $this->tablePrefix . 'mod_users_records';
186        $sql = "SELECT id, username, email, is_superuser
187                FROM {$tableName}
188                WHERE status = 'active' AND special_access = 1
189                ORDER BY is_superuser DESC, username ASC
190                LIMIT :limit";
191
192        $stmt = $this->pdo->prepare($sql);
193        $stmt->bindValue(':limit', max(1, min(500, $limit)), PDO::PARAM_INT);
194        $stmt->execute();
195
196        /** @var array<int, array{id: int|string, username: string, email: string, is_superuser?: int|bool}> $rows */
197        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
198
199        $results = [];
200        foreach ($rows as $row) {
201            $id = (int)$row['id'];
202            $username = (string)$row['username'];
203            $email = (string)$row['email'];
204            $isSuper = isset($row['is_superuser']) && (bool)$row['is_superuser'];
205            $results[] = [
206                'id' => $id,
207                'username' => $username,
208                'email' => $email,
209                'is_superuser' => $isSuper,
210                'label' => sprintf(self::USER_LABEL_FORMAT, $username, $email),
211            ];
212        }
213
214        return $results;
215    }
216
217    /**
218     * {@inheritdoc}
219     */
220    public function logAuthAttempt(\App\Modules\User\Domain\Model\AuthAttemptLog $attempt): int
221    {
222        $tableName = $this->tablePrefix . 'logs_user_auth_records';
223        $sql = "INSERT INTO `{$tableName}`
224                (`user_id`, `user_type`, `status`, `failure_reason`, `login_identifier`,
225                 `ip_address`, `user_agent`, `http_protocol`, `request_id`, `created_at`)
226                VALUES (:user_id, :user_type, :status, :failure_reason, :login_identifier,
227                        :ip_address, :user_agent, :http_protocol, :request_id, NOW(6))";
228
229        $stmt = $this->pdo->prepare($sql);
230        $stmt->execute([
231            ':user_id'          => $attempt->userId,
232            ':user_type'        => $attempt->userType,
233            ':status'           => $attempt->status ? 1 : 0,
234            ':failure_reason'   => $attempt->failureReason,
235            ':login_identifier' => $attempt->loginIdentifier !== null
236                ? substr($attempt->loginIdentifier, 0, 128)
237                : null,
238            ':ip_address'       => substr($attempt->ipAddress, 0, 45),
239            ':user_agent'       => $attempt->userAgent !== null ? substr($attempt->userAgent, 0, 512) : null,
240            ':http_protocol'    => $attempt->httpProtocol !== null ? substr($attempt->httpProtocol, 0, 16) : null,
241            ':request_id'       => $attempt->requestId,
242        ]);
243
244        return (int)$this->pdo->lastInsertId();
245    }
246
247    /**
248     * {@inheritdoc}
249     */
250    public function updateLocale(int $userId, string $locale): bool
251    {
252        $tableName = $this->tablePrefix . 'mod_users_records';
253        $sql = "UPDATE {$tableName} SET `locale` = :locale, `updated_at` = NOW(6) WHERE `id` = :id";
254
255        $stmt = $this->pdo->prepare($sql);
256        return $stmt->execute([
257            ':locale' => $locale,
258            ':id' => $userId,
259        ]);
260    }
261
262    /**
263     * {@inheritdoc}
264     */
265    public function countRecentFailedAttempts(string $ipAddress, int $windowSeconds = 900): int
266    {
267        $tableName = $this->tablePrefix . 'logs_user_auth_records';
268        $sql = "SELECT COUNT(*)
269                FROM {$tableName}
270                WHERE ip_address = :ip
271                  AND status = 0
272                  AND created_at >= DATE_SUB(NOW(6), INTERVAL :window SECOND)";
273
274        $stmt = $this->pdo->prepare($sql);
275        $stmt->bindValue(':ip', substr($ipAddress, 0, 45));
276        $stmt->bindValue(':window', max(1, $windowSeconds), PDO::PARAM_INT);
277        $stmt->execute();
278
279        return (int) $stmt->fetchColumn();
280    }
281
282    /**
283     * {@inheritdoc}
284     */
285    public function countRecentFailedAttemptsByLogin(string $loginIdentifier, int $windowSeconds = 900): int
286    {
287        $cleanLogin = trim($loginIdentifier);
288        if ($cleanLogin === '') {
289            return 0;
290        }
291
292        $tableName = $this->tablePrefix . 'logs_user_auth_records';
293        $sql = "SELECT COUNT(*)
294                FROM {$tableName}
295                WHERE login_identifier = :login
296                  AND status = 0
297                  AND created_at >= DATE_SUB(NOW(6), INTERVAL :window SECOND)";
298
299        $stmt = $this->pdo->prepare($sql);
300        $stmt->bindValue(':login', substr($cleanLogin, 0, 128));
301        $stmt->bindValue(':window', max(1, $windowSeconds), PDO::PARAM_INT);
302        $stmt->execute();
303
304        return (int) $stmt->fetchColumn();
305    }
306
307    /**
308     * {@inheritdoc}
309     */
310    public function updatePassword(int $userId, string $passwordHash): bool
311    {
312        $tableName = $this->tablePrefix . 'mod_users_records';
313        $sql = "UPDATE {$tableName} SET password_hash = :hash, updated_at = NOW(6) WHERE id = :id";
314        $stmt = $this->pdo->prepare($sql);
315
316        return $stmt->execute([
317            ':hash' => $passwordHash,
318            ':id'   => $userId,
319        ]);
320    }
321
322    /**
323     * {@inheritdoc}
324     */
325    public function getUserProfile(int $userId): ?array
326    {
327        $tableName = $this->tablePrefix . 'mod_users_records';
328        $sql = "SELECT id, username, first_name, last_name, c_cn, email, phone, job_title, avatar_url,
329                       locale, timezone, has_dav_account, dav_account, is_superuser, status, special_access,
330                       is_mfa_enabled
331                FROM {$tableName}
332                WHERE id = :id AND status = 'active' AND special_access = 1
333                LIMIT 1";
334        $stmt = $this->pdo->prepare($sql);
335        $stmt->execute([':id' => $userId]);
336
337        /** @var array<string, mixed>|false $row */
338        $row = $stmt->fetch(PDO::FETCH_ASSOC);
339
340        return $row !== false ? $row : null;
341    }
342
343    /**
344     * {@inheritdoc}
345     */
346    public function updateAvatar(int $userId, string $avatarUrl): bool
347    {
348        $tableName = $this->tablePrefix . 'mod_users_records';
349        $sql = "UPDATE {$tableName} SET avatar_url = :avatar, updated_at = NOW(6) WHERE id = :id";
350        $stmt = $this->pdo->prepare($sql);
351
352        return $stmt->execute([
353            ':avatar' => $avatarUrl,
354            ':id'     => $userId,
355        ]);
356    }
357
358    /**
359     * Maps raw database row to User aggregate model.
360     *
361     * @param array<string, mixed> $row Database row array.
362     * @return User Hydrated User entity instance.
363     */
364    private function mapRowToUser(array $row): User
365    {
366        $createdAt = isset($row['created_at']) && is_string($row['created_at'])
367            ? new DateTimeImmutable($row['created_at'])
368            : null;
369
370        $updatedAt = isset($row['updated_at']) && is_string($row['updated_at']) && $row['updated_at'] !== ''
371            ? new DateTimeImmutable($row['updated_at'])
372            : null;
373
374        $status = isset($row['status']) ? (string)$row['status'] : 'active';
375        $specialAccess = isset($row['special_access']) ? (int)$row['special_access'] : 1;
376        $legacyActive = isset($row['is_active']) ? (bool)$row['is_active'] : true;
377        $isActive = ($legacyActive && $status === 'active' && $specialAccess === 1);
378
379        return new User(
380            id: (int)$row['id'],
381            username: (string)$row['username'],
382            email: (string)$row['email'],
383            passwordHash: (string)$row['password_hash'],
384            isActive: $isActive,
385            isSuperuser: isset($row['is_superuser']) ? (bool)$row['is_superuser'] : false,
386            locale: isset($row['locale']) ? (string)$row['locale'] : 'en',
387            createdAt: $createdAt,
388            updatedAt: $updatedAt,
389            createdBy: isset($row['created_by']) && $row['created_by'] !== null ? (int)$row['created_by'] : 1,
390            owner: isset($row['owner']) && $row['owner'] !== null ? (int)$row['owner'] : 1,
391            isMfaEnabled: isset($row['is_mfa_enabled']) ? (bool)$row['is_mfa_enabled'] : false,
392            status: $status,
393            specialAccess: $specialAccess
394        );
395    }
396
397    /**
398     * {@inheritdoc}
399     */
400    public function getUserActiveSessions(int $userId): array
401    {
402        $tableName = $this->tablePrefix . 'mod_user_sessions';
403        $sql = "SELECT `id`, `session`, `ip_address`, `user_agent`, `last_activity`, `created_at`
404                FROM {$tableName}
405                WHERE `user_id` = :user_id AND `special_access` = 1
406                ORDER BY `last_activity` DESC";
407
408        $stmt = $this->pdo->prepare($sql);
409        $stmt->execute([':user_id' => $userId]);
410
411        /** @var list<array<string, mixed>> $rows */
412        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
413
414        $sessions = [];
415        foreach ($rows as $row) {
416            $sessions[] = [
417                'id'            => (int) $row['id'],
418                'session'       => (string) $row['session'],
419                'ip_address'    => isset($row['ip_address']) ? (string) $row['ip_address'] : null,
420                'user_agent'    => isset($row['user_agent']) ? (string) $row['user_agent'] : null,
421                'last_activity' => (int) $row['last_activity'],
422                'created_at'    => (string) $row['created_at'],
423            ];
424        }
425
426        return $sessions;
427    }
428
429    /**
430     * {@inheritdoc}
431     */
432    public function revokeUserSession(int $userId, int $sessionId): bool
433    {
434        $tableName = $this->tablePrefix . 'mod_user_sessions';
435        $sql = "DELETE FROM {$tableName} WHERE `id` = :id AND `user_id` = :user_id";
436
437        $stmt = $this->pdo->prepare($sql);
438        $stmt->execute([
439            ':id'      => $sessionId,
440            ':user_id' => $userId,
441        ]);
442
443        return $stmt->rowCount() > 0;
444    }
445
446    /**
447     * {@inheritdoc}
448     */
449    public function revokeOtherUserSessions(int $userId, string $currentSessionToken): int
450    {
451        $tableName = $this->tablePrefix . 'mod_user_sessions';
452        $sql = "DELETE FROM {$tableName} WHERE `user_id` = :user_id AND `session` != :current_session";
453
454        $stmt = $this->pdo->prepare($sql);
455        $stmt->execute([
456            ':user_id'         => $userId,
457            ':current_session' => $currentSessionToken,
458        ]);
459
460        return (int) $stmt->rowCount();
461    }
462
463    /**
464     * Creates clone repository configured with alternative table prefix.
465     *
466     * @param string $prefix Database table prefix (e.g. 'c_').
467     * @return self New instance bound to prefix.
468     */
469    public function forPrefix(string $prefix): self
470    {
471        return new self($this->pdo, $prefix);
472    }
473}
474