Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.75% covered (warning)
79.75%
126 / 158
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserData
79.75% covered (warning)
79.75%
126 / 158
60.00% covered (warning)
60.00%
6 / 10
78.23
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
 get
71.43% covered (warning)
71.43%
25 / 35
0.00% covered (danger)
0.00%
0 / 1
21.97
 fetch
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 fetchAll
86.67% covered (success)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
5.06
 set
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 load
72.22% covered (warning)
72.22%
13 / 18
0.00% covered (danger)
0.00%
0 / 1
8.05
 save
59.46% covered (warning)
59.46%
22 / 37
0.00% covered (danger)
0.00%
0 / 1
10.26
 add
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 delete
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 emailExists
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3/**
4 * The userdata class provides methods to manage user information.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Lars Tiedemann <php@larstiedemann.de>
12 * @copyright 2005-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2005-09-18
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\User;
21
22use phpMyFAQ\Configuration;
23use phpMyFAQ\Database;
24use Symfony\Component\HttpFoundation\Request;
25
26/**
27 * UserData.
28 *
29 * @package   phpMyFAQ
30 * @author    Lars Tiedemann <php@larstiedemann.de>
31 * @copyright 2005-2026 phpMyFAQ Team
32 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
33 * @link      https://www.phpmyfaq.de
34 * @since     2005-09-18
35 */
36class UserData
37{
38    /**
39     * associative array containing user data.
40     *
41     * @var array<array-key, mixed>
42     */
43    private array $data = [];
44
45    /**
46     * User-ID.
47     */
48    private int $userId = 0;
49
50    /**
51     * Constructor.
52     */
53    public function __construct(
54        private readonly Configuration $configuration,
55    ) {
56    }
57
58    /**
59     * Returns the field $field of the user data. If $field is an
60     * array, an associative array will be returned.
61     *
62     * @param mixed $field Field(s)
63     */
64    public function get(mixed $field): mixed
65    {
66        $singleReturn = !is_array($field);
67        $fields = is_array($field)
68            ? implode(', ', array_map(static fn(mixed $column): string => (string) $column, $field))
69            : (string) $field;
70
71        $select = sprintf(
72            'SELECT %s FROM %sfaquserdata WHERE user_id = %d',
73            $fields,
74            Database::getTablePrefix(),
75            $this->userId,
76        );
77
78        try {
79            $res = $this->configuration->getDb()->query($select);
80        } catch (\Throwable) {
81            if ($singleReturn && $field === 'keycloak_sub') {
82                return '';
83            }
84
85            return false;
86        }
87
88        if ($res === false) {
89            if ($singleReturn && $field === 'keycloak_sub') {
90                return '';
91            }
92
93            return false;
94        }
95
96        if ($this->configuration->getDb()->numRows($res) !== 1) {
97            return false;
98        }
99
100        $array = $this->configuration->getDb()->fetchArray($res);
101        if (!is_array($array)) {
102            return false;
103        }
104
105        // Decode HTML entities in display_name for backward compatibility
106        if (array_key_exists('display_name', $array) && is_string($array['display_name'])) {
107            $array['display_name'] = html_entity_decode(
108                $array['display_name'],
109                ENT_QUOTES | ENT_HTML5 | ENT_SUBSTITUTE,
110                encoding: 'UTF-8',
111            );
112        }
113
114        if ($singleReturn && $field !== '*') {
115            return match ($field) {
116                'display_name', 'email', 'keycloak_sub', 'secret' => (string) ($array[$field] ?? ''),
117                default => $array[$field] ?? null,
118            };
119        }
120
121        return $array;
122    }
123
124    /**
125     * Returns the first result of the given key.
126     */
127    public function fetch(string $key, string $value): ?string
128    {
129        $select = sprintf(
130            "SELECT %s FROM %sfaquserdata WHERE %s = '%s'",
131            $key,
132            Database::getTablePrefix(),
133            $key,
134            $this->configuration->getDb()->escape($value),
135        );
136
137        $res = $this->configuration->getDb()->query($select);
138
139        if (0 === $this->configuration->getDb()->numRows($res)) {
140            return null;
141        }
142
143        $row = $this->configuration->getDb()->fetchObject($res);
144
145        /* @mago-expect analysis:mixed-return-statement - user data fields are heterogeneous by design */
146        return $row instanceof \stdClass ? $row->$key : null;
147    }
148
149    /**
150     * Returns the data of the given key.
151     *
152     * @return array<array-key, mixed>
153     */
154    public function fetchAll(string $key, string $value): array
155    {
156        // $key is a column name chosen by internal callers, never user input; only
157        // the value is bound as a parameter.
158        $select = sprintf('SELECT
159                user_id, last_modified, display_name, email, keycloak_sub, is_visible, twofactor_enabled, secret
160            FROM %sfaquserdata WHERE %s = ?', Database::getTablePrefix(), $key);
161
162        try {
163            $res = $this->configuration->getDb()->queryPrepared($select, [$value]);
164        } catch (\Throwable) {
165            $res = false;
166        }
167
168        if ($res === false) {
169            $select = sprintf('SELECT
170                    user_id, last_modified, display_name, email, is_visible, twofactor_enabled, secret
171                FROM %sfaquserdata WHERE %s = ?', Database::getTablePrefix(), $key);
172            $res = $this->configuration->getDb()->queryPrepared($select, [$value]);
173        }
174
175        if ($this->configuration->getDb()->numRows($res) !== 1) {
176            return ['user_id' => -1];
177        }
178
179        $row = $this->configuration->getDb()->fetchArray($res);
180        $this->data = is_array($row) ? $row : [];
181        $this->data['keycloak_sub'] ??= '';
182
183        return $this->data;
184    }
185
186    /**
187     * Sets the user data given by $field and $value. If $field
188     * and $value are arrays, all fields with the corresponding
189     * values are updated. Changes are being stored in the database.
190     *
191     * @param mixed $field Field(s)
192     * @param mixed $value Value(s)
193     */
194    public function set(mixed $field, mixed $value = null): bool
195    {
196        // check input
197        if (!is_array($field)) {
198            $field = [$field];
199        }
200
201        if (!is_array($value)) {
202            $value = [$value];
203        }
204
205        if (count($field) !== count($value)) {
206            return false;
207        }
208
209        // update data
210        $num = count($field);
211        for ($i = 0; $i < $num; ++$i) {
212            $this->data[$field[$i]] = $value[$i];
213        }
214
215        return $this->save();
216    }
217
218    /**
219     * Loads the user-data from the database and returns an
220     * associative array with the fields and values.
221     *
222     * @param int $userId User ID
223     */
224    public function load(int $userId): bool
225    {
226        if ($userId <= 0 && $userId !== -1) {
227            return false;
228        }
229
230        $this->userId = $userId;
231        $select = sprintf('
232            SELECT
233                last_modified, 
234                display_name, 
235                email,
236                keycloak_sub,
237                is_visible,
238                twofactor_enabled, 
239                secret
240            FROM
241                %sfaquserdata
242            WHERE
243                user_id = %d', Database::getTablePrefix(), $this->userId);
244
245        try {
246            $res = $this->configuration->getDb()->query($select);
247        } catch (\Throwable) {
248            $res = false;
249        }
250
251        if ($res === false) {
252            $select = sprintf('
253            SELECT
254                last_modified, 
255                display_name, 
256                email,
257                is_visible,
258                twofactor_enabled, 
259                secret
260            FROM
261                %sfaquserdata
262            WHERE
263                user_id = %d', Database::getTablePrefix(), $this->userId);
264            $res = $this->configuration->getDb()->query($select);
265        }
266
267        if ($this->configuration->getDb()->numRows($res) !== 1) {
268            return false;
269        }
270
271        $row = $this->configuration->getDb()->fetchArray($res);
272        $this->data = is_array($row) ? $row : [];
273        $this->data['keycloak_sub'] ??= '';
274
275        return true;
276    }
277
278    /**
279     * Saves the current user-data into the database.
280     * Returns true on success, otherwise false.
281     */
282    public function save(): bool
283    {
284        $keycloakSubRaw = $this->data['keycloak_sub'] ?? null;
285        $keycloakSubValue = is_string($keycloakSubRaw) && trim($keycloakSubRaw) !== ''
286            ? "'" . $this->configuration->getDb()->escape($keycloakSubRaw) . "'"
287            : 'NULL';
288
289        $update = sprintf(
290            "
291            UPDATE
292                %sfaquserdata
293            SET
294                last_modified = '%s',
295                display_name = '%s',
296                email = '%s',
297                keycloak_sub = %s,
298                is_visible = %d,
299                twofactor_enabled = %d,
300                secret = '%s'
301            WHERE
302                user_id = %d",
303            Database::getTablePrefix(),
304            date(format: 'YmdHis', timestamp: (int) Request::createFromGlobals()->server->get('REQUEST_TIME')),
305            $this->configuration->getDb()->escape((string) ($this->data['display_name'] ?? '')),
306            $this->configuration->getDb()->escape((string) ($this->data['email'] ?? '')),
307            $keycloakSubValue,
308            (int) ($this->data['is_visible'] ?? 0),
309            (int) ($this->data['twofactor_enabled'] ?? 0),
310            $this->configuration->getDb()->escape((string) ($this->data['secret'] ?? '')),
311            $this->userId,
312        );
313
314        try {
315            $res = $this->configuration->getDb()->query($update);
316        } catch (\Throwable) {
317            $res = false;
318        }
319
320        if ($res === false) {
321            // Only bail out if the user actually has a Keycloak subject to
322            // persist. An empty placeholder must not block the fallback UPDATE
323            // for schemas that lack the keycloak_sub column.
324            if (is_string($keycloakSubRaw) && trim($keycloakSubRaw) !== '') {
325                return false;
326            }
327
328            $update = sprintf(
329                "
330            UPDATE
331                %sfaquserdata
332            SET
333                last_modified = '%s',
334                display_name = '%s',
335                email = '%s',
336                is_visible = %d,
337                twofactor_enabled = %d,
338                secret = '%s'
339            WHERE
340                user_id = %d",
341                Database::getTablePrefix(),
342                date(format: 'YmdHis', timestamp: (int) Request::createFromGlobals()->server->get('REQUEST_TIME')),
343                $this->configuration->getDb()->escape((string) ($this->data['display_name'] ?? '')),
344                $this->configuration->getDb()->escape((string) ($this->data['email'] ?? '')),
345                (int) ($this->data['is_visible'] ?? 0),
346                (int) ($this->data['twofactor_enabled'] ?? 0),
347                $this->configuration->getDb()->escape((string) ($this->data['secret'] ?? '')),
348                $this->userId,
349            );
350
351            $res = $this->configuration->getDb()->query($update);
352        }
353
354        return (bool) $res;
355    }
356
357    /**
358     * Adds a new user entry for user-data in the database.
359     * Returns true on success, otherwise false.
360     *
361     * @param int $userId User ID
362     */
363    public function add(int $userId): bool
364    {
365        if ($userId <= 0 && $userId !== -1) {
366            return false;
367        }
368
369        $this->userId = $userId;
370        $insert = sprintf(
371            "
372            INSERT INTO
373                %sfaquserdata
374            (user_id, last_modified, is_visible, twofactor_enabled, secret)
375                VALUES
376            (%d, '%s', 1, 0, '')",
377            Database::getTablePrefix(),
378            $this->userId,
379            date(format: 'YmdHis', timestamp: (int) Request::createFromGlobals()->server->get('REQUEST_TIME')),
380        );
381
382        $res = $this->configuration->getDb()->query($insert);
383        return (bool) $res;
384    }
385
386    /**
387     * Deletes the user-data entry for the given user-ID $userId.
388     * Returns true on success, otherwise false.
389     *
390     * @param int $userId User ID
391     */
392    public function delete(int $userId): bool
393    {
394        if ($userId <= 0 && $userId !== -1) {
395            return false;
396        }
397
398        $this->userId = $userId;
399        $delete = sprintf('DELETE FROM %sfaquserdata WHERE user_id = %d', Database::getTablePrefix(), $this->userId);
400
401        $res = $this->configuration->getDb()->query($delete);
402        if (!$res) {
403            return false;
404        }
405
406        $this->data = [];
407
408        return true;
409    }
410
411    /**
412     * Checks if an email address already exists in the user data table.
413     * Returns true if the email exists, false otherwise.
414     *
415     * @param string $email Email address to check
416     */
417    public function emailExists(string $email): bool
418    {
419        if ($email === '' || $email === '0') {
420            return false;
421        }
422
423        $select = sprintf(
424            "SELECT user_id FROM %sfaquserdata WHERE email = '%s'",
425            Database::getTablePrefix(),
426            $this->configuration->getDb()->escape($email),
427        );
428
429        $res = $this->configuration->getDb()->query($select);
430        return $this->configuration->getDb()->numRows($res) > 0;
431    }
432}