Passed
Push — master ( 87b47c...561fa9 )
by Nils
06:37
created

ItemModel::encryptPassword()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 4
nc 1
nop 1
dl 0
loc 6
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * Teampass - a collaborative passwords manager.
4
 * ---
5
 * This file is part of the TeamPass project.
6
 * 
7
 * TeamPass is free software: you can redistribute it and/or modify it
8
 * under the terms of the GNU General Public License as published by
9
 * the Free Software Foundation, version 3 of the License.
10
 * 
11
 * TeamPass is distributed in the hope that it will be useful,
12
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
 * GNU General Public License for more details.
15
 * 
16
 * You should have received a copy of the GNU General Public License
17
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
18
 * 
19
 * Certain components of this file may be under different licenses. For
20
 * details, see the `licenses` directory or individual file headers.
21
 * ---
22
 * @version    API
23
 *
24
 * @file      ItemModel.php
25
 * @author    Nils Laumaillé ([email protected])
26
 * @copyright 2009-2026 Teampass.net
27
 * @license   GPL-3.0
28
 * @see       https://www.teampass.net
29
 */
30
31
use TeampassClasses\NestedTree\NestedTree;
32
use TeampassClasses\ConfigManager\ConfigManager;
33
use ZxcvbnPhp\Zxcvbn;
34
35
class ItemModel
36
{
37
38
    /**
39
     * Get the list of items to return
40
     *
41
     * @param string $sqlExtra
42
     * @param integer $limit
43
     * @param string $userPrivateKey
44
     * @param integer $userId
45
     * @param bool $showItem
46
     * 
47
     * @return array
48
     */
49
    public function getItems(string $sqlExtra, int $limit, string $userPrivateKey, int $userId, bool $showItem = false): array
50
    {
51
        // Get items
52
        $rows = DB::query(
53
            "SELECT i.id, i.label, i.description, i.pw, i.url, i.id_tree, i.login, i.email, 
54
                i.viewed_no, i.fa_icon, i.inactif, i.perso, i.favicon_url, i.anyone_can_modify,
55
                t.title as folder_label, 
56
                io.secret as otp_secret,
57
                (SELECT GROUP_CONCAT(tg.tag SEPARATOR ', ') 
58
                 FROM " . prefixTable('tags') . " AS tg 
59
                 WHERE tg.item_id = i.id) as tags
60
            FROM " . prefixTable('items') . " AS i
61
            LEFT JOIN " . prefixTable('nested_tree') . " AS t ON (t.id = i.id_tree)
62
            LEFT JOIN " . prefixTable('items_otp') . " AS io ON (io.item_id = i.id)".
63
            $sqlExtra . 
64
            " ORDER BY i.id ASC" .
65
            ($limit > 0 ? " LIMIT ". $limit : '')
66
        ); 
67
        
68
        $ret = [];
69
        foreach ($rows as $row) {
70
            $userKey = DB::queryfirstrow(
71
                'SELECT share_key
72
                FROM ' . prefixTable('sharekeys_items') . '
73
                WHERE user_id = %i AND object_id = %i',
74
                $userId,
75
                $row['id']
76
            );
77
            if (DB::count() === 0 || empty($row['pw']) === true) {
78
                // No share key found
79
                // Exit this item
80
                continue;
81
            }
82
83
            // Get password
84
            try {
85
                $pwd = base64_decode(
86
                    (string) doDataDecryption(
87
                        $row['pw'],
88
                        decryptUserObjectKey(
89
                            $userKey['share_key'],
90
                            $userPrivateKey
91
                        )
92
                    )
93
                );
94
            } catch (Exception $e) {
95
                // Password is not encrypted
96
                // deepcode ignore ServerLeak: No important data
97
                echo "ERROR";
98
            }
99
            
100
101
            // get path to item
102
            $tree = new NestedTree(prefixTable('nested_tree'), 'id', 'parent_id', 'title');
103
            $arbo = $tree->getPath($row['id_tree'], false);
104
            $path = '';
105
            foreach ($arbo as $elem) {
106
                if (empty($path) === true) {
107
                    $path = htmlspecialchars(stripslashes(htmlspecialchars_decode($elem->title, ENT_QUOTES)), ENT_QUOTES);
108
                } else {
109
                    $path .= '/' . htmlspecialchars(stripslashes(htmlspecialchars_decode($elem->title, ENT_QUOTES)), ENT_QUOTES);
110
                }
111
            }
112
113
            // Get TOTP
114
            if (empty($row['otp_secret']) === false) {
115
                $decryptedTotp = cryption(
116
                    $row['otp_secret'],
117
                    '',
118
                    'decrypt'
119
                );
120
                $row['otp_secret'] = $decryptedTotp['string'];
121
            }
122
123
            array_push(
124
                $ret,
125
                [
126
                    'id' => (int) $row['id'],
127
                    'label' => $row['label'],
128
                    'description' => $row['description'],
129
                    'pwd' => $pwd,
130
                    'url' => $row['url'],
131
                    'login' => $row['login'],
132
                    'email' => $row['email'],
133
                    'viewed_no' => (int) $row['viewed_no'],
134
                    'fa_icon' => $row['fa_icon'],
135
                    'inactif' => (int) $row['inactif'],
136
                    'perso' => (int) $row['perso'],
137
                    'id_tree' => (int) $row['id_tree'],
138
                    'folder_label' => $row['folder_label'],
139
                    'path' => empty($path) === true ? '' : $path,
140
                    'totp' => $row['otp_secret'],
141
                    'favicon_url' => $row['favicon_url'],
142
                    'tags' => $row['tags'],
143
                    'anyone_can_modify' => $row['anyone_can_modify'],
144
                ]
145
            );
146
147
            // Increase viewed number
148
            if ($showItem === true) {
149
                logItems(
150
                    [],
151
                    (int) $row['id'],
152
                    $row['label'] ?? '',
153
                    (int) $userId,
154
                    'at_shown',
155
                    ''
156
                );
157
            }
158
        }
159
160
        return $ret;
161
    }
162
    //end getItems() 
163
164
    /**
165
     * Main function to add a new item to the database.
166
     * It handles data preparation, validation, password checks, folder settings,
167
     * item creation, and post-insertion tasks (like logging, sharing, and tagging).
168
     */
169
    public function addItem(
170
        array $arrItemParams
171
    ) : array
172
    {
173
        try {
174
            include_once API_ROOT_PATH . '/../sources/main.functions.php';
175
176
            // Extract parameters
177
            $folderId = (int) $arrItemParams['folder_id'];
178
            $label = (string) $arrItemParams['label'];
179
            $password = (string) $arrItemParams['password'];
180
            $tags = (string) $arrItemParams['tags'] ?? '';
181
            $userId = (int) $arrItemParams['id'];
182
            $username = (string) $arrItemParams['username'];
183
184
            // Load config
185
            $configManager = new ConfigManager();
186
            $SETTINGS = $configManager->getAllSettings();
187
188
            // Step 1: Prepare data and sanitize inputs
189
            $data = $this->prepareData($arrItemParams);
190
            $this->validateData($data); // Step 2: Validate the data
191
192
            // Step 3: Validate password rules (length, emptiness)
193
            $this->validatePassword($password, $SETTINGS);
194
195
            // Step 4: Check folder settings for permission checks
196
            $itemInfos = $this->getFolderSettings($folderId);
197
198
            // Step 5: Ensure the password meets folder complexity requirements
199
            $this->checkPasswordComplexity($password, array_merge($itemInfos, ['folderId' => $folderId]));
200
201
            // Step 6: Check for duplicates in the system
202
            $this->checkForDuplicates($label, $SETTINGS, $itemInfos);
203
204
            // Step 7: Encrypt password if provided
205
            $cryptedData = $this->encryptPassword($password);
206
            $passwordKey = $cryptedData['passwordKey'];
207
            $password = $cryptedData['encrypted'];
208
209
            // Generate favicon URL if URL is provided and favicon_url is empty
210
            if (empty($data['url']) === false) {
211
                $data['favicon_url'] = $this->getFaviconUrl($data['url']);
212
            }
213
214
            // Step 8: Insert the new item into the database
215
            $newID = $this->insertNewItem($data, $password, $itemInfos);
216
217
            // Step 9: Handle post-insert tasks (logging, sharing, tagging)
218
            $this->handlePostInsertTasks($newID, $itemInfos, $folderId, $passwordKey, $userId, $username, $tags, $data, $SETTINGS);
219
220
            // Success response
221
            return [
222
                'error' => false,
223
                'message' => 'Item added successfully',
224
                'newId' => $newID,
225
            ];
226
227
        } catch (Exception $e) {
228
            // Error response
229
            return [
230
                'error' => true,
231
                'error_header' => 'HTTP/1.1 422 Unprocessable Entity',
232
                'error_message' => $e->getMessage(),
233
            ];
234
        }
235
    }
236
237
    /**
238
     * Generate favicon URL using Google service
239
     * 
240
     * @param string $url Website URL
241
     * @return string|null Favicon URL
242
     */
243
    private function getFaviconUrl(string $url): ?string
244
    {
245
        try {
246
            $parsedUrl = parse_url($url);
247
            if (!isset($parsedUrl['host'])) {
248
                return null;
249
            }
250
            
251
            $domain = $parsedUrl['host'];
252
            
253
            // Quick DNS validation (very fast, ~50-100ms)
254
            if (!$this->isValidDomain($domain)) {
255
                return null;
256
            }
257
            
258
            // Google's service handles the rest gracefully
259
            return 'https://www.google.com/s2/favicons?domain=' . $domain . '&sz=32';
260
            
261
        } catch (Exception $e) {
262
            // Silent fail
263
            error_log('Favicon URL generation failed: ' . $e->getMessage());
264
            return null;
265
        }
266
    }
267
268
    /**
269
     * Validate domain using DNS lookup only
270
     * Very fast check (~50ms) without HTTP overhead
271
     * 
272
     * @param string $domain Domain to validate
273
     * @return bool True if domain has valid DNS records
274
     */
275
    private function isValidDomain(string $domain): bool
276
    {
277
        // Check for A or AAAA DNS records
278
        return checkdnsrr($domain, 'A') || checkdnsrr($domain, 'AAAA');
279
    }
280
281
    /**
282
     * Prepares the data array for processing by combining all inputs.
283
     * @param array $arrItemParams - Array of item parameters
284
     * @return array - Returns the prepared data
285
     */
286
    private function prepareData(
287
        array $arrItemParams
288
    ) : array {
289
        return [
290
            'folderId' => (int) $arrItemParams['folder_id'],
291
            'label' => (string) $arrItemParams['label'],
292
            'password' => (string) $arrItemParams['password'],
293
            'description' => (string) $arrItemParams['description'] ?? '',
294
            'login' => (string) $arrItemParams['login'] ?? '',
295
            'email' => (string) $arrItemParams['email'] ?? '',
296
            'tags' => (string) $arrItemParams['tags'] ?? '',
297
            'anyoneCanModify' => (int) $arrItemParams['anyone_can_modify'] ?? '0',
298
            'url' => (string) $arrItemParams['url'] ?? '',
299
            'icon' => (string) $arrItemParams['icon'] ?? '',
300
            'totp' => (string) $arrItemParams['totp'] ?? '',
301
            'favicon_url' => '',
302
        ];
303
    }
304
305
    /**
306
     * Sanitizes and validates the input data according to specified filters.
307
     * If validation fails, throws an exception.
308
     * @param array $data - Data to be validated and sanitized
309
     * @throws Exception - If the data is invalid
310
     */
311
    private function validateData(array $data) : void
312
    {
313
        $filters = [
314
            'folderId' => 'cast:integer',
315
            'label' => 'trim|escape',
316
            'password' => 'trim|escape',
317
            'description' => 'trim|escape',
318
            'login' => 'trim|escape',
319
            'email' => 'trim|escape',
320
            'tags' => 'trim|escape',
321
            'anyoneCanModify' => 'trim|escape',
322
            'url' => 'trim|escape',
323
            'icon' => 'trim|escape',
324
            'totp' => 'trim|escape',
325
        ];
326
327
        $inputData = dataSanitizer($data, $filters);
328
        if (is_string($inputData)) {
329
            throw new Exception('Data is not valid');
330
        }
331
    }
332
333
    /**
334
     * Validates the password against length and empty password rules.
335
     * Throws an exception if validation fails.
336
     * @param string $password - The password to validate
337
     * @param array $SETTINGS - Global settings from configuration
338
     * @throws Exception - If the password is invalid
339
     */
340
    private function validatePassword(string $password, array $SETTINGS) : void
341
    {
342
        if ($this->isPasswordEmptyAllowed($password, $SETTINGS['create_item_without_password'])) {
343
            throw new Exception('Empty password is not allowed');
344
        }
345
346
        if (strlen($password) > $SETTINGS['pwd_maximum_length']) {
347
            throw new Exception('Password is too long (max allowed is ' . $SETTINGS['pwd_maximum_length'] . ' characters)');
348
        }
349
    }
350
351
    /**
352
     * Retrieves folder-specific settings, including permission to modify and create items.
353
     * @param int $folderId - The folder ID to fetch settings for
354
     * @return array - Returns an array with folder-specific permissions
355
     */
356
    private function getFolderSettings(int $folderId) : array
357
    {
358
        $dataFolderSettings = DB::queryFirstRow(
359
            'SELECT bloquer_creation, bloquer_modification, personal_folder
360
            FROM ' . prefixTable('nested_tree') . ' 
361
            WHERE id = %i',
362
            $folderId
363
        );
364
365
        return [
366
            'personal_folder' => $dataFolderSettings['personal_folder'],
367
            'no_complex_check_on_modification' => (int) $dataFolderSettings['personal_folder'] === 1 ? 1 : (int) $dataFolderSettings['bloquer_modification'],
368
            'no_complex_check_on_creation' => (int) $dataFolderSettings['personal_folder'] === 1 ? 1 : (int) $dataFolderSettings['bloquer_creation'],
369
        ];
370
    }
371
372
    /**
373
     * Validates that the password meets the complexity requirements of the folder.
374
     * Throws an exception if the password is too weak.
375
     * @param string $password - The password to check
376
     * @param array $itemInfos - Folder settings including password complexity requirements
377
     * @throws Exception - If the password complexity is insufficient
378
     */
379
    private function checkPasswordComplexity(string $password, array $itemInfos) : void
380
    {
381
        // Check existence first
382
        if (isset($itemInfos['folderId']) === false) {
383
            throw new Exception('Folder ID is missing');
384
        }
385
386
        // Cast to integer for strict validation
387
        $folderId = (int) $itemInfos['folderId'];
388
389
        // Validate value
390
        if ($folderId <= 0) {
391
            throw new Exception('Invalid folder ID for complexity check');
392
        }
393
        
394
        $folderComplexity = DB::queryFirstRow(
395
            'SELECT valeur
396
            FROM ' . prefixTable('misc') . '
397
            WHERE type = %s AND intitule = %i',
398
            'complex',
399
            $itemInfos['folderId']
400
        );
401
402
        $requested_folder_complexity = $folderComplexity !== null ? (int) $folderComplexity['valeur'] : 0;
403
404
        $zxcvbn = new Zxcvbn();
405
        $passwordStrength = $zxcvbn->passwordStrength($password);
406
        $passwordStrengthScore = convertPasswordStrength($passwordStrength['score']);
407
error_log('Password strength score: ' . $passwordStrengthScore . ' | Required: ' . $requested_folder_complexity);
408
        if ($passwordStrengthScore < $requested_folder_complexity && (int) $itemInfos['no_complex_check_on_creation'] === 0) {
409
            throw new Exception('Password strength is too low');
410
        }
411
    }
412
413
    /**
414
     * Checks if an item with the same label already exists in the folder.
415
     * Throws an exception if duplicates are not allowed.
416
     * @param string $label - The label of the item to check for duplicates
417
     * @param array $SETTINGS - Global settings for duplicate items
418
     * @param array $itemInfos - Folder-specific settings
419
     * @throws Exception - If a duplicate item is found and not allowed
420
     */
421
    private function checkForDuplicates(string $label, array $SETTINGS, array $itemInfos) : void
422
    {
423
        DB::queryFirstRow(
424
            'SELECT * FROM ' . prefixTable('items') . '
425
            WHERE label = %s AND inactif = %i',
426
            $label,
427
            0
428
        );
429
430
        if (DB::count() > 0 && (
431
	     (isset($SETTINGS['duplicate_item']) && (int) $SETTINGS['duplicate_item'] === 0)
432
	     && (int) $itemInfos['personal_folder'] === 0)
433
        ) {
434
            throw new Exception('Similar item already exists. Duplicates are not allowed.');
435
        }
436
    }
437
438
    /**
439
     * Encrypts the password using the system's encryption function.
440
     * Returns an array containing both the encrypted password and the encryption key.
441
     * @param string $password - The password to encrypt
442
     * @return array - Returns the encrypted password and the encryption key
443
     */
444
    private function encryptPassword(string $password) : array
445
    {
446
        $cryptedStuff = doDataEncryption($password);
447
        return [
448
            'encrypted' => $cryptedStuff['encrypted'],
449
            'passwordKey' => $cryptedStuff['objectKey'],
450
        ];
451
    }
452
453
    /**
454
     * Inserts the new item into the database with all its associated data.
455
     * @param array $data - The item data to insert
456
     * @param string $password - The encrypted password
457
     * @param array $itemInfos - Folder-specific settings
458
     * @return int - Returns the ID of the newly created item
459
     */
460
    private function insertNewItem(array $data, string $password, array $itemInfos) : int
461
    {
462
        include_once API_ROOT_PATH . '/../sources/main.functions.php';
463
464
        DB::insert(
465
            prefixTable('items'),
466
            [
467
                'label' => $data['label'],
468
                'description' => $data['description'],
469
                'pw' => $password,
470
                'pw_iv' => '',
471
                'pw_len' => strlen($data['password']),
472
                'email' => $data['email'],
473
                'url' => $data['url'],
474
                'id_tree' => $data['folderId'],
475
                'login' => $data['login'],
476
                'inactif' => 0,
477
                'restricted_to' => '',
478
                'perso' => $itemInfos['personal_folder'],
479
                'anyone_can_modify' => $data['anyoneCanModify'],
480
                'complexity_level' => $this->getPasswordComplexityLevel($password),
481
                'encryption_type' => 'teampass_aes',
482
                'fa_icon' => $data['icon'],
483
                'item_key' => uniqidReal(50),
484
                'created_at' => time(),
485
                'favicon_url' => $data['favicon_url'],
486
            ]
487
        );
488
489
        $newItemId = DB::insertId();
490
491
        // Handle TOTP if provided
492
        if (empty($data['totp']) === true) {
493
            $encryptedSecret = cryption(
494
                $data['totp'],
495
                '',
496
                'encrypt'
497
            );
498
499
            DB::insert(
500
                prefixTable('items_otp'),
501
                array(
502
                    'item_id' => $newItemId,
503
                    'secret' => $encryptedSecret['string'],
504
                    'phone_number' => '',
505
                    'timestamp' => time(),
506
                    'enabled' => 1,
507
                )
508
            );
509
        }
510
511
        return $newItemId;
512
    }
513
514
    /**
515
     * Determines the complexity level of a password based on its strength score.
516
     * @param string $password - The encrypted password for which complexity is being evaluated
517
     * @return int - Returns the complexity level (0 for weak, higher numbers for stronger passwords)
518
     */
519
    private function getPasswordComplexityLevel(string $password) : int
520
    {
521
        $zxcvbn = new Zxcvbn();
522
        $passwordStrength = $zxcvbn->passwordStrength($password);
523
        return convertPasswordStrength($passwordStrength['score']);
524
    }
525
526
    /**
527
     * Handles tasks that need to be performed after the item is inserted:
528
     * 1. Stores sharing keys
529
     * 2. Logs the item creation
530
     * 3. Adds a task if the folder is not personal
531
     * 4. Adds tags to the item
532
     * @param int $newID - The ID of the newly created item
533
     * @param array $itemInfos - Folder-specific settings
534
     * @param int $folderId - Folder ID of the item
535
     * @param string $passwordKey - The encryption key for the item
536
     * @param int $userId - ID of the user creating the item
537
     * @param string $username - Username of the creator
538
     * @param string $tags - Tags to be associated with the item
539
     * @param array $data - The original data used to create the item (including the label)
540
     * @param array $SETTINGS - System settings for logging and task creation
541
     */
542
    private function handlePostInsertTasks(
543
        int $newID,
544
        array $itemInfos,
545
        int $folderId,
546
        string $passwordKey,
547
        int $userId,
548
        string $username,
549
        string $tags,
550
        array $data,
551
        array $SETTINGS
552
    ) : void {
553
        // Create share keys for the creator
554
        storeUsersShareKey(
555
            'sharekeys_items',
556
            (int) $itemInfos['personal_folder'],
557
            (int) $newID,
558
            $passwordKey,
559
            true,
560
            false,
561
            [],
562
            -1,
563
            $userId
564
        );
565
566
        // Log the item creation
567
        logItems($SETTINGS, $newID, $data['label'], $userId, 'at_creation', $username);
568
569
        // Create a task if the folder is not personal
570
        if ((int) $itemInfos['personal_folder'] === 0) {
571
            storeTask('new_item', $userId, 0, $folderId, $newID, $passwordKey, [], []);
572
        }
573
574
        // Add tags to the item
575
        $this->addTags($newID, $tags);
576
    }
577
578
579
    /**
580
     * Splits the tags string into individual tags and inserts them into the database.
581
     * @param int $newID - The ID of the item to associate tags with
582
     * @param string $tags - A comma-separated string of tags
583
     */
584
    private function addTags(int $newID, string $tags) : void
585
    {
586
        $tagsArray = explode(',', $tags);
587
        foreach ($tagsArray as $tag) {
588
            if (!empty($tag)) {
589
                DB::insert(
590
                    prefixTable('tags'),
591
                    ['item_id' => $newID, 'tag' => strtolower($tag)]
592
                );
593
            }
594
        }
595
    }
596
597
598
    private function isPasswordEmptyAllowed($password, $create_item_without_password)
599
    {
600
        if (
601
            empty($password) === true
602
            && null !== $create_item_without_password
603
            && (int) $create_item_without_password !== 1
604
        ) {
605
            return true;
606
        }
607
        return false;
608
    }
609
610
    /**
611
     * Main function to update an existing item in the database.
612
     * It handles data validation, password encryption, folder permission checks,
613
     * and updates the item with the provided fields.
614
     *
615
     * @param int $itemId The ID of the item to update
616
     * @param array $params Array of parameters to update
617
     * @param array $userData User data from JWT token
618
     * @param string $userPrivateKey User's private key for encryption
619
     * @return array Returns success or error response
620
     */
621
    public function updateItem(
622
        int $itemId,
623
        array $params,
624
        array $userData,
625
        string $userPrivateKey
0 ignored issues
show
Unused Code introduced by
The parameter $userPrivateKey is not used and could be removed. ( Ignorable by Annotation )

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

625
        /** @scrutinizer ignore-unused */ string $userPrivateKey

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
626
    ): array
627
    {
628
        try {
629
            include_once API_ROOT_PATH . '/../sources/main.functions.php';
630
631
            // Load config
632
            $configManager = new ConfigManager();
633
            $SETTINGS = $configManager->getAllSettings();
634
635
            // Load current item data
636
            $currentItem = DB::queryFirstRow(
637
                'SELECT * FROM ' . prefixTable('items') . ' WHERE id = %i',
638
                $itemId
639
            );
640
641
            if (DB::count() === 0) {
642
                return [
643
                    'error' => true,
644
                    'error_message' => 'Item not found',
645
                    'error_header' => 'HTTP/1.1 404 Not Found',
646
                ];
647
            }
648
649
            // Prepare update data
650
            $updateData = [];
651
            $passwordKey = null;
652
            $newPassword = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $newPassword is dead and can be removed.
Loading history...
653
654
            // Handle folder_id change
655
            if (isset($params['folder_id'])) {
656
                $newFolderId = (int) $params['folder_id'];
657
658
                // Check if user has access to the new folder
659
                if (!in_array((string) $newFolderId, $userData['folders_list'], true)) {
660
                    return [
661
                        'error' => true,
662
                        'error_message' => 'Access denied to the target folder',
663
                        'error_header' => 'HTTP/1.1 403 Forbidden',
664
                    ];
665
                }
666
667
                $updateData['id_tree'] = $newFolderId;
668
            }
669
670
            // Generate favicon URL if URL is provided and favicon_url is empty
671
            if (empty($currentItem['url']) === false) {
672
                $updateData['favicon_url'] = $this->getFaviconUrl($currentItem['url']);
673
            }
674
675
            $fieldsDefinitions = [
676
                'label'             => ['db_key' => 'label', 'type' => 'string'],
677
                'description'       => ['db_key' => 'description', 'type' => 'string'],
678
                'login'             => ['db_key' => 'login', 'type' => 'string'],
679
                'email'             => ['db_key' => 'email', 'type' => 'string'],
680
                'url'               => ['db_key' => 'url', 'type' => 'string'],
681
                'icon'              => ['db_key' => 'fa_icon', 'type' => 'string'],
682
                'anyone_can_modify' => ['db_key' => 'anyone_can_modify', 'type' => 'int'],
683
                'favicon_url' => ['db_key' => 'favicon_url', 'type' => 'string']
684
            ];
685
            foreach ($fieldsDefinitions as $paramKey => $def) {
686
                if (isset($params[$paramKey])) {
687
                    $updateData[$def['db_key']] = match($def['type']) {
688
                        'int'   => (int) $params[$paramKey],
689
                        default => $params[$paramKey],
690
                    };
691
                }
692
            }
693
694
            // Handle password update
695
            if (isset($params['password']) && !empty($params['password'])) {
696
                $newPassword = $params['password'];
697
698
                // Validate password length
699
                if (strlen($newPassword) > $SETTINGS['pwd_maximum_length']) {
700
                    return [
701
                        'error' => true,
702
                        'error_message' => 'Password is too long (max allowed is ' . $SETTINGS['pwd_maximum_length'] . ' characters)',
703
                        'error_header' => 'HTTP/1.1 400 Bad Request',
704
                    ];
705
                }
706
707
                // Get folder ID for complexity check
708
                $folderId = isset($updateData['id_tree']) ? $updateData['id_tree'] : $currentItem['id_tree'];
709
710
                // Get folder settings
711
                $itemInfos = $this->getFolderSettings((int) $folderId);
712
713
                // Check password complexity
714
                $this->checkPasswordComplexity($newPassword, array_merge($itemInfos, ['folderId' => $folderId]));
715
716
                // Encrypt password
717
                $cryptedData = $this->encryptPassword($newPassword);
718
                $passwordKey = $cryptedData['passwordKey'];
719
                $updateData['pw'] = $cryptedData['encrypted'];
720
                $updateData['pw_len'] = strlen($newPassword);
721
                $updateData['complexity_level'] = $this->getPasswordComplexityLevel($newPassword);
722
            }
723
            
724
            // Update the item
725
            if (!empty($updateData) || (isset($params['totp']) === true && empty($params['totp']) === false)) {
726
                $updateData['updated_at'] = time();
727
728
                DB::update(
729
                    prefixTable('items'),
730
                    $updateData,
731
                    'id = %i',
732
                    $itemId
733
                );
734
735
                // Handle TOTP update
736
                if (isset($params['totp']) === true && empty($params['totp']) === false) {
737
                    $encryptedSecret = cryption(
738
                        $params['totp'],
739
                        '',
740
                        'encrypt'
741
                    );
742
                    
743
                    DB::insertUpdate(
744
                        prefixTable('items_otp'),
745
                        [
746
                            'item_id' => $itemId,
747
                            'secret' => $encryptedSecret['string'],
748
                            'phone_number' => '',
749
                            'timestamp' => time(),
750
                            'enabled' => 1,
751
                        ]
752
                    );
753
                }
754
            } else if (empty($updateData)) {
755
                // Check if an entry exists for TOTP in items_otp table
756
                // IF yes delete it
757
                if (isset($params['totp']) === true && empty($params['totp']) === true) {
758
                    DB::delete(
759
                        prefixTable('items_otp'),
760
                        'item_id = %i',
761
                        $itemId
762
                    );
763
                }
764
            }
765
            
766
            // Handle tags update
767
            if (isset($params['tags'])) {
768
                // Delete existing tags
769
                DB::delete(
770
                    prefixTable('tags'),
771
                    'item_id = %i',
772
                    $itemId
773
                );
774
775
                // Add new tags
776
                $this->addTags($itemId, $params['tags']);
777
            }
778
779
            // If password was updated, update share keys
780
            if ($passwordKey !== null) {
781
                // Get folder ID (either new or current)
782
                $folderId = isset($updateData['id_tree']) ? $updateData['id_tree'] : $currentItem['id_tree'];
783
784
                // Get folder settings
785
                $itemInfos = $this->getFolderSettings((int) $folderId);
786
787
                // Update share keys for all users with access
788
                storeUsersShareKey(
789
                    'sharekeys_items',
790
                    (int) $itemInfos['personal_folder'],
791
                    $itemId,
792
                    $passwordKey,
793
                    false,
794
                    true,
795
                    [],
796
                    -1,
797
                    $userData['id']
798
                );
799
            }
800
801
            // Log the update
802
            $label = isset($updateData['label']) ? $updateData['label'] : $currentItem['label'];
803
            logItems($SETTINGS, $itemId, $label, $userData['id'], 'at_modification', $userData['username']);
804
805
            // Success response
806
            return [
807
                'error' => false,
808
                'message' => 'Item updated successfully',
809
                'item_id' => $itemId,
810
            ];
811
812
        } catch (Exception $e) {
813
            // Error response
814
            return [
815
                'error' => true,
816
                'error_header' => 'HTTP/1.1 500 Internal Server Error',
817
                'error_message' => $e->getMessage(),
818
            ];
819
        }
820
    }
821
822
    /**
823
     * Main function to delete an existing item in the database.
824
     *
825
     * @param int $itemId The ID of the item to delete
826
     * @param array $userData User data from JWT token
827
     * @return array Returns success or error response
828
     */
829
    public function deleteItem(
830
        int $itemId,
831
        array $userData
832
    ): array
833
    {
834
        try {
835
            include_once API_ROOT_PATH . '/../sources/main.functions.php';
836
837
            // Load config
838
            $configManager = new ConfigManager();
839
            $SETTINGS = $configManager->getAllSettings();
840
841
            // Load current item data
842
            $currentItem = DB::queryFirstRow(
843
                'SELECT * FROM ' . prefixTable('items') . ' WHERE id = %i',
844
                $itemId
845
            );
846
847
            if (DB::count() === 0) {
848
                return [
849
                    'error' => true,
850
                    'error_message' => 'Item not found',
851
                    'error_header' => 'HTTP/1.1 404 Not Found',
852
                ];
853
            }
854
855
            // delete item consists in disabling it
856
            DB::update(
857
                prefixTable('items'),
858
                array(
859
                    'inactif' => '1',
860
                    'deleted_at' => time(),
861
                ),
862
                'id = %i',
863
                $itemId
864
            );
865
866
            logItems($SETTINGS, $itemId, $currentItem['label'], $userData['id'], 'at_delete', $userData['username']);
867
868
            // Success response
869
            return [
870
                'error' => false,
871
                'message' => 'Item deleted successfully',
872
                'item_id' => $itemId,
873
            ];
874
875
        } catch (Exception $e) {
876
            // Error response
877
            return [
878
                'error' => true,
879
                'error_header' => 'HTTP/1.1 500 Internal Server Error',
880
                'error_message' => $e->getMessage(),
881
            ];
882
        }
883
    }
884
885
}
886