Passed
Push — master ( d7fbc5...7b4259 )
by Nils
07:19
created

secureOutput()   A

Complexity

Conditions 6
Paths 5

Size

Total Lines 16
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 8
c 1
b 0
f 0
nc 5
nop 2
dl 0
loc 16
rs 9.2222
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * Teampass - a collaborative passwords manager.
7
 * ---
8
 * This file is part of the TeamPass project.
9
 * 
10
 * TeamPass is free software: you can redistribute it and/or modify it
11
 * under the terms of the GNU General Public License as published by
12
 * the Free Software Foundation, version 3 of the License.
13
 * 
14
 * TeamPass is distributed in the hope that it will be useful,
15
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17
 * GNU General Public License for more details.
18
 * 
19
 * You should have received a copy of the GNU General Public License
20
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
21
 * 
22
 * Certain components of this file may be under different licenses. For
23
 * details, see the `licenses` directory or individual file headers.
24
 * ---
25
 * @file      main.functions.php
26
 * @author    Nils Laumaillé ([email protected])
27
 * @copyright 2009-2026 Teampass.net
28
 * @license   GPL-3.0
29
 * @see       https://www.teampass.net
30
 */
31
32
use LdapRecord\Connection;
33
use Elegant\Sanitizer\Sanitizer;
34
use voku\helper\AntiXSS;
35
use Hackzilla\PasswordGenerator\Generator\ComputerPasswordGenerator;
36
use Hackzilla\PasswordGenerator\RandomGenerator\Php7RandomGenerator;
37
use TeampassClasses\SessionManager\SessionManager;
38
use Symfony\Component\HttpFoundation\Request as SymfonyRequest;
39
use TeampassClasses\Language\Language;
40
use TeampassClasses\NestedTree\NestedTree;
41
use Defuse\Crypto\Key;
42
use Defuse\Crypto\Crypto;
43
use Defuse\Crypto\KeyProtectedByPassword;
44
use Defuse\Crypto\File as CryptoFile;
45
use Defuse\Crypto\Exception as CryptoException;
46
use TeampassClasses\PasswordManager\PasswordManager;
47
use Symfony\Component\Process\PhpExecutableFinder;
48
use TeampassClasses\Encryption\Encryption;
49
use TeampassClasses\ConfigManager\ConfigManager;
50
use TeampassClasses\EmailService\EmailService;
51
use TeampassClasses\EmailService\EmailSettings;
52
use TeampassClasses\CryptoManager\CryptoManager;
53
use Symfony\Component\Process\Process;
54
55
header('Content-type: text/html; charset=utf-8');
56
header('Cache-Control: no-cache, must-revalidate');
57
58
loadClasses('DB');
59
$session = SessionManager::getSession();
60
61
// Load config if $SETTINGS not defined
62
$configManager = new ConfigManager();
63
$SETTINGS = $configManager->getAllSettings();
64
65
/**
66
 * Checks if a string is hex encoded
67
 *
68
 * @param string $str
69
 * @return boolean
70
 */
71
function isHex(string $str): bool
72
{
73
    if (str_starts_with(strtolower($str), '0x')) {
74
        $str = substr($str, 2);
75
    }
76
77
    return ctype_xdigit($str);
78
}
79
80
/**
81
 * Defuse cryption function.
82
 *
83
 * @param string $message   what to de/crypt
84
 * @param string $ascii_key key to use
85
 * @param string $type      operation to perform
86
 * @param array  $SETTINGS  Teampass settings
87
 *
88
 * @return array
89
 */
90
function cryption(string $message, string $ascii_key, string $type, ?array $SETTINGS = []): array
91
{
92
    $ascii_key = empty($ascii_key) === true ? file_get_contents(SECUREPATH.'/'.SECUREFILE) : $ascii_key;
93
    $err = false;
94
    
95
    // convert KEY
96
    $key = Key::loadFromAsciiSafeString($ascii_key);
97
    try {
98
        if ($type === 'encrypt') {
99
            $text = Crypto::encrypt($message, $key);
100
        } elseif ($type === 'decrypt') {
101
            $text = Crypto::decrypt($message, $key);
102
        }
103
    } catch (CryptoException\WrongKeyOrModifiedCiphertextException $ex) {
104
        error_log('TEAMPASS-Error-Wrong key or modified ciphertext: ' . $ex->getMessage());
105
        $err = 'wrong_key_or_modified_ciphertext';
106
    } catch (CryptoException\BadFormatException $ex) {
107
        error_log('TEAMPASS-Error-Bad format exception: ' . $ex->getMessage());
108
        $err = 'bad_format';
109
    } catch (CryptoException\EnvironmentIsBrokenException $ex) {
110
        error_log('TEAMPASS-Error-Environment: ' . $ex->getMessage());
111
        $err = 'environment_error';
112
    } catch (CryptoException\IOException $ex) {
113
        error_log('TEAMPASS-Error-IO: ' . $ex->getMessage());
114
        $err = 'io_error';
115
    } catch (Exception $ex) {
116
        error_log('TEAMPASS-Error-Unexpected exception: ' . $ex->getMessage());
117
        $err = 'unexpected_error';
118
    }
119
120
    return [
121
        'string' => $text ?? '',
122
        'error' => $err,
123
    ];
124
}
125
126
/**
127
 * Generating a defuse key.
128
 *
129
 * @return string
130
 */
131
function defuse_generate_key()
132
{
133
    $key = Key::createNewRandomKey();
134
    $key = $key->saveToAsciiSafeString();
135
    return $key;
136
}
137
138
/**
139
 * Generate a Defuse personal key.
140
 *
141
 * @param string $psk psk used
142
 *
143
 * @return string
144
 */
145
function defuse_generate_personal_key(string $psk): string
146
{
147
    $protected_key = KeyProtectedByPassword::createRandomPasswordProtectedKey($psk);
148
    return $protected_key->saveToAsciiSafeString(); // save this in user table
149
}
150
151
/**
152
 * Validate persoanl key with defuse.
153
 *
154
 * @param string $psk                   the user's psk
155
 * @param string $protected_key_encoded special key
156
 *
157
 * @return string
158
 */
159
function defuse_validate_personal_key(string $psk, string $protected_key_encoded): string
160
{
161
    try {
162
        $protected_key_encoded = KeyProtectedByPassword::loadFromAsciiSafeString($protected_key_encoded);
163
        $user_key = $protected_key_encoded->unlockKey($psk);
164
        $user_key_encoded = $user_key->saveToAsciiSafeString();
165
    } catch (CryptoException\EnvironmentIsBrokenException $ex) {
166
        return 'Error - Major issue as the encryption is broken.';
167
    } catch (CryptoException\WrongKeyOrModifiedCiphertextException $ex) {
168
        return 'Error - The saltkey is not the correct one.';
169
    }
170
171
    return $user_key_encoded;
172
    // store it in session once user has entered his psk
173
}
174
175
/**
176
 * Decrypt a defuse string if encrypted.
177
 *
178
 * @param string $value Encrypted string
179
 *
180
 * @return string Decrypted string
181
 */
182
function defuseReturnDecrypted(string $value): string
183
{
184
    if (substr($value, 0, 3) === 'def') {
185
        $value = cryption($value, '', 'decrypt')['string'];
186
    }
187
188
    return $value;
189
}
190
191
/**
192
 * Trims a string depending on a specific string.
193
 *
194
 * @param string|array $chaine  what to trim
195
 * @param string       $element trim on what
196
 *
197
 * @return string
198
 */
199
function trimElement($chaine, string $element): string
200
{
201
    if (! empty($chaine)) {
202
        if (is_array($chaine) === true) {
203
            $chaine = implode(';', $chaine);
204
        }
205
        $chaine = trim($chaine);
206
        if (substr($chaine, 0, 1) === $element) {
207
            $chaine = substr($chaine, 1);
208
        }
209
        if (substr($chaine, strlen($chaine) - 1, 1) === $element) {
210
            $chaine = substr($chaine, 0, strlen($chaine) - 1);
211
        }
212
    }
213
214
    return $chaine;
215
}
216
217
/**
218
 * Permits to suppress all "special" characters from string.
219
 *
220
 * @param string $string  what to clean
221
 * @param bool   $special use of special chars?
222
 *
223
 * @return string
224
 */
225
function cleanString(string $string, bool $special = false): string
226
{
227
    // Create temporary table for special characters escape
228
    $tabSpecialChar = [];
229
    for ($i = 0; $i <= 31; ++$i) {
230
        $tabSpecialChar[] = chr($i);
231
    }
232
    array_push($tabSpecialChar, '<br />');
233
    if ((int) $special === 1) {
234
        $tabSpecialChar = array_merge($tabSpecialChar, ['</li>', '<ul>', '<ol>']);
235
    }
236
237
    return str_replace($tabSpecialChar, "\n", $string);
238
}
239
240
/**
241
 * Erro manager for DB.
242
 *
243
 * @param array $params output from query
244
 *
245
 * @return void
246
 */
247
function db_error_handler(array $params): void
248
{
249
    echo 'Error: ' . $params['error'] . "<br>\n";
250
    echo 'Query: ' . $params['query'] . "<br>\n";
251
    throw new Exception('Error - Query', 1);
252
}
253
254
/**
255
 * Identify user's rights
256
 *
257
 * @param string|array $groupesVisiblesUser  [description]
258
 * @param string|array $groupesInterditsUser [description]
259
 * @param string       $isAdmin              [description]
260
 * @param string|null       $idFonctions          [description]
261
 *
262
 * @return bool
263
 */
264
function identifyUserRights(
265
    $groupesVisiblesUser,
266
    $groupesInterditsUser,
267
    $isAdmin,
268
    ?string $idFonctions,
269
    $SETTINGS
270
) {
271
    $session = SessionManager::getSession();
272
    $tree = new NestedTree(prefixTable('nested_tree'), 'id', 'parent_id', 'title');
273
274
    // Check if user is ADMINISTRATOR    
275
    (int) $isAdmin === 1 ?
276
        identAdmin(
277
            $idFonctions ?? ''
278
        )
279
        :
280
        identUser(
281
            $groupesVisiblesUser,
282
            $groupesInterditsUser,
283
            $idFonctions,
284
            $SETTINGS, /** @scrutinizer ignore-type */
285
            $tree
286
        );
287
288
    // update user's timestamp
289
    DB::update(
290
        prefixTable('users'),
291
        [
292
            'timestamp' => time(),
293
        ],
294
        'id=%i',
295
        $session->get('user-id')
296
    );
297
298
    return true;
299
}
300
301
/**
302
 * Identify administrator.
303
 *
304
 * @param string $idFonctions Roles of user
305
 *
306
 * @return bool
307
 */
308
function identAdmin(string $idFonctions)
309
{
310
    
311
    $session = SessionManager::getSession();
312
    $groupesVisibles = [];
313
    $session->set('user-personal_folders', []);
314
    $session->set('user-accessible_folders', []);
315
    $session->set('user-no_access_folders', []);
316
    $session->set('user-personal_visible_folders', []);
317
    $session->set('user-read_only_folders', []);
318
    $session->set('system-list_restricted_folders_for_items', []);
319
    $session->set('system-list_folders_editable_by_role', []);
320
    $session->set('user-list_folders_limited', []);
321
    $session->set('user-forbiden_personal_folders', []);
322
    
323
    // Get list of Folders
324
    $rows = DB::query('SELECT id FROM ' . prefixTable('nested_tree') . ' WHERE personal_folder = %i', 0);
325
    foreach ($rows as $record) {
326
        array_push($groupesVisibles, $record['id']);
327
    }
328
    $session->set('user-accessible_folders', $groupesVisibles);
329
330
    // get complete list of ROLES
331
    $tmp = array_filter(explode(';', $idFonctions !== null ? $idFonctions : ''));
0 ignored issues
show
introduced by
The condition $idFonctions !== null is always true.
Loading history...
332
    $rows = DB::query(
333
        'SELECT * FROM ' . prefixTable('roles_title') . '
334
        ORDER BY title ASC'
335
    );
336
    foreach ($rows as $record) {
337
        if (! empty($record['id']) && ! in_array($record['id'], $tmp)) {
338
            array_push($tmp, $record['id']);
339
        }
340
    }
341
    $session->set('user-roles', implode(';', $tmp));
342
    $session->set('user-admin', 1);
343
    // Check if admin has created Folders and Roles
344
    DB::query('SELECT * FROM ' . prefixTable('nested_tree') . '');
345
    $session->set('user-nb_folders', DB::count());
346
    DB::query('SELECT * FROM ' . prefixTable('roles_title'));
347
    $session->set('user-nb_roles', DB::count());
348
349
    return true;
350
}
351
352
/**
353
 * Permits to convert an element to array.
354
 *
355
 * @param string|array $element Any value to be returned as array
356
 *
357
 * @return array
358
 */
359
function convertToArray($element): ?array
360
{
361
    if (is_string($element) === true) {
362
        if (empty($element) === true) {
363
            return [];
364
        }
365
        return explode(
366
            ';',
367
            trimElement($element, ';')
368
        );
369
    }
370
    return $element;
371
}
372
373
/**
374
 * Defines the rights the user has.
375
 *
376
 * @param string|array $allowedFolders  Allowed folders
377
 * @param string|array $noAccessFolders Not allowed folders
378
 * @param string|array $userRoles       Roles of user
379
 * @param array        $SETTINGS        Teampass settings
380
 * @param object       $tree            Tree of folders
381
 * 
382
 * @return bool
383
 */
384
function identUser(
385
    $allowedFolders,
386
    $noAccessFolders,
387
    $userRoles,
388
    array $SETTINGS,
389
    object $tree
390
) {
391
    $session = SessionManager::getSession();
392
    // Init
393
    $session->set('user-accessible_folders', []);
394
    $session->set('user-personal_folders', []);
395
    $session->set('user-no_access_folders', []);
396
    $session->set('user-personal_visible_folders', []);
397
    $session->set('user-read_only_folders', []);
398
    $session->set('user-user-roles', $userRoles);
399
    $session->set('user-admin', 0);
400
    // init
401
    $personalFolders = [];
402
    $readOnlyFolders = [];
403
    $noAccessPersonalFolders = [];
404
    $restrictedFoldersForItems = [];
405
    $foldersLimited = [];
406
    $foldersLimitedFull = [];
407
    $allowedFoldersByRoles = [];
408
    $globalsUserId = $session->get('user-id');
409
    $globalsPersonalFolders = $session->get('user-personal_folder_enabled');
410
    // Ensure consistency in array format
411
    $noAccessFolders = convertToArray($noAccessFolders);
412
    $userRoles = convertToArray($userRoles) ?? [];
413
    $allowedFolders = convertToArray($allowedFolders);
414
    $session->set('user-allowed_folders_by_definition', $allowedFolders);
415
    
416
    // Get list of folders depending on Roles
417
    $arrays = identUserGetFoldersFromRoles(
418
        $userRoles,
419
        $allowedFoldersByRoles,
420
        $readOnlyFolders,
421
        $allowedFolders
422
    );
423
    $allowedFoldersByRoles = $arrays['allowedFoldersByRoles'];
424
    $readOnlyFolders = $arrays['readOnlyFolders'];
425
426
    // Does this user is allowed to see other items
427
    $inc = 0;
428
    $rows = DB::query(
429
        'SELECT id, id_tree FROM ' . prefixTable('items') . '
430
            WHERE restricted_to LIKE %ss AND inactif = %s'.
431
            (count($allowedFolders) > 0 ? ' AND id_tree NOT IN ('.implode(',', $allowedFolders).')' : ''),
432
        $globalsUserId,
433
        '0'
434
    );
435
    foreach ($rows as $record) {
436
        // Exclude restriction on item if folder is fully accessible
437
        //if (in_array($record['id_tree'], $allowedFolders) === false) {
438
            $restrictedFoldersForItems[$record['id_tree']][$inc] = $record['id'];
439
            ++$inc;
440
        //}
441
    }
442
443
    // Check for the users roles if some specific rights exist on items
444
    $rows = DB::query(
445
        'SELECT i.id_tree, r.item_id
446
        FROM ' . prefixTable('items') . ' as i
447
        INNER JOIN ' . prefixTable('restriction_to_roles') . ' as r ON (r.item_id=i.id)
448
        WHERE i.id_tree <> "" '.
449
        (count($userRoles) > 0 ? 'AND r.role_id IN %li ' : '').
450
        'ORDER BY i.id_tree ASC',
451
        $userRoles
452
    );
453
    $inc = 0;
454
    foreach ($rows as $record) {
455
        //if (isset($record['id_tree'])) {
456
            $foldersLimited[$record['id_tree']][$inc] = $record['item_id'];
457
            array_push($foldersLimitedFull, $record['id_tree']);
458
            ++$inc;
459
        //}
460
    }
461
462
    // Get list of Personal Folders
463
    $arrays = identUserGetPFList(
464
        $globalsPersonalFolders,
465
        $allowedFolders,
466
        $globalsUserId,
467
        $personalFolders,
468
        $noAccessPersonalFolders,
469
        $foldersLimitedFull,
470
        $allowedFoldersByRoles,
471
        array_keys($restrictedFoldersForItems),
472
        $readOnlyFolders,
473
        $noAccessFolders,
474
        isset($SETTINGS['enable_pf_feature']) === true ? $SETTINGS['enable_pf_feature'] : 0,
475
        $tree
476
    );
477
    $allowedFolders = $arrays['allowedFolders'];
478
    $personalFolders = $arrays['personalFolders'];
479
    $noAccessPersonalFolders = $arrays['noAccessPersonalFolders'];
480
481
    // Return data
482
    $session->set('user-accessible_folders', array_unique(array_merge($allowedFolders, $personalFolders), SORT_NUMERIC));
483
    $session->set('user-read_only_folders', $readOnlyFolders);
484
    $session->set('user-no_access_folders', $noAccessFolders);
485
    $session->set('user-personal_folders', $personalFolders);
486
    $session->set('user-list_folders_limited', $foldersLimited);
487
    $session->set('system-list_folders_editable_by_role', $allowedFoldersByRoles, 'SESSION');
488
    $session->set('system-list_restricted_folders_for_items', $restrictedFoldersForItems);
489
    $session->set('user-forbiden_personal_folders', $noAccessPersonalFolders);
490
    // Folders and Roles numbers
491
    DB::queryFirstRow('SELECT id FROM ' . prefixTable('nested_tree') . '');
492
    DB::queryFirstRow('SELECT id FROM ' . prefixTable('nested_tree') . '');
493
    $session->set('user-nb_folders', DB::count());
494
    DB::queryFirstRow('SELECT id FROM ' . prefixTable('roles_title'));
495
    DB::queryFirstRow('SELECT id FROM ' . prefixTable('roles_title'));
496
    $session->set('user-nb_roles', DB::count());
497
    // check if change proposals on User's items
498
    if (isset($SETTINGS['enable_suggestion']) === true && (int) $SETTINGS['enable_suggestion'] === 1) {
499
        $countNewItems = DB::query(
500
            'SELECT COUNT(*)
501
            FROM ' . prefixTable('items_change') . ' AS c
502
            LEFT JOIN ' . prefixTable('log_items') . ' AS i ON (c.item_id = i.id_item)
503
            WHERE i.action = %s AND i.id_user = %i',
504
            'at_creation',
505
            $globalsUserId
506
        );
507
        $session->set('user-nb_item_change_proposals', $countNewItems);
508
    } else {
509
        $session->set('user-nb_item_change_proposals', 0);
510
    }
511
512
    return true;
513
}
514
515
/**
516
 * Get list of folders depending on Roles
517
 * 
518
 * @param array $userRoles
519
 * @param array $allowedFoldersByRoles
520
 * @param array $readOnlyFolders
521
 * @param array $allowedFolders
522
 * 
523
 * @return array
524
 */
525
function identUserGetFoldersFromRoles(array $userRoles, array $allowedFoldersByRoles = [], array $readOnlyFolders = [], array $allowedFolders = []) : array
526
{
527
    $rows = DB::query(
528
        'SELECT *
529
        FROM ' . prefixTable('roles_values') . '
530
        WHERE type IN %ls'.(count($userRoles) > 0 ? ' AND role_id IN %li' : ''),
531
        ['W', 'ND', 'NE', 'NDNE', 'R'],
532
        $userRoles,
533
    );
534
    foreach ($rows as $record) {
535
        if ($record['type'] === 'R') {
536
            array_push($readOnlyFolders, $record['folder_id']);
537
        } elseif (in_array($record['folder_id'], $allowedFolders) === false) {
538
            array_push($allowedFoldersByRoles, $record['folder_id']);
539
        }
540
    }
541
    $allowedFoldersByRoles = array_unique($allowedFoldersByRoles);
542
    $readOnlyFolders = array_unique($readOnlyFolders);
543
    
544
    // Clean arrays
545
    foreach ($allowedFoldersByRoles as $value) {
546
        $key = array_search($value, $readOnlyFolders);
547
        if ($key !== false) {
548
            unset($readOnlyFolders[$key]);
549
        }
550
    }
551
    return [
552
        'readOnlyFolders' => $readOnlyFolders,
553
        'allowedFoldersByRoles' => $allowedFoldersByRoles
554
    ];
555
}
556
557
/**
558
 * Get list of Personal Folders
559
 * 
560
 * @param int $globalsPersonalFolders
561
 * @param array $allowedFolders
562
 * @param int $globalsUserId
563
 * @param array $personalFolders
564
 * @param array $noAccessPersonalFolders
565
 * @param array $foldersLimitedFull
566
 * @param array $allowedFoldersByRoles
567
 * @param array $restrictedFoldersForItems
568
 * @param array $readOnlyFolders
569
 * @param array $noAccessFolders
570
 * @param int $enablePfFeature
571
 * @param object $tree
572
 * 
573
 * @return array
574
 */
575
function identUserGetPFList(
576
    $globalsPersonalFolders,
577
    $allowedFolders,
578
    $globalsUserId,
579
    $personalFolders,
580
    $noAccessPersonalFolders,
581
    $foldersLimitedFull,
582
    $allowedFoldersByRoles,
583
    $restrictedFoldersForItems,
584
    $readOnlyFolders,
585
    $noAccessFolders,
586
    $enablePfFeature,
587
    $tree
588
)
589
{
590
    if (
591
        (int) $enablePfFeature === 1
592
        && (int) $globalsPersonalFolders === 1
593
    ) {
594
        $persoFld = DB::queryFirstRow(
595
            'SELECT id
596
            FROM ' . prefixTable('nested_tree') . '
597
            WHERE title = %s AND personal_folder = %i'.
598
            (count($allowedFolders) > 0 ? ' AND id NOT IN ('.implode(',', $allowedFolders).')' : ''),
599
            $globalsUserId,
600
            1
601
        );
602
        if (empty($persoFld['id']) === false) {
603
            array_push($personalFolders, $persoFld['id']);
604
            array_push($allowedFolders, $persoFld['id']);
605
            // get all descendants
606
            $ids = $tree->getDescendants($persoFld['id'], false, false, true);
607
            foreach ($ids as $id) {
608
                //array_push($allowedFolders, $id);
609
                array_push($personalFolders, $id);
610
            }
611
        }
612
    }
613
    
614
    // Exclude all other PF
615
    $where = new WhereClause('and');
616
    $where->add('personal_folder=%i', 1);
617
    if (count($personalFolders) > 0) {
618
        $where->add('id NOT IN ('.implode(',', $personalFolders).')');
619
    }
620
    if (
621
        (int) $enablePfFeature === 1
622
        && (int) $globalsPersonalFolders === 1
623
    ) {
624
        $where->add('title=%s', $globalsUserId);
625
        $where->negateLast();
626
    }
627
    $persoFlds = DB::query(
628
        'SELECT id
629
        FROM ' . prefixTable('nested_tree') . '
630
        WHERE %l',
631
        $where
632
    );
633
    foreach ($persoFlds as $persoFldId) {
634
        array_push($noAccessPersonalFolders, $persoFldId['id']);
635
    }
636
637
    // All folders visibles
638
    $allowedFolders = array_unique(array_merge(
639
        $allowedFolders,
640
        $foldersLimitedFull,
641
        $allowedFoldersByRoles,
642
        $restrictedFoldersForItems,
643
        $readOnlyFolders
644
    ), SORT_NUMERIC);
645
    // Exclude from allowed folders all the specific user forbidden folders
646
    if (count($noAccessFolders) > 0) {
647
        $allowedFolders = array_diff($allowedFolders, $noAccessFolders);
648
    }
649
650
    return [
651
        'allowedFolders' => array_diff(array_diff($allowedFolders, $noAccessPersonalFolders), $personalFolders),
652
        'personalFolders' => $personalFolders,
653
        'noAccessPersonalFolders' => $noAccessPersonalFolders
654
    ];
655
}
656
657
658
/**
659
 * Update the CACHE table.
660
 *
661
 * @param string $action   What to do
662
 * @param array  $SETTINGS Teampass settings
663
 * @param int    $ident    Ident format
664
 * 
665
 * @return void
666
 */
667
function updateCacheTable(string $action, ?int $ident = null): void
668
{
669
    if ($action === 'reload') {
670
        // Rebuild full cache table
671
        cacheTableRefresh();
672
    } elseif ($action === 'update_value' && is_null($ident) === false) {
673
        // UPDATE an item
674
        cacheTableUpdate($ident);
675
    } elseif ($action === 'add_value' && is_null($ident) === false) {
676
        // ADD an item
677
        cacheTableAdd($ident);
678
    } elseif ($action === 'delete_value' && is_null($ident) === false) {
679
        // DELETE an item
680
        DB::delete(prefixTable('cache'), 'id = %i', $ident);
681
    }
682
}
683
684
/**
685
 * Cache table - refresh.
686
 *
687
 * @return void
688
 */
689
function cacheTableRefresh(): void
690
{
691
    // Load class DB
692
    loadClasses('DB');
693
694
    //Load Tree
695
    $tree = new NestedTree(prefixTable('nested_tree'), 'id', 'parent_id', 'title');
696
    // truncate table
697
    DB::query('TRUNCATE TABLE ' . prefixTable('cache'));
698
    // reload date
699
        $rows = DB::query(
700
            'SELECT i.*,
701
                IFNULL(l.id_user, 0) AS id_user,
702
                IFNULL(l.date, 0) AS date
703
            FROM ' . prefixTable('items') . ' as i
704
            LEFT JOIN ' . prefixTable('log_items') . ' as l
705
                ON (l.id_item = i.id AND l.action = %s)
706
            WHERE i.inactif = %i',
707
            'at_creation',
708
            0
709
        );
710
711
    foreach ($rows as $record) {
712
        if (empty($record['id_tree']) === false) {
713
            // Get all TAGS
714
            $tags = '';
715
            $itemTags = DB::query(
716
                'SELECT tag
717
                FROM ' . prefixTable('tags') . '
718
                WHERE item_id = %i AND tag != ""',
719
                $record['id']
720
            );
721
            foreach ($itemTags as $itemTag) {
722
                $tags .= $itemTag['tag'] . ' ';
723
            }
724
725
            // Get renewal period
726
            $resNT = DB::queryFirstRow(
727
                'SELECT renewal_period
728
                FROM ' . prefixTable('nested_tree') . '
729
                WHERE id = %i',
730
                $record['id_tree']
731
            );
732
            // form id_tree to full foldername
733
            $folder = [];
734
            $arbo = $tree->getPath($record['id_tree'], true);
735
            foreach ($arbo as $elem) {
736
                // Check if title is the ID of a user
737
                if (is_numeric($elem->title) === true) {
738
                    // Is this a User id?
739
                    $user = DB::queryFirstRow(
740
                        'SELECT login
741
                        FROM ' . prefixTable('users') . '
742
                        WHERE id = %i',
743
                        $elem->title
744
                    );
745
                    if ($user !== null) {
746
                        $elem->title = $user['login'];
747
                    }
748
                }
749
                // Build path
750
                array_push($folder, stripslashes($elem->title));
751
            }
752
            // store data
753
            DB::insert(
754
                prefixTable('cache'),
755
                [
756
                    'id' => $record['id'],
757
                    'label' => $record['label'],
758
                    'description' => $record['description'] ?? '',
759
                    'url' => isset($record['url']) && ! empty($record['url']) ? $record['url'] : '0',
760
                    'tags' => $tags,
761
                    'id_tree' => $record['id_tree'],
762
                    'perso' => $record['perso'],
763
                    'restricted_to' => isset($record['restricted_to']) && ! empty($record['restricted_to']) ? $record['restricted_to'] : '0',
764
                    'login' => $record['login'] ?? '',
765
                    'folder' => implode(' » ', $folder),
766
                    'author' => $record['id_user'],
767
                    'renewal_period' => $resNT['renewal_period'] ?? '0',
768
                    'timestamp' => $record['date'],
769
                ]
770
            );
771
        }
772
    }
773
}
774
775
/**
776
 * Cache table - update existing value.
777
 *
778
 * @param int    $ident    Ident format
779
 * 
780
 * @return void
781
 */
