Passed
Pull Request — master (#5041)
by
unknown
06:24
created

tpRestoreAuthorizationFetchByHash()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 21
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 13
c 1
b 0
f 0
nc 3
nop 1
dl 0
loc 21
rs 9.8333
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      backup.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
/**
33
 * Teampass - Backup helper functions
34
 * This file provides reusable functions for database backup creation
35
 * (manual UI + scheduled/background tasks).
36
 */
37
38
if (!function_exists('tpCreateDatabaseBackup')) {
39
    /**
40
     * Create a Teampass database backup file (optionally encrypted) in files folder.
41
     *
42
     * @param array  $SETTINGS      Teampass settings array (must include path_to_files_folder)
43
     * @param string $encryptionKey Encryption key (Defuse). If empty => no encryption
44
     * @param array  $options       Optional:
45
     *                              - output_dir (string) default: $SETTINGS['path_to_files_folder']
46
     *                              - filename_prefix (string) default: '' (ex: 'scheduled-')
47
     *                              - chunk_rows (int) default: 1000
48
     *                              - flush_every_inserts (int) default: 200
49
     *                              - include_tables (array<string>) default: [] (empty => all)
50
     *                              - exclude_tables (array<string>) default: [] (empty => none)
51
     * @return array
52
     * @psalm-return array{success: bool, filename: string, filepath: string, encrypted: bool, size_bytes: int, message: string}
53
     */
54
    function tpCreateDatabaseBackup(array $SETTINGS, string $encryptionKey = '', array $options = []): array
55
    {
56
        // Ensure required dependencies are loaded
57
        $mainFunctionsPath = __DIR__ . '/main.functions.php';
58
        if ((!function_exists('GenerateCryptKey') || !function_exists('prefixTable')) && is_file($mainFunctionsPath)) {
59
            require_once $mainFunctionsPath;
60
        }
61
        if (function_exists('loadClasses') && !class_exists('DB')) {
62
            loadClasses('DB');
63
        }
64
65
        // Enable maintenance mode for the whole backup operation, then restore previous value at the end.
66
        // This is best-effort: a failure to toggle maintenance must not break the backup itself.
67
        /** @scrutinizer ignore-unused */
68
        $__tpMaintenanceGuard = new class() {
0 ignored issues
show
Unused Code introduced by
The assignment to $__tpMaintenanceGuard is dead and can be removed.
Loading history...
69
            private $prev = null;
70
            private $changed = false;
71
72
            public function __construct()
73
            {
74
                try {
75
                    $row = DB::queryFirstRow(
76
                        'SELECT valeur FROM ' . prefixTable('misc') . ' WHERE intitule=%s AND type=%s',
77
                        'maintenance_mode',
78
                        'admin'
79
                    );
80
81
                    if (is_array($row) && array_key_exists('valeur', $row)) {
82
                        $this->prev = (string) $row['valeur'];
83
                    }
84
85
                    // Only toggle if it was not already enabled
86
                    if ($this->prev !== '1') {
87
                        DB::update(
88
                            prefixTable('misc'),
89
                            array(
90
                                'valeur' => '1',
91
                                'updated_at' => time(),
92
                            ),
93
                            'intitule = %s AND type= %s',
94
                            'maintenance_mode',
95
                            'admin'
96
                        );
97
                        $this->changed = true;
98
                    }
99
                } catch (Throwable $ignored) {
100
                    // ignore
101
                }
102
            }
103
104
            public function __destruct()
105
            {
106
                if ($this->changed !== true) {
107
                    return;
108
                }
109
110
                try {
111
                    DB::update(
112
                        prefixTable('misc'),
113
                        array(
114
                            'valeur' => (string) ($this->prev ?? '0'),
115
                            'updated_at' => time(),
116
                        ),
117
                        'intitule = %s AND type= %s',
118
                        'maintenance_mode',
119
                        'admin'
120
                    );
121
                } catch (Throwable $ignored) {
122
                    // ignore
123
                }
124
            }
125
        };
126
127
        $outputDir = $options['output_dir'] ?? ($SETTINGS['path_to_files_folder'] ?? '');
128
        $prefix = (string)($options['filename_prefix'] ?? '');
129
        $chunkRows = (int)($options['chunk_rows'] ?? 1000);
130
        $flushEvery = (int)($options['flush_every_inserts'] ?? 200);
131
        $includeTables = $options['include_tables'] ?? [];
132
        $excludeTables = $options['exclude_tables'] ?? [];
133
134
        if ($outputDir === '' || !is_dir($outputDir) || !is_writable($outputDir)) {
135
            return [
136
                'success' => false,
137
                'filename' => '',
138
                'filepath' => '',
139
                'encrypted' => false,
140
                'size_bytes' => 0,
141
                'message' => 'Backup folder is not writable or not found: ' . $outputDir,
142
            ];
143
        }
144
145
        // Generate filename
146
        $token = function_exists('GenerateCryptKey')
147
            ? GenerateCryptKey(20, false, true, true, false, true)
148
            : bin2hex(random_bytes(10));
149
150
        // Schema level token in filename (used for compatibility checks during migrations)
151
$schemaLevel = '';
152
if (defined('UPGRADE_MIN_DATE')) {
153
    $schemaLevel = (string) UPGRADE_MIN_DATE;
154
}
155
if ($schemaLevel !== '' && preg_match('/^\d+$/', $schemaLevel) !== 1) {
156
    $schemaLevel = '';
157
}
158
$schemaSuffix = ($schemaLevel !== '') ? ('-sl' . $schemaLevel) : '';
159
$filename = $prefix . time() . '-' . $token . $schemaSuffix . '.sql';
160
        $filepath = rtrim($outputDir, '/') . '/' . $filename;
161
162
        $handle = @fopen($filepath, 'w+');
163
        if ($handle === false) {
164
            return [
165
                'success' => false,
166
                'filename' => $filename,
167
                'filepath' => $filepath,
168
                'encrypted' => false,
169
                'size_bytes' => 0,
170
                'message' => 'Could not create backup file: ' . $filepath,
171
            ];
172
        }
173
174
        $insertCount = 0;
175
176
        try {
177
            // Get all tables
178
            $tables = [];
179
            $result = DB::query('SHOW TABLES');
180
            foreach ($result as $row) {
181
                // SHOW TABLES returns key like 'Tables_in_<DB_NAME>'
182
                foreach ($row as $v) {
183
                    $tables[] = (string) $v;
184
                    break;
185
                }
186
            }
187
188
            // Filter tables if requested
189
            if (!empty($includeTables) && is_array($includeTables)) {
190
                $tables = array_values(array_intersect($tables, $includeTables));
191
            }
192
            if (!empty($excludeTables) && is_array($excludeTables)) {
193
                $tables = array_values(array_diff($tables, $excludeTables));
194
            }
195
196
            foreach ($tables as $tableName) {
197
                // Safety: only allow typical MySQL table identifiers
198
                if (!preg_match('/^[a-zA-Z0-9_]+$/', $tableName)) {
199
                    continue;
200
                }
201
202
                // Write drop and creation
203
                fwrite($handle, 'DROP TABLE IF EXISTS `' . $tableName . "`;\n");
204
205
                $row2 = DB::queryFirstRow('SHOW CREATE TABLE `' . $tableName . '`');
206
                if (!is_array($row2) || empty($row2['Create Table'])) {
207
                    // Skip table if structure cannot be fetched
208
                    fwrite($handle, "\n");
209
                    continue;
210
                }
211
212
                fwrite($handle, $row2['Create Table'] . ";\n\n");
213
214
                // Process table data in chunks to reduce memory usage
215
                $offset = 0;
216
                while (true) {
217
                    $rows = DB::query(
218
                        'SELECT * FROM `' . $tableName . '` LIMIT %i OFFSET %i',
219
                        $chunkRows,
220
                        $offset
221
                    );
222
223
                    if (empty($rows)) {
224
                        break;
225
                    }
226
227
                    foreach ($rows as $record) {
228
                        $values = [];
229
                        foreach ($record as $value) {
230
                            if ($value === null) {
231
                                $values[] = 'NULL';
232
                                continue;
233
                            }
234
235
                            // Force scalar/string
236
                            if (is_bool($value)) {
237
                                $value = $value ? '1' : '0';
238
                            } elseif (is_numeric($value)) {
239
                                // keep numeric as string but quoted (safe & consistent)
240
                                $value = (string) $value;
241
                            } else {
242
                                $value = (string) $value;
243
                            }
244
245
                            // Escape and keep newlines
246
                            $value = addslashes(preg_replace("/\n/", '\\n', $value));
247
                            $values[] = '"' . $value . '"';
248
                        }
249
250
                        $insertQuery = 'INSERT INTO `' . $tableName . '` VALUES(' . implode(',', $values) . ");\n";
251
                        fwrite($handle, $insertQuery);
252
253
                        $insertCount++;
254
                        if ($flushEvery > 0 && ($insertCount % $flushEvery) === 0) {
255
                            fflush($handle);
256
                        }
257
                    }
258
259
                    $offset += $chunkRows;
260
                    fflush($handle);
261
                }
262
263
                fwrite($handle, "\n\n");
264
                fflush($handle);
265
            }
266
        } catch (Throwable $e) {
267
            if (is_resource($handle)) {
268
                fclose($handle);
269
            }
270
271
            $errorMessage = 'Backup failed: ' . $e->getMessage();
272
273
            // Suppression sécurisée sans @
274
            if (file_exists($filepath)) {
275
                $deleted = unlink($filepath);
276
                if ($deleted === false) {
277
                    $errorMessage .= ' (Note: Temporary backup file could not be deleted from disk)';
278
                }
279
            }
280
281
            return [
282
                'success' => false,
283
                'filename' => $filename,
284
                'filepath' => $filepath,
285
                'encrypted' => false,
286
                'size_bytes' => 0,
287
                'message' => $errorMessage,
288
            ];
289
        }
290
291
        fclose($handle);
292
293
        // Encrypt the file if key provided
294
        $encrypted = false;
295
        if ($encryptionKey !== '') {
296
            $tmpPath = rtrim($outputDir, '/') . '/defuse_temp_' . $filename;
297
298
            if (!function_exists('prepareFileWithDefuse')) {
299
                if (file_exists($filepath)) {
300
                    unlink($filepath);
301
                }
302
                return [
303
                    'success' => false,
304
                    'filename' => $filename,
305
                    'filepath' => $filepath,
306
                    'encrypted' => false,
307
                    'size_bytes' => 0,
308
                    'message' => 'Missing prepareFileWithDefuse() dependency (main.functions.php not loaded?)',
309
                ];
310
            }
311
312
            $ret = prepareFileWithDefuse('encrypt', $filepath, $tmpPath, $encryptionKey);
313
314
            if ($ret !== true) {
315
                if (file_exists($filepath)) {
316
                    unlink($filepath);
317
                }
318
                if (file_exists($tmpPath)) {
319
                    unlink($tmpPath);
320
                }
321
                return [
322
                    'success' => false,
323
                    'filename' => $filename,
324
                    'filepath' => $filepath,
325
                    'encrypted' => false,
326
                    'size_bytes' => 0,
327
                    'message' => 'Encryption failed: ' . (is_string($ret) ? $ret : 'unknown error'),
0 ignored issues
show
introduced by
The condition is_string($ret) is always true.
Loading history...
328
                ];
329
            }
330
331
            // Replace original with encrypted version
332
            if (file_exists($filepath)) {
333
                unlink($filepath);
334
            }
335
            
336
            // On vérifie le succès de rename() sans @
337
            if (is_file($tmpPath) && !rename($tmpPath, $filepath)) {
338
                if (file_exists($tmpPath)) {
339
                    unlink($tmpPath);
340
                }
341
                return [
342
                    'success' => false,
343
                    'filename' => $filename,
344
                    'filepath' => $filepath,
345
                    'encrypted' => false,
346
                    'size_bytes' => 0,
347
                    'message' => 'Encryption succeeded but could not finalize file (rename failed)',
348
                ];
349
            }
350
351
            $encrypted = true;
352
        }
353
354
        // Gestion de filesize sans @
355
        $size = 0;
356
        if (is_file($filepath)) {
357
            $size = filesize($filepath);
358
            if ($size === false) {
359
                $size = 0;
360
            }
361
        }
362
363
        return [
364
            'success' => true,
365
            'filename' => $filename,
366
            'filepath' => $filepath,
367
            'encrypted' => $encrypted,
368
            'size_bytes' => (int) $size,
369
            'message' => '',
370
        ];
371
    }
372
}
373
374
375
// -----------------------------------------------------------------------------
376
// Helpers for restore logic (used by backups.queries.php)
377
// -----------------------------------------------------------------------------
378
379
if (function_exists('tpSafeUtf8String') === false) {
380
    /**
381
     * Ensure the returned string is valid UTF-8 and JSON-safe.
382
     *
383
     * Some crypto libraries can return messages containing non-UTF8 bytes.
384
     * Those would break json_encode() / prepareExchangedData().
385
     */
386
    function tpSafeUtf8String($value): string
387
    {
388
        if ($value === null) {
389
            return '';
390
        }
391
        if (is_bool($value)) {
392
            return $value ? '1' : '0';
393
        }
394
        if (is_scalar($value) === false) {
395
            $value = print_r($value, true);
396
        }
397
398
        $str = (string) $value;
399
400
        $isUtf8 = false;
401
        if (function_exists('mb_check_encoding')) {
402
            $isUtf8 = mb_check_encoding($str, 'UTF-8');
403
        } else {
404
            $isUtf8 = (@preg_match('//u', $str) === 1);
405
        }
406
407
        if ($isUtf8 === false) {
408
            // ASCII safe fallback
409
            return '[hex]' . bin2hex($str);
410
        }
411
412
        // Strip ASCII control chars
413
        $str = preg_replace("/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/", '', $str) ?? $str;
414
        return $str;
415
    }
416
}
417
if (function_exists('tpPrepareFileWithDefuseNormalized') === false) {
418
    /**
419
     * Wrapper around prepareFileWithDefuse() that normalizes return values across TeamPass versions.
420
     *
421
     * @return array{success: bool, message: string}
422
     */
423
    function tpPrepareFileWithDefuseNormalized(
424
        string $mode,
425
        string $sourceFile,
426
        string $destFile,
427
        string $encryptionKey
428
    ): array {
429
        if (function_exists('prepareFileWithDefuse') === false) {
430
            return ['success' => false, 'message' => 'prepareFileWithDefuse() is not available'];
431
        }
432
433
        try {
434
            $ret = prepareFileWithDefuse($mode, $sourceFile, $destFile, $encryptionKey);
435
436
            if ($ret === true) {
437
                return ['success' => true, 'message' => ''];
438
            }
439
440
            if (is_string($ret)) {
0 ignored issues
show
introduced by
The condition is_string($ret) is always true.
Loading history...
441
                return ['success' => false, 'message' => tpSafeUtf8String($ret)];
442
            }
443
444
            return ['success' => false, 'message' => 'Unknown error'];
445
        } catch (\Throwable $e) {
446
            return ['success' => false, 'message' => tpSafeUtf8String($e->getMessage())];
447
        }
448
    }
449
}
450
451
if (function_exists('tpDefuseDecryptWithCandidates') === false) {
452
    /**
453
     * Try to decrypt a file using Defuse with multiple candidate keys.
454
     *
455
     * @param array<int,string> $candidateKeys
456
     * @return array{success: bool, message: string, key_used?: string}
457
     */
458
    function tpDefuseDecryptWithCandidates(
459
        string $encryptedFile,
460
        string $decryptedFile,
461
        array $candidateKeys,
462
        array $SETTINGS = []
0 ignored issues
show
Unused Code introduced by
The parameter $SETTINGS 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

462
        /** @scrutinizer ignore-unused */ array $SETTINGS = []

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...
463
    ): array {
464
        $lastMsg = '';
465
        foreach ($candidateKeys as $k) {
466
            $k = (string)$k;
467
            if ($k === '') {
468
                continue;
469
            }
470
471
            // Ensure we start from a clean slate.
472
            if (is_file($decryptedFile) && !unlink($decryptedFile)) {
473
                // Nothing to do, try next key
474
            }
475
476
            $r = tpPrepareFileWithDefuseNormalized('decrypt', $encryptedFile, $decryptedFile, $k);
477
            if (!empty($r['success'])) {
478
                return ['success' => true, 'message' => '', 'key_used' => $k];
479
            }
480
            $lastMsg = tpSafeUtf8String((string)($r['message'] ?? ''));
481
        }
482
483
        return ['success' => false, 'message' => ($lastMsg !== '' ? $lastMsg : 'Unable to decrypt')];
484
    }
485
}
486
487
// -----------------------------------------------------------------------------
488
// Backup metadata helpers (.meta.json sidecar) and schema token parsing
489
// -----------------------------------------------------------------------------
490
// NOTE: schema_level is stored for internal checks only. UI must never display schema_level.
491
492
if (function_exists('tpGetTpFilesVersion') === false) {
493
    function tpGetTpFilesVersion(): string
494
    {
495
        if (defined('TP_VERSION') && defined('TP_VERSION_MINOR')) {
496
            return (string) TP_VERSION . '.' . (string) TP_VERSION_MINOR;
497
        }
498
        return '';
499
    }
500
}
501
502
if (function_exists('tpGetSchemaLevel') === false) {
503
    function tpGetSchemaLevel(): string
504
    {
505
        if (defined('UPGRADE_MIN_DATE')) {
506
            $v = (string) UPGRADE_MIN_DATE;
507
            if ($v !== '' && preg_match('/^\d+$/', $v) === 1) {
508
                return $v;
509
            }
510
        }
511
        return '';
512
    }
513
}
514
515
if (function_exists('tpGetBackupMetadataPath') === false) {
516
    function tpGetBackupMetadataPath(string $backupFilePath): string
517
    {
518
        return $backupFilePath . '.meta.json';
519
    }
520
}
521
522
if (function_exists('tpWriteBackupMetadata') === false) {
523
    /**
524
     * Write backup metadata sidecar file (<backup>.meta.json).
525
     *
526
     * @return array{success: bool, message: string, meta_path: string}
527
     */
528
    function tpWriteBackupMetadata(string $backupFilePath, string $tpFilesVersion = '', string $schemaLevel = '', array $extra = []): array
529
    {
530
        $metaPath = tpGetBackupMetadataPath($backupFilePath);
531
532
        if ($tpFilesVersion === '') {
533
            $tpFilesVersion = tpGetTpFilesVersion();
534
        }
535
        if ($schemaLevel === '') {
536
            $schemaLevel = tpGetSchemaLevel();
537
        }
538
539
        $payload = array_merge(
540
            [
541
                'tp_files_version' => $tpFilesVersion !== '' ? $tpFilesVersion : null,
542
                'schema_level' => $schemaLevel !== '' ? $schemaLevel : null,
543
                'created_at' => gmdate('c'),
544
            ],
545
            $extra
546
        );
547
548
        $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
549
        if ($json === false) {
550
            return ['success' => false, 'message' => 'Unable to encode metadata as JSON', 'meta_path' => $metaPath];
551
        }
552
553
        $ok = @file_put_contents($metaPath, $json, LOCK_EX);
554
        if ($ok === false) {
555
            return ['success' => false, 'message' => 'Unable to write metadata file', 'meta_path' => $metaPath];
556
        }
557
558
        return ['success' => true, 'message' => '', 'meta_path' => $metaPath];
559
    }
560
}
561
562
if (function_exists('tpReadBackupMetadata') === false) {
563
    function tpReadBackupMetadata(string $backupFilePath): array
564
    {
565
        $metaPath = tpGetBackupMetadataPath($backupFilePath);
566
        if (!is_file($metaPath)) {
567
            return [];
568
        }
569
        $raw = @file_get_contents($metaPath);
570
        if ($raw === false || trim($raw) === '') {
571
            return [];
572
        }
573
        $data = json_decode($raw, true);
574
        return is_array($data) ? $data : [];
575
    }
576
}
577
578
if (function_exists('tpParseSchemaLevelFromBackupFilename') === false) {
579
    function tpParseSchemaLevelFromBackupFilename(string $filename): string
580
    {
581
        $bn = basename($filename);
582
        if (preg_match('/-sl(\d+)(?:\D|$)/', $bn, $m) === 1) {
583
            return (string) $m[1];
584
        }
585
        return '';
586
    }
587
}
588
589
if (function_exists('tpGetBackupSchemaLevelFromMetaOrFilename') === false) {
590
    function tpGetBackupSchemaLevelFromMetaOrFilename(string $backupFilePath): string
591
    {
592
        $meta = tpReadBackupMetadata($backupFilePath);
593
        if (!empty($meta['schema_level']) && is_scalar($meta['schema_level'])) {
594
            $v = (string) $meta['schema_level'];
595
            if ($v !== '' && preg_match('/^\d+$/', $v) === 1) {
596
                return $v;
597
            }
598
        }
599
        $v = tpParseSchemaLevelFromBackupFilename($backupFilePath);
600
        return ($v !== '' && preg_match('/^\d+$/', $v) === 1) ? $v : '';
601
    }
602
}
603
604
if (function_exists('tpGetBackupTpFilesVersionFromMeta') === false) {
605
    function tpGetBackupTpFilesVersionFromMeta(string $backupFilePath): string
606
    {
607
        $meta = tpReadBackupMetadata($backupFilePath);
608
        if (!empty($meta['tp_files_version']) && is_scalar($meta['tp_files_version'])) {
609
            return (string) $meta['tp_files_version'];
610
        }
611
        return '';
612
    }
613
}
614
615
/**
616
 * -----------------------------------------------------------------------------
617
 * CLI Restore Authorization (one-shot token stored in teampass_misc)
618
 * -----------------------------------------------------------------------------
619
 * type    : restore_authorization_cli
620
 * intitule: sha256(token)
621
 * valeur  : JSON payload (status, initiator, file info, encrypted secrets)
622
 *
623
 * TTL fixed at 3600s (1 hour) for now.
624
 */
625
626
if (function_exists('tpRestoreAuthorizationCreate') === false) {
627
    function tpRestoreAuthorizationCreate(
628
        int $userId,
629
        string $userLogin,
630
        string $filePath,
631
        string $serverScope,
632
        string $serverFile,
633
        int $operationId,
634
        string $encryptionKey,
635
        string $overrideKey,
636
        array $compat,
637
        int $ttl,
638
        array $SETTINGS
639
    ): array {
640
        $ttl = (int) $ttl;
641
        if ($ttl <= 0) {
642
            $ttl = 3600;
643
        }
644
645
        $now = time();
646
647
        try {
648
            $token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
649
        } catch (Throwable $e) {
650
            return ['success' => false, 'message' => $e->getMessage()];
651
        }
652
653
        $tokenHash = hash('sha256', $token);
654
655
        $real = realpath($filePath);
656
        $resolvedPath = ($real !== false) ? $real : $filePath;
657
658
        $payload = [
659
            'status' => 'pending',
660
            'created_at' => $now,
661
            'expires_at' => $now + $ttl,
662
            'ttl' => $ttl,
663
            'initiator' => [
664
                'id' => $userId,
665
                'login' => $userLogin,
666
            ],
667
            'file' => [
668
                'path' => $resolvedPath,
669
                'basename' => basename($resolvedPath),
670
                'size' => is_file($resolvedPath) ? filesize($resolvedPath) : null,
671
                'mtime' => is_file($resolvedPath) ? filemtime($resolvedPath) : null,
672
            ],
673
            'source' => [
674
                'serverScope' => $serverScope,
675
                'serverFile' => $serverFile,
676
                'operation_id' => $operationId,
677
            ],
678
            'compat' => $compat,
679
            'secrets' => [],
680
        ];
681
682
        if ($encryptionKey !== '') {
683
            $enc = cryption($encryptionKey, '', 'encrypt', $SETTINGS);
684
            $payload['secrets']['encryptionKey'] = isset($enc['string']) ? (string) $enc['string'] : '';
685
        }
686
687
        if ($overrideKey !== '') {
688
            $enc = cryption($overrideKey, '', 'encrypt', $SETTINGS);
689
            $payload['secrets']['overrideKey'] = isset($enc['string']) ? (string) $enc['string'] : '';
690
        }
691
692
        $json = json_encode($payload, JSON_UNESCAPED_SLASHES);
693
        if ($json === false) {
694
            return ['success' => false, 'message' => 'json_encode_failed'];
695
        }
696
697
        // Insert token entry
698
        try {
699
            DB::insert(
700
                prefixTable('misc'),
701
                [
702
                    'type' => 'restore_authorization_cli',
703
                    'intitule' => $tokenHash,
704
                    'valeur' => $json,
705
                    'updated_at' => $now,
706
                ]
707
            );
708
            $id = (int) DB::insertId();
709
        } catch (Throwable $e) {
710
            // Fallback if updated_at column does not exist
711
            try {
712
                DB::insert(
713
                    prefixTable('misc'),
714
                    [
715
                        'type' => 'restore_authorization_cli',
716
                        'intitule' => $tokenHash,
717
                        'valeur' => $json,
718
                    ]
719
                );
720
                $id = (int) DB::insertId();
721
            } catch (Throwable $e2) {
722
                return ['success' => false, 'message' => $e2->getMessage()];
723
            }
724
        }
725
726
        return [
727
            'success' => true,
728
            'id' => $id,
729
            'token' => $token,
730
            'token_hash' => $tokenHash,
731
            'expires_at' => (int) $payload['expires_at'],
732
            'ttl' => $ttl,
733
        ];
734
    }
735
}
736
737
if (function_exists('tpRestoreAuthorizationFetchByHash') === false) {
738
    function tpRestoreAuthorizationFetchByHash(string $tokenHash): array
739
    {
740
        $row = DB::queryFirstRow(
741
            'SELECT increment_id, valeur FROM ' . prefixTable('misc') . ' WHERE type = %s AND intitule = %s LIMIT 1',
742
            'restore_authorization_cli',
743
            $tokenHash
744
        );
745
746
        if (empty($row)) {
747
            return ['success' => false, 'message' => 'not_found'];
748
        }
749
750
        $payload = json_decode((string) ($row['valeur'] ?? ''), true);
751
        if (!is_array($payload)) {
752
            return ['success' => false, 'message' => 'invalid_payload'];
753
        }
754
755
        return [
756
            'success' => true,
757
            'id' => (int) ($row['increment_id'] ?? 0),
758
            'payload' => $payload,
759
        ];
760
    }
761
}
762
763
if (function_exists('tpRestoreAuthorizationUpdatePayload') === false) {
764
    function tpRestoreAuthorizationUpdatePayload(int $id, array $payload): bool
765
    {
766
        $payload['updated_at'] = time();
767
        $json = json_encode($payload, JSON_UNESCAPED_SLASHES);
768
        if ($json === false) {
769
            return false;
770
        }
771
772
        try {
773
            DB::update(
774
                prefixTable('misc'),
775
                [
776
                    'valeur' => $json,
777
                    'updated_at' => time(),
778
                ],
779
                'increment_id=%i AND type=%s',
780
                $id,
781
                'restore_authorization_cli'
782
            );
783
            return true;
784
        } catch (Throwable $e) {
785
            // Fallback if updated_at column does not exist
786
            try {
787
                DB::update(
788
                    prefixTable('misc'),
789
                    [
790
                        'valeur' => $json,
791
                    ],
792
                    'increment_id=%i AND type=%s',
793
                    $id,
794
                    'restore_authorization_cli'
795
                );
796
                return true;
797
            } catch (Throwable $e2) {
798
                return false;
799
            }
800
        }
801
    }
802
}
803
804