782
function cacheTableUpdate(?int $ident = null): void
783
{
784
    $session = SessionManager::getSession();
785
    loadClasses('DB');
786
787
    //Load Tree
788
    $tree = new NestedTree(prefixTable('nested_tree'), 'id', 'parent_id', 'title');
789
    // get new value from db
790
    $data = DB::queryFirstRow(
791
        'SELECT label, description, id_tree, perso, restricted_to, login, url
792
        FROM ' . prefixTable('items') . '
793
        WHERE id=%i',
794
        $ident
795
    );
796
    // Get all TAGS
797
    $tags = '';
798
    $itemTags = DB::query(
799
        'SELECT tag
800
            FROM ' . prefixTable('tags') . '
801
            WHERE item_id = %i AND tag != ""',
802
        $ident
803
    );
804
    foreach ($itemTags as $itemTag) {
805
        $tags .= $itemTag['tag'] . ' ';
806
    }
807
    // form id_tree to full foldername
808
    $folder = [];
809
    $arbo = $tree->getPath($data['id_tree'], true);
810
    foreach ($arbo as $elem) {
811
        // Check if title is the ID of a user
812
        if (is_numeric($elem->title) === true) {
813
            // Is this a User id?
814
            $user = DB::queryFirstRow(
815
                'SELECT id, login
816
                FROM ' . prefixTable('users') . '
817
                WHERE id = %i',
818
                $elem->title
819
            );
820
            if ($user !== null) {
821
                $elem->title = $user['login'];
822
            }
823
        }
824
        // Build path
825
        array_push($folder, stripslashes($elem->title));
826
    }
827
    // finaly update
828
    DB::update(
829
        prefixTable('cache'),
830
        [
831
            'label' => $data['label'],
832
            'description' => $data['description'],
833
            'tags' => $tags,
834
            'url' => isset($data['url']) && ! empty($data['url']) ? $data['url'] : '0',
835
            'id_tree' => $data['id_tree'],
836
            'perso' => $data['perso'],
837
            'restricted_to' => isset($data['restricted_to']) && ! empty($data['restricted_to']) ? $data['restricted_to'] : '0',
838
            'login' => $data['login'] ?? '',
839
            'folder' => implode(' » ', $folder),
840
            'author' => $session->get('user-id'),
841
        ],
842
        'id = %i',
843
        $ident
844
    );
845
}
846
847
/**
848
 * Cache table - add new value.
849
 *
850
 * @param int    $ident    Ident format
851
 * 
852
 * @return void
853
 */
854
function cacheTableAdd(?int $ident = null): void
855
{
856
    $session = SessionManager::getSession();
857
    $globalsUserId = $session->get('user-id');
858
859
    // Load class DB
860
    loadClasses('DB');
861
862
    //Load Tree
863
    $tree = new NestedTree(prefixTable('nested_tree'), 'id', 'parent_id', 'title');
864
    // get new value from db
865
    $data = DB::queryFirstRow(
866
        'SELECT i.label, i.description, i.id_tree as id_tree, i.perso, i.restricted_to, i.id, i.login, i.url,
867
            IFNULL(l.date, 0) AS date
868
        FROM ' . prefixTable('items') . ' as i
869
        LEFT JOIN ' . prefixTable('log_items') . ' as l
870
            ON (l.id_item = i.id AND l.action = %s)
871
        WHERE i.id = %i',
872
        'at_creation',
873
        $ident
874
    );
875
    // Get all TAGS
876
    $tags = '';
877
    $itemTags = DB::query(
878
        'SELECT tag
879
            FROM ' . prefixTable('tags') . '
880
            WHERE item_id = %i AND tag != ""',
881
        $ident
882
    );
883
    foreach ($itemTags as $itemTag) {
884
        $tags .= $itemTag['tag'] . ' ';
885
    }
886
    // form id_tree to full foldername
887
    $folder = [];
888
    $arbo = $tree->getPath($data['id_tree'], true);
889
    foreach ($arbo as $elem) {
890
        // Check if title is the ID of a user
891
        if (is_numeric($elem->title) === true) {
892
            // Is this a User id?
893
            $user = DB::queryFirstRow(
894
                'SELECT id, login
895
                FROM ' . prefixTable('users') . '
896
                WHERE id = %i',
897
                $elem->title
898
            );
899
            if ($user !== null) {
900
                $elem->title = $user['login'];
901
            }
902
        }
903
        // Build path
904
        array_push($folder, stripslashes($elem->title));
905
    }
906
    // finaly update
907
    DB::insert(
908
        prefixTable('cache'),
909
        [
910
            'id' => $data['id'],
911
            'label' => $data['label'],
912
            'description' => $data['description'],
913
            'tags' => empty($tags) === false ? $tags : 'None',
914
            'url' => isset($data['url']) && ! empty($data['url']) ? $data['url'] : '0',
915
            'id_tree' => $data['id_tree'],
916
            'perso' => isset($data['perso']) && empty($data['perso']) === false && $data['perso'] !== 'None' ? $data['perso'] : '0',
917
            'restricted_to' => isset($data['restricted_to']) && empty($data['restricted_to']) === false ? $data['restricted_to'] : '0',
918
            'login' => $data['login'] ?? '',
919
            'folder' => implode(' » ', $folder),
920
            'author' => $globalsUserId,
921
            'timestamp' => $data['date'],
922
        ]
923
    );
924
}
925
926
/**
927
 * Do statistics.
928
 *
929
 * @param array $SETTINGS Teampass settings
930
 *
931
 * @return array
932
 */
933
function getStatisticsData(array $SETTINGS): array
934
{
935
    DB::query(
936
        'SELECT id FROM ' . prefixTable('nested_tree') . ' WHERE personal_folder = %i',
937
        0
938
    );
939
    $counter_folders = DB::count();
940
    DB::query(
941
        'SELECT id FROM ' . prefixTable('nested_tree') . ' WHERE personal_folder = %i',
942
        1
943
    );
944
    $counter_folders_perso = DB::count();
945
    DB::query(
946
        'SELECT id FROM ' . prefixTable('items') . ' WHERE perso = %i',
947
        0
948
    );
949
    $counter_items = DB::count();
950
        DB::query(
951
        'SELECT id FROM ' . prefixTable('items') . ' WHERE perso = %i',
952
        1
953
    );
954
    $counter_items_perso = DB::count();
955
        DB::query(
956
        'SELECT id FROM ' . prefixTable('users') . ' WHERE login NOT IN (%s, %s, %s)',
957
        'OTV', 'TP', 'API'
958
    );
959
    $counter_users = DB::count();
960
        DB::query(
961
        'SELECT id FROM ' . prefixTable('users') . ' WHERE admin = %i',
962
        1
963
    );
964
    $admins = DB::count();
965
    DB::query(
966
        'SELECT id FROM ' . prefixTable('users') . ' WHERE gestionnaire = %i',
967
        1
968
    );
969
    $managers = DB::count();
970
    DB::query(
971
        'SELECT id FROM ' . prefixTable('users') . ' WHERE read_only = %i',
972
        1
973
    );
974
    $readOnly = DB::count();
975
    // list the languages
976
    $usedLang = [];
977
    $tp_languages = DB::query(
978
        'SELECT name FROM ' . prefixTable('languages')
979
    );
980
    foreach ($tp_languages as $tp_language) {
981
        DB::query(
982
            'SELECT * FROM ' . prefixTable('users') . ' WHERE user_language = %s',
983
            $tp_language['name']
984
        );
985
        $usedLang[$tp_language['name']] = round((DB::count() * 100 / $counter_users), 0);
986
    }
987
988
    // get list of ips
989
    $usedIp = [];
990
    $tp_ips = DB::query(
991
        'SELECT user_ip FROM ' . prefixTable('users')
992
    );
993
    foreach ($tp_ips as $ip) {
994
        if (array_key_exists($ip['user_ip'], $usedIp)) {
995
            $usedIp[$ip['user_ip']] += $usedIp[$ip['user_ip']];
996
        } elseif (! empty($ip['user_ip']) && $ip['user_ip'] !== 'none') {
997
            $usedIp[$ip['user_ip']] = 1;
998
        }
999
    }
1000
1001
    return [
1002
        'error' => '',
1003
        'stat_phpversion' => phpversion(),
1004
        'stat_folders' => $counter_folders,
1005
        'stat_folders_shared' => intval($counter_folders) - intval($counter_folders_perso),
1006
        'stat_items' => $counter_items,
1007
        'stat_items_shared' => intval($counter_items) - intval($counter_items_perso),
1008
        'stat_users' => $counter_users,
1009
        'stat_admins' => $admins,
1010
        'stat_managers' => $managers,
1011
        'stat_ro' => $readOnly,
1012
        'stat_kb' => $SETTINGS['enable_kb'],
1013
        'stat_pf' => $SETTINGS['enable_pf_feature'],
1014
        'stat_fav' => $SETTINGS['enable_favourites'],
1015
        'stat_teampassversion' => TP_VERSION,
1016
        'stat_ldap' => $SETTINGS['ldap_mode'],
1017
        'stat_agses' => $SETTINGS['agses_authentication_enabled'],
1018
        'stat_duo' => $SETTINGS['duo'],
1019
        'stat_suggestion' => $SETTINGS['enable_suggestion'],
1020
        'stat_api' => $SETTINGS['api'],
1021
        'stat_customfields' => $SETTINGS['item_extra_fields'],
1022
        'stat_syslog' => $SETTINGS['syslog_enable'],
1023
        'stat_2fa' => $SETTINGS['google_authentication'],
1024
        'stat_stricthttps' => $SETTINGS['enable_sts'],
1025
        'stat_mysqlversion' => DB::serverVersion(),
1026
        'stat_languages' => $usedLang,
1027
        'stat_country' => $usedIp,
1028
    ];
1029
}
1030
1031
/**
1032
 * Permits to prepare the way to send the email
1033
 * 
1034
 * @param string $subject       email subject
1035
 * @param string $body          email message
1036
 * @param string $email         email
1037
 * @param string $receiverName  Receiver name
1038
 * @param string $encryptedUserPassword      encryptedUserPassword
1039
 *
1040
 * @return void
1041
 */
1042
function prepareSendingEmail(
1043
    $subject,
1044
    $body,
1045
    $email,
1046
    $receiverName = '',
1047
    $encryptedUserPassword = ''
1048
): void 
1049
{
1050
    DB::insert(
1051
        prefixTable('background_tasks'),
1052
        array(
1053
            'created_at' => time(),
1054
            'process_type' => 'send_email',
1055
            'arguments' => json_encode([
1056
                'subject' => html_entity_decode($subject, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
1057
                'receivers' => $email,
1058
                'body' => $body,
1059
                'receiver_name' => $receiverName,
1060
                'encryptedUserPassword' => $encryptedUserPassword,
1061
            ], JSON_HEX_QUOT | JSON_HEX_TAG),
1062
        )
1063
    );
1064
}
1065
1066
/**
1067
 * Returns the email body.
1068
 *
1069
 * @param string $textMail Text for the email
1070
 */
1071
function emailBody(string $textMail): string
1072
{
1073
    return '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.=
1074
    w3.org/TR/html4/loose.dtd"><html>
1075
    <head><title>Email Template</title>
1076
    <style type="text/css">
1077
    body { background-color: #f0f0f0; padding: 10px 0; margin:0 0 10px =0; }
1078
    </style></head>
1079
    <body style="-ms-text-size-adjust: none; size-adjust: none; margin: 0; padding: 10px 0; background-color: #f0f0f0;" bgcolor="#f0f0f0" leftmargin="0" topmargin="0" marginwidth="0" marginheight="0">
1080
    <table border="0" width="100%" height="100%" cellpadding="0" cellspacing="0" bgcolor="#f0f0f0" style="border-spacing: 0;">
1081
    <tr><td style="border-collapse: collapse;"><br>
1082
        <table border="0" width="100%" cellpadding="0" cellspacing="0" bgcolor="#17357c" style="border-spacing: 0; margin-bottom: 25px;">
1083
        <tr><td style="border-collapse: collapse; padding: 11px 20px;">
1084
            <div style="max-width:150px; max-height:34px; color:#f0f0f0; font-weight:bold;">Teampass</div>
1085
        </td></tr></table></td>
1086
    </tr>
1087
    <tr><td align="center" valign="top" bgcolor="#f0f0f0" style="border-collapse: collapse; background-color: #f0f0f0;">
1088
        <table width="600" cellpadding="0" cellspacing="0" border="0" class="container" bgcolor="#ffffff" style="border-spacing: 0; border-bottom: 1px solid #e0e0e0; box-shadow: 0 0 3px #ddd; color: #434343; font-family: Helvetica, Verdana, sans-serif;">
1089
        <tr><td class="container-padding" bgcolor="#ffffff" style="border-collapse: collapse; border-left: 1px solid #e0e0e0; background-color: #ffffff; padding-left: 30px; padding-right: 30px;">
1090
        <br><div style="float:right;">' .
1091
        $textMail .
1092
        '<br><br></td></tr></table>
1093
    </td></tr></table>
1094
    <br></body></html>';
1095
}
1096
1097
/**
1098
 * Convert date to timestamp.
1099
 *
1100
 * @param string $date        The date
1101
 * @param string $date_format Date format
1102
 *
1103
 * @return int
1104
 */
1105
function dateToStamp(string $date, string $date_format): int
1106
{
1107
    $date = date_parse_from_format($date_format, $date);
1108
    if ((int) $date['warning_count'] === 0 && (int) $date['error_count'] === 0) {
1109
        return mktime(
1110
            empty($date['hour']) === false ? $date['hour'] : 23,
1111
            empty($date['minute']) === false ? $date['minute'] : 59,
1112
            empty($date['second']) === false ? $date['second'] : 59,
1113
            $date['month'],
1114
            $date['day'],
1115
            $date['year']
1116
        );
1117
    }
1118
    return 0;
1119
}
1120
1121
/**
1122
 * Is this a date.
1123
 *
1124
 * @param string $date Date
1125
 *
1126
 * @return bool
1127
 */
1128
function isDate(string $date): bool
1129
{
1130
    return strtotime($date) !== false;
1131
}
1132
1133
/**
1134
 * Check if isUTF8().
1135
 *
1136
 * @param string|array $string Is the string
1137
 *
1138
 * @return int is the string in UTF8 format
1139
 */
1140
function isUTF8($string): int
1141
{
1142
    if (is_array($string) === true) {
1143
        $string = $string['string'];
1144
    }
1145
1146
    return preg_match(
1147
        '%^(?:
1148
        [\x09\x0A\x0D\x20-\x7E] # ASCII
1149
        | [\xC2-\xDF][\x80-\xBF] # non-overlong 2-byte
1150
        | \xE0[\xA0-\xBF][\x80-\xBF] # excluding overlongs
1151
        | [\xE1-\xEC\xEE\xEF][\x80-\xBF]{2} # straight 3-byte
1152
        | \xED[\x80-\x9F][\x80-\xBF] # excluding surrogates
1153
        | \xF0[\x90-\xBF][\x80-\xBF]{2} # planes 1-3
1154
        | [\xF1-\xF3][\x80-\xBF]{3} # planes 4-15
1155
        | \xF4[\x80-\x8F][\x80-\xBF]{2} # plane 16
1156
        )*$%xs',
1157
        $string
1158
    );
1159
}
1160
1161
/**
1162
 * Prepare an array to UTF8 format before JSON_encode.
1163
 *
1164
 * @param array $array Array of values
1165
 *
1166
 * @return array
1167
 */
1168
function utf8Converter(array $array): array
1169
{
1170
    array_walk_recursive(
1171
        $array,
1172
        static function (&$item): void {
1173
            if (mb_detect_encoding((string) $item, 'utf-8', true) === false) {
1174
                $item = mb_convert_encoding($item, 'ISO-8859-1', 'UTF-8');
1175
            }
1176
        }
1177
    );
1178
    return $array;
1179
}
1180
1181
/**
1182
 * Permits to prepare data to be exchanged.
1183
 *
1184
 * @param array|string $data Text
1185
 * @param string       $type Parameter
1186
 * @param string       $key  Optional key
1187
 *
1188
 * @return string|array
1189
 */
1190
function prepareExchangedData($data, string $type, ?string $key = null)
1191
{
1192
    $session = SessionManager::getSession();
1193
    $key = empty($key) ? $session->get('key') : $key;
1194
    
1195
    // Perform
1196
    if ($type === 'encode' && is_array($data) === true) {
1197
        // json encoding
1198
        $data = json_encode(
1199
            $data,
1200
            JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP
1201
        );
1202
        
1203
        // Now encrypt
1204
        if ((int) $session->get('encryptClientServer') === 1) {
1205
            $data = Encryption::encrypt(
1206
                $data,
1207
                $key
1208
            );
1209
        }
1210
1211
        return $data;
1212
    }
1213
1214
    if ($type === 'decode' && is_array($data) === false) {
1215
        // Decrypt if needed
1216
        if ((int) $session->get('encryptClientServer') === 1) {
1217
            $data = (string) Encryption::decrypt(
1218
                (string) $data,
1219
                $key
1220
            );
1221
        } else {
1222
            // Double html encoding received
1223
            $data = html_entity_decode(html_entity_decode(/** @scrutinizer ignore-type */$data)); // @codeCoverageIgnore Is always a string (not an array)
1224
        }
1225
1226
        // Check if $data is a valid string before json_decode
1227
        if (is_string($data) && !empty($data)) {
1228
            // Return data array
1229
            return json_decode($data, true);
1230
        }
1231
    }
1232
1233
    return '';
1234
}
1235
1236
1237
/**
1238
 * Create a thumbnail.
1239
 *
1240
 * @param string  $src           Source
1241
 * @param string  $dest          Destination
1242
 * @param int $desired_width Size of width
1243
 * 
1244
 * @return void|string|bool
1245
 */
1246
function makeThumbnail(string $src, string $dest, int $desired_width)
1247
{
1248
    /* read the source image */
1249
    if (is_file($src) === true && mime_content_type($src) === 'image/png') {
1250
        $source_image = imagecreatefrompng($src);
1251
        if ($source_image === false) {
1252
            return "Error: Not a valid PNG file! It's type is ".mime_content_type($src);
1253
        }
1254
    } else {
1255
        return "Error: Not a valid PNG file! It's type is ".mime_content_type($src);
1256
    }
1257
1258
    // Get height and width
1259
    $width = imagesx($source_image);
1260
    $height = imagesy($source_image);
1261
    /* find the "desired height" of this thumbnail, relative to the desired width  */
1262
    $desired_height = (int) floor($height * $desired_width / $width);
1263
    /* create a new, "virtual" image */
1264
    $virtual_image = imagecreatetruecolor($desired_width, $desired_height);
1265
    if ($virtual_image === false) {
1266
        return false;
1267
    }
1268
    /* copy source image at a resized size */
1269
    imagecopyresampled($virtual_image, $source_image, 0, 0, 0, 0, $desired_width, $desired_height, $width, $height);
1270
    /* create the physical thumbnail image to its destination */
1271
    imagejpeg($virtual_image, $dest);
1272
}
1273
1274
/**
1275
 * Check table prefix in SQL query.
1276
 *
1277
 * @param string $table Table name
1278
 * 
1279
 * @return string
1280
 */
1281
function prefixTable(string $table): string
1282
{
1283
    $safeTable = htmlspecialchars(DB_PREFIX . $table);
1284
    return $safeTable;
1285
}
1286
1287
/**
1288
 * GenerateCryptKey
1289
 *
1290
 * @param int     $size      Length
1291
 * @param bool $secure Secure
1292
 * @param bool $numerals Numerics
1293
 * @param bool $uppercase Uppercase letters
1294
 * @param bool $symbols Symbols
1295
 * @param bool $lowercase Lowercase
1296
 * 
1297
 * @return string
1298
 */
1299
function GenerateCryptKey(
1300
    int $size = 20,
1301
    bool $secure = false,
1302
    bool $numerals = false,
1303
    bool $uppercase = false,
1304
    bool $symbols = false,
1305
    bool $lowercase = false
1306
): string {
1307
    $generator = new ComputerPasswordGenerator();
1308
    $generator->setRandomGenerator(new Php7RandomGenerator());
1309
    
1310
    // Manage size
1311
    $generator->setLength((int) $size);
1312
    if ($secure === true) {
1313
        $generator->setSymbols(true);
1314
        $generator->setLowercase(true);
1315
        $generator->setUppercase(true);
1316
        $generator->setNumbers(true);
1317
    } else {
1318
        $generator->setLowercase($lowercase);
1319
        $generator->setUppercase($uppercase);
1320
        $generator->setNumbers($numerals);
1321
        $generator->setSymbols($symbols);
1322
    }
1323
1324
    return $generator->generatePasswords()[0];
1325
}
1326
1327
/**
1328
 * GenerateGenericPassword
1329
 *
1330
 * @param int     $size      Length
1331
 * @param bool $secure Secure
1332
 * @param bool $numerals Numerics
1333
 * @param bool $uppercase Uppercase letters
1334
 * @param bool $symbols Symbols
1335
 * @param bool $lowercase Lowercase
1336
 * @param array   $SETTINGS  SETTINGS
1337
 * 
1338
 * @return string
1339
 */
1340
function generateGenericPassword(
1341
    int $size,
1342
    bool $secure,
1343
    bool $lowercase,
1344
    bool $capitalize,
1345
    bool $numerals,
1346
    bool $symbols,
1347
    array $SETTINGS
1348
): string
1349
{
1350
    if ((int) $size > (int) $SETTINGS['pwd_maximum_length']) {
1351
        return prepareExchangedData(
1352
            array(
1353
                'error_msg' => 'Password length is too long! ',
1354
                'error' => 'true',
1355
            ),
1356
            'encode'
1357
        );
1358
    }
1359
    // Load libraries
1360
    $generator = new ComputerPasswordGenerator();
1361
    $generator->setRandomGenerator(new Php7RandomGenerator());
1362
1363
    // Manage size
1364
    $generator->setLength(($size <= 0) ? 10 : $size);
1365
1366
    if ($secure === true) {
1367
        $generator->setSymbols(true);
1368
        $generator->setLowercase(true);
1369
        $generator->setUppercase(true);
1370
        $generator->setNumbers(true);
1371
    } else {
1372
        $generator->setLowercase($lowercase);
1373
        $generator->setUppercase($capitalize);
1374
        $generator->setNumbers($numerals);
1375
        $generator->setSymbols($symbols);
1376
    }
1377
1378
    return prepareExchangedData(
1379
        array(
1380
            'key' => $generator->generatePasswords(),
1381
            'error' => '',
1382
        ),
1383
        'encode'
1384
    );
1385
}
1386
1387
/**
1388
 * Send sysLOG message
1389
 *
1390
 * @param string    $message
1391
 * @param string    $host
1392
 * @param int       $port
1393
 * @param string    $component
1394
 * 
1395
 * @return void
1396
*/
1397
function send_syslog($message, $host, $port, $component = 'teampass'): void
1398
{
1399
    $sock = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
1400
    $syslog_message = '<123>' . date('M d H:i:s ') . $component . ': ' . $message;
1401
    socket_sendto($sock, (string) $syslog_message, strlen($syslog_message), 0, (string) $host, (int) $port);
1402
    socket_close($sock);
1403
}
1404
1405
/**
1406
 * Permits to log events into DB
1407
 *
1408
 * @param array  $SETTINGS Teampass settings
1409
 * @param string $type     Type
1410
 * @param string $label    Label
1411
 * @param string $who      Who
1412
 * @param string $login    Login
1413
 * @param string|int $field_1  Field
1414
 * 
1415
 * @return void
1416
 */
1417
function logEvents(
1418
    array $SETTINGS, 
1419
    string $type, 
1420
    string $label, 
1421
    string $who, 
1422
    ?string $login = null, 
1423
    $field_1 = null
1424
): void
1425
{
1426
    if (empty($who)) {
1427
        $who = getClientIpServer();
1428
    }
1429
1430
    // Load class DB
1431
    loadClasses('DB');
1432
1433
    // Detect API context (official browser extension uses API)
1434
    $isApiContext = defined('API_ROOT_PATH')
1435
        || (isset($_SERVER['SCRIPT_NAME']) && strpos((string) $_SERVER['SCRIPT_NAME'], '/api/') !== false)
1436
        || (isset($_SERVER['REQUEST_URI']) && strpos((string) $_SERVER['REQUEST_URI'], '/api/index.php') !== false);
1437
1438
    // Mark API-originated connection events (extension / API) so the logs UI can distinguish sources
1439
    if ($type === 'user_connection' && $isApiContext) {
1440
        $field_1_str = $field_1 === null ? '' : (string) $field_1;
1441
        if (strpos($field_1_str, 'tp_src=api') === false) {
1442
            $field_1_str = trim($field_1_str);
1443
            $field_1_str = $field_1_str === '' ? 'tp_src=api' : $field_1_str . ' | tp_src=api';
1444
        }
1445
        $field_1 = $field_1_str;
1446
    }
1447
1448
        try {
1449
    DB::insert(
1450
            prefixTable('log_system'),
1451
            [
1452
                'type' => $type,
1453
                'date' => time(),
1454
                'label' => $label,
1455
                'qui' => $who,
1456
                'field_1' => $field_1 === null ? '' : $field_1,
1457
            ]
1458
        );
1459
    
1460
    } catch (\Throwable $e) {
1461
        // Logging must never break API or UI flows
1462
        return;
1463
    }
1464
1465
    // If SYSLOG
1466
    if (isset($SETTINGS['syslog_enable']) === true && (int) $SETTINGS['syslog_enable'] === 1) {
1467
        if ($type === 'user_mngt') {
1468
            send_syslog(
1469
                'action=' . str_replace('at_', '', $label) . ' attribute=user user=' . $who . ' userid="' . $login . '" change="' . $field_1 . '" ',
1470
                $SETTINGS['syslog_host'],
1471
                $SETTINGS['syslog_port'],
1472
                'teampass'
1473
            );
1474
        } else {
1475
            send_syslog(
1476
                'action=' . $type . ' attribute=' . $label . ' user=' . $who . ' userid="' . $login . '" ',
1477
                $SETTINGS['syslog_host'],
1478
                $SETTINGS['syslog_port'],
1479
                'teampass'
1480
            );
1481
        }
1482
    }
1483
}
1484
1485
/**
1486
 * Log events.
1487
 *
1488
 * @param array  $SETTINGS        Teampass settings
1489
 * @param int    $item_id         Item id
1490
 * @param string $item_label      Item label
1491
 * @param int    $id_user         User id
1492
 * @param string $action          Code for reason
1493
 * @param string $login           User login
1494
 * @param string $raison          Code for reason
1495
 * @param string $encryption_type Encryption on
1496
 * @param string $time Encryption Time
1497
 * @param string $old_value       Old value
1498
 * 
1499
 * @return void
1500
 */
1501
function logItems(
1502
    array $SETTINGS,
1503
    int $item_id,
1504
    string $item_label,
1505
    int $id_user,
1506
    string $action,
1507
    ?string $login = null,
1508
    ?string $raison = null,
1509
    ?string $encryption_type = null,
1510
    ?string $time = null,
1511
    ?string $old_value = null
1512
): void {
1513
    // Load class DB
1514
    loadClasses('DB');
1515
1516
    // Normalize event timestamp
1517
    $eventTime = is_null($time) === true ? time() : (int) $time;
1518
1519
    // Detect API context (official browser extension uses API) and tag logs accordingly
1520
    $isApiContext = defined('API_ROOT_PATH')
1521
        || (isset($_SERVER['SCRIPT_NAME']) && strpos((string) $_SERVER['SCRIPT_NAME'], '/api/') !== false)
1522
        || (isset($_SERVER['REQUEST_URI']) && strpos((string) $_SERVER['REQUEST_URI'], '/api/index.php') !== false);
1523
    $sourceMarker = 'tp_src=api';
1524
1525
    if ($isApiContext) {
1526
        // Add marker to reason to allow UI filtering/display
1527
        $raison = $raison === null ? '' : (string) $raison;
1528
        if (strpos($raison, $sourceMarker) === false) {
1529
            $raison = trim($raison);
1530
            $raison = $raison === '' ? $sourceMarker : $raison . ' | ' . $sourceMarker;
1531
        }
1532
1533
        // Avoid duplicate "shown" logs caused by multiple API calls within a short window
1534
        if ($action === 'at_shown') {
1535
            try {
1536
            $existing = DB::queryFirstField(
1537
                'SELECT id FROM ' . prefixTable('log_items') . '
1538
                WHERE id_item = %i AND id_user = %i AND action = %s AND date >= %i AND raison LIKE %ss
1539
                ORDER BY date DESC
1540
                LIMIT 1',
1541
                $item_id,
1542
                $id_user,
1543
                $action,
1544
                $eventTime - 5,
1545
                $sourceMarker
1546
            );
1547
        } catch (\Throwable $e) {
1548
            $existing = null; // Never block API calls because of logging
1549
        }
1550
            if (!empty($existing)) {
1551
                return;
1552
            }
1553
        }
1554
    }
1555
1556
    $raisonForDb = $raison === null ? '' : (string) $raison;
1557
1558
    // Insert log in DB
1559
    try {
1560
    DB::insert(
1561
        prefixTable('log_items'),
1562
        [
1563
            'id_item' => $item_id,
1564
            'date' => $eventTime,
1565
            'id_user' => $id_user,
1566
            'action' => $action,
1567
            'raison' => $raisonForDb,
1568
            'old_value' => $old_value,
1569
            'encryption_type' => is_null($encryption_type) === true ? TP_ENCRYPTION_NAME : $encryption_type,
1570
        ]
1571
    );
1572
    
1573
    } catch (\Throwable $e) {
1574
        // Logging must never break API or UI flows
1575
        return;
1576
    }
1577
1578
    // Timestamp the last change
1579
    if (in_array($action, ['at_creation', 'at_modifiation', 'at_delete', 'at_import'], true)) {
1580
        try {
1581
            DB::update(
1582
                prefixTable('misc'),
1583
                [
1584
                    'valeur' => time(),
1585
                    'updated_at' => time(),
1586
                ],
1587
                'type = %s AND intitule = %s',
1588
                'timestamp',
1589
                'last_item_change'
1590
            );
1591
        } catch (\Throwable $e) {
1592
            // ignore logging-related DB errors
1593
        }
1594
    }
1595
1596
    // Prepare reason for syslog: remove internal source marker if present
1597
    $raisonForSyslog = $raison;
1598
    if ($isApiContext && $raisonForSyslog !== null) {
1599
        $raisonForSyslog = preg_replace('/\s*\|\s*tp_src=api\s*$/', '', (string) $raisonForSyslog);
1600
        $raisonForSyslog = trim((string) $raisonForSyslog);
1601
        if ($raisonForSyslog === '') {
1602
            $raisonForSyslog = null;
1603
        }
1604
    }
1605
1606
    // SYSLOG
1607
    if (isset($SETTINGS['syslog_enable']) === true && (int) $SETTINGS['syslog_enable'] === 1) {
1608
        // Extract reason
1609
        $attribute = is_null($raisonForSyslog) === true ? Array('') : explode(' : ', $raisonForSyslog);
1610
        // Get item info if not known
1611
        if (empty($item_label) === true) {
1612
            try {
1613
                $dataItem = DB::queryFirstRow(
1614
                    'SELECT id, id_tree, label
1615
                    FROM ' . prefixTable('items') . '
1616
                    WHERE id = %i',
1617
                    $item_id
1618
                );
1619
                $item_label = $dataItem['label'];
1620
            } catch (\Throwable $e) {
1621
                // ignore logging-related DB errors
1622
            }
1623
        }
1624
1625
        send_syslog(
1626
            'action=' . str_replace('at_', '', $action) .
1627
                ' attribute=' . str_replace('at_', '', $attribute[0]) .
1628
                ' itemno=' . $item_id .
1629
                ' user=' . (is_null($login) === true ? '' : addslashes((string) $login)) .
1630
                ' itemname="' . addslashes($item_label) . '"',
1631
            $SETTINGS['syslog_host'],
1632
            $SETTINGS['syslog_port'],
1633
            'teampass'
1634
        );
1635
    }
1636
1637
    // send notification if enabled
1638
    //notifyOnChange($item_id, $action, $SETTINGS);
1639
}
1640
1641
/**
1642
 * Prepare notification email to subscribers.
1643
 *
1644
 * @param int    $item_id  Item id
1645
 * @param string $label    Item label
1646
 * @param array  $changes  List of changes
1647
 * @param array  $SETTINGS Teampass settings
1648
 * 
1649
 * @return void
1650
 */
1651
function notifyChangesToSubscribers(int $item_id, string $label, array $changes, array $SETTINGS): void
1652
{
1653
    $session = SessionManager::getSession();
1654
    $lang = new Language($session->get('user-language') ?? 'english');
1655
    $globalsUserId = $session->get('user-id');
1656
    $globalsLastname = $session->get('user-lastname');
1657
    $globalsName = $session->get('user-name');
1658
    // send email to user that what to be notified
1659
    $notification = DB::queryFirstField(
1660
        'SELECT email
1661
        FROM ' . prefixTable('notification') . ' AS n
1662
        INNER JOIN ' . prefixTable('users') . ' AS u ON (n.user_id = u.id)
1663
        WHERE n.item_id = %i AND n.user_id != %i',
1664
        $item_id,
1665
        $globalsUserId
1666
    );
1667
    if (DB::count() > 0) {
1668
        // Prepare path
1669
        $path = geItemReadablePath($item_id, '', $SETTINGS);
1670
        // Get list of changes
1671
        $htmlChanges = '<ul>';
1672
        foreach ($changes as $change) {
1673
            $htmlChanges .= '<li>' . $change . '</li>';
1674
        }
1675
        $htmlChanges .= '</ul>';
1676
        // send email
1677
        DB::insert(
1678
            prefixTable('emails'),
1679
            [
1680
                'timestamp' => time(),
1681
                'subject' => $lang->get('email_subject_item_updated'),
1682
                'body' => str_replace(
1683
                    ['#item_label#', '#folder_name#', '#item_id#', '#url#', '#name#', '#lastname#', '#changes#'],
1684
                    [$label, $path, (string) $item_id, $SETTINGS['cpassman_url'], $globalsName, $globalsLastname, $htmlChanges],
1685
                    $lang->get('email_body_item_updated')
1686
                ),
1687
                'receivers' => implode(',', $notification),
1688
                'status' => '',
1689
            ]
1690
        );
1691
    }
1692
}
1693
1694
/**
1695
 * Returns the Item + path.
1696
 *
1697
 * @param int    $id_tree  Node id
1698
 * @param string $label    Label
1699
 * @param array  $SETTINGS TP settings
1700
 * 
1701
 * @return string
1702
 */
1703
function geItemReadablePath(int $id_tree, string $label, array $SETTINGS): string
1704
{
1705
    $tree = new NestedTree(prefixTable('nested_tree'), 'id', 'parent_id', 'title');
1706
    $arbo = $tree->getPath($id_tree, true);
1707
    $path = '';
1708
    foreach ($arbo as $elem) {
1709
        if (empty($path) === true) {
1710
            $path = htmlspecialchars(stripslashes(htmlspecialchars_decode($elem->title, ENT_QUOTES)), ENT_QUOTES) . ' ';
1711
        } else {
1712
            $path .= '&#8594; ' . htmlspecialchars(stripslashes(htmlspecialchars_decode($elem->title, ENT_QUOTES)), ENT_QUOTES);
1713
        }
1714
    }
1715
1716
    // Build text to show user
1717
    if (empty($label) === false) {
1718
        return empty($path) === true ? addslashes($label) : addslashes($label) . ' (' . $path . ')';
1719
    }
1720
    return empty($path) === true ? '' : $path;
1721
}
1722
1723
/**
1724
 * Get the client ip address.
1725
 *
1726
 * @return string IP address
1727
 */
1728
function getClientIpServer(): string
1729
{
1730
    if (getenv('HTTP_CLIENT_IP')) {
1731
        $ipaddress = getenv('HTTP_CLIENT_IP');
1732
    } elseif (getenv('HTTP_X_FORWARDED_FOR')) {
1733
        $ipaddress = getenv('HTTP_X_FORWARDED_FOR');
1734
    } elseif (getenv('HTTP_X_FORWARDED')) {
1735
        $ipaddress = getenv('HTTP_X_FORWARDED');
1736
    } elseif (getenv('HTTP_FORWARDED_FOR')) {
1737
        $ipaddress = getenv('HTTP_FORWARDED_FOR');
1738
    } elseif (getenv('HTTP_FORWARDED')) {
1739
        $ipaddress = getenv('HTTP_FORWARDED');
1740
    } elseif (getenv('REMOTE_ADDR')) {
1741
        $ipaddress = getenv('REMOTE_ADDR');
1742
    } else {
1743
        $ipaddress = 'UNKNOWN';
1744
    }
1745
1746
    return $ipaddress;
1747
}
1748
1749
/**
1750
 * Escape all HTML, JavaScript, and CSS.
1751
 *
1752
 * @param string $input    The input string
1753
 * @param string $encoding Which character encoding are we using?
1754
 * 
1755
 * @return string
1756
 */
1757
function noHTML(string $input, string $encoding = 'UTF-8'): string
1758
{
1759
    return htmlspecialchars($input, ENT_QUOTES | ENT_XHTML, $encoding, false);
1760
}
1761
1762
/**
1763
 * Rebuilds the Teampass config file.
1764
 *
1765
 * @param string $configFilePath Path to the config file.
1766
 * @param array  $settings       Teampass settings.
1767
 *
1768
 * @return string|bool
1769
 */
1770
function rebuildConfigFile(string $configFilePath, array $settings)
1771
{
1772
    // Perform a copy if the file exists
1773
    if (file_exists($configFilePath)) {
1774
        $backupFilePath = $configFilePath . '.' . date('Y_m_d_His', time());
1775
        if (!copy($configFilePath, $backupFilePath)) {
1776
            return "ERROR: Could not copy file '$configFilePath'";
1777
        }
1778
    }
1779
1780
    // Regenerate the config file
1781
    $data = ["<?php\n", "global \$SETTINGS;\n", "\$SETTINGS = array (\n"];
1782
    $rows = DB::query('SELECT * FROM ' . prefixTable('misc') . ' WHERE type=%s', 'admin');
1783
    foreach ($rows as $record) {
1784
        $value = getEncryptedValue($record['valeur'], $record['is_encrypted']);
1785
        $data[] = "    '{$record['intitule']}' => '". htmlspecialchars_decode($value, ENT_COMPAT) . "',\n";
1786
    }
1787
    $data[] = ");\n";
1788
    $data = array_unique($data);
1789
1790
    // Update the file
1791
    file_put_contents($configFilePath, implode('', $data));
1792
1793
    return true;
1794
}
1795
1796
/**
1797
 * Returns the encrypted value if needed.
1798
 *
1799
 * @param string $value       Value to encrypt.
1800
 * @param int   $isEncrypted Is the value encrypted?
1801
 *
1802
 * @return string
1803
 */
1804
function getEncryptedValue(string $value, int $isEncrypted): string
1805
{
1806
    return $isEncrypted ? cryption($value, '', 'encrypt')['string'] : $value;
1807
}
1808
1809
/**
1810
 * Permits to replace &#92; to permit correct display
1811
 *
1812
 * @param string $input Some text
1813
 * 
1814
 * @return string
1815
 */
1816
function handleBackslash(string $input): string
1817
{
1818
    return str_replace('&amp;#92;', '&#92;', $input);
1819
}
1820
1821
/**
1822
 * Permits to load settings
1823
 * 
1824
 * @return void
1825
*/
1826
function loadSettings(): void
1827
{
1828
    global $SETTINGS;
1829
    /* LOAD CPASSMAN SETTINGS */
1830
    if (! isset($SETTINGS['loaded']) || $SETTINGS['loaded'] !== 1) {
1831
        $SETTINGS = [];
1832
        $SETTINGS['duplicate_folder'] = 0;
1833
        //by default, this is set to 0;
1834
        $SETTINGS['duplicate_item'] = 0;
1835
        //by default, this is set to 0;
1836
        $SETTINGS['number_of_used_pw'] = 5;
1837
        //by default, this value is set to 5;
1838
        $settings = [];
1839
        $rows = DB::query(
1840
            'SELECT * FROM ' . prefixTable('misc') . ' WHERE type=%s_type OR type=%s_type2',
1841
            [
1842
                'type' => 'admin',
1843
                'type2' => 'settings',
1844
            ]
1845
        );
1846
        foreach ($rows as $record) {
1847
            if ($record['type'] === 'admin') {
1848
                $SETTINGS[$record['intitule']] = $record['valeur'];
1849
            } else {
1850
                $settings[$record['intitule']] = $record['valeur'];
1851
            }
1852
        }
1853
        $SETTINGS['loaded'] = 1;
1854
        $SETTINGS['default_session_expiration_time'] = 5;
1855
    }
1856
}
1857
1858
/**
1859
 * check if folder has custom fields.
1860
 * Ensure that target one also has same custom fields
1861
 * 
1862
 * @param int $source_id
1863
 * @param int $target_id 
1864
 * 
1865
 * @return bool
1866
*/
1867
function checkCFconsistency(int $source_id, int $target_id): bool
1868
{
1869
    $source_cf = [];
1870
    $rows = DB::query(
1871
        'SELECT id_category
1872
            FROM ' . prefixTable('categories_folders') . '
1873
            WHERE id_folder = %i',
1874
        $source_id
1875
    );
1876
    foreach ($rows as $record) {
1877
        array_push($source_cf, $record['id_category']);
1878
    }
1879
1880
    $target_cf = [];
1881
    $rows = DB::query(
1882
        'SELECT id_category
1883
            FROM ' . prefixTable('categories_folders') . '
1884
            WHERE id_folder = %i',
1885
        $target_id
1886
    );
1887
    foreach ($rows as $record) {
1888
        array_push($target_cf, $record['id_category']);
1889
    }
1890
1891
    $cf_diff = array_diff($source_cf, $target_cf);
1892
    if (count($cf_diff) > 0) {
1893
        return false;
1894
    }
1895
1896
    return true;
1897
}
1898
1899
/**
1900
 * Will encrypte/decrypt a fil eusing Defuse.
1901
 *
1902
 * @param string $type        can be either encrypt or decrypt
1903
 * @param string $source_file path to source file
1904
 * @param string $target_file path to target file
1905
 * @param array  $SETTINGS    Settings
1906
 * @param string $password    A password
1907
 *
1908
 * @return string|bool
1909
 */
1910
function prepareFileWithDefuse(
1911
    string $type,
1912
    string $source_file,
1913
    string $target_file,
1914
    ?string $password = null
1915
) {
1916
    // Load AntiXSS
1917
    $antiXss = new AntiXSS();
1918
    // Protect against bad inputs
1919
    if (is_array($source_file) === true || is_array($target_file) === true) {
1920
        return 'error_cannot_be_array';
1921
    }
1922
1923
    // Sanitize
1924
    $source_file = $antiXss->xss_clean($source_file);
1925
    $target_file = $antiXss->xss_clean($target_file);
1926
    if (empty($password) === true || is_null($password) === true) {
1927
        // get KEY to define password
1928
        $ascii_key = file_get_contents(SECUREPATH.'/'.SECUREFILE);
1929
        $password = Key::loadFromAsciiSafeString($ascii_key);
1930
    }
1931
1932
    $err = '';
1933
    if ($type === 'decrypt') {
1934
        // Decrypt file
1935
        $err = defuseFileDecrypt(
1936
            $source_file,
1937
            $target_file,
1938
            $password
0 ignored issues
show
Bug introduced by
It seems like $password can also be of type Defuse\Crypto\Key; however, parameter $password of defuseFileDecrypt() does only seem to accept null|string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1938
            /** @scrutinizer ignore-type */ $password
Loading history...
1939
        );
1940
    } elseif ($type === 'encrypt') {
1941
        // Encrypt file
1942
        $err = defuseFileEncrypt(
1943
            $source_file,
1944
            $target_file,
1945
            $password
0 ignored issues
show
Bug introduced by
It seems like $password can also be of type Defuse\Crypto\Key; however, parameter $password of defuseFileEncrypt() does only seem to accept null|string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1945
            /** @scrutinizer ignore-type */ $password
Loading history...
1946
        );
1947
    }
1948
1949
    // return error
1950
    return $err === true ? $err : '';
1951
}
1952
1953
/**
1954
 * Encrypt a file with Defuse.
1955
 *
1956
 * @param string $source_file path to source file
1957
 * @param string $target_file path to target file
1958
 * @param array  $SETTINGS    Settings
1959
 * @param string $password    A password
1960
 *
1961
 * @return string|bool
1962
 */
1963
function defuseFileEncrypt(
1964
    string $source_file,
1965
    string $target_file,
1966
    ?string $password = null
1967
) {
1968
    $err = '';
1969
    try {
1970
        CryptoFile::encryptFileWithPassword(
1971
            $source_file,
1972
            $target_file,
1973
            $password
1974
        );
1975
    } catch (CryptoException\WrongKeyOrModifiedCiphertextException $ex) {
1976
        $err = 'wrong_key';
1977
    } catch (CryptoException\EnvironmentIsBrokenException $ex) {
1978
        error_log('TEAMPASS-Error-Environment: ' . $ex->getMessage());
1979
        $err = 'environment_error';
1980
    } catch (CryptoException\IOException $ex) {
1981
        error_log('TEAMPASS-Error-General: ' . $ex->getMessage());
1982
        $err = 'general_error';
1983
    }
1984
1985
    // return error
1986
    return empty($err) === false ? $err : true;
1987
}
1988
1989
/**
1990
 * Decrypt a file with Defuse.
1991
 *
1992
 * @param string $source_file path to source file
1993
 * @param string $target_file path to target file
1994
 * @param string $password    A password
1995
 *
1996
 * @return string|bool
1997
 */
1998
function defuseFileDecrypt(
1999
    string $source_file,
2000
    string $target_file,
2001
    ?string $password = null
2002
) {
2003
    $err = '';
2004
    try {
2005
        CryptoFile::decryptFileWithPassword(
2006
            $source_file,
2007
            $target_file,
2008
            $password
2009
        );
2010
    } catch (CryptoException\WrongKeyOrModifiedCiphertextException $ex) {
2011
        $err = 'wrong_key';
2012
    } catch (CryptoException\EnvironmentIsBrokenException $ex) {
2013
        error_log('TEAMPASS-Error-Environment: ' . $ex->getMessage());
2014
        $err = 'environment_error';
2015
    } catch (CryptoException\IOException $ex) {
2016
        error_log('TEAMPASS-Error-General: ' . $ex->getMessage());
2017
        $err = 'general_error';
2018
    }
2019
2020
    // return error
2021
    return empty($err) === false ? $err : true;
2022
}
2023
2024
/*
2025
* NOT TO BE USED
2026
*/
2027
/**
2028
 * Undocumented function.
2029
 *
2030
 * @param string $text Text to debug
2031
 */
2032
function debugTeampass(string $text): void
2033
{
2034
    $debugFile = fopen('D:/wamp64/www/TeamPass/debug.txt', 'r+');
2035
    if ($debugFile !== false) {
2036
        fputs($debugFile, $text);
2037
        fclose($debugFile);
2038
    }
2039
}
2040
2041
/**
2042
 * DELETE the file with expected command depending on server type.
2043
 *
2044
 * @param string $file     Path to file
2045
 * @param array  $SETTINGS Teampass settings
2046
 *
2047
 * @return void
2048
 */
2049
function fileDelete(string $file, array $SETTINGS): void
2050
{
2051
    // Load AntiXSS
2052
    $antiXss = new AntiXSS();
2053
    $file = $antiXss->xss_clean($file);
2054
    if (is_file($file)) {
2055
        unlink($file);
2056
    }
2057
}
2058
2059
/**
2060
 * Permits to extract the file extension.
2061
 *
2062
 * @param string $file File name
2063
 *
2064
 * @return string
2065
 */
2066
function getFileExtension(string $file): string
2067
{
2068
    if (strpos($file, '.') === false) {
2069
        return $file;
2070
    }
2071
2072
    return substr($file, strrpos($file, '.') + 1);
2073
}
2074
2075
/**
2076
 * Chmods files and folders with different permissions.
2077
 *
2078
 * This is an all-PHP alternative to using: \n
2079
 * <tt>exec("find ".$path." -type f -exec chmod 644 {} \;");</tt> \n
2080
 * <tt>exec("find ".$path." -type d -exec chmod 755 {} \;");</tt>
2081
 *
2082
 * @author Jeppe Toustrup (tenzer at tenzer dot dk)
2083
  *
2084
 * @param string $path      An either relative or absolute path to a file or directory which should be processed.
2085
 * @param int    $filePerm The permissions any found files should get.
2086
 * @param int    $dirPerm  The permissions any found folder should get.
2087
 *
2088
 * @return bool Returns TRUE if the path if found and FALSE if not.
2089
 *
2090
 * @warning The permission levels has to be entered in octal format, which
2091
 * normally means adding a zero ("0") in front of the permission level. \n
2092
 * More info at: http://php.net/chmod.
2093
*/
2094
2095
function recursiveChmod(
2096
    string $path,
2097
    int $filePerm = 0644,
2098
    int  $dirPerm = 0755
2099
) {
2100
    // Check if the path exists
2101
    $path = basename($path);
2102
    if (! file_exists($path)) {
2103
        return false;
2104
    }
2105
2106
    // See whether this is a file
2107
    if (is_file($path)) {
2108
        // Chmod the file with our given filepermissions
2109
        try {
2110
            chmod($path, $filePerm);
2111
        } catch (Exception $e) {
2112
            return false;
2113
        }
2114
    // If this is a directory...
2115
    } elseif (is_dir($path)) {
2116
        // Then get an array of the contents
2117
        $foldersAndFiles = scandir($path);
2118
        // Remove "." and ".." from the list
2119
        $entries = array_slice($foldersAndFiles, 2);
2120
        // Parse every result...
2121
        foreach ($entries as $entry) {
2122
            // And call this function again recursively, with the same permissions
2123
            recursiveChmod($path.'/'.$entry, $filePerm, $dirPerm);
2124
        }
2125
2126
        // When we are done with the contents of the directory, we chmod the directory itself
2127
        try {
2128
            chmod($path, $filePerm);
2129
        } catch (Exception $e) {
2130
            return false;
2131
        }
2132
    }
2133
2134
    // Everything seemed to work out well, return true
2135
    return true;
2136
}
2137
2138
/**
2139
 * Check if user can access to this item.
2140
 *
2141
 * @param int   $item_id ID of item
2142
 * @param array $SETTINGS
2143
 *
2144
 * @return bool|string
2145
 */
2146
function accessToItemIsGranted(int $item_id, array $SETTINGS)
2147
{
2148
    
2149
    $session = SessionManager::getSession();
2150
    $session_groupes_visibles = $session->get('user-accessible_folders');
2151
    $session_list_restricted_folders_for_items = $session->get('system-list_restricted_folders_for_items');
2152
    // Load item data
2153
    $data = DB::queryFirstRow(
2154
        'SELECT id_tree
2155
        FROM ' . prefixTable('items') . '
2156
        WHERE id = %i',
2157
        $item_id
2158
    );
2159
    // Check if user can access this folder
2160
    if (in_array($data['id_tree'], $session_groupes_visibles) === false) {
2161
        // Now check if this folder is restricted to user
2162
        if (isset($session_list_restricted_folders_for_items[$data['id_tree']]) === true
2163
            && in_array($item_id, $session_list_restricted_folders_for_items[$data['id_tree']]) === false
2164
        ) {
2165
            return 'ERR_FOLDER_NOT_ALLOWED';
2166
        }
2167
    }
2168
2169
    return true;
2170
}
2171
2172
/**
2173
 * Creates a unique key.
2174
 *
2175
 * @param int $lenght Key lenght
2176
 *
2177
 * @return string
2178
 */
2179
function uniqidReal(int $lenght = 13): string
2180
{
2181
    if (function_exists('random_bytes')) {
2182
        $bytes = random_bytes(intval(ceil($lenght / 2)));
2183
    } elseif (function_exists('openssl_random_pseudo_bytes')) {
2184
        $bytes = openssl_random_pseudo_bytes(intval(ceil($lenght / 2)));
2185
    } else {
2186
        throw new Exception('no cryptographically secure random function available');
2187
    }
2188
2189
    return substr(bin2hex($bytes), 0, $lenght);
2190
}
2191
2192
/**
2193
 * Obfuscate an email.
2194
 *
2195
 * @param string $email Email address
2196
 *
2197
 * @return string
2198
 */
2199
function obfuscateEmail(string $email): string
2200
{
2201
    $email = explode("@", $email);
2202
    $name = $email[0];
2203
    if (strlen($name) > 3) {
2204
        $name = substr($name, 0, 2);
2205
        for ($i = 0; $i < strlen($email[0]) - 3; $i++) {
2206
            $name .= "*";
2207
        }
2208
        $name .= substr($email[0], -1, 1);
2209
    }
2210
    $host = explode(".", $email[1])[0];
2211
    if (strlen($host) > 3) {
2212
        $host = substr($host, 0, 1);
2213
        for ($i = 0; $i < strlen(explode(".", $email[1])[0]) - 2; $i++) {
2214
            $host .= "*";
2215
        }
2216
        $host .= substr(explode(".", $email[1])[0], -1, 1);
2217
    }
2218
    $email = $name . "@" . $host . "." . explode(".", $email[1])[1];
2219
    return $email;
2220
}
2221
2222
/**
2223
 * Get id and title from role_titles table.
2224
 *
2225
 * @return array
2226
 */
2227
function getRolesTitles(): array
2228
{
2229
    // Load class DB
2230
    loadClasses('DB');
2231
    
2232
    // Insert log in DB
2233
    return DB::query(
2234
        'SELECT id, title
2235
        FROM ' . prefixTable('roles_title')
2236
    );
2237
}
2238
2239
/**
2240
 * Undocumented function.
2241
 *
2242
 * @param int $bytes Size of file
2243
 *
2244
 * @return string
2245
 */
2246
function formatSizeUnits(int $bytes): string
2247
{
2248
    if ($bytes >= 1073741824) {
2249
        $bytes = number_format($bytes / 1073741824, 2) . ' GB';
2250
    } elseif ($bytes >= 1048576) {
2251
        $bytes = number_format($bytes / 1048576, 2) . ' MB';
2252
    } elseif ($bytes >= 1024) {
2253
        $bytes = number_format($bytes / 1024, 2) . ' KB';
2254
    } elseif ($bytes > 1) {
2255
        $bytes .= ' bytes';
2256
    } elseif ($bytes === 1) {
2257
        $bytes .= ' byte';
2258
    } else {
2259
        $bytes = '0 bytes';
2260
    }
2261
2262
    return $bytes;
2263
}
2264
2265
/**
2266
 * Generate user pair of keys.
2267
 *
2268
 * @param string $userPwd User password
2269
 *
2270
 * @return array
2271
 */
2272
function generateUserKeys(string $userPwd, ?array $SETTINGS = null): array
2273
{
2274
    // Sanitize
2275
    $antiXss = new AntiXSS();
2276
    $userPwd = $antiXss->xss_clean($userPwd);
2277
2278
    // Generate RSA key pair using CryptoManager (phpseclib v3)
2279
    $res = \TeampassClasses\CryptoManager\CryptoManager::generateRSAKeyPair(4096);
2280
2281
    // Encrypt the private key with user password using AES (SHA-256 for v3)
2282
    $privatekey = \TeampassClasses\CryptoManager\CryptoManager::aesEncrypt($res['privatekey'], $userPwd, 'cbc', 'sha256');
2283
2284
    $result = [
2285
        'private_key' => base64_encode($privatekey),
2286
        'public_key' => base64_encode($res['publickey']),
2287
        'private_key_clear' => base64_encode($res['privatekey']),
2288
    ];
2289
2290
    // Generate transparent recovery data
2291
    // Generate unique seed for this user
2292
    $userSeed = bin2hex(openssl_random_pseudo_bytes(32));
2293
2294
    // Derive backup encryption key
2295
    $derivedKey = deriveBackupKey($userSeed, $result['public_key'], $SETTINGS);
2296
2297
    // Encrypt private key with derived key (backup, SHA-256 for v3)
2298
    $privatekeyBackup = \TeampassClasses\CryptoManager\CryptoManager::aesEncrypt($res['privatekey'], $derivedKey, 'cbc', 'sha256');
2299
2300
    // Generate integrity hash
2301
    $serverSecret = getServerSecret();
2302
    $integrityHash = generateKeyIntegrityHash($userSeed, $result['public_key'], $serverSecret);
2303
2304
    $result['user_seed'] = $userSeed;
2305
    $result['private_key_backup'] = base64_encode($privatekeyBackup);
2306
    $result['key_integrity_hash'] = $integrityHash;
2307
2308
    return $result;
2309
}
2310
2311
/**
2312
 * Permits to decrypt the user's privatekey.
2313
 *
2314
 * @param string $userPwd        User password
2315
 * @param string $userPrivateKey User private key
2316
 *
2317
 * @return string|object
2318
 */
2319
function decryptPrivateKey(string $userPwd, string $userPrivateKey)
2320
{
2321
    // Sanitize
2322
    $antiXss = new AntiXSS();
2323
    $userPwd = $antiXss->xss_clean($userPwd);
2324
    $userPrivateKey = $antiXss->xss_clean($userPrivateKey);
2325
2326
    if (empty($userPwd) === false) {
2327
        try {
2328
            // Decrypt using CryptoManager with version detection (tries SHA-256 then SHA-1)
2329
            $result = \TeampassClasses\CryptoManager\CryptoManager::aesDecryptWithVersionDetection(
2330
                base64_decode($userPrivateKey),
2331
                $userPwd,
2332
                'cbc'
2333
            );
2334
            return base64_encode((string) $result['data']);
2335
        } catch (Exception $e) {
2336
            // Log error for debugging
2337
            if (defined('LOG_TO_SERVER') && LOG_TO_SERVER === true) {
2338
                error_log('TEAMPASS Error - decryptPrivateKey failed: ' . $e->getMessage());
2339
            }
2340
            // Return empty string on decryption failure
2341
            return '';
2342
        }
2343
    }
2344
    return '';
2345
}
2346
2347
/**
2348
 * Permits to encrypt the user's privatekey.
2349
 *
2350
 * @param string $userPwd        User password
2351
 * @param string $userPrivateKey User private key
2352
 *
2353
 * @return string
2354
 */
2355
function encryptPrivateKey(string $userPwd, string $userPrivateKey): string
2356
{
2357
    // Sanitize
2358
    $antiXss = new AntiXSS();
2359
    $userPwd = $antiXss->xss_clean($userPwd);
2360
    $userPrivateKey = $antiXss->xss_clean($userPrivateKey);
2361
2362
    if (empty($userPwd) === false) {
2363
        try {
2364
            // Encrypt using CryptoManager (phpseclib v3, SHA-256)
2365
            $encrypted = CryptoManager::aesEncrypt(
2366
                base64_decode($userPrivateKey),
2367
                $userPwd,
2368
                'cbc',
2369
                'sha256'
2370
            );
2371
            return base64_encode($encrypted);
2372
        } catch (Exception $e) {
2373
            return $e->getMessage();
2374
        }
2375
    }
2376
    return '';
2377
}
2378
2379
/**
2380
 * Decrypt user's private key with automatic migration from v1 to v3
2381
 *
2382
 * This function handles the migration of user private keys from phpseclib v1 (SHA-1)
2383
 * to v3 (SHA-256) transparently during login.
2384
 *
2385
 * Uses aesDecryptWithVersionDetection() to automatically try SHA-256 (v3) first,
2386
 * then fallback to SHA-1 (v1). More robust than relying on encryption_version in DB.
2387
 *
2388
 * Logic:
2389
 * - Try decryption with version detection (SHA-256 then SHA-1)
2390
 * - If v1 was used: Trigger migration to v3
2391
 * - If v3 was used: No migration needed
2392
 * - If error: Set migration_error flag
2393
 *
2394
 * @param string $userPwd User's password (clear text)
2395
 * @param string $userPrivateKey Encrypted private key (base64)
2396
 * @param int $userId User ID for migration
2397
 * @param int $encryptionVersion Current encryption version from DB (for logging only)
2398
 * @return array ['private_key_clear' => string, 'migration_error' => bool, 'needs_migration' => bool]
2399
 */
2400
function decryptPrivateKeyWithMigration(
2401
    string $userPwd,
2402
    string $userPrivateKey,
2403
    int $userId,
2404
    int $encryptionVersion
2405
): array {
2406
    $antiXss = new AntiXSS();
2407
    $userPwd = $antiXss->xss_clean($userPwd);
2408
    $userPrivateKey = $antiXss->xss_clean($userPrivateKey);
2409
2410
    try {
2411
        // Use automatic version detection (tries SHA-256 first, then SHA-1)
2412
        $result = CryptoManager::aesDecryptWithVersionDetection(
2413
            base64_decode($userPrivateKey),
2414
            $userPwd,
2415
            'cbc'
2416
        );
2417
        $decrypted = $result['data'];
2418
        $versionUsed = $result['version_used'];
2419
2420
        if (!empty($decrypted)) {
2421
            $privateKeyClear = base64_encode($decrypted);
2422
2423
            // Check if migration is needed
2424
            $needsMigration = ($versionUsed === 1);
2425
2426
            // Log if DB version doesn't match actual version used
2427
            if ($versionUsed !== $encryptionVersion && defined('LOG_TO_SERVER') && LOG_TO_SERVER === true) {
2428
                error_log('TEAMPASS Migration Notice - User ' . $userId . ' encryption_version mismatch: DB says ' . $encryptionVersion . ' but v' . $versionUsed . ' was used for decryption');
2429
            }
2430
2431
            return [
2432
                'private_key_clear' => $privateKeyClear,
2433
                'migration_error' => false,
2434
                'needs_migration' => $needsMigration,
2435
                'version_used' => $versionUsed,
2436
            ];
2437
        }
2438
2439
        // Empty decrypted data
2440
        return [
2441
            'private_key_clear' => '',
2442
            'migration_error' => true,
2443
            'needs_migration' => false,
2444
        ];
2445
2446
    } catch (Exception $e) {
2447
        // Decryption failed with both versions
2448
        if (defined('LOG_TO_SERVER') && LOG_TO_SERVER === true) {
2449
            error_log('TEAMPASS Migration Error - User ' . $userId . ' private key decryption failed: ' . $e->getMessage());
2450
        }
2451
2452
        return [
2453
            'private_key_clear' => '',
2454
            'migration_error' => true,
2455
            'needs_migration' => false,
2456
        ];
2457
    }
2458
}
2459
2460
/**
2461
 * Migrate all user keys from v1 (SHA-1) to v3 (SHA-256)
2462
 *
2463
 * This function re-encrypts:
2464
 * - private_key (with user password)
2465
 * - private_key_backup (with derived key from seed, if exists)
2466
 *
2467
 * And updates encryption_version to 3 in the database.
2468
 *
2469
 * @param int $userId User ID
2470
 * @param string $userPwd User's password (clear text)
2471
 * @param string $privateKeyClear User's private key (decrypted, base64)
2472
 * @param array $userInfo User information from database (must include user_derivation_seed if backup exists)
2473
 * @return bool True if migration successful, false otherwise
2474
 */
2475
function migrateAllUserKeysToV3(
2476
    int $userId,
2477
    string $userPwd,
2478
    string $privateKeyClear,
2479
    array $userInfo
2480
): bool {
2481
    try {
2482
        $updateData = [];
2483
2484
        // Re-encrypt private_key with SHA-256
2485
        $encrypted = \TeampassClasses\CryptoManager\CryptoManager::aesEncrypt(
2486
            base64_decode($privateKeyClear),
2487
            $userPwd,
2488
            'cbc',
2489
            'sha256' // v3 uses SHA-256
2490
        );
2491
        $updateData['private_key'] = base64_encode($encrypted);
2492
2493
        // Re-encrypt private_key_backup if it exists
2494
        if (!empty($userInfo['private_key_backup']) && !empty($userInfo['user_derivation_seed'])) {
2495
            try {
2496
                // Derive backup key (same as before, uses SHA-256 in derivation)
2497
                $configManager = new ConfigManager();
2498
                $SETTINGS = $configManager->getAllSettings();
2499
                $derivedKey = deriveBackupKey(
2500
                    $userInfo['user_derivation_seed'],
2501
                    $userInfo['public_key'],
2502
                    $SETTINGS
2503
                );
2504
2505
                // Re-encrypt backup with SHA-256
2506
                $encryptedBackup = \TeampassClasses\CryptoManager\CryptoManager::aesEncrypt(
2507
                    base64_decode($privateKeyClear),
2508
                    $derivedKey,
2509
                    'cbc',
2510
                    'sha256' // v3 uses SHA-256
2511
                );
2512
                $updateData['private_key_backup'] = base64_encode($encryptedBackup);
2513
            } catch (Exception $e) {
2514
                // Log error but don't fail the whole migration
2515
                if (defined('LOG_TO_SERVER') && LOG_TO_SERVER === true) {
2516
                    error_log('TEAMPASS Migration Warning - User ' . $userId . ' private_key_backup migration failed: ' . $e->getMessage());
2517
                }
2518
            }
2519
        }
2520
2521
        // Update encryption_version to 3
2522
        $updateData['encryption_version'] = 3;
2523
2524
        // Update database
2525
        DB::update(
2526
            prefixTable('users'),
2527
            $updateData,
2528
            'id = %i',
2529
            $userId
2530
        );
2531
2532
        // Log successful migration
2533
        if (defined('LOG_TO_SERVER') && LOG_TO_SERVER === true) {
2534
            error_log('TEAMPASS Migration Success - User ' . $userId . ' keys migrated to v3 (SHA-256)');
2535
        }
2536
2537
        return true;
2538
    } catch (Exception $e) {
2539
        // Log migration error
2540
        if (defined('LOG_TO_SERVER') && LOG_TO_SERVER === true) {
2541
            error_log('TEAMPASS Migration Error - User ' . $userId . ' - ' . $e->getMessage());
2542
        }
2543
        return false;
2544
    }
2545
}
2546
2547
/**
2548
 * Derives a backup encryption key from user seed and public key.
2549
 * Uses PBKDF2 with 100k iterations for strong key derivation.
2550
 *
2551
 * @param string $userSeed User's unique derivation seed (64 hex chars)
2552
 * @param string $publicKey User's public RSA key (base64 encoded)
2553
 * @param array $SETTINGS Teampass settings
2554
 *
2555
 * @return string Derived key (32 bytes, raw binary)
2556
 */
2557
function deriveBackupKey(string $userSeed, string $publicKey, ?array $SETTINGS = null): string
2558
{
2559
    // Sanitize inputs
2560
    $antiXss = new AntiXSS();
2561
    $userSeed = $antiXss->xss_clean($userSeed);
2562
    $publicKey = $antiXss->xss_clean($publicKey);
2563
2564
    // Use public key hash as salt for key derivation
2565
    $salt = hash('sha256', $publicKey, true);
2566
2567
    // Get PBKDF2 iterations from settings (default 100000)
2568
    $iterations = isset($SETTINGS['transparent_key_recovery_pbkdf2_iterations'])
2569
        ? (int) $SETTINGS['transparent_key_recovery_pbkdf2_iterations']
2570
        : 100000;
2571
2572
    // PBKDF2 key derivation with SHA256
2573
    return hash_pbkdf2(
2574
        'sha256',
2575
        hex2bin($userSeed),
2576
        $salt,
2577
        $iterations,
2578
        32, // 256 bits key length
2579
        true // raw binary output
2580
    );
2581
}
2582
2583
/**
2584
 * Generates key integrity hash to detect tampering.
2585
 *
2586
 * @param string $userSeed User derivation seed
2587
 * @param string $publicKey User public key
2588
 * @param string $serverSecret Server-wide secret key
2589
 *
2590
 * @return string HMAC hash (64 hex chars)
2591
 */
2592
function generateKeyIntegrityHash(string $userSeed, string $publicKey, string $serverSecret): string
2593
{
2594
    return hash_hmac('sha256', $userSeed . $publicKey, $serverSecret);
2595
}
2596
2597
/**
2598
 * Verifies key integrity to detect SQL injection or tampering.
2599
 *
2600
 * @param array $userInfo User information from database
2601
 * @param string $serverSecret Server-wide secret key
2602
 *
2603
 * @return bool True if integrity is valid
2604
 */
2605
function verifyKeyIntegrity(array $userInfo, string $serverSecret): bool
2606
{
2607
    // Skip check if no integrity hash stored (legacy users)
2608
    if (empty($userInfo['key_integrity_hash'])) {
2609
        return true;
2610
    }
2611
2612
    if (empty($userInfo['user_derivation_seed']) || empty($userInfo['public_key'])) {
2613
        return false;
2614
    }
2615
2616
    $expectedHash = generateKeyIntegrityHash(
2617
        $userInfo['user_derivation_seed'],
2618
        $userInfo['public_key'],
2619
        $serverSecret
2620
    );
2621
2622
    return hash_equals($expectedHash, $userInfo['key_integrity_hash']);
2623
}
2624
2625
/**
2626
 * Gets server secret for integrity checks.
2627
 * Reads from file or generates if not exists.
2628
 * *
2629
 * @return string Server secret key
2630
 */
2631
function getServerSecret(): string
2632
{
2633
    $ascii_key = file_get_contents(SECUREPATH.'/'.SECUREFILE);
2634
    $key = Key::loadFromAsciiSafeString($ascii_key);
2635
    return $key->saveToAsciiSafeString();
2636
}
2637
2638
/**
2639
 * Attempts transparent recovery when password change is detected.
2640
 *
2641
 * @param array $userInfo User information from database
2642
 * @param string $newPassword New password (clear)
2643
 * @param array $SETTINGS Teampass settings
2644
 *
2645
 * @return array Result with private key or error
2646
 */
2647
function attemptTransparentRecovery(array $userInfo, string $newPassword, array $SETTINGS): array
2648
{
2649
    $session = SessionManager::getSession();
2650
    try {
2651
        // Check if user has recovery data
2652
        if (empty($userInfo['user_derivation_seed']) || empty($userInfo['private_key_backup'])) {
2653
            return [
2654
                'success' => false,
2655
                'error' => 'no_recovery_data',
2656
                'private_key_clear' => '',
2657
            ];
2658
        }
2659
2660
        // Verify key integrity
2661
        $serverSecret = getServerSecret();
2662
        if (!verifyKeyIntegrity($userInfo, $serverSecret)) {
2663
            // Critical security event - integrity check failed
2664
            logEvents(
2665
                $SETTINGS,
2666
                'security_alert',
2667
                'key_integrity_check_failed',
2668
                (string) $userInfo['id'],
2669
                'User: ' . $userInfo['login']
2670
            );
2671
            return [
2672
                'success' => false,
2673
                'error' => 'integrity_check_failed',
2674
                'private_key_clear' => '',
2675
            ];
2676
        }
2677
2678
        // Derive backup key
2679
        $derivedKey = deriveBackupKey(
2680
            $userInfo['user_derivation_seed'],
2681
            $userInfo['public_key'],
2682
            $SETTINGS
2683
        );
2684
2685
        // Decrypt private key using derived key (using CryptoManager - phpseclib v3)
2686
        // Use version detection since backup may be encrypted with SHA-1 (v1) or SHA-256 (v3)
2687
        $decryptResult = \TeampassClasses\CryptoManager\CryptoManager::aesDecryptWithVersionDetection(
2688
            base64_decode($userInfo['private_key_backup']),
2689
            $derivedKey,
2690
            'cbc'
2691
        );
2692
        $privateKeyClear = base64_encode($decryptResult['data']);
2693
2694
        // Re-encrypt with new password
2695
        $newPrivateKeyEncrypted = encryptPrivateKey($newPassword, $privateKeyClear);
2696
2697
        // Re-encrypt backup with derived key (SHA-256 for v3)
2698
        $encrypted = \TeampassClasses\CryptoManager\CryptoManager::aesEncrypt(
2699
            base64_decode($privateKeyClear),
2700
            $derivedKey,
2701
            'cbc',
2702
            'sha256'
2703
        );
2704
        $newPrivateKeyBackup = base64_encode($encrypted);
2705
        
2706
        // Update database
2707
        DB::update(
2708
            prefixTable('users'),
2709
            [
2710
                'private_key' => $newPrivateKeyEncrypted,
2711
                'private_key_backup' => $newPrivateKeyBackup,
2712
                'last_pw_change' => time(),
2713
                'special' => 'none',
2714
            ],
2715
            'id = %i',
2716
            $userInfo['id']
2717
        );
2718
2719
        // Log success
2720
        logEvents(
2721
            $SETTINGS,
2722
            'user_connection',
2723
            'auto_reencryption_success',
2724
            (string) $userInfo['id'],
2725
            'User: ' . $userInfo['login']
2726
        );
2727
2728
        // Store in session for immediate use
2729
        $session->set('user-private_key', $privateKeyClear);
2730
        $session->set('user-private_key_recovered', true);
2731
2732
        return [
2733
            'success' => true,
2734
            'error' => '',
2735
            'private_key_clear' => $privateKeyClear,
2736
        ];
2737
2738
    } catch (Exception $e) {
2739
        // Log failure
2740
        logEvents(
2741
            $SETTINGS,
2742
            'security_alert',
2743
            'auto_reencryption_failed',
2744
            (string) $userInfo['id'],
2745
            'User: ' . $userInfo['login'] . ' - Error: ' . $e->getMessage()
2746
        );
2747
2748
        return [
2749
            'success' => false,
2750
            'error' => 'decryption_failed: ' . $e->getMessage(),
2751
            'private_key_clear' => '',
2752
        ];
2753
    }
2754
}
2755
2756
/**
2757
 * Handles external password change (LDAP/OAuth2) with automatic re-encryption.
2758
 *
2759
 * @param int $userId User ID
2760
 * @param string $newPassword New password (clear)
2761
 * @param array $userInfo User information from database
2762
 * @param array $SETTINGS Teampass settings
2763
 *
2764
 * @return bool True if  handled successfully
2765
 */
2766
function handleExternalPasswordChange(int $userId, string $newPassword, array $userInfo, array $SETTINGS): bool
2767
{
2768
    // Check if password was changed recently (< 20s) to avoid duplicate processing
2769
    if (!empty($userInfo['last_pw_change'])) {
2770
        $timeSinceChange = time() - (int) $userInfo['last_pw_change'];
2771
        if ($timeSinceChange < 20) { // 20 seconds
2772
            return true; // Already processed
2773
        }
2774
    }
2775
2776
    // Try to decrypt with new password first (maybe already updated)
2777
    try {
2778
        $testDecrypt = decryptPrivateKey($newPassword, $userInfo['private_key']);
2779
        if (!empty($testDecrypt)) {
2780
            // Password already works, just update timestamp
2781
            DB::update(
2782
                prefixTable('users'),
2783
                [
2784
                    'last_pw_change' => time(),
2785
                    'otp_provided' => 1,
2786
                    'special' => 'none',
2787
                ],
2788
                'id = %i',
2789
                $userId
2790
            );
2791
            return true;
2792
        }
2793
    } catch (Exception $e) {
2794
        // Expected - old password doesn't work, continue with recovery
2795
    }
2796
2797
    // Attempt transparent recovery
2798
    $result = attemptTransparentRecovery($userInfo, $newPassword, $SETTINGS);
2799
2800
    if ($result['success']) {
2801
        return true;
2802
    }
2803
2804
    // Recovery failed - disable user and alert admins
2805
    DB::update(
2806
        prefixTable('users'),
2807
        [
2808
            'disabled' => 1,
2809
            'special' => 'recrypt-private-key',
2810
        ],
2811
        'id = %i',
2812
        $userId
2813
    );
2814
2815
    // Log critical event
2816
    logEvents(
2817
        $SETTINGS,
2818
        'security_alert',
2819
        'auto_reencryption_critical_failure',
2820
        (string) $userId,
2821
        'User: ' . $userInfo['login'] . ' - disabled due to key recovery failure'
2822
    );
2823
2824
    return false;
2825
}
2826
2827
/**
2828
 * Encrypts a string using AES.
2829
 *
2830
 * @param string $data String to encrypt
2831
 * @param string $key
2832
 *
2833
 * @return array
2834
 */
2835
function doDataEncryption(string $data, ?string $key = null): array
2836
{
2837
    // Sanitize
2838
    $antiXss = new AntiXSS();
2839
    $data = $antiXss->xss_clean($data);
2840
2841
    // Generate an object key
2842
    $objectKey = is_null($key) === true ? uniqidReal(KEY_LENGTH) : $antiXss->xss_clean($key);
2843
2844
    // Encrypt using CryptoManager with CBC mode (phpseclib v3)
2845
    $encrypted = \TeampassClasses\CryptoManager\CryptoManager::aesEncrypt($data, $objectKey, 'cbc');
2846
2847
    return [
2848
        'encrypted' => base64_encode($encrypted),
2849
        'objectKey' => base64_encode($objectKey),
2850
    ];
2851
}
2852
2853
/**
2854
 * Decrypts a string using AES.
2855
 *
2856
 * @param string $data Encrypted data
2857
 * @param string $key  Key to uncrypt
2858
 *
2859
 * @return string
2860
 */
2861
function doDataDecryption(string $data, string $key): string
2862
{
2863
    // Sanitize
2864
    $antiXss = new AntiXSS();
2865
    $data = $antiXss->xss_clean($data);
2866
    $key = $antiXss->xss_clean($key);
2867
2868
    // Decrypt using CryptoManager (phpseclib v3)
2869
    $decrypted = \TeampassClasses\CryptoManager\CryptoManager::aesDecrypt(
2870
        base64_decode($data),
2871
        base64_decode($key)
2872
    );
2873
2874
    return base64_encode((string) $decrypted);
2875
}
2876
2877
/**
2878
 * Encrypts using RSA a string using a public key.
2879
 *
2880
 * @param string $key       Key to be encrypted
2881
 * @param string $publicKey User public key
2882
 *
2883
 * @return string
2884
 */
2885
function encryptUserObjectKey(string $key, string $publicKey): string
2886
{
2887
    // Empty password
2888
    if (empty($key)) return '';
2889
2890
    // Sanitize
2891
    $antiXss = new AntiXSS();
2892
    $publicKey = $antiXss->xss_clean($publicKey);
2893
2894
    // Encrypt using CryptoManager (phpseclib v3)
2895
    try {
2896
        $encrypted = \TeampassClasses\CryptoManager\CryptoManager::rsaEncrypt(
2897
            base64_decode($key),
2898
            $publicKey
2899
        );
2900
        if (empty($encrypted)) {  // Check if key is empty or null
2901
            throw new RuntimeException("Error while encrypting key.");
2902
        }
2903
        // Return
2904
        return base64_encode($encrypted);
2905
    } catch (Exception $e) {
2906
        throw new RuntimeException("Error while encrypting key: " . $e->getMessage());
2907
    }
2908
}
2909
2910
/**
2911
 * Decrypts using RSA an encrypted string using a private key.
2912
 *
2913
 * @param string $key        Encrypted key
2914
 * @param string $privateKey User private key
2915
 *
2916
 * @return string
2917
 */
2918
function decryptUserObjectKey(string $key, string $privateKey): string
2919
{
2920
    // Sanitize
2921
    $antiXss = new AntiXSS();
2922
    $privateKey = $antiXss->xss_clean($privateKey);
2923
2924
    // Decrypt using CryptoManager with backward compatibility (phpseclib v3)
2925
    try {
2926
        $decodedKey = base64_decode($key, true);
2927
        if ($decodedKey === false) {
2928
            throw new InvalidArgumentException("Error while decoding key.");
2929
        }
2930
2931
        // Use CryptoManager with automatic v1 fallback (SHA-1)
2932
        $decrypted = \TeampassClasses\CryptoManager\CryptoManager::rsaDecrypt(
2933
            $decodedKey,
2934
            $privateKey,
2935
            true  // Enable legacy v1 compatibility
2936
        );
2937
2938
        if (!empty($decrypted)) {
2939
            return base64_encode($decrypted);
2940
        } else {
2941
            return '';
2942
        }
2943
    } catch (Exception $e) {
2944
        if (defined('LOG_TO_SERVER') && LOG_TO_SERVER === true) {
2945
            error_log('TEAMPASS Error - decrypt - '.$e->getMessage());
2946
        }
2947
        return 'Exception: could not decrypt object';
2948
    }
2949
}
2950
2951
/**
2952
 * Decrypt user object key with automatic v1→v3 migration
2953
 *
2954
 * This function decrypts a sharekey and automatically re-encrypts it with phpseclib v3
2955
 * if it was encrypted with v1 (detected during decryption).
2956
 *
2957
 * @param string $encryptedKey Base64 encoded encrypted sharekey
2958
 * @param string $privateKey User's private key (PEM format)
2959
 * @param string $publicKey User's public key (PEM format) - required for migration
2960
 * @param int $sharekeyId Increment ID from sharekeys_* table - required for migration
2961
 * @param string $sharekeyTable Table name (e.g., 'sharekeys_items') - required for migration
2962
 * @return string Base64 encoded decrypted sharekey
2963
 * @throws Exception
2964
 */
2965
function decryptUserObjectKeyWithMigration(
2966
    string $encryptedKey,
2967
    string $privateKey,
2968
    string $publicKey,
2969
    int $sharekeyId,
2970
    string $sharekeyTable
2971
): string {
2972
    // Sanitize
2973
    $antiXss = new AntiXSS();
2974
    $privateKey = $antiXss->xss_clean($privateKey);
2975
2976
    try {
2977
        $decodedKey = base64_decode($encryptedKey, true);
2978
        if ($decodedKey === false) {
2979
            throw new InvalidArgumentException("Error while decoding key.");
2980
        }
2981
2982
        // Decrypt with version detection
2983
        $result = \TeampassClasses\CryptoManager\CryptoManager::rsaDecryptWithVersionDetection(
2984
            $decodedKey,
2985
            $privateKey
2986
        );
2987
2988
        $decryptedKey = $result['data'];
2989
        $versionUsed = $result['version_used'];
2990
2991
        // Automatic migration: if v1 was used, re-encrypt with v3
2992
        if ($versionUsed === 1) {
2993
            try {
2994
                migrateSharekeyToV3(
2995
                    $sharekeyId,
2996
                    $sharekeyTable,
2997
                    $decryptedKey,
2998
                    $publicKey
2999
                );
3000
            } catch (Exception $migrationError) {
3001
                // Log migration error but don't fail the decryption
3002
                if (defined('LOG_TO_SERVER') && LOG_TO_SERVER === true) {
3003
                    error_log("TEAMPASS Migration Error - {$sharekeyTable}:{$sharekeyId} - " . $migrationError->getMessage());
3004
                }
3005
            }
3006
        }
3007
3008
        if (!empty($decryptedKey)) {
3009
            return base64_encode($decryptedKey);
3010
        } else {
3011
            return '';
3012
        }
3013
    } catch (Exception $e) {
3014
        if (defined('LOG_TO_SERVER') && LOG_TO_SERVER === true) {
3015
            error_log('TEAMPASS Error - decryptWithMigration - ' . $e->getMessage());
3016
        }
3017
        return 'Exception: could not decrypt object';
3018
    }
3019
}
3020
3021
/**
3022
 * Migrate a sharekey from phpseclib v1 to v3 encryption
3023
 *
3024
 * Re-encrypts the sharekey with phpseclib v3 (SHA-256) and updates the database
3025
 *
3026
 * @param int $sharekeyId Increment ID from sharekeys_* table
3027
 * @param string $sharekeyTable Table name (e.g., 'sharekeys_items')
3028
 * @param string $decryptedKey Decrypted sharekey (raw binary)
3029
 * @param string $publicKey User's public key (PEM format)
3030
 * @return void
3031
 * @throws Exception
3032
 */
3033
function migrateSharekeyToV3(
3034
    int $sharekeyId,
3035
    string $sharekeyTable,
3036
    string $decryptedKey,
3037
    string $publicKey
3038
): void {
3039
    // Re-encrypt with v3 (uses SHA-256 by default)
3040
    $reencryptedKey = \TeampassClasses\CryptoManager\CryptoManager::rsaEncrypt(
3041
        $decryptedKey,
3042
        $publicKey
3043
    );
3044
3045
    // Update database with new v3 encrypted key
3046
    DB::update(
3047
        prefixTable($sharekeyTable),
3048
        [
3049
            'share_key' => base64_encode($reencryptedKey),
3050
            'encryption_version' => 3,
3051
        ],
3052
        'increment_id = %i',
3053
        $sharekeyId
3054
    );
3055
}
3056
3057
/**
3058
 * Update migration statistics for a sharekeys table
3059
 *
3060
 * DISABLED: This function has been disabled to improve performance.
3061
 * It was causing slowdowns by executing COUNT queries on large tables
3062
 * for each item access. Statistics can be calculated manually if needed
3063
 * using: SELECT encryption_version, COUNT(*) FROM sharekeys_* GROUP BY encryption_version
3064
 *
3065
 * @param string $sharekeyTable Table name (e.g., 'sharekeys_items')
3066
 * @return void
3067
 */
3068
/*
3069
function updateMigrationStatistics(string $sharekeyTable): void
3070
{
3071
    try {
3072
        // Calculate current statistics
3073
        $stats = DB::queryFirstRow(
3074
            'SELECT
3075
                COUNT(*) as total,
3076
                SUM(CASE WHEN encryption_version = 1 THEN 1 ELSE 0 END) as v1,
3077
                SUM(CASE WHEN encryption_version = 3 THEN 1 ELSE 0 END) as v3
3078
             FROM ' . prefixTable($sharekeyTable)
3079
        );
3080
3081
        // Update statistics table
3082
        DB::query(
3083
            'INSERT INTO ' . prefixTable('encryption_migration_stats') . '
3084
             (table_name, total_records, v1_records, v3_records)
3085
             VALUES (%s, %i, %i, %i)
3086
             ON DUPLICATE KEY UPDATE
3087
                total_records = %i,
3088
                v1_records = %i,
3089
                v3_records = %i',
3090
            $sharekeyTable,
3091
            $stats['total'],
3092
            $stats['v1'],
3093
            $stats['v3'],
3094
            $stats['total'],
3095
            $stats['v1'],
3096
            $stats['v3']
3097
        );
3098
    } catch (Exception $e) {
3099
        // Statistics update failed, but don't throw - this is not critical
3100
        if (defined('LOG_TO_SERVER') && LOG_TO_SERVER === true) {
3101
            error_log('TEAMPASS Statistics Error - ' . $e->getMessage());
3102
        }
3103
    }
3104
}
3105
*/
3106
3107
/**
3108
 * Encrypts a file.
3109
 *
3110
 * @param string $fileInName File name
3111
 * @param string $fileInPath Path to file
3112
 *
3113
 * @return array
3114
 */
3115
function encryptFile(string $fileInName, string $fileInPath): array
3116
{
3117
    if (defined('FILE_BUFFER_SIZE') === false) {
3118
        define('FILE_BUFFER_SIZE', 128 * 1024);
3119
    }
3120
3121
    // Create AES cipher using CryptoManager (phpseclib v3)
3122
    $cipher = \TeampassClasses\CryptoManager\CryptoManager::createAESCipher('cbc');
3123
3124
    // Generate an object key
3125
    $objectKey = uniqidReal(32);
3126
    // Set it as password
3127
    $cipher->setPassword($objectKey);
3128
    // Prevent against out of memory
3129
    $cipher->enableContinuousBuffer();
3130
3131
    // Encrypt the file content
3132
    $filePath = filter_var($fileInPath . '/' . $fileInName, FILTER_SANITIZE_URL);
3133
    $fileContent = file_get_contents($filePath);
3134
    $plaintext = $fileContent;
3135
    $ciphertext = $cipher->encrypt($plaintext);
3136
3137
    // Save new file
3138
    // deepcode ignore InsecureHash: is simply used to get a unique name
3139
    $hash = uniqid('', true);
3140
    $fileOut = $fileInPath . '/' . TP_FILE_PREFIX . $hash;
3141
    file_put_contents($fileOut, $ciphertext);
3142
    unlink($fileInPath . '/' . $fileInName);
3143
    return [
3144
        'fileHash' => base64_encode($hash),
3145
        'objectKey' => base64_encode($objectKey),
3146
    ];
3147
}
3148
3149
/**
3150
 * Decrypt a file.
3151
 *
3152
 * @param string $fileName File name
3153
 * @param string $filePath Path to file
3154
 * @param string $key      Key to use
3155
 *
3156
 * @return string|array
3157
 */
3158
function decryptFile(string $fileName, string $filePath, string $key): string|array
3159
{
3160
    if (! defined('FILE_BUFFER_SIZE')) {
3161
        define('FILE_BUFFER_SIZE', 128 * 1024);
3162
    }
3163
3164
    // Create AES cipher using CryptoManager (phpseclib v3)
3165
    $cipher = \TeampassClasses\CryptoManager\CryptoManager::createAESCipher('cbc');
3166
    $antiXSS = new AntiXSS();
3167
3168
    // Get file name
3169
    $safeFileName = $antiXSS->xss_clean(base64_decode($fileName));
3170
3171
    // Set the object key
3172
    $cipher->setPassword(base64_decode($key));
3173
    // Prevent against out of memory
3174
    $cipher->enableContinuousBuffer();
3175
    $cipher->disablePadding();
3176
    // Get file content
3177
    $safeFilePath = realpath($filePath . '/' . TP_FILE_PREFIX . $safeFileName);
3178
    if ($safeFilePath !== false && file_exists($safeFilePath)) {
3179
        $ciphertext = file_get_contents(filter_var($safeFilePath, FILTER_SANITIZE_URL));
3180
    } else {
3181
        // Handle the error: file doesn't exist or path is invalid
3182
        return [
3183
            'error' => true,
3184
            'message' => 'This file has not been found.',
3185
        ];
3186
    }
3187
3188
    if (WIP) error_log('DEBUG: File image url -> '.filter_var($safeFilePath, FILTER_SANITIZE_URL));
3189
3190
    // Decrypt file content and return
3191
    return base64_encode($cipher->decrypt($ciphertext));
3192
}
3193
3194
/**
3195
 * Generate a simple password
3196
 *
3197
 * @param int $length Length of string
3198
 * @param bool $symbolsincluded Allow symbols
3199
 *
3200
 * @return string
3201
 */
3202
function generateQuickPassword(int $length = 16, bool $symbolsincluded = true): string
3203
{
3204
    // Generate new user password
3205
    $small_letters = range('a', 'z');
3206
    $big_letters = range('A', 'Z');
3207
    $digits = range(0, 9);
3208
    $symbols = $symbolsincluded === true ?
3209
        ['#', '_', '-', '@', '$', '+', '!'] : [];
3210
    $res = array_merge($small_letters, $big_letters, $digits, $symbols);
3211
    $count = count($res);
3212
    // first variant
3213
3214
    $random_string = '';
3215
    for ($i = 0; $i < $length; ++$i) {
3216
        $random_string .= $res[random_int(0, $count - 1)];
3217
    }
3218
3219
    return $random_string;
3220
}
3221
3222
/**
3223
 * Permit to store the sharekey of an object for users.
3224
 *
3225
 * @param string $object_name             Type for table selection
3226
 * @param int    $post_folder_is_personal Personal
3227
 * @param int    $post_object_id          Object
3228
 * @param string $objectKey               Object key
3229
 * @param array  $SETTINGS                Teampass settings
3230
 * @param int    $user_id                 User ID if needed
3231
 * @param bool   $onlyForUser             If is TRUE, then the sharekey is only for the user
3232
 * @param bool   $deleteAll               If is TRUE, then all existing entries are deleted
3233
 * @param array  $objectKeyArray          Array of objects
3234
 * @param int    $all_users_except_id     All users except this one
3235
 * @param int    $apiUserId               API User ID
3236
 *
3237
 * @return void
3238
 */
3239
function storeUsersShareKey(
3240
    string $object_name,
3241
    int $post_folder_is_personal,
3242
    int $post_object_id,
3243
    string $objectKey,
3244
    bool $onlyForUser = false,
3245
    bool $deleteAll = true,
3246
    array $objectKeyArray = [],
3247
    int $all_users_except_id = -1,
3248
    int $apiUserId = -1
3249
): void {
3250
3251
    $session = SessionManager::getSession();
3252
    loadClasses('DB');
3253
3254
    // Get the user ID
3255
    $userId = ($apiUserId === -1) ? (int) $session->get('user-id') : $apiUserId;
0 ignored issues
show
Unused Code introduced by
The assignment to $userId is dead and can be removed.
Loading history...
3256
3257
    // Create sharekey for each user
3258
    $user_ids = [OTV_USER_ID, SSH_USER_ID, API_USER_ID];
3259
    if ($all_users_except_id !== -1) {
3260
        array_push($user_ids, (int) $all_users_except_id);
3261
    }
3262
    $users = DB::query(
3263
        'SELECT id, public_key
3264
        FROM ' . prefixTable('users') . '
3265
        WHERE id NOT IN %li
3266
        AND public_key != ""',
3267
        $user_ids
3268
    );
3269
3270
    // Insert or update sharekeys first, track which users were processed
3271
    $processedUserIds = [];
3272
    foreach ($users as $user) {
3273
        // Insert in DB the new object key for this item by user
3274
        if (count($objectKeyArray) === 0) {
3275
            if (WIP === true) {
3276
                error_log('TEAMPASS Debug - storeUsersShareKey case1 - ' . $object_name . ' - ' . $post_object_id . ' - ' . $user['id']);
3277
            }
3278
3279
            insertOrUpdateSharekey(
3280
                prefixTable($object_name),
3281
                $post_object_id,
3282
                (int) $user['id'],
3283
                encryptUserObjectKey($objectKey, $user['public_key'])
3284
            );
3285
        } else {
3286
            foreach ($objectKeyArray as $object) {
3287
                if (WIP === true) {
3288
                    error_log('TEAMPASS Debug - storeUsersShareKey case2 - ' . $object_name . ' - ' . $object['objectId'] . ' - ' . $user['id']);
3289
                }
3290
3291
                insertOrUpdateSharekey(
3292
                    prefixTable($object_name),
3293
                    (int) $object['objectId'],
3294
                    (int) $user['id'],
3295
                    encryptUserObjectKey($object['objectKey'], $user['public_key'])
3296
                );
3297
            }
3298
        }
3299
        $processedUserIds[] = (int) $user['id'];
3300
    }
3301
3302
    // Remove stale sharekeys for users who no longer qualify
3303
    // This replaces the previous DELETE-all-then-INSERT pattern which
3304
    // created a race condition window where all sharekeys were absent.
3305
    if ($deleteAll === true) {
3306
        if (!empty($processedUserIds)) {
3307
            DB::query(
3308
                'DELETE FROM ' . prefixTable($object_name) . '
3309
                WHERE object_id = %i AND user_id NOT IN %li',
3310
                $post_object_id,
3311
                $processedUserIds
3312
            );
3313
        } else {
3314
            // No eligible users found: remove all sharekeys for this object
3315
            DB::delete(
3316
                prefixTable($object_name),
3317
                'object_id = %i',
3318
                $post_object_id
3319
            );
3320
        }
3321
    }
3322
}
3323
3324
/**
3325
 * Insert or update sharekey for a user
3326
 * Handles duplicate key errors gracefully
3327
 * 
3328
 * @param string $tableName Table name (with prefix)
3329
 * @param int $objectId Object ID
3330
 * @param int $userId User ID
3331
 * @param string $shareKey Encrypted share key
3332
 * @return bool Success status
3333
 */
3334
function insertOrUpdateSharekey(
3335
    string $tableName,
3336
    int $objectId,
3337
    int $userId,
3338
    string $shareKey
3339
): bool {
3340
    try {
3341
        DB::query(
3342
            'INSERT INTO ' . $tableName . ' 
3343
            (object_id, user_id, share_key, encryption_version) 
3344
            VALUES (%i, %i, %s, %i)
3345
            ON DUPLICATE KEY UPDATE share_key = VALUES(share_key)',
3346
            $objectId,
3347
            $userId,
3348
            $shareKey,
3349
            3
3350
        );
3351
        return true;
3352
    } catch (Exception $e) {
3353
        error_log('TEAMPASS Error - insertOrUpdateSharekey: ' . $e->getMessage());
3354
        return false;
3355
    }
3356
}
3357
3358
/**
3359
 * Is this string base64 encoded?
3360
 *
3361
 * @param string $str Encoded string?
3362
 *
3363
 * @return bool
3364
 */
3365
function isBase64(string $str): bool
3366
{
3367
    $str = (string) trim($str);
3368
    if (! isset($str[0])) {
3369
        return false;
3370
    }
3371
3372
    $base64String = (string) base64_decode($str, true);
3373
    if ($base64String && base64_encode($base64String) === $str) {
3374
        return true;
3375
    }
3376
3377
    return false;
3378
}
3379
3380
/**
3381
 * Undocumented function
3382
 *
3383
 * @param string $field Parameter
3384
 *
3385
 * @return array|bool|resource|string
3386
 */
3387
function filterString(string $field)
3388
{
3389
    // Sanitize string
3390
    $field = filter_var(trim($field), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
3391
    if (empty($field) === false) {
3392
        // Load AntiXSS
3393
        $antiXss = new AntiXSS();
3394
        // Return
3395
        return $antiXss->xss_clean($field);
3396
    }
3397
3398
    return false;
3399
}
3400
3401
/**
3402
 * CHeck if provided credentials are allowed on server
3403
 *
3404
 * @param string $login    User Login
3405
 * @param string $password User Pwd
3406
 * @param array  $SETTINGS Teampass settings
3407
 *
3408
 * @return bool
3409
 */
3410
function ldapCheckUserPassword(string $login, string $password, array $SETTINGS): bool
3411
{
3412
    // Build ldap configuration array
3413
    $config = [
3414
        // Mandatory Configuration Options
3415
        'hosts' => [$SETTINGS['ldap_hosts']],
3416
        'base_dn' => $SETTINGS['ldap_bdn'],
3417
        'username' => $SETTINGS['ldap_username'],
3418
        'password' => $SETTINGS['ldap_password'],
3419
3420
        // Optional Configuration Options
3421
        'port' => $SETTINGS['ldap_port'],
3422
        'use_ssl' => (int) $SETTINGS['ldap_ssl'] === 1 ? true : false,
3423
        'use_tls' => (int) $SETTINGS['ldap_tls'] === 1 ? true : false,
3424
        'version' => 3,
3425
        'timeout' => 5,
3426
        'follow_referrals' => false,
3427
3428
        // Custom LDAP Options
3429
        'options' => [
3430
            // See: http://php.net/ldap_set_option
3431
            LDAP_OPT_X_TLS_REQUIRE_CERT => (isset($SETTINGS['ldap_tls_certificate_check']) ? $SETTINGS['ldap_tls_certificate_check'] : LDAP_OPT_X_TLS_HARD),
3432
        ],
3433
    ];
3434
    
3435
    $connection = new Connection($config);
3436
    // Connect to LDAP
3437
    try {
3438
        $connection->connect();
3439
    } catch (\LdapRecord\Auth\BindException $e) {
3440
        $error = $e->getDetailedError();
3441
        if ($error && defined('LOG_TO_SERVER') && LOG_TO_SERVER === true) {
3442
            error_log('TEAMPASS Error - LDAP - '.$error->getErrorCode()." - ".$error->getErrorMessage(). " - ".$error->getDiagnosticMessage());
3443
        }
3444
        // deepcode ignore ServerLeak: No important data is sent
3445
        echo 'An error occurred.';
3446
        return false;
3447
    }
3448
3449
    // Authenticate user
3450
    try {
3451
        if ($SETTINGS['ldap_type'] === 'ActiveDirectory') {
3452
            $connection->auth()->attempt($login, $password, $stayAuthenticated = true);
3453
        } else {
3454
            $connection->auth()->attempt($SETTINGS['ldap_user_attribute'].'='.$login.','.(isset($SETTINGS['ldap_dn_additional_user_dn']) && !empty($SETTINGS['ldap_dn_additional_user_dn']) ? $SETTINGS['ldap_dn_additional_user_dn'].',' : '').$SETTINGS['ldap_bdn'], $password, $stayAuthenticated = true);
3455
        }
3456
    } catch (\LdapRecord\Auth\BindException $e) {
3457
        $error = $e->getDetailedError();
3458
        if ($error && defined('LOG_TO_SERVER') && LOG_TO_SERVER === true) {
3459
            error_log('TEAMPASS Error - LDAP - '.$error->getErrorCode()." - ".$error->getErrorMessage(). " - ".$error->getDiagnosticMessage());
3460
        }
3461
        // deepcode ignore ServerLeak: No important data is sent
3462
        echo 'An error occurred.';
3463
        return false;
3464
    }
3465
3466
    return true;
3467
}
3468
3469
/**
3470
 * Removes from DB all sharekeys of this user
3471
 *
3472
 * @param int $userId User's id
3473
 * @param array   $SETTINGS Teampass settings
3474
 *
3475
 * @return bool
3476
 */
3477
function deleteUserObjetsKeys(int $userId, array $SETTINGS = []): bool
3478
{
3479
    // Return if technical accounts
3480
    if ($userId === OTV_USER_ID
3481
        || $userId === SSH_USER_ID
0 ignored issues
show
introduced by
The condition $userId === SSH_USER_ID is always false.
Loading history...
3482
        || $userId === API_USER_ID
0 ignored issues
show
introduced by
The condition $userId === API_USER_ID is always false.
Loading history...
3483
        || $userId === TP_USER_ID
0 ignored issues
show
introduced by
The condition $userId === TP_USER_ID is always false.
Loading history...
3484
    ) {
3485
        return false;
3486
    }
3487
3488
    // Load class DB
3489
    loadClasses('DB');
3490
3491
    // Remove all item sharekeys items
3492
    // expect if personal item
3493
    DB::delete(
3494
        prefixTable('sharekeys_items'),
3495
        'user_id = %i',// AND object_id NOT IN (SELECT i.id FROM ' . prefixTable('items') . ' AS i WHERE i.perso = 1)'',
3496
        $userId
3497
    );
3498
    // Remove all item sharekeys files
3499
    DB::delete(
3500
        prefixTable('sharekeys_files'),
3501
        'user_id = %i',
3502
        $userId
3503
    );
3504
    // Remove all item sharekeys fields
3505
    DB::delete(
3506
        prefixTable('sharekeys_fields'),
3507
        'user_id = %i',
3508
        $userId
3509
    );
3510
    // Remove all item sharekeys logs
3511
    DB::delete(
3512
        prefixTable('sharekeys_logs'),
3513
        'user_id = %i', // AND object_id NOT IN (SELECT i.id FROM ' . prefixTable('items') . ' AS i WHERE i.perso = 1)',
3514
        $userId
3515
    );
3516
    // Remove all item sharekeys suggestions
3517
    DB::delete(
3518
        prefixTable('sharekeys_suggestions'),
3519
        'user_id = %i',// AND object_id NOT IN (SELECT i.id FROM ' . prefixTable('items') . ' AS i WHERE i.perso = 1)',
3520
        $userId
3521
    );
3522
    return false;
3523
}
3524
3525
/**
3526
 * Manage list of timezones   $SETTINGS Teampass settings
3527
 *
3528
 * @return array
3529
 */
3530
function timezone_list()
3531
{
3532
    static $timezones = null;
3533
    if ($timezones === null) {
3534
        $timezones = [];
3535
        $offsets = [];
3536
        $now = new DateTime('now', new DateTimeZone('UTC'));
3537
        foreach (DateTimeZone::listIdentifiers() as $timezone) {
3538
            $now->setTimezone(new DateTimeZone($timezone));
3539
            $offsets[] = $offset = $now->getOffset();
3540
            $timezones[$timezone] = '(' . format_GMT_offset($offset) . ') ' . format_timezone_name($timezone);
3541
        }
3542
3543
        array_multisort($offsets, $timezones);
3544
    }
3545
3546
    return $timezones;
3547
}
3548
3549
/**
3550
 * Provide timezone offset
3551
 *
3552
 * @param int $offset Timezone offset
3553
 *
3554
 * @return string
3555
 */
3556
function format_GMT_offset($offset): string
3557
{
3558
    $hours = intval($offset / 3600);
3559
    $minutes = abs(intval($offset % 3600 / 60));
3560
    return 'GMT' . ($offset ? sprintf('%+03d:%02d', $hours, $minutes) : '');
3561
}
3562
3563
/**
3564
 * Provides timezone name
3565
 *
3566
 * @param string $name Timezone name
3567
 *
3568
 * @return string
3569
 */
3570
function format_timezone_name($name): string
3571
{
3572
    $name = str_replace('/', ', ', $name);
3573
    $name = str_replace('_', ' ', $name);
3574
3575
    return str_replace('St ', 'St. ', $name);
3576
}
3577
3578
/**
3579
 * Provides info if user should use MFA based on roles
3580
 *
3581
 * @param string $userRolesIds  User roles ids
3582
 * @param string $mfaRoles      Roles for which MFA is requested
3583
 *
3584
 * @return bool
3585
 */
3586
function mfa_auth_requested_roles(string $userRolesIds, string $mfaRoles): bool
3587
{
3588
    if (empty($mfaRoles) === true) {
3589
        return true;
3590
    }
3591
3592
    $mfaRoles = array_values(json_decode($mfaRoles, true));
3593
    $userRolesIds = array_filter(explode(';', $userRolesIds));
3594
    if (count($mfaRoles) === 0 || count(array_intersect($mfaRoles, $userRolesIds)) > 0) {
3595
        return true;
3596
    }
3597
3598
    return false;
3599
}
3600
3601
/**
3602
 * Permits to clean a string for export purpose
3603
 *
3604
 * @param string $text
3605
 * @param bool $emptyCheckOnly
3606
 * 
3607
 * @return string
3608
 */
3609
function cleanStringForExport(string $text, bool $emptyCheckOnly = false): string
3610
{
3611
    if (is_null($text) === true || empty($text) === true) {
3612
        return '';
3613
    }
3614
    // only expected to check if $text was empty
3615
    elseif ($emptyCheckOnly === true) {
3616
        return $text;
3617
    }
3618
3619
    return strip_tags(
3620
        cleanString(
3621
            html_entity_decode($text, ENT_QUOTES | ENT_XHTML, 'UTF-8'),
3622
            true)
3623
        );
3624
}
3625
3626
/**
3627
 * Permits to check if user ID is valid
3628
 *
3629
 * @param integer $post_user_id
3630
 * @return bool
3631
 */
3632
function isUserIdValid($userId): bool
3633
{
3634
    if (is_null($userId) === false
3635
        && empty($userId) === false
3636
    ) {
3637
        return true;
3638
    }
3639
    return false;
3640
}
3641
3642
/**
3643
 * Check if a key exists and if its value equal the one expected
3644
 *
3645
 * @param string $key
3646
 * @param integer|string $value
3647
 * @param array $array
3648
 * 
3649
 * @return boolean
3650
 */
3651
function isKeyExistingAndEqual(
3652
    string $key,
3653
    /*PHP8 - integer|string*/$value,
3654
    array $array
3655
): bool
3656
{
3657
    if (isset($array[$key]) === true
3658
        && (is_int($value) === true ?
3659
            (int) $array[$key] === $value :
3660
            (string) $array[$key] === $value)
3661
    ) {
3662
        return true;
3663
    }
3664
    return false;
3665
}
3666
3667
/**
3668
 * Check if a variable is not set or equal to a value
3669
 *
3670
 * @param string|null $var
3671
 * @param integer|string $value
3672
 * 
3673
 * @return boolean
3674
 */
3675
function isKeyNotSetOrEqual(
3676
    /*PHP8 - string|null*/$var,
3677
    /*PHP8 - integer|string*/$value
3678
): bool
3679
{
3680
    if (isset($var) === false
3681
        || (is_int($value) === true ?
3682
            (int) $var === $value :
3683
            (string) $var === $value)
3684
    ) {
3685
        return true;
3686
    }
3687
    return false;
3688
}
3689
3690
/**
3691
 * Check if a key exists and if its value < to the one expected
3692
 *
3693
 * @param string $key
3694
 * @param integer $value
3695
 * @param array $array
3696
 * 
3697
 * @return boolean
3698
 */
3699
function isKeyExistingAndInferior(string $key, int $value, array $array): bool
3700
{
3701
    if (isset($array[$key]) === true && (int) $array[$key] < $value) {
3702
        return true;
3703
    }
3704
    return false;
3705
}
3706
3707
/**
3708
 * Check if a key exists and if its value > to the one expected
3709
 *
3710
 * @param string $key
3711
 * @param integer $value
3712
 * @param array $array
3713
 * 
3714
 * @return boolean
3715
 */
3716
function isKeyExistingAndSuperior(string $key, int $value, array $array): bool
3717
{
3718
    if (isset($array[$key]) === true && (int) $array[$key] > $value) {
3719
        return true;
3720
    }
3721
    return false;
3722
}
3723
3724
/**
3725
 * Check if values in array are set
3726
 * Return true if all set
3727
 * Return false if one of them is not set
3728
 *
3729
 * @param array $arrayOfValues
3730
 * @return boolean
3731
 */
3732
function isSetArrayOfValues(array $arrayOfValues): bool
3733
{
3734
    foreach($arrayOfValues as $value) {
3735
        if (isset($value) === false) {
3736
            return false;
3737
        }
3738
    }
3739
    return true;
3740
}
3741
3742
/**
3743
 * Check if values in array are set
3744
 * Return true if all set
3745
 * Return false if one of them is not set
3746
 *
3747
 * @param array $arrayOfValues
3748
 * @param integer|string $value
3749
 * @return boolean
3750
 */
3751
function isArrayOfVarsEqualToValue(
3752
    array $arrayOfVars,
3753
    /*PHP8 - integer|string*/$value
3754
) : bool
3755
{
3756
    foreach($arrayOfVars as $variable) {
3757
        if ($variable !== $value) {
3758
            return false;
3759
        }
3760
    }
3761
    return true;
3762
}
3763
3764
/**
3765
 * Checks if at least one variable in array is equal to value
3766
 *
3767
 * @param array $arrayOfValues
3768
 * @param integer|string $value
3769
 * @return boolean
3770
 */
3771
function isOneVarOfArrayEqualToValue(
3772
    array $arrayOfVars,
3773
    /*PHP8 - integer|string*/$value
3774
) : bool
3775
{
3776
    foreach($arrayOfVars as $variable) {
3777
        if ($variable === $value) {
3778
            return true;
3779
        }
3780
    }
3781
    return false;
3782
}
3783
3784
/**
3785
 * Checks is value is null, not set OR empty
3786
 *
3787
 * @param string|int|null $value
3788
 * @return boolean
3789
 */
3790
function isValueSetNullEmpty(string|int|null $value) : bool
3791
{
3792
    if (is_null($value) === true || empty($value) === true) {
3793
        return true;
3794
    }
3795
    return false;
3796
}
3797
3798
/**
3799
 * Checks if value is set and if empty is equal to passed boolean
3800
 *
3801
 * @param string|int $value
3802
 * @param boolean $boolean
3803
 * @return boolean
3804
 */
3805
function isValueSetEmpty($value, $boolean = true) : bool
3806
{
3807
    if (empty($value) === $boolean) {
3808
        return true;
3809
    }
3810
    return false;
3811
}
3812
3813
/**
3814
 * Ensure Complexity is translated
3815
 *
3816
 * @return void
3817
 */
3818
function defineComplexity() : void
3819
{
3820
    // Load user's language
3821
    $session = SessionManager::getSession();
3822
    $lang = new Language($session->get('user-language') ?? 'english');
3823
    
3824
    if (defined('TP_PW_COMPLEXITY') === false) {
3825
        define(
3826
            'TP_PW_COMPLEXITY',
3827
            [
3828
                TP_PW_STRENGTH_1 => array(TP_PW_STRENGTH_1, $lang->get('complex_level1'), 'fas fa-thermometer-empty text-danger'),
3829
                TP_PW_STRENGTH_2 => array(TP_PW_STRENGTH_2, $lang->get('complex_level2'), 'fas fa-thermometer-quarter text-warning'),
3830
                TP_PW_STRENGTH_3 => array(TP_PW_STRENGTH_3, $lang->get('complex_level3'), 'fas fa-thermometer-half text-warning'),
3831
                TP_PW_STRENGTH_4 => array(TP_PW_STRENGTH_4, $lang->get('complex_level4'), 'fas fa-thermometer-three-quarters text-success'),
3832
                TP_PW_STRENGTH_5 => array(TP_PW_STRENGTH_5, $lang->get('complex_level5'), 'fas fa-thermometer-full text-success'),
3833
            ]
3834
        );
3835
    }
3836
}
3837
3838
/**
3839
 * Uses Sanitizer to perform data sanitization
3840
 *
3841
 * @param array     $data
3842
 * @param array     $filters
3843
 * @return array|string
3844
 */
3845
function dataSanitizer(array $data, array $filters): array|string
3846
{
3847
    // Load Sanitizer library
3848
    $sanitizer = new Sanitizer($data, $filters);
3849
3850
    // Load AntiXSS
3851
    $antiXss = new AntiXSS();
3852
3853
    // Sanitize post and get variables
3854
    return $antiXss->xss_clean($sanitizer->sanitize());
3855
}
3856
3857
/**
3858
 * Recursively cleans data using AntiXSS library
3859
 * Handles strings, arrays, and objects
3860
 *
3861
 * @param mixed $data The data to clean (string, array, or object)
3862
 * @param AntiXSS $antiXss The AntiXSS instance to use
3863
 * @return mixed The cleaned data
3864
 */
3865
function secureStringWithAntiXss(mixed $data, AntiXSS $antiXss): mixed
3866
{
3867
    if (is_string($data)) {
3868
        return $antiXss->xss_clean($data);
3869
    }
3870
3871
    if (is_array($data)) {
3872
        foreach ($data as $key => $value) {
3873
            $data[$key] = secureStringWithAntiXss($value, $antiXss);
3874
        }
3875
        return $data;
3876
    }
3877
3878
    if (is_object($data)) {
3879
        foreach (get_object_vars($data) as $key => $value) {
3880
            $data->$key = secureStringWithAntiXss($value, $antiXss);
3881
        }
3882
        return $data;
3883
    }
3884
3885
    return $data;
3886
}
3887
3888
/**
3889
 * Clean output data to prevent XSS attacks
3890
 * Applies htmlspecialchars with UTF-8 encoding
3891
 *
3892
 * @param mixed $data The data to secure (array or string)
3893
 * @param array $fields Fields to sanitize (for arrays only)
3894
 * @return mixed The secured data
3895
 */
3896
function secureOutput(mixed $data, array $fields = []): mixed
3897
{
3898
    if (is_array($data)) {
3899
        foreach ($fields as $field) {
3900
            if (isset($data[$field]) && is_string($data[$field])) {
3901
                $data[$field] = htmlspecialchars($data[$field], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
3902
            }
3903
        }
3904
        return $data;
3905
    }
3906
3907
    if (is_string($data)) {
3908
        return htmlspecialchars($data, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
3909
    }
3910
3911
    return $data;
3912
}
3913
3914
/**
3915
 * Permits to manage the cache tree for a user
3916
 *
3917
 * @param integer $user_id
3918
 * @param string $data
3919
 * @param array $SETTINGS
3920
 * @param string $field_update
3921
 * @return void
3922
 */
3923
function cacheTreeUserHandler(int $user_id, string $data, array $SETTINGS, string $field_update = '')
3924
{
3925
    // Load class DB
3926
    loadClasses('DB');
3927
3928
    // Exists ?
3929
    $userCacheId = DB::queryFirstRow(
3930
        'SELECT increment_id
3931
        FROM ' . prefixTable('cache_tree') . '
3932
        WHERE user_id = %i',
3933
        $user_id
3934
    );
3935
    
3936
    if (is_null($userCacheId) === true || count($userCacheId) === 0) {
3937
        // insert in table
3938
        DB::insert(
3939
            prefixTable('cache_tree'),
3940
            array(
3941
                'data' => $data,
3942
                'timestamp' => time(),
3943
                'user_id' => $user_id,
3944
                'visible_folders' => '',
3945
            )
3946
        );
3947
    } else {
3948
        if (empty($field_update) === true) {
3949
            DB::update(
3950
                prefixTable('cache_tree'),
3951
                [
3952
                    'timestamp' => time(),
3953
                    'data' => $data,
3954
                ],
3955
                'increment_id = %i',
3956
                $userCacheId['increment_id']
3957
            );
3958
        /* USELESS
3959
        } else {
3960
            DB::update(
3961
                prefixTable('cache_tree'),
3962
                [
3963
                    $field_update => $data,
3964
                ],
3965
                'increment_id = %i',
3966
                $userCacheId['increment_id']
3967
            );*/
3968
        }
3969
    }
3970
}
3971
3972
/**
3973
 * Permits to calculate a %
3974
 *
3975
 * @param float $nombre
3976
 * @param float $total
3977
 * @param float $pourcentage
3978
 * @return float
3979
 */
3980
function pourcentage(float $nombre, float $total, float $pourcentage): float
3981
{ 
3982
    $resultat = ($nombre/$total) * $pourcentage;
3983
    return round($resultat);
3984
}
3985
3986
/**
3987
 * Load the folders list from the cache
3988
 *
3989
 * @param string $fieldName
3990
 * @param string $sessionName
3991
 * @param boolean $forceRefresh
3992
 * @return array
3993
 */
3994
function loadFoldersListByCache(
3995
    string $fieldName,
3996
    string $sessionName,
3997
    bool $forceRefresh = false
3998
): array
3999
{
4000
    // Case when refresh is EXPECTED / MANDATORY
4001
    if ($forceRefresh === true) {
4002
        return [
4003
            'state' => false,
4004
            'data' => [],
4005
        ];
4006
    }
4007
    
4008
    $session = SessionManager::getSession();
4009
4010
    // Get last folder update
4011
    $lastFolderChange = DB::queryFirstRow(
4012
        'SELECT valeur FROM ' . prefixTable('misc') . '
4013
        WHERE type = %s AND intitule = %s',
4014
        'timestamp',
4015
        'last_folder_change'
4016
    );
4017
    if (DB::count() === 0) {
4018
        $lastFolderChange['valeur'] = 0;
4019
    }
4020
4021
    // Case when an update in the tree has been done
4022
    // Refresh is then mandatory
4023
    if ((int) $lastFolderChange['valeur'] > (int) (null !== $session->get('user-tree_last_refresh_timestamp') ? $session->get('user-tree_last_refresh_timestamp') : 0)) {
4024
        return [
4025
            'state' => false,
4026
            'data' => [],
4027
        ];
4028
    }
4029
    
4030
    // Does this user has a tree cache
4031
    $userCacheTree = DB::queryFirstRow(
4032
        'SELECT '.$fieldName.'
4033
        FROM ' . prefixTable('cache_tree') . '
4034
        WHERE user_id = %i',
4035
        $session->get('user-id')
4036
    );
4037
    if (empty($userCacheTree[$fieldName]) === false && $userCacheTree[$fieldName] !== '[]') {
4038
        return [
4039
            'state' => true,
4040
            'data' => $userCacheTree[$fieldName],
4041
            'extra' => '',
4042
        ];
4043
    }
4044
4045
    return [
4046
        'state' => false,
4047
        'data' => [],
4048
    ];
4049
}
4050
4051
4052
/**
4053
 * Permits to refresh the categories of folders
4054
 *
4055
 * @param array $folderIds
4056
 * @return void
4057
 */
4058
function handleFoldersCategories(
4059
    array $folderIds
4060
)
4061
{
4062
    // Load class DB
4063
    loadClasses('DB');
4064
4065
    $arr_data = array();
4066
4067
    // force full list of folders
4068
    if (count($folderIds) === 0) {
4069
        $folderIds = DB::queryFirstColumn(
4070
            'SELECT id
4071
            FROM ' . prefixTable('nested_tree') . '
4072
            WHERE personal_folder=%i',
4073
            0
4074
        );
4075
    }
4076
4077
    // Get complexity
4078
    defineComplexity();
4079
4080
    // update
4081
    foreach ($folderIds as $folder) {
4082
        // Do we have Categories
4083
        // get list of associated Categories
4084
        $arrCatList = array();
4085
        $rows_tmp = DB::query(
4086
            'SELECT c.id, c.title, c.level, c.type, c.masked, c.order, c.encrypted_data, c.role_visibility, c.is_mandatory,
4087
            f.id_category AS category_id
4088
            FROM ' . prefixTable('categories_folders') . ' AS f
4089
            INNER JOIN ' . prefixTable('categories') . ' AS c ON (f.id_category = c.parent_id)
4090
            WHERE id_folder=%i',
4091
            $folder
4092
        );
4093
        if (DB::count() > 0) {
4094
            foreach ($rows_tmp as $row) {
4095
                $arrCatList[$row['id']] = array(
4096
                    'id' => $row['id'],
4097
                    'title' => $row['title'],
4098
                    'level' => $row['level'],
4099
                    'type' => $row['type'],
4100
                    'masked' => $row['masked'],
4101
                    'order' => $row['order'],
4102
                    'encrypted_data' => $row['encrypted_data'],
4103
                    'role_visibility' => $row['role_visibility'],
4104
                    'is_mandatory' => $row['is_mandatory'],
4105
                    'category_id' => $row['category_id'],
4106
                );
4107
            }
4108
        }
4109
        $arr_data['categories'] = $arrCatList;
4110
4111
        // Now get complexity
4112
        $valTemp = '';
4113
        $data = DB::queryFirstRow(
4114
            'SELECT valeur
4115
            FROM ' . prefixTable('misc') . '
4116
            WHERE type = %s AND intitule=%i',
4117
            'complex',
4118
            $folder
4119
        );
4120
        if (DB::count() > 0 && empty($data['valeur']) === false) {
4121
            $valTemp = array(
4122
                'value' => $data['valeur'],
4123
                'text' => TP_PW_COMPLEXITY[$data['valeur']][1],
4124
            );
4125
        }
4126
        $arr_data['complexity'] = $valTemp;
4127
4128
        // Now get Roles
4129
        $valTemp = '';
4130
        $rows_tmp = DB::query(
4131
            'SELECT t.title
4132
            FROM ' . prefixTable('roles_values') . ' as v
4133
            INNER JOIN ' . prefixTable('roles_title') . ' as t ON (v.role_id = t.id)
4134
            WHERE v.folder_id = %i
4135
            GROUP BY title',
4136
            $folder
4137
        );
4138
        foreach ($rows_tmp as $record) {
4139
            $valTemp .= (empty($valTemp) === true ? '' : ' - ') . $record['title'];
4140
        }
4141
        $arr_data['visibilityRoles'] = $valTemp;
4142
4143
        // now save in DB
4144
        DB::update(
4145
            prefixTable('nested_tree'),
4146
            array(
4147
                'categories' => json_encode($arr_data),
4148
            ),
4149
            'id = %i',
4150
            $folder
4151
        );
4152
    }
4153
}
4154
4155
/**
4156
 * List all users that have specific roles
4157
 *
4158
 * @param array $roles
4159
 * @return array
4160
 */
4161
function getUsersWithRoles(
4162
    array $roles
4163
): array
4164
{
4165
    $session = SessionManager::getSession();
4166
    $arrUsers = array();
4167
4168
    foreach ($roles as $role) {
4169
        // loop on users and check if user has this role
4170
        $rows = DB::query(
4171
            'SELECT u.id,
4172
            GROUP_CONCAT(ur.role_id ORDER BY ur.role_id SEPARATOR ";") AS fonction_id
4173
            FROM ' . prefixTable('users') . ' AS u
4174
            INNER JOIN ' . prefixTable('users_roles') . ' AS ur 
4175
                ON ur.user_id = u.id AND ur.source = "manual"
4176
            WHERE u.id != %i AND u.admin = 0
4177
            GROUP BY u.id',
4178
            $session->get('user-id')
4179
        );
4180
        foreach ($rows as $user) {
4181
            $userRoles = empty($user['fonction_id']) ? [] : array_map('intval', explode(';', (string) $user['fonction_id']));
4182
            if (in_array((int) $role, $userRoles, true) === true) {
4183
                array_push($arrUsers, $user['id']);
4184
            }
4185
        }
4186
    }
4187
    
4188
    return $arrUsers;
4189
}
4190
4191
4192
/**
4193
 * Get all users informations
4194
 *
4195
 * @param integer $userId
4196
 * @return array
4197
 */
4198
function getFullUserInfos(
4199
    int $userId
4200
): array
4201
{
4202
    if (empty($userId) === true) {
4203
        return array();
4204
    }
4205
4206
    $val = DB::queryFirstRow(
4207
        'SELECT *
4208
        FROM ' . prefixTable('users') . '
4209
        WHERE id = %i',
4210
        $userId
4211
    );
4212
4213
    return $val;
4214
}
4215
4216
/**
4217
 * Is required an upgrade
4218
 *
4219
 * @return boolean
4220
 */
4221
function upgradeRequired(): bool
4222
{
4223
    // Get settings.php
4224
    include_once __DIR__. '/../includes/config/settings.php';
4225
4226
    // Get timestamp in DB
4227
    $val = DB::queryFirstRow(
4228
        'SELECT valeur
4229
        FROM ' . prefixTable('misc') . '
4230
        WHERE type = %s AND intitule = %s',
4231
        'admin',
4232
        'upgrade_timestamp'
4233
    );
4234
4235
    // Check if upgrade is required
4236
    return (
4237
        is_null($val) || count($val) === 0 || !defined('UPGRADE_MIN_DATE') || 
4238
        empty($val['valeur']) || (int) $val['valeur'] < (int) UPGRADE_MIN_DATE
4239
    );
4240
}
4241
4242
/**
4243
 * Permits to change the user keys on his demand
4244
 *
4245
 * @param integer $userId
4246
 * @param string $passwordClear
4247
 * @param integer $nbItemsToTreat
4248
 * @param string $encryptionKey
4249
 * @param boolean $deleteExistingKeys
4250
 * @param boolean $sendEmailToUser
4251
 * @param boolean $encryptWithUserPassword
4252
 * @param boolean $generate_user_new_password
4253
 * @param string $emailBody
4254
 * @param boolean $user_self_change
4255
 * @param string $recovery_public_key
4256
 * @param string $recovery_private_key
4257
 * @param bool $userHasToEncryptPersonalItemsAfter
4258
 * @return string
4259
 */
4260
function handleUserKeys(
4261
    int $userId,
4262
    string $passwordClear,
4263
    int $nbItemsToTreat,
4264
    string $encryptionKey = '',
4265
    bool $deleteExistingKeys = false,
4266
    bool $sendEmailToUser = true,
4267
    bool $encryptWithUserPassword = false,
4268
    bool $generate_user_new_password = false,
4269
    string $emailBody = '',
4270
    bool $user_self_change = false,
4271
    string $recovery_public_key = '',
4272
    string $recovery_private_key = '',
4273
    bool $userHasToEncryptPersonalItemsAfter = false
4274
): string
4275
{
4276
    $session = SessionManager::getSession();
4277
    $lang = new Language($session->get('user-language') ?? 'english');
4278
4279
    // prepapre background tasks for item keys generation        
4280
    $userTP = DB::queryFirstRow(
4281
        'SELECT pw, public_key, private_key
4282
        FROM ' . prefixTable('users') . '
4283
        WHERE id = %i',
4284
        TP_USER_ID
4285
    );
4286
    if (DB::count() === 0) {
4287
        return prepareExchangedData(
4288
            array(
4289
                'error' => true,
4290
                'message' => 'User not exists',
4291
            ),
4292
            'encode'
4293
        );
4294
    }
4295
4296
    // Do we need to generate new user password
4297
    if ($generate_user_new_password === true) {
4298
        // Generate a new password
4299
        $passwordClear = GenerateCryptKey(20, false, true, true, false, true);
4300
    }
4301
4302
    // Create password hash
4303
    $passwordManager = new PasswordManager();
4304
    $hashedPassword = $passwordManager->hashPassword($passwordClear);
4305
    if ($passwordManager->verifyPassword($hashedPassword, $passwordClear) === false) {
4306
        return prepareExchangedData(
4307
            array(
4308
                'error' => true,
4309
                'message' => $lang->get('pw_hash_not_correct'),
4310
            ),
4311
            'encode'
4312
        );
4313
    }
4314
4315
    // Check if valid public/private keys
4316
    if ($recovery_public_key !== '' && $recovery_private_key !== '') {
4317
        try {
4318
            // Generate random string
4319
            $random_str = generateQuickPassword(12, false);
4320
            // Encrypt random string with user publick key
4321
            $encrypted = encryptUserObjectKey($random_str, $recovery_public_key);
4322
            // Decrypt $encrypted with private key
4323
            $decrypted = decryptUserObjectKey($encrypted, $recovery_private_key);
4324
            // Check if decryptUserObjectKey returns our random string
4325
            if ($decrypted !== $random_str) {
4326
                throw new Exception('Public/Private keypair invalid.');
4327
            }
4328
        } catch (Exception $e) {
4329
            // Show error message to user and log event
4330
            if (defined('LOG_TO_SERVER') && LOG_TO_SERVER === true) {
4331
                error_log('ERROR: User '.$userId.' - '.$e->getMessage());
4332
            }
4333
            return prepareExchangedData([
4334
                    'error' => true,
4335
                    'message' => $lang->get('pw_encryption_error'),
4336
                ],
4337
                'encode'
4338
            );
4339
        }
4340
    }
4341
4342
    // Generate new keys
4343
    if ($user_self_change === true && empty($recovery_public_key) === false && empty($recovery_private_key) === false){
4344
        $userKeys = [
4345
            'public_key' => $recovery_public_key,
4346
            'private_key_clear' => $recovery_private_key,
4347
            'private_key' => encryptPrivateKey($passwordClear, $recovery_private_key),
4348
        ];
4349
    } else {
4350
        $userKeys = generateUserKeys($passwordClear);
4351
    }
4352
    
4353
    // Handle private key
4354
    insertPrivateKeyWithCurrentFlag(
4355
        $userId,
4356
        $userKeys['private_key'],
4357
    );
4358
4359
    // Save in DB
4360
    // TODO: remove private key field from Users table
4361
    DB::update(
4362
        prefixTable('users'),
4363
        array(
4364
            'pw' => $hashedPassword,
4365
            'public_key' => $userKeys['public_key'],
4366
            'private_key' => $userKeys['private_key'],
4367
            'keys_recovery_time' => NULL,
4368
        ),
4369
        'id=%i',
4370
        $userId
4371
    );
4372
4373
4374
    // update session too
4375
    if ($userId === $session->get('user-id')) {
4376
        $session->set('user-private_key', $userKeys['private_key_clear']);
4377
        $session->set('user-public_key', $userKeys['public_key']);
4378
        // Notify user that he must re download his keys:
4379
        $session->set('user-keys_recovery_time', NULL);
4380
    }
4381
4382
    // Manage empty encryption key
4383
    // Let's take the user's password if asked and if no encryption key provided
4384
    $encryptionKey = $encryptWithUserPassword === true && empty($encryptionKey) === true ? $passwordClear : $encryptionKey;
4385
4386
    // Create process
4387
    DB::insert(
4388
        prefixTable('background_tasks'),
4389
        array(
4390
            'created_at' => time(),
4391
            'process_type' => 'create_user_keys',
4392
            'arguments' => json_encode([
4393
                'new_user_id' => (int) $userId,
4394
                'new_user_pwd' => cryption($passwordClear, '','encrypt')['string'],
4395
                'new_user_code' => cryption(empty($encryptionKey) === true ? uniqidReal(20) : $encryptionKey, '','encrypt')['string'],
4396
                'owner_id' => (int) TP_USER_ID,
4397
                'creator_pwd' => $userTP['pw'],
4398
                'send_email' => $sendEmailToUser === true ? 1 : 0,
4399
                'otp_provided_new_value' => 1,
4400
                'email_body' => empty($emailBody) === true ? '' : $lang->get($emailBody),
4401
                'user_self_change' => $user_self_change === true ? 1 : 0,
4402
                'userHasToEncryptPersonalItemsAfter' => $userHasToEncryptPersonalItemsAfter === true ? 1 : 0,
4403
            ]),
4404
        )
4405
    );
4406
    $processId = DB::insertId();
4407
4408
    // Delete existing keys
4409
    if ($deleteExistingKeys === true) {
4410
        deleteUserObjetsKeys(
4411
            (int) $userId,
4412
        );
4413
    }
4414
4415
    // Create tasks
4416
    createUserTasks($processId, $nbItemsToTreat);
4417
4418
    // update user's new status
4419
    DB::update(
4420
        prefixTable('users'),
4421
        [
4422
            'is_ready_for_usage' => 0,
4423
            'otp_provided' => 1,
4424
            'ongoing_process_id' => $processId,
4425
            'special' => 'generate-keys',
4426
        ],
4427
        'id=%i',
4428
        $userId
4429
    );
4430
4431
    return prepareExchangedData(
4432
        array(
4433
            'error' => false,
4434
            'message' => '',
4435
            'user_password' => $generate_user_new_password === true ? $passwordClear : '',
4436
        ),
4437
        'encode'
4438
    );
4439
}
4440
4441
/**
4442
 * Permits to generate a new password for a user
4443
 *
4444
 * @param integer $processId
4445
 * @param integer $nbItemsToTreat
4446
 * @return void
4447
 
4448
 */
4449
function createUserTasks($processId, $nbItemsToTreat): void
4450
{
4451
    // Create subtask for step 0
4452
    DB::insert(
4453
        prefixTable('background_subtasks'),
4454
        array(
4455
            'task_id' => $processId,
4456
            'created_at' => time(),
4457
            'task' => json_encode([
4458
                'step' => 'step0',
4459
                'index' => 0,
4460
                'nb' => $nbItemsToTreat,
4461
            ]),
4462
        )
4463
    );
4464
4465
    // Prepare the subtask queries
4466
    $queries = [
4467
        'step20' => 'SELECT * FROM ' . prefixTable('items'),
4468
4469
        'step30' => 'SELECT * FROM ' . prefixTable('log_items') . 
4470
                    ' WHERE raison LIKE "at_pw :%" AND encryption_type = "teampass_aes"',
4471
4472
        'step40' => 'SELECT * FROM ' . prefixTable('categories_items') . 
4473
                    ' WHERE encryption_type = "teampass_aes"',
4474
4475
        'step50' => 'SELECT * FROM ' . prefixTable('suggestion'),
4476
4477
        'step60' => 'SELECT * FROM ' . prefixTable('files') . ' AS f
4478
                        INNER JOIN ' . prefixTable('items') . ' AS i ON i.id = f.id_item
4479
                        WHERE f.status = "' . TP_ENCRYPTION_NAME . '"'
4480
    ];
4481
4482
    // Perform loop on $queries to create sub-tasks
4483
    foreach ($queries as $step => $query) {
4484
        DB::query($query);
4485
        createAllSubTasks($step, DB::count(), $nbItemsToTreat, $processId);
4486
    }
4487
4488
    // Create subtask for step 99
4489
    DB::insert(
4490
        prefixTable('background_subtasks'),
4491
        array(
4492
            'task_id' => $processId,
4493
            'created_at' => time(),
4494
            'task' => json_encode([
4495
                'step' => 'step99',
4496
            ]),
4497
        )
4498
    );
4499
}
4500
4501
/**
4502
 * Create all subtasks for a given action
4503
 * @param string $action The action to be performed
4504
 * @param int $totalElements Total number of elements to process
4505
 * @param int $elementsPerIteration Number of elements per iteration
4506
 * @param int $taskId The ID of the task
4507
 */
4508
function createAllSubTasks($action, $totalElements, $elementsPerIteration, $taskId) {
4509
    // Calculate the number of iterations
4510
    $iterations = ceil($totalElements / $elementsPerIteration);
4511
4512
    // Create the subtasks
4513
    for ($i = 0; $i < $iterations; $i++) {
4514
        DB::insert(prefixTable('background_subtasks'), [
4515
            'task_id' => $taskId,
4516
            'created_at' => time(),
4517
            'task' => json_encode([
4518
                "step" => $action,
4519
                "index" => $i * $elementsPerIteration,
4520
                "nb" => $elementsPerIteration,
4521
            ]),
4522
        ]);
4523
    }
4524
}
4525
4526
/**
4527
 * Permeits to check the consistency of date versus columns definition
4528
 *
4529
 * @param string $table
4530
 * @param array $dataFields
4531
 * @return array
4532
 */
4533
function validateDataFields(
4534
    string $table,
4535
    array $dataFields
4536
): array
4537
{
4538
    // Get table structure
4539
    $result = DB::query(
4540
        "SELECT `COLUMN_NAME`, `CHARACTER_MAXIMUM_LENGTH` FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '%l' AND TABLE_NAME = '%l';",
4541
        DB_NAME,
4542
        $table
4543
    );
4544
4545
    foreach ($result as $row) {
4546
        $field = $row['COLUMN_NAME'];
4547
        $maxLength = is_null($row['CHARACTER_MAXIMUM_LENGTH']) === false ? (int) $row['CHARACTER_MAXIMUM_LENGTH'] : '';
4548
4549
        if (isset($dataFields[$field]) === true && is_array($dataFields[$field]) === false && empty($maxLength) === false) {
4550
            if (strlen((string) $dataFields[$field]) > $maxLength) {
4551
                return [
4552
                    'state' => false,
4553
                    'field' => $field,
4554
                    'maxLength' => $maxLength,
4555
                    'currentLength' => strlen((string) $dataFields[$field]),
4556
                ];
4557
            }
4558
        }
4559
    }
4560
    
4561
    return [
4562
        'state' => true,
4563
        'message' => '',
4564
    ];
4565
}
4566
4567
/**
4568
 * Adapt special characters sanitized during filter_var with option FILTER_SANITIZE_SPECIAL_CHARS operation
4569
 *
4570
 * @param string $string
4571
 * @return string
4572
 */
4573
function filterVarBack(string $string): string
4574
{
4575
    $arr = [
4576
        '&#060;' => '<',
4577
        '&#062;' => '>',
4578
        '&#034;' => '"',
4579
        '&#039;' => "'",
4580
        '&#038;' => '&',
4581
    ];
4582
4583
    foreach ($arr as $key => $value) {
4584
        $string = str_replace($key, $value, $string);
4585
    }
4586
4587
    return $string;
4588
}
4589
4590
/**
4591
 * 
4592
 */
4593
function storeTask(
4594
    string $taskName,
4595
    int $user_id,
4596
    int $is_personal_folder,
4597
    int $folder_destination_id,
4598
    int $item_id,
4599
    string $object_keys,
4600
    array $fields_keys = [],
4601
    array $files_keys = []
4602
)
4603
{
4604
    if (in_array($taskName, ['item_copy', 'new_item', 'update_item'])) {
4605
        // Create process
4606
        DB::insert(
4607
            prefixTable('background_tasks'),
4608
            array(
4609
                'created_at' => time(),
4610
                'process_type' => $taskName,
4611
                'arguments' => json_encode([
4612
                    'item_id' => $item_id,
4613
                    'object_key' => $object_keys,
4614
                ]),
4615
                'item_id' => $item_id,
4616
            )
4617
        );
4618
        $processId = DB::insertId();
4619
4620
        // Create tasks
4621
        // 1- Create password sharekeys for users of this new ITEM
4622
        DB::insert(
4623
            prefixTable('background_subtasks'),
4624
            array(
4625
                'task_id' => $processId,
4626
                'created_at' => time(),
4627
                'task' => json_encode([
4628
                    'step' => 'create_users_pwd_key',
4629
                    'index' => 0,
4630
                ]),
4631
            )
4632
        );
4633
4634
        // 2- Create fields sharekeys for users of this new ITEM
4635
        DB::insert(
4636
            prefixTable('background_subtasks'),
4637
            array(
4638
                'task_id' => $processId,
4639
                'created_at' => time(),
4640
                'task' => json_encode([
4641
                    'step' => 'create_users_fields_key',
4642
                    'index' => 0,
4643
                    'fields_keys' => $fields_keys,
4644
                ]),
4645
            )
4646
        );
4647
4648
        // 3- Create files sharekeys for users of this new ITEM
4649
        DB::insert(
4650
            prefixTable('background_subtasks'),
4651
            array(
4652
                'task_id' => $processId,
4653
                'created_at' => time(),
4654
                'task' => json_encode([
4655
                    'step' => 'create_users_files_key',
4656
                    'index' => 0,
4657
                    'files_keys' => $files_keys,
4658
                ]),
4659
            )
4660
        );
4661
    }
4662
}
4663
4664
/**
4665
 * 
4666
 */
4667
function createTaskForItem(
4668
    string $processType,
4669
    string|array $taskName,
4670
    int $itemId,
4671
    int $userId,
4672
    string $objectKey,
4673
    int $parentId = -1,
4674
    array $fields_keys = [],
4675
    array $files_keys = []
4676
)
4677
{
4678
    // 1- Create main process
4679
    // ---
4680
    
4681
    // Create process
4682
    DB::insert(
4683
        prefixTable('background_tasks'),
4684
        array(
4685
            'created_at' => time(),
4686
            'process_type' => $processType,
4687
            'arguments' => json_encode([
4688
                'all_users_except_id' => (int) $userId,
4689
                'item_id' => (int) $itemId,
4690
                'object_key' => $objectKey,
4691
                'author' => (int) $userId,
4692
            ]),
4693
            'item_id' => (int) $parentId !== -1 ?  $parentId : null,
4694
        )
4695
    );
4696
    $processId = DB::insertId();
4697
4698
    // 2- Create expected tasks
4699
    // ---
4700
    if (is_array($taskName) === false) {
0 ignored issues
show
introduced by
The condition is_array($taskName) === false is always false.
Loading history...
4701
        $taskName = [$taskName];
4702
    }
4703
    foreach($taskName as $task) {
4704
        if (WIP === true) error_log('createTaskForItem - task: '.$task);
4705
        switch ($task) {
4706
            case 'item_password':
4707
                
4708
                DB::insert(
4709
                    prefixTable('background_subtasks'),
4710
                    array(
4711
                        'task_id' => $processId,
4712
                        'created_at' => time(),
4713
                        'task' => json_encode([
4714
                            'step' => 'create_users_pwd_key',
4715
                            'index' => 0,
4716
                        ]),
4717
                    )
4718
                );
4719
4720
                break;
4721
            case 'item_field':
4722
                
4723
                DB::insert(
4724
                    prefixTable('background_subtasks'),
4725
                    array(
4726
                        'task_id' => $processId,
4727
                        'created_at' => time(),
4728
                        'task' => json_encode([
4729
                            'step' => 'create_users_fields_key',
4730
                            'index' => 0,
4731
                            'fields_keys' => $fields_keys,
4732
                        ]),
4733
                    )
4734
                );
4735
4736
                break;
4737
            case 'item_file':
4738
4739
                DB::insert(
4740
                    prefixTable('background_subtasks'),
4741
                    array(
4742
                        'task_id' => $processId,
4743
                        'created_at' => time(),
4744
                        'task' => json_encode([
4745
                            'step' => 'create_users_files_key',
4746
                            'index' => 0,
4747
                            'fields_keys' => $files_keys,
4748
                        ]),
4749
                    )
4750
                );
4751
                break;
4752
            default:
4753
                # code...
4754
                break;
4755
        }
4756
    }
4757
}
4758
4759
4760
function deleteProcessAndRelatedTasks(int $processId)
4761
{
4762
    // Delete process
4763
    DB::delete(
4764
        prefixTable('background_tasks'),
4765
        'id=%i',
4766
        $processId
4767
    );
4768
4769
    // Delete tasks
4770
    DB::delete(
4771
        prefixTable('background_subtasks'),
4772
        'task_id=%i',
4773
        $processId
4774
    );
4775
4776
}
4777
4778
/**
4779
 * Return PHP binary path
4780
 *
4781
 * @return string
4782
 */
4783
function getPHPBinary(): string
4784
{
4785
    // Get PHP binary path
4786
    $phpBinaryFinder = new PhpExecutableFinder();
4787
    $phpBinaryPath = $phpBinaryFinder->find();
4788
    return $phpBinaryPath === false ? 'false' : $phpBinaryPath;
4789
}
4790
4791
4792
4793
/**
4794
 * Delete unnecessary keys for personal items
4795
 *
4796
 * @param boolean $allUsers
4797
 * @param integer $user_id
4798
 * @return void
4799
 */
4800
function purgeUnnecessaryKeys(bool $allUsers = true, int $user_id=0)
4801
{
4802
    if ($allUsers === true) {
4803
        // Load class DB
4804
        if (class_exists('DB') === false) {
4805
            loadClasses('DB');
4806
        }
4807
4808
        $users = DB::query(
4809
            'SELECT id
4810
            FROM ' . prefixTable('users') . '
4811
            WHERE id NOT IN ('.OTV_USER_ID.', '.TP_USER_ID.', '.SSH_USER_ID.', '.API_USER_ID.')
4812
            ORDER BY login ASC'
4813
        );
4814
        foreach ($users as $user) {
4815
            purgeUnnecessaryKeysForUser((int) $user['id']);
4816
        }
4817
    } else {
4818
        purgeUnnecessaryKeysForUser((int) $user_id);
4819
    }
4820
}
4821
4822
/**
4823
 * Delete unnecessary keys for personal items
4824
 *
4825
 * @param integer $user_id
4826
 * @return void
4827
 */
4828
function purgeUnnecessaryKeysForUser(int $user_id=0)
4829
{
4830
    if ($user_id === 0) {
4831
        return;
4832
    }
4833
4834
    // Load class DB
4835
    loadClasses('DB');
4836
4837
    $personalItems = DB::queryFirstColumn(
4838
        'SELECT id
4839
        FROM ' . prefixTable('items') . ' AS i
4840
        INNER JOIN ' . prefixTable('log_items') . ' AS li ON li.id_item = i.id
4841
        WHERE i.perso = 1 AND li.action = "at_creation" AND li.id_user IN (%i, '.TP_USER_ID.')',
4842
        $user_id
4843
    );
4844
    if (count($personalItems) > 0) {
4845
        // Item keys
4846
        DB::delete(
4847
            prefixTable('sharekeys_items'),
4848
            'object_id IN %li AND user_id NOT IN %ls',
4849
            $personalItems,
4850
            [$user_id, TP_USER_ID, API_USER_ID, OTV_USER_ID,SSH_USER_ID]
4851
        );
4852
        // Files keys
4853
        DB::delete(
4854
            prefixTable('sharekeys_files'),
4855
            'object_id IN %li AND user_id NOT IN %ls',
4856
            $personalItems,
4857
            [$user_id, TP_USER_ID, API_USER_ID, OTV_USER_ID,SSH_USER_ID]
4858
        );
4859
        // Fields keys
4860
        DB::delete(
4861
            prefixTable('sharekeys_fields'),
4862
            'object_id IN %li AND user_id NOT IN %ls',
4863
            $personalItems,
4864
            [$user_id, TP_USER_ID, API_USER_ID, OTV_USER_ID,SSH_USER_ID]
4865
        );
4866
        // Logs keys
4867
        DB::delete(
4868
            prefixTable('sharekeys_logs'),
4869
            'object_id IN %li AND user_id NOT IN %ls',
4870
            $personalItems,
4871
            [$user_id, TP_USER_ID, API_USER_ID, OTV_USER_ID,SSH_USER_ID]
4872
        );
4873
    }
4874
}
4875
4876
/**
4877
 * Generate recovery keys file
4878
 *
4879
 * @param integer $userId
4880
 * @param array $SETTINGS
4881
 * @return string
4882
 */
4883
function handleUserRecoveryKeysDownload(int $userId, array $SETTINGS):string
4884
{
4885
    $session = SessionManager::getSession();
4886
    // Check if user exists
4887
    $userInfo = DB::queryFirstRow(
4888
        'SELECT login
4889
        FROM ' . prefixTable('users') . '
4890
        WHERE id = %i',
4891
        $userId
4892
    );
4893
4894
    if (DB::count() > 0) {
4895
        $now = (int) time();
4896
        // Prepare file content
4897
        $export_value = file_get_contents(__DIR__."/../includes/core/teampass_ascii.txt")."\n".
4898
            "Generation date: ".date($SETTINGS['date_format'] . ' ' . $SETTINGS['time_format'], $now)."\n\n".
4899
            "RECOVERY KEYS - Not to be shared - To be store safely\n\n".
4900
            "Public Key:\n".$session->get('user-public_key')."\n\n".
4901
            "Private Key:\n".$session->get('user-private_key')."\n\n";
4902
4903
        // Update user's keys_recovery_time
4904
        DB::update(
4905
            prefixTable('users'),
4906
            [
4907
                'keys_recovery_time' => $now,
4908
            ],
4909
            'id=%i',
4910
            $userId
4911
        );
4912
        $session->set('user-keys_recovery_time', $now);
4913
4914
        //Log into DB the user's disconnection
4915
        logEvents($SETTINGS, 'user_mngt', 'at_user_keys_download', (string) $userId, $userInfo['login']);
4916
        
4917
        // Return data
4918
        return prepareExchangedData(
4919
            array(
4920
                'error' => false,
4921
                'datetime' => date($SETTINGS['date_format'] . ' ' . $SETTINGS['time_format'], $now),
4922
                'timestamp' => $now,
4923
                'content' => base64_encode($export_value),
4924
                'login' => $userInfo['login'],
4925
            ),
4926
            'encode'
4927
        );
4928
    }
4929
4930
    return prepareExchangedData(
4931
        array(
4932
            'error' => true,
4933
            'datetime' => '',
4934
        ),
4935
        'encode'
4936
    );
4937
}
4938
4939
/**
4940
 * Permits to load expected classes
4941
 *
4942
 * @param string $className
4943
 * @return void
4944
 */
4945
function loadClasses(string $className = ''): void
4946
{
4947
    require_once __DIR__. '/../includes/config/include.php';
4948
    require_once __DIR__. '/../includes/config/settings.php';
4949
    require_once __DIR__.'/../vendor/autoload.php';
4950
4951
    // Load phpseclib v1 autoloader for backward compatibility
4952
    // This allows CryptoManager to fall back to v1 API for decrypting old data
4953
    require_once __DIR__ . '/../includes/libraries/phpseclibV1_autoload.php';
4954
4955
    if (defined('DB_PASSWD_CLEAR') === false) {
4956
        define('DB_PASSWD_CLEAR', defuseReturnDecrypted(DB_PASSWD));
4957
    }
4958
4959
    if (empty($className) === false) {
4960
        // Load class DB
4961
        if ((string) $className === 'DB') {
4962
            //Connect to DB
4963
            DB::$host = DB_HOST;
4964
            DB::$user = DB_USER;
4965
            DB::$password = DB_PASSWD_CLEAR;
4966
            DB::$dbName = DB_NAME;
4967
            DB::$port = DB_PORT;
4968
            DB::$encoding = DB_ENCODING;
4969
            DB::$ssl = DB_SSL;
4970
            DB::$connect_options = DB_CONNECT_OPTIONS;
4971
        }
4972
    }
4973
}
4974
4975
/**
4976
 * Returns the page the user is visiting.
4977
 *
4978
 * @return string The page name
4979
 */
4980
function getCurrectPage($SETTINGS)
4981
{
4982
    
4983
    $request = SymfonyRequest::createFromGlobals();
4984
4985
    // Parse the url
4986
    parse_str(
4987
        substr(
4988
            (string) $request->getRequestUri(),
4989
            strpos((string) $request->getRequestUri(), '?') + 1
4990
        ),
4991
        $result
4992
    );
4993
4994
    return $result['page'];
4995
}
4996
4997
/**
4998
 * Permits to return value if set
4999
 *
5000
 * @param string|int $value
5001
 * @param string|int|null $retFalse
5002
 * @param string|int $retTrue
5003
 * @return mixed
5004
 */
5005
function returnIfSet($value, $retFalse = '', $retTrue = null): mixed
5006
{
5007
    if (!empty($value)) {
5008
        return is_null($retTrue) ? $value : $retTrue;
5009
    }
5010
    return $retFalse;
5011
}
5012
5013
5014
/**
5015
 * SEnd email to user
5016
 *
5017
 * @param string $post_receipt
5018
 * @param string $post_body
5019
 * @param string $post_subject
5020
 * @param array $post_replace
5021
 * @param boolean $immediate_email
5022
 * @param string $encryptedUserPassword
5023
 * @return string
5024
 */
5025
function sendMailToUser(
5026
    string $post_receipt,
5027
    string $post_body,
5028
    string $post_subject,
5029
    array $post_replace,
5030
    bool $immediate_email = false,
5031
    $encryptedUserPassword = ''
5032
): ?string {
5033
    global $SETTINGS;
5034
    $emailSettings = new EmailSettings($SETTINGS);
5035
    $emailService = new EmailService();
5036
    $antiXss = new AntiXSS();
5037
5038
    // Sanitize inputs
5039
    $post_receipt = filter_var($post_receipt, FILTER_SANITIZE_EMAIL);
5040
    $post_subject = $antiXss->xss_clean($post_subject);
5041
    $post_body = $antiXss->xss_clean($post_body);
5042
5043
    if (count($post_replace) > 0) {
5044
        $post_body = str_replace(
5045
            array_keys($post_replace),
5046
            array_values($post_replace),
5047
            $post_body
5048
        );
5049
    }
5050
5051
    // Remove newlines to prevent header injection
5052
    $post_body = str_replace(array("\r", "\n"), '', $post_body);    
5053
5054
    if ($immediate_email === true) {
5055
        // Send email
5056
        $ret = $emailService->sendMail(
5057
            $post_subject,
5058
            $post_body,
5059
            $post_receipt,
5060
            $emailSettings,
5061
            '',
5062
            false
5063
        );
5064
    
5065
        $ret = json_decode($ret, true);
5066
    
5067
        return prepareExchangedData(
5068
            array(
5069
                'error' => empty($ret['error']) === true ? false : true,
5070
                'message' => $ret['message'],
5071
            ),
5072
            'encode'
5073
        );
5074
    } else {
5075
        // Send through task handler
5076
        prepareSendingEmail(
5077
            $post_subject,
5078
            $post_body,
5079
            $post_receipt,
5080
            "",
5081
            $encryptedUserPassword,
5082
        );
5083
    }
5084
5085
    return null;
5086
}
5087
5088
/**
5089
 * Converts a password strengh value to zxcvbn level
5090
 * 
5091
 * @param integer $passwordStrength
5092
 * 
5093
 * @return integer
5094
 */
5095
function convertPasswordStrength($passwordStrength): int
5096
{
5097
    if ($passwordStrength === 0) {
5098
        return TP_PW_STRENGTH_1;
5099
    } else if ($passwordStrength === 1) {
5100
        return TP_PW_STRENGTH_2;
5101
    } else if ($passwordStrength === 2) {
5102
        return TP_PW_STRENGTH_3;
5103
    } else if ($passwordStrength === 3) {
5104
        return TP_PW_STRENGTH_4;
5105
    } else {
5106
        return TP_PW_STRENGTH_5;
5107
    }
5108
}
5109
5110
/**
5111
 * Check that a password is strong. The password needs to have at least :
5112
 *   - length >= 10.
5113
 *   - Uppercase and lowercase chars.
5114
 *   - Number or special char.
5115
 *   - Not contain username, name or mail part.
5116
 *   - Different from previous password.
5117
 * 
5118
 * @param string $password - Password to ckeck.
5119
 * @return bool - true if the password is strong, false otherwise.
5120
 */
5121
function isPasswordStrong($password) {
5122
    $session = SessionManager::getSession();
5123
5124
    // Password can't contain login, name or lastname
5125
    $forbiddenWords = [
5126
        $session->get('user-login'),
5127
        $session->get('user-name'),
5128
        $session->get('user-lastname'),
5129
    ];
5130
5131
    // Cut out the email
5132
    if ($email = $session->get('user-email')) {
5133
        $emailParts = explode('@', $email);
5134
5135
        if (count($emailParts) === 2) {
5136
            // Mail username (removed @domain.tld)
5137
            $forbiddenWords[] = $emailParts[0];
5138
5139
            // Organisation name (removed username@ and .tld)
5140
            $domain = explode('.', $emailParts[1]);
5141
            if (count($domain) > 1)
5142
                $forbiddenWords[] = $domain[0];
5143
        }
5144
    }
5145
5146
    // Search forbidden words in password
5147
    foreach ($forbiddenWords as $word) {
5148
        if (empty($word))
5149
            continue;
5150
5151
        // Stop if forbidden word found in password
5152
        if (stripos($password, $word) !== false)
5153
            return false;
5154
    }
5155
5156
    // Get password complexity
5157
    $length = strlen($password);
5158
    $hasUppercase = preg_match('/[A-Z]/', $password);
5159
    $hasLowercase = preg_match('/[a-z]/', $password);
5160
    $hasNumber = preg_match('/[0-9]/', $password);
5161
    $hasSpecialChar = preg_match('/[\W_]/', $password);
5162
5163
    // Get current user hash
5164
    $userHash = DB::queryFirstRow(
5165
        "SELECT pw FROM " . prefixtable('users') . " WHERE id = %d;",
5166
        $session->get('user-id')
5167
    )['pw'];
5168
5169
    $passwordManager = new PasswordManager();
5170
    
5171
    return $length >= 8
5172
           && $hasUppercase
5173
           && $hasLowercase
5174
           && ($hasNumber || $hasSpecialChar)
5175
           && !$passwordManager->verifyPassword($userHash, $password);
5176
}
5177
5178
5179
/**
5180
 * Converts a value to a string, handling various types and cases.
5181
 *
5182
 * @param mixed $value La valeur à convertir
5183
 * @param string $default Valeur par défaut si la conversion n'est pas possible
5184
 * @return string
5185
 */
5186
function safeString($value, string $default = ''): string
5187
{
5188
    // Simple cases
5189
    if (is_string($value)) {
5190
        return $value;
5191
    }
5192
    
5193
    if (is_scalar($value)) {
5194
        return (string) $value;
5195
    }
5196
    
5197
    // Special cases
5198
    if (is_null($value)) {
5199
        return $default;
5200
    }
5201
    
5202
    if (is_array($value)) {
5203
        return empty($value) ? $default : json_encode($value, JSON_UNESCAPED_UNICODE);
5204
    }
5205
    
5206
    if (is_object($value)) {
5207
        // Vérifie si l'objet implémente __toString()
5208
        if (method_exists($value, '__toString')) {
5209
            return (string) $value;
5210
        }
5211
        
5212
        // Alternative: serialize ou json selon le contexte
5213
        return get_class($value) . (method_exists($value, 'getId') ? '#' . $value->getId() : '');
5214
    }
5215
    
5216
    if (is_resource($value)) {
5217
        return 'Resource#' . get_resource_id($value) . ' of type ' . get_resource_type($value);
5218
    }
5219
    
5220
    // Cas par défaut
5221
    return $default;
5222
}
5223
5224
/**
5225
 * Check if a user has access to a file
5226
 *
5227
 * @param integer $userId
5228
 * @param integer $fileId
5229
 * @return boolean
5230
 */
5231
function userHasAccessToFile(int $userId, int $fileId): bool
5232
{
5233
    // Check if user is admin
5234
    // Refuse access if user does not exist and/or is admin
5235
    $user = DB::queryFirstRow(
5236
        'SELECT admin
5237
        FROM ' . prefixTable('users') . '
5238
        WHERE id = %i',
5239
        $userId
5240
    );
5241
    if (DB::count() === 0 || (int) $user['admin'] === 1) {
5242
        return false;
5243
    }
5244
5245
    // Get file info
5246
    $file = DB::queryFirstRow(
5247
        'SELECT f.id_item, i.id_tree
5248
        FROM ' . prefixTable('files') . ' as f
5249
        INNER JOIN ' . prefixTable('items') . ' AS i ON i.id = f.id_item
5250
        WHERE f.id = %i',
5251
        $fileId
5252
    );
5253
    if (DB::count() === 0) {
5254
        return false;
5255
    }
5256
5257
    // Check if user has access to the item
5258
    include_once __DIR__. '/items.queries.php';
5259
    $itemAccess = getCurrentAccessRights(
5260
        (int) filter_var($userId, FILTER_SANITIZE_NUMBER_INT),
5261
        (int) filter_var($file['id_item'], FILTER_SANITIZE_NUMBER_INT),
5262
        (int) filter_var($file['id_tree'], FILTER_SANITIZE_NUMBER_INT),
5263
        (string) filter_var('show', FILTER_SANITIZE_SPECIAL_CHARS),
5264
    );
5265
5266
    return $itemAccess['access'] === true;
5267
}
5268
5269
/**
5270
 * Check if a user has access to a backup file
5271
 * 
5272
 * @param integer $userId
5273
 * @param string $file
5274
 * @param string $key
5275
 * @param string $keyTmp
5276
 * @return boolean
5277
 */
5278
function userHasAccessToBackupFile(int $userId, string $file, string $key, string $keyTmp): bool
5279
{
5280
    $session = SessionManager::getSession();
5281
5282
    // Ensure session keys are ok
5283
    if ($session->get('key') !== $key || $session->get('user-key_tmp') !== $keyTmp) {
5284
        return false;
5285
    }
5286
    
5287
    // Check if user is admin
5288
    // Refuse access if user does not exist and/or is not admin
5289
    $user = DB::queryFirstRow(
5290
        'SELECT admin
5291
        FROM ' . prefixTable('users') . '
5292
        WHERE id = %i',
5293
        $userId
5294
    );
5295
    if (DB::count() === 0 || (int) $user['admin'] === 0) {
5296
        return false;
5297
    }
5298
    
5299
    // Ensure that user has performed the backup
5300
    DB::queryFirstRow(
5301
        'SELECT f.id
5302
        FROM ' . prefixTable('log_system') . ' as f
5303
        WHERE f.type = %s AND f.label = %s AND f.qui = %i AND f.field_1 = %s',
5304
        'admin_action',
5305
        'dataBase backup',
5306
        $userId,
5307
        $file
5308
    );
5309
    if (DB::count() === 0) {
5310
        return false;
5311
    }
5312
5313
    return true;
5314
}
5315
5316
/**
5317
 * Ensure that personal items have only keys for their owner
5318
 *
5319
 * @param integer $userId
5320
 * @param integer $itemId
5321
 * @return boolean
5322
 */
5323
function EnsurePersonalItemHasOnlyKeysForOwner(int $userId, int $itemId): bool
5324
{
5325
    // Check if user is admin
5326
    // Refuse access if user does not exist and/or is admin
5327
    $user = DB::queryFirstRow(
5328
        'SELECT admin
5329
        FROM ' . prefixTable('users') . '
5330
        WHERE id = %i',
5331
        $userId
5332
    );
5333
    if (DB::count() === 0 || (int) $user['admin'] === 1) {
5334
        return false;
5335
    }
5336
5337
    // Get item info
5338
    $item = DB::queryFirstRow(
5339
        'SELECT i.perso, i.id_tree
5340
        FROM ' . prefixTable('items') . ' as i
5341
        WHERE i.id = %i',
5342
        $itemId
5343
    );
5344
    if (DB::count() === 0 || (int) $item['perso'] === 0) {
5345
        return false;
5346
    }
5347
5348
    // Get item owner
5349
    $itemOwner = DB::queryFirstRow(
5350
        'SELECT li.id_user
5351
        FROM ' . prefixTable('log_items') . ' as li
5352
        WHERE li.id_item = %i AND li.action = %s',
5353
        $itemId,
5354
        'at_creation'
5355
    );
5356
    if (DB::count() === 0 || (int) $itemOwner['id_user'] !== $userId) {
5357
        return false;
5358
    }
5359
5360
    // Delete all keys for this item except for the owner and TeamPass system user
5361
    DB::delete(
5362
        prefixTable('sharekeys_items'),
5363
        'object_id = %i AND user_id NOT IN %ls',
5364
        $itemId,
5365
        [$userId, TP_USER_ID, API_USER_ID, OTV_USER_ID,SSH_USER_ID]
5366
    );
5367
    DB::delete(
5368
        prefixTable('sharekeys_files'),
5369
        'object_id IN (SELECT id FROM '.prefixTable('files').' WHERE id_item = %i) AND user_id NOT IN %ls',
5370
        $itemId,
5371
        [$userId, TP_USER_ID, API_USER_ID, OTV_USER_ID,SSH_USER_ID]
5372
    );
5373
    DB::delete(
5374
        prefixTable('sharekeys_fields'),
5375
        'object_id IN (SELECT id FROM '.prefixTable('fields').' WHERE id_item = %i) AND user_id NOT IN %ls',
5376
        $itemId,
5377
        [$userId, TP_USER_ID, API_USER_ID, OTV_USER_ID,SSH_USER_ID]
5378
    );
5379
    DB::delete(
5380
        prefixTable('sharekeys_logs'),
5381
        'object_id IN (SELECT id FROM '.prefixTable('log_items').' WHERE id_item = %i) AND user_id NOT IN %ls',
5382
        $itemId,
5383
        [$userId, TP_USER_ID, API_USER_ID, OTV_USER_ID,SSH_USER_ID]
5384
    );
5385
5386
    return true;
5387
}
5388
5389
/**
5390
 * Insert a new record in a table with an "is_current" flag.
5391
 * This function ensures that only one record per user has the "is_current" flag set to true.
5392
 * 
5393
 * @param int $userId The ID of the user.
5394
 * @param string $privateKey The private key to be inserted.
5395
 * @return void
5396
 */
5397
function insertPrivateKeyWithCurrentFlag(int $userId, string $privateKey) {    
5398
    try {
5399
        DB::startTransaction();
5400
        
5401
        // Disable is_current for existing records of the user
5402
        DB::update(
5403
            prefixTable('user_private_keys'),
5404
            array('is_current' => false),
5405
            "user_id = %i AND is_current = %i",
5406
            $userId,
5407
            true
5408
        );
5409
        
5410
        // Insert the new record
5411
        DB::insert(
5412
            prefixTable('user_private_keys'),
5413
            array(
5414
                'user_id' => $userId,
5415
                'private_key' => $privateKey,
5416
                'is_current' => true,
5417
            )
5418
        );
5419
        
5420
        DB::commit();
5421
        
5422
    } catch (Exception $e) {
5423
        DB::rollback();
5424
        throw $e;
5425
    }
5426
}
5427
5428
/**
5429
 * Check and migrate personal items at user login
5430
 * After successful authentication and private key decryption
5431
 * 
5432
 * @param int $userId User ID
5433
 * @param string $privateKeyDecrypted Decrypted private key from login
5434
 * @param string $passwordClear Clear user password
5435
 * @return void
5436
 */
5437
function checkAndMigratePersonalItems($userId, $privateKeyDecrypted, $passwordClear) {
5438
    $session = SessionManager::getSession();
5439
    $tree = new NestedTree(prefixTable('nested_tree'), 'id', 'parent_id', 'title');
5440
5441
    // 1. Check migration flag in users table
5442
    $user = DB::queryFirstRow(
5443
        "SELECT personal_items_migrated, login 
5444
         FROM ".prefixTable('users')." 
5445
         WHERE id = %i",
5446
        $userId
5447
    );
5448
    
5449
    if ((int) $user['personal_items_migrated'] === 1) {
5450
        return; // Already migrated, nothing to do
5451
    }
5452
    
5453
    // 2. Check if user actually has personal items to migrate
5454
    $personalFolderId = DB::queryFirstField(
5455
        "SELECT id FROM ".prefixTable('nested_tree') ."
5456
         WHERE personal_folder = 1 
5457
         AND title = %s",
5458
        $userId
5459
    );
5460
    
5461
    if (!$personalFolderId) {
5462
        // User has no personal folder, mark as migrated
5463
        DB::update(prefixTable('users'), [
5464
            'personal_items_migrated' => 1
5465
        ], ['id' => $userId]);
5466
        return;
5467
    }
5468
    
5469
    // 3. Count items to migrate
5470
    // Get list of all personal subfolders
5471
    $personalFoldersIds = $tree->getDescendants($personalFolderId, true, false, true);
5472
    $itemsToMigrate = DB::query(
5473
        "SELECT i.id
5474
         FROM ".prefixTable('items')." i
5475
         WHERE i.perso = 1 
5476
         AND i.id_tree IN %li",
5477
        $personalFoldersIds
5478
    );
5479
    
5480
    $totalItems = count($itemsToMigrate);
5481
    
5482
    if ($totalItems == 0) {
5483
        // No items to migrate, mark user as migrated
5484
        DB::update(prefixTable('users'), [
5485
            'personal_items_migrated' => 1
5486
        ], ['id' => $userId]);
5487
        return;
5488
    }
5489
    
5490
    // 4. Check if migration task already exists and is pending
5491
    $existingTask = DB::queryFirstRow(
5492
        "SELECT increment_id, status FROM ".prefixTable('background_tasks')."
5493
         WHERE process_type = 'migrate_user_personal_items'
5494
         AND item_id = %i
5495
         AND status IN ('pending', 'in_progress')
5496
         ORDER BY created_at DESC LIMIT 1",
5497
        $userId
5498
    );
5499
    
5500
    if ($existingTask) {
5501
        // Migration already in progress
5502
        $session->set('migration_personal_items_in_progress', true);
5503
        return;
5504
    }
5505
    
5506
    // 5. Create migration task
5507
    createUserMigrationTask($userId, $privateKeyDecrypted, $passwordClear, json_encode($personalFoldersIds));
5508
    
5509
    // 6. Notify user
5510
    $session->set('migration_personal_items_started', true);
5511
    $session->set('migration_total_items', $totalItems);
5512
}
5513
5514
/**
5515
 * Check and trigger forced phpseclib v3 migration on user login
5516
 *
5517
 * This function triggers a complete migration of all user's sharekeys from
5518
 * phpseclib v1 (SHA-1) to v3 (SHA-256) when FORCE_PHPSECLIBV3_MIGRATION is enabled.
5519
 *
5520
 * Unlike the progressive migration (which migrates on-access), this forces
5521
 * migration of ALL sharekeys owned by the user at login time.
5522
 *
5523
 * @param int $userId User ID
5524
 * @param string $privateKeyDecrypted Decrypted private key (base64)
5525
 * @param string $passwordClear Clear user password
5526
 * @return void
5527
 */
5528
function triggerPhpseclibV3MigrationOnLogin(int $userId, string $privateKeyDecrypted, string $passwordClear): void
5529
{
5530
    // Check if forced migration is enabled
5531
    if (!defined('FORCE_PHPSECLIBV3_MIGRATION') || FORCE_PHPSECLIBV3_MIGRATION !== true) {
5532
        return; // Forced migration disabled, use progressive migration instead
5533
    }
5534
5535
    $session = SessionManager::getSession();
5536
5537
    // Check if user already completed forced migration
5538
    $user = DB::queryFirstRow(
5539
        "SELECT phpseclibv3_migration_completed, phpseclibv3_migration_task_id, encryption_version, login
5540
         FROM " . prefixTable('users') . "
5541
         WHERE id = %i",
5542
        $userId
5543
    );
5544
5545
    if ((int) $user['phpseclibv3_migration_completed'] === 1) {
5546
        return; // Already migrated, nothing to do
5547
    }
5548
5549
    // Check if user has other background tasks in progress or pending
5550
    // We exclude 'user_build_cache_tree' and 'send_email' as they don't conflict
5551
    // We search in the JSON arguments column for "user_id":$userId
5552
    $existingTasks = DB::query(
5553
        "SELECT increment_id, process_type, arguments FROM " . prefixTable('background_tasks') . "
5554
         WHERE is_in_progress != -1
5555
         AND process_type NOT IN ('user_build_cache_tree', 'send_email')"
5556
    );
5557
5558
    foreach ($existingTasks as $task) {
5559
        $arguments = json_decode($task['arguments'], true);
5560
        if (isset($arguments['user_id']) && (int) $arguments['user_id'] === $userId) {
5561
            // User has another task in progress, skip migration for now
5562
            if (defined('LOG_TO_SERVER') && LOG_TO_SERVER === true) {
5563
                error_log('TEAMPASS phpseclibv3_migration - User ' . $userId . ' has other task (' . $task['process_type'] . ') in progress, skipping migration');
5564
            }
5565
            return;
5566
        }
5567
    }
5568
5569
    // Check if user's private key is already in v3
5570
    // If encryption_version = 3, user keys are v3, only sharekeys need migration
5571
    $userKeysV3 = ((int) $user['encryption_version'] === 3);
5572
5573
    // Count sharekeys to migrate (encryption_version = 1) for this user
5574
    $sharekeys_tables = [
5575
        'sharekeys_items',
5576
        'sharekeys_logs',
5577
        'sharekeys_fields',
5578
        'sharekeys_files',
5579
        'sharekeys_suggestions'
5580
    ];
5581
5582
    $totalSharekeysToMigrate = 0;
5583
    $sharekeysPerTable = [];
5584
5585
    foreach ($sharekeys_tables as $table) {
5586
        $count = DB::queryFirstField(
5587
            "SELECT COUNT(*) FROM " . prefixTable($table) . "
5588
             WHERE user_id = %i AND encryption_version = 1",
5589
            $userId
5590
        );
5591
        $sharekeysPerTable[$table] = (int) $count;
5592
        $totalSharekeysToMigrate += (int) $count;
5593
    }
5594
5595
    // If no sharekeys to migrate, mark as completed
5596
    if ($totalSharekeysToMigrate === 0 && $userKeysV3) {
5597
        DB::update(
5598
            prefixTable('users'),
5599
            ['phpseclibv3_migration_completed' => 1],
5600
            'id = %i',
5601
            $userId
5602
        );
5603
        return;
5604
    }
5605
5606
    // Check if migration task already exists and is in progress
5607
    $existingTask = DB::queryFirstRow(
5608
        "SELECT increment_id, status FROM " . prefixTable('background_tasks') . "
5609
         WHERE process_type = 'phpseclibv3_migration'
5610
         AND item_id = %i
5611
         AND status IN ('pending', 'in_progress')
5612
         ORDER BY created_at DESC LIMIT 1",
5613
        $userId
5614
    );
5615
5616
    if ($existingTask) {
5617
        // Migration already in progress
5618
        $session->set('phpseclibv3_migration_in_progress', true);
5619
        $session->set('phpseclibv3_migration_task_id', $existingTask['increment_id']);
5620
        $session->set('phpseclibv3_migration_total', $totalSharekeysToMigrate);
5621
        return;
5622
    }
5623
5624
    // Create migration background task
5625
    $taskId = createPhpseclibV3MigrationTask(
5626
        $userId,
5627
        $privateKeyDecrypted,
5628
        $passwordClear,
5629
        $sharekeysPerTable
5630
    );
5631
5632
    // Set session variables for UI modal
5633
    $session->set('phpseclibv3_migration_started', true);
5634
    $session->set('phpseclibv3_migration_task_id', $taskId);
5635
    $session->set('phpseclibv3_migration_total', $totalSharekeysToMigrate);
5636
5637
    // Log migration start
5638
    if (defined('LOG_TO_SERVER') && LOG_TO_SERVER === true) {
5639
        error_log('TEAMPASS phpseclibv3_migration - User ' . $userId . ' (' . $user['login'] . ') - Starting forced migration for ' . $totalSharekeysToMigrate . ' sharekeys');
5640
    }
5641
}
5642
5643
/**
5644
 * Create phpseclib v3 migration background task
5645
 *
5646
 * @param int $userId User ID
5647
 * @param string $privateKeyDecrypted Decrypted private key (base64)
5648
 * @param string $passwordClear Clear user password
5649
 * @param array $sharekeysPerTable Array of sharekeys count per table
5650
 * @return int Task ID
5651
 */
5652
function createPhpseclibV3MigrationTask(
5653
    int $userId,
5654
    string $privateKeyDecrypted,
5655
    string $passwordClear,
5656
    array $sharekeysPerTable
5657
): int {
5658
    // Create main background task
5659
    DB::insert(
5660
        prefixTable('background_tasks'),
5661
        [
5662
            'created_at' => time(),
5663
            'process_type' => 'phpseclibv3_migration',
5664
            'arguments' => json_encode([
5665
                'user_id' => $userId,
5666
                'user_pwd' => cryption($passwordClear, '', 'encrypt')['string'],
5667
                'user_private_key' => cryption($privateKeyDecrypted, '', 'encrypt')['string'],
5668
                'sharekeys_per_table' => $sharekeysPerTable,
5669
            ]),
5670
            'is_in_progress' => 0,
5671
            'status' => 'pending',
5672
            'item_id' => $userId // Use item_id to store user_id for filtering
5673
        ]
5674
    );
5675
5676
    $taskId = DB::insertId();
5677
5678
    // Create subtasks for each sharekeys table
5679
    createPhpseclibV3MigrationSubTasks($taskId, $sharekeysPerTable, NUMBER_ITEMS_IN_BATCH);
5680
5681
    // Update user's migration status
5682
    DB::update(
5683
        prefixTable('users'),
5684
        [
5685
            'phpseclibv3_migration_task_id' => $taskId,
5686
        ],
5687
        'id = %i',
5688
        $userId
5689
    );
5690
5691
    return $taskId;
5692
}
5693
5694
/**
5695
 * Create subtasks for phpseclib v3 migration
5696
 *
5697
 * @param int $taskId Main task ID
5698
 * @param array $sharekeysPerTable Sharekeys count per table
5699
 * @param int $batchSize Number of sharekeys per subtask
5700
 * @return void
5701
 */
5702
function createPhpseclibV3MigrationSubTasks(int $taskId, array $sharekeysPerTable, int $batchSize): void
5703
{
5704
    $subtaskOrder = 1;
5705
5706
    foreach ($sharekeysPerTable as $table => $count) {
5707
        if ($count === 0) {
5708
            continue; // Skip tables with no sharekeys to migrate
5709
        }
5710
5711
        // Calculate number of batches needed
5712
        $numBatches = (int) ceil($count / $batchSize);
5713
5714
        // Create subtasks for each batch
5715
        // IMPORTANT: We don't use OFFSET anymore (always 0) because as sharekeys
5716
        // are migrated, they disappear from WHERE encryption_version = 1
5717
        for ($batch = 0; $batch < $numBatches; $batch++) {
5718
            DB::insert(
5719
                prefixTable('background_subtasks'),
5720
                [
5721
                    'task_id' => $taskId,
5722
                    'task' => json_encode([
5723
                        'step' => 'migrate_sharekeys_table',
5724
                        'table' => $table,
5725
                        'limit' => $batchSize,
5726
                        'batch' => $batch + 1,
5727
                        'total_batches' => $numBatches,
5728
                    ]),
5729
                    'is_in_progress' => 0,
5730
                    'status' => 'pending',
5731
                    'finished_at' => null,
5732
                    'created_at' => time(),
5733
                    'updated_at' => time(),
5734
                ]
5735
            );
5736
            $subtaskOrder++;
5737
        }
5738
    }
5739
5740
    // Add final subtask to mark migration as completed
5741
    DB::insert(
5742
        prefixTable('background_subtasks'),
5743
        [
5744
            'task_id' => $taskId,
5745
            'task' => json_encode([
5746
                'step' => 'finalize_phpseclibv3_migration',
5747
            ]),
5748
            'is_in_progress' => 0,
5749
            'status' => 'pending',
5750
            'finished_at' => null,
5751
            'updated_at' => time(),
5752
            'created_at' => time(),
5753
        ]
5754
    );
5755
}
5756
5757
/**
5758
 * Create migration task for a specific user
5759
 * 
5760
 * @param int $userId User ID
5761
 * @param string $privateKeyDecrypted Decrypted private key
5762
 * @param string $passwordClear Clear user password
5763
 * @param string $personalFolderIds
5764
 * @return void
5765
 */
5766
function createUserMigrationTask($userId, $privateKeyDecrypted, $passwordClear, $personalFolderIds): void
5767
{
5768
    // Decrypt all personal items with this key
5769
    // Launch the re-encryption process for personal items
5770
    // Create process
5771
    DB::insert(
5772
        prefixTable('background_tasks'),
5773
        array(
5774
            'created_at' => time(),
5775
            'process_type' => 'migrate_user_personal_items',
5776
            'arguments' => json_encode([
5777
                'user_id' => (int) $userId,
5778
                'user_pwd' => cryption($passwordClear, '','encrypt')['string'],
5779
                'user_private_key' => cryption($privateKeyDecrypted, '','encrypt')['string'],
5780
                'personal_folders_ids' => $personalFolderIds,
5781
            ]),
5782
            'is_in_progress' => 0,
5783
            'status' => 'pending',
5784
            'item_id' => $userId // Use item_id to store user_id for easy filtering
5785
        )
5786
    );
5787
    $processId = DB::insertId();
5788
5789
    // Create tasks
5790
    createUserMigrationSubTasks($processId, NUMBER_ITEMS_IN_BATCH);
5791
5792
    // update user's new status
5793
    DB::update(
5794
        prefixTable('users'),
5795
        [
5796
            'is_ready_for_usage' => 0,
5797
            'ongoing_process_id' => $processId,
5798
        ],
5799
        'id=%i',
5800
        $userId
5801
    );
5802
}
5803
5804
function createUserMigrationSubTasks($processId, $nbItemsToTreat): void
5805
{
5806
    // Prepare the subtask queries
5807
    $queries = [
5808
        'user-personal-items-migration-step10' => 'SELECT * FROM ' . prefixTable('items'),
5809
5810
        'user-personal-items-migration-step20' => 'SELECT * FROM ' . prefixTable('log_items') . 
5811
                    ' WHERE raison LIKE "at_pw :%" AND encryption_type = "teampass_aes"',
5812
5813
        'user-personal-items-migration-step30' => 'SELECT * FROM ' . prefixTable('categories_items') . 
5814
                    ' WHERE encryption_type = "teampass_aes"',
5815
5816
        'user-personal-items-migration-step40' => 'SELECT * FROM ' . prefixTable('suggestion'),
5817
5818
        'user-personal-items-migration-step50' => 'SELECT * FROM ' . prefixTable('files') . ' AS f
5819
                        INNER JOIN ' . prefixTable('items') . ' AS i ON i.id = f.id_item
5820
                        WHERE f.status = "' . TP_ENCRYPTION_NAME . '"'
5821
    ];
5822
5823
    // Perform loop on $queries to create sub-tasks
5824
    foreach ($queries as $step => $query) {
5825
        DB::query($query);
5826
        createAllSubTasks($step, DB::count(), $nbItemsToTreat, $processId);
5827
    }
5828
5829
    // Create subtask for step final
5830
    DB::insert(
5831
        prefixTable('background_subtasks'),
5832
        array(
5833
            'task_id' => $processId,
5834
            'created_at' => time(),
5835
            'task' => json_encode([
5836
                'step' => 'user-personal-items-migration-step-final',
5837
            ]),
5838
        )
5839
    );
5840
}
5841
5842
/**
5843
 * Add or update an item in user's latest items list (max 20, FIFO)
5844
 * 
5845
 * @param int $userId User ID
5846
 * @param int $itemId Item ID to add
5847
 * @return void
5848
 */
5849
function updateUserLatestItems(int $userId, int $itemId): void
5850
{
5851
    // 1. Insert or update the item with current timestamp
5852
    DB::query(
5853
        'INSERT INTO ' . prefixTable('users_latest_items') . ' (user_id, item_id, accessed_at)
5854
        VALUES (%i, %i, NOW())
5855
        ON DUPLICATE KEY UPDATE accessed_at = NOW()',
5856
        $userId,
5857
        $itemId
5858
    );
5859
    
5860
    // 2. Keep only the 20 most recent items (delete older ones)
5861
    DB::query(
5862
        'DELETE FROM ' . prefixTable('users_latest_items') . '
5863
        WHERE user_id = %i
5864
        AND increment_id NOT IN (
5865
            SELECT increment_id FROM (
5866
                SELECT increment_id 
5867
                FROM ' . prefixTable('users_latest_items') . '
5868
                WHERE user_id = %i
5869
                ORDER BY accessed_at DESC
5870
                LIMIT 20
5871
            ) AS keep_items
5872
        )',
5873
        $userId,
5874
        $userId
5875
    );
5876
}
5877
5878
/**
5879
 * Get complete user data with all relational tables
5880
 * 
5881
 * @param string $login User login
5882
 * @param int|null $userId User ID (alternative to login)
5883
 * @return array|null User data or null if not found
5884
 */
5885
function getUserCompleteData($login = null, $userId = null)
5886
{
5887
    if (empty($login) && empty($userId)) {
5888
        return null;
5889
    }
5890
    
5891
    // Build WHERE clause
5892
    if (!empty($login)) {
5893
        $whereClause = 'u.login = %s AND u.deleted_at IS NULL';
5894
        $whereParam = $login;
5895
    } else {
5896
        $whereClause = 'u.id = %i AND u.deleted_at IS NULL';
5897
        $whereParam = $userId;
5898
    }
5899
    
5900
    // Get user with all related data
5901
    $data = DB::queryFirstRow(
5902
        'SELECT u.*, 
5903
         a.value AS api_key, a.enabled AS api_enabled, a.allowed_folders as api_allowed_folders, a.allowed_to_create as api_allowed_to_create, a.allowed_to_read as api_allowed_to_read, a.allowed_to_update as api_allowed_to_update , a.allowed_to_delete as api_allowed_to_delete,
5904
         GROUP_CONCAT(DISTINCT ug.group_id ORDER BY ug.group_id SEPARATOR ";") AS groupes_visibles,
5905
         GROUP_CONCAT(DISTINCT ugf.group_id ORDER BY ugf.group_id SEPARATOR ";") AS groupes_interdits,
5906
         GROUP_CONCAT(DISTINCT CASE WHEN ur.source = "manual" THEN ur.role_id END ORDER BY ur.role_id SEPARATOR ";") AS fonction_id,
5907
         GROUP_CONCAT(DISTINCT CASE WHEN ur.source = "ad" THEN ur.role_id END ORDER BY ur.role_id SEPARATOR ";") AS roles_from_ad_groups,
5908
         GROUP_CONCAT(DISTINCT uf.item_id ORDER BY uf.created_at SEPARATOR ";") AS favourites,
5909
         GROUP_CONCAT(DISTINCT ul.item_id ORDER BY ul.accessed_at DESC SEPARATOR ";") AS latest_items
5910
        FROM ' . prefixTable('users') . ' AS u
5911
        LEFT JOIN ' . prefixTable('api') . ' AS a ON (u.id = a.user_id)
5912
        LEFT JOIN ' . prefixTable('users_groups') . ' AS ug ON (u.id = ug.user_id)
5913
        LEFT JOIN ' . prefixTable('users_groups_forbidden') . ' AS ugf ON (u.id = ugf.user_id)
5914
        LEFT JOIN ' . prefixTable('users_roles') . ' AS ur ON (u.id = ur.user_id)
5915
        LEFT JOIN ' . prefixTable('users_favorites') . ' AS uf ON (u.id = uf.user_id)
5916
        LEFT JOIN ' . prefixTable('users_latest_items') . ' AS ul ON (u.id = ul.user_id)
5917
        WHERE ' . $whereClause . '
5918
        GROUP BY u.id',
5919
        $whereParam
5920
    );
5921
    
5922
    // Ensure empty strings instead of NULL for concatenated fields
5923
    if ($data) {
5924
        $data['groupes_visibles'] = $data['groupes_visibles'] ?? '';
5925
        $data['groupes_interdits'] = $data['groupes_interdits'] ?? '';
5926
        $data['fonction_id'] = $data['fonction_id'] ?? '';
5927
        $data['roles_from_ad_groups'] = $data['roles_from_ad_groups'] ?? '';
5928
        $data['favourites'] = $data['favourites'] ?? '';
5929
        $data['latest_items'] = $data['latest_items'] ?? '';
5930
    }
5931
    
5932
    return $data;
5933
}
5934
5935
5936
5937
5938
// ----------------------
5939
// Users Groups
5940
// ----------------------
5941
/**
5942
 * Add a group to user's accessible groups
5943
 */
5944
function addUserGroup(int $userId, int $groupId): void
5945
{
5946
    DB::query(
5947
        'INSERT IGNORE INTO ' . prefixTable('users_groups') . ' (user_id, group_id)
5948
        VALUES (%i, %i)',
5949
        $userId,
5950
        $groupId
5951
    );
5952
}
5953
5954
/**
5955
 * Remove a group from user's accessible groups
5956
 */
5957
function removeUserGroup(int $userId, int $groupId): void
5958
{
5959
    DB::query(
5960
        'DELETE FROM ' . prefixTable('users_groups') . '
5961
        WHERE user_id = %i AND group_id = %i',
5962
        $userId,
5963
        $groupId
5964
    );
5965
}
5966
5967
/**
5968
 * Replace all user's groups with a new list
5969
 * 
5970
 * @param int $userId User ID
5971
 * @param array $groupIds Array of group IDs
5972
 */
5973
function setUserGroups(int $userId, array $groupIds): void
5974
{
5975
    // Delete all existing groups
5976
    DB::query(
5977
        'DELETE FROM ' . prefixTable('users_groups') . ' WHERE user_id = %i',
5978
        $userId
5979
    );
5980
    
5981
    // Insert new groups
5982
    foreach ($groupIds as $groupId) {
5983
        if (!empty($groupId) && is_numeric($groupId)) {
5984
            addUserGroup($userId, (int) $groupId);
5985
        }
5986
    }
5987
}
5988
5989
/**
5990
 * Get all user's accessible groups
5991
 * 
5992
 * @return array Array of group IDs
5993
 */
5994
function getUserGroups(int $userId): array
5995
{
5996
    $result = DB::query(
5997
        'SELECT group_id FROM ' . prefixTable('users_groups') . ' 
5998
        WHERE user_id = %i ORDER BY group_id',
5999
        $userId
6000
    );
6001
    return array_column($result, 'group_id');
6002
}
6003
// --<
6004
6005
// ----------------------
6006
// Users Groups Forbidden
6007
// ----------------------
6008
/**
6009
 * Add a forbidden group to user
6010
 */
6011
function addUserForbiddenGroup(int $userId, int $groupId): void
6012
{
6013
    DB::query(
6014
        'INSERT IGNORE INTO ' . prefixTable('users_groups_forbidden') . ' (user_id, group_id)
6015
        VALUES (%i, %i)',
6016
        $userId,
6017
        $groupId
6018
    );
6019
}
6020
6021
/**
6022
 * Remove a forbidden group from user
6023
 */
6024
function removeUserForbiddenGroup(int $userId, int $groupId): void
6025
{
6026
    DB::query(
6027
        'DELETE FROM ' . prefixTable('users_groups_forbidden') . '
6028
        WHERE user_id = %i AND group_id = %i',
6029
        $userId,
6030
        $groupId
6031
    );
6032
}
6033
6034
/**
6035
 * Replace all user's forbidden groups
6036
 */
6037
function setUserForbiddenGroups(int $userId, array $groupIds): void
6038
{
6039
    DB::query(
6040
        'DELETE FROM ' . prefixTable('users_groups_forbidden') . ' WHERE user_id = %i',
6041
        $userId
6042
    );
6043
    
6044
    foreach ($groupIds as $groupId) {
6045
        if (!empty($groupId) && is_numeric($groupId)) {
6046
            addUserForbiddenGroup($userId, (int) $groupId);
6047
        }
6048
    }
6049
}
6050
6051
/**
6052
 * Get all user's forbidden groups
6053
 */
6054
function getUserForbiddenGroups(int $userId): array
6055
{
6056
    $result = DB::query(
6057
        'SELECT group_id FROM ' . prefixTable('users_groups_forbidden') . ' 
6058
        WHERE user_id = %i ORDER BY group_id',
6059
        $userId
6060
    );
6061
    return array_column($result, 'group_id');
6062
}
6063
// ---<
6064
6065
// ----------------------
6066
// Users Roles
6067
// ----------------------
6068
/**
6069
 * Add a role to user
6070
 * 
6071
 * @param string $source 'manual', 'ad', 'ldap', 'oauth2'
6072
 */
6073
function addUserRole(int $userId, int $roleId, string $source = 'manual'): void
6074
{
6075
    DB::query(
6076
        'INSERT IGNORE INTO ' . prefixTable('users_roles') . ' (user_id, role_id, source)
6077
        VALUES (%i, %i, %s)',
6078
        $userId,
6079
        $roleId,
6080
        $source
6081
    );
6082
}
6083
6084
/**
6085
 * Remove a role from user
6086
 */
6087
function removeUserRole(int $userId, int $roleId, string $source = 'manual'): void
6088
{
6089
    DB::query(
6090
        'DELETE FROM ' . prefixTable('users_roles') . '
6091
        WHERE user_id = %i AND role_id = %i AND source = %s',
6092
        $userId,
6093
        $roleId,
6094
        $source
6095
    );
6096
}
6097
6098
/**
6099
 * Remove all roles of a specific source
6100
 */
6101
function removeUserRolesBySource(int $userId, string $source): void
6102
{
6103
    DB::query(
6104
        'DELETE FROM ' . prefixTable('users_roles') . '
6105
        WHERE user_id = %i AND source = %s',
6106
        $userId,
6107
        $source
6108
    );
6109
}
6110
6111
/**
6112
 * Replace all user's roles for a specific source
6113
 * 
6114
 * @param string $source 'manual', 'ad', 'ldap', 'oauth2'
6115
 */
6116
function setUserRoles(int $userId, array $roleIds, string $source = 'manual'): void
6117
{
6118
    // Delete existing roles for this source
6119
    removeUserRolesBySource($userId, $source);
6120
    
6121
    // Insert new roles
6122
    foreach ($roleIds as $roleId) {
6123
        if (!empty($roleId) && is_numeric($roleId)) {
6124
            addUserRole($userId, (int) $roleId, $source);
6125
        }
6126
    }
6127
}
6128
6129
/**
6130
 * Get all user's roles by source
6131
 * 
6132
 * @param string|null $source Filter by source, null = all sources
6133
 */
6134
function getUserRoles(int $userId, ?string $source = null): array
6135
{
6136
    if ($source !== null) {
6137
        $result = DB::query(
6138
            'SELECT role_id FROM ' . prefixTable('users_roles') . ' 
6139
            WHERE user_id = %i AND source = %s ORDER BY role_id',
6140
            $userId,
6141
            $source
6142
        );
6143
    } else {
6144
        $result = DB::query(
6145
            'SELECT role_id FROM ' . prefixTable('users_roles') . ' 
6146
            WHERE user_id = %i ORDER BY role_id',
6147
            $userId
6148
        );
6149
    }
6150
    return array_column($result, 'role_id');
6151
}
6152
// ---<
6153
6154
// ----------------------
6155
// Users Favorites
6156
// ----------------------
6157
/**
6158
 * Add an item to user's favorites
6159
 */
6160
function addUserFavorite(int $userId, int $itemId): void
6161
{
6162
    DB::query(
6163
        'INSERT IGNORE INTO ' . prefixTable('users_favorites') . ' (user_id, item_id)
6164
        VALUES (%i, %i)',
6165
        $userId,
6166
        $itemId
6167
    );
6168
}
6169
6170
/**
6171
 * Remove an item from user's favorites
6172
 */
6173
function removeUserFavorite(int $userId, int $itemId): void
6174
{
6175
    DB::query(
6176
        'DELETE FROM ' . prefixTable('users_favorites') . '
6177
        WHERE user_id = %i AND item_id = %i',
6178
        $userId,
6179
        $itemId
6180
    );
6181
}
6182
6183
/**
6184
 * Toggle favorite (add if not exists, remove if exists)
6185
 */
6186
function toggleUserFavorite(int $userId, int $itemId): bool
6187
{
6188
    $exists = DB::queryFirstRow(
6189
        'SELECT increment_id FROM ' . prefixTable('users_favorites') . '
6190
        WHERE user_id = %i AND item_id = %i',
6191
        $userId,
6192
        $itemId
6193
    );
6194
    
6195
    if ($exists) {
6196
        removeUserFavorite($userId, $itemId);
6197
        return false; // Removed
6198
    } else {
6199
        addUserFavorite($userId, $itemId);
6200
        return true; // Added
6201
    }
6202
}
6203
6204
/**
6205
 * Replace all user's favorites
6206
 */
6207
function setUserFavorites(int $userId, array $itemIds): void
6208
{
6209
    DB::query(
6210
        'DELETE FROM ' . prefixTable('users_favorites') . ' WHERE user_id = %i',
6211
        $userId
6212
    );
6213
    
6214
    foreach ($itemIds as $itemId) {
6215
        if (!empty($itemId) && is_numeric($itemId)) {
6216
            addUserFavorite($userId, (int) $itemId);
6217
        }
6218
    }
6219
}
6220
6221
/**
6222
 * Get all user's favorites
6223
 */
6224
function getUserFavorites(int $userId): array
6225
{
6226
    $result = DB::query(
6227
        'SELECT item_id FROM ' . prefixTable('users_favorites') . ' 
6228
        WHERE user_id = %i ORDER BY created_at DESC',
6229
        $userId
6230
    );
6231
    return array_column($result, 'item_id');
6232
}
6233
6234
/**
6235
 * Check if item is in user's favorites
6236
 */
6237
function isUserFavorite(int $userId, int $itemId): bool
6238
{
6239
    $result = DB::queryFirstRow(
6240
        'SELECT increment_id FROM ' . prefixTable('users_favorites') . '
6241
        WHERE user_id = %i AND item_id = %i',
6242
        $userId,
6243
        $itemId
6244
    );
6245
    return !empty($result);
6246
}
6247
// ---<
6248
6249
/**
6250
 * Sanitizes specific fields from a data array using a mapping of fields and filters.
6251
 *
6252
 * @param array $rawData The source array containing raw input data.
6253
 * @param array $inputsDefinition Associative array mapping field names to their filters (e.g., ['login' => 'trim|escape']).
6254
 * @return array The original array merged with the sanitized values.
6255
 */
6256
function sanitizeData(array $rawData, array $inputsDefinition): array
6257
{
6258
    $fieldsToProcess = [];
6259
    $filters = [];
6260
6261
    // Extract only the values we want to sanitize based on the definition
6262
    foreach ($inputsDefinition as $field => $filter) {
6263
        $fieldsToProcess[$field] = isset($rawData[$field]) ? $rawData[$field] : '';
6264
        $filters[$field] = $filter;
6265
    }
6266
6267
    // Perform sanitization and merge back into the original data set
6268
    // This ensures non-sanitized fields remain untouched
6269
    return array_merge(
6270
        $rawData,
6271
        dataSanitizer($fieldsToProcess, $filters)
0 ignored issues
show
Bug introduced by
It seems like dataSanitizer($fieldsToProcess, $filters) can also be of type string; however, parameter $arrays of array_merge() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

6271
        /** @scrutinizer ignore-type */ dataSanitizer($fieldsToProcess, $filters)
Loading history...
6272
    );
6273
}
6274
6275
6276
// <--
6277
/**
6278
 * Get or regenerate temporary key based on lifetime
6279
 * Creates a new key if current one is older than lifetime, otherwise returns existing key
6280
 * 
6281
 * @param int $userId User ID
6282
 * @param int $lifetimeSeconds Key lifetime in seconds (default: 3600 = 1 hour)
6283
 * 
6284
 * @return string Valid temporary key (existing or newly generated)
6285
 */
6286
function getOrRotateKeyTempo(int $userId, int $lifetimeSeconds = 3600): string
6287
{
6288
    $userData = DB::queryFirstRow(
6289
        'SELECT key_tempo, key_tempo_created_at FROM %l WHERE id=%i',
6290
        prefixTable('users'),
6291
        $userId
6292
    );
6293
    
6294
    // No key exists or no timestamp - generate new one
6295
    if (!$userData || empty($userData['key_tempo']) || $userData['key_tempo_created_at'] === null) {
6296
        return generateNewKeyTempo($userId);
6297
    }
6298
    
6299
    // Check if key is expired
6300
    $age = time() - (int)$userData['key_tempo_created_at'];
6301
    
6302
    if ($age > $lifetimeSeconds) {
6303
        // Key expired - generate new one
6304
        return generateNewKeyTempo($userId);
6305
    }
6306
    
6307
    // Key still valid - return existing
6308
    return $userData['key_tempo'];
6309
}
6310
6311
/**
6312
 * Generate a new temporary key with timestamp
6313
 * 
6314
 * @param int $userId User ID
6315
 * 
6316
 * @return string Generated key
6317
 */
6318
function generateNewKeyTempo(int $userId): string
6319
{
6320
    $keyTempo = bin2hex(random_bytes(16));
6321
    $createdAt = time();
6322
    
6323
    DB::update(
6324
        prefixTable('users'),
6325
        [
6326
            'key_tempo' => $keyTempo,
6327
            'key_tempo_created_at' => $createdAt
6328
        ],
6329
        'id=%i',
6330
        $userId
6331
    );
6332
    
6333
    return $keyTempo;
6334
}
6335
// -->
6336
6337
/**
6338
 * Trigger the background tasks handler manually.
6339
 * @return void
6340
 */
6341
function triggerBackgroundHandler(): void
6342
{
6343
    // Create the process to run the handler script
6344
    $process = new Process(['php', __DIR__.'/../scripts/background_tasks___handler.php']);
6345
    
6346
    // Run it asynchronously to avoid blocking the UI
6347
    $process->start();
6348
}