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

tpCheckRestoreCompatibility()   F

Complexity

Conditions 23
Paths 232

Size

Total Lines 175
Code Lines 131

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 23
eloc 131
c 2
b 0
f 0
nc 232
nop 4
dl 0
loc 175
rs 2.3466

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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      backups.queries.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 TeampassClasses\NestedTree\NestedTree;
33
use TeampassClasses\SessionManager\SessionManager;
34
use Symfony\Component\HttpFoundation\Request as SymfonyRequest;
35
use TeampassClasses\Language\Language;
36
use EZimuel\PHPSecureSession;
37
use TeampassClasses\PerformChecks\PerformChecks;
38
use TeampassClasses\ConfigManager\ConfigManager;
39
40
41
// Load functions
42
require_once 'main.functions.php';
43
require_once __DIR__ . '/backup.functions.php';
44
$session = SessionManager::getSession();
45
46
47
// init
48
loadClasses('DB');
49
$session = SessionManager::getSession();
50
$request = SymfonyRequest::createFromGlobals();
51
// Detect if this is a restore continuation call.
52
// During a database restore, tables used for session/access checks can temporarily disappear.
53
// We allow continuation calls when they present a valid restore token stored in PHP session.
54
$earlyType = (string) $request->request->get('type', '');
55
if ($earlyType === '') {
56
    $earlyType = (string) $request->query->get('type', '');
57
}
58
$restoreToken = (string) $request->request->get('restore_token', '');
59
$restoreTokenSession = (string) ($session->get('restore-token') ?? '');
60
$isRestoreContinuation = (
61
    $earlyType === 'onthefly_restore'
62
    && $restoreToken !== ''
63
    && $restoreTokenSession !== ''
64
    && hash_equals($restoreTokenSession, $restoreToken)
65
);
66
67
$lang = new Language($session->get('user-language') ?? 'english');
68
69
// Load config (for restore continuations, prefer the snapshot stored in session)
70
$configManager = new ConfigManager();
71
$SETTINGS = [];
72
if ($isRestoreContinuation === true) {
73
    $tmpSettings = $session->get('restore-settings');
74
    if (is_array($tmpSettings) && !empty($tmpSettings)) {
75
        $SETTINGS = $tmpSettings;
76
    }
77
}
78
if (empty($SETTINGS)) {
79
    $SETTINGS = $configManager->getAllSettings();
80
}
81
82
// Do checks (skip for restore continuation calls, as the DB can be temporarily inconsistent)
83
if ($isRestoreContinuation === false) {
84
    // Do checks
85
    // Instantiate the class with posted data
86
    $checkUserAccess = new PerformChecks(
87
        dataSanitizer(
88
            [
89
                'type' => htmlspecialchars($request->request->get('type', ''), ENT_QUOTES, 'UTF-8'),
90
            ],
91
            [
92
                'type' => 'trim|escape',
93
            ],
94
        ),
95
        [
96
            'user_id' => returnIfSet($session->get('user-id'), null),
97
            'user_key' => returnIfSet($session->get('key'), null),
98
        ]
99
    );
100
    // Handle the case
101
    echo $checkUserAccess->caseHandler();
102
    if (
103
        $checkUserAccess->userAccessPage('backups') === false ||
104
        $checkUserAccess->checkSession() === false
105
    ) {
106
        // Not allowed page
107
        $session->set('system-error_code', ERR_NOT_ALLOWED);
108
        include $SETTINGS['cpassman_dir'] . '/error.php';
109
        exit;
110
    }
111
}
112
113
// Define Timezone
114
date_default_timezone_set($SETTINGS['timezone'] ?? 'UTC');
115
116
// Set header properties
117
header('Content-type: text/html; charset=utf-8');
118
header('Cache-Control: no-cache, no-store, must-revalidate');
119
120
// --------------------------------- //
121
122
123
// Prepare POST variables
124
$post_type = filter_input(INPUT_POST, 'type', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
125
if (empty($post_type) === true) {
126
    $post_type = filter_input(INPUT_GET, 'type', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
127
}
128
129
$post_key = filter_input(INPUT_POST, 'key', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
130
if (empty($post_key) === true) {
131
    $post_key = filter_input(INPUT_GET, 'key', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
132
}
133
$post_data = filter_input(
134
    INPUT_POST,
135
    'data',
136
    FILTER_SANITIZE_FULL_SPECIAL_CHARS,
137
    FILTER_FLAG_NO_ENCODE_QUOTES
138
);
139
140
// manage action required
141
    if (null !== $post_type) {
142
    /**
143
     * Read a setting from teampass_misc (type='settings', intitule=key).
144
     */
145
    function tpGetSettingsValue(string $key, string $default = ''): string
146
    {
147
        $val = DB::queryFirstField(
148
            'SELECT valeur FROM ' . prefixTable('misc') . ' WHERE type=%s AND intitule=%s LIMIT 1',
149
            'settings',
150
            $key
151
        );
152
153
        return ($val === null || $val === false || $val === '') ? $default : (string) $val;
154
    }
155
156
    /**
157
     * Upsert a setting into teampass_misc (type='settings', intitule=key).
158
     */
159
    function tpUpsertSettingsValue(string $key, string $value): void
160
    {
161
        $exists = DB::queryFirstField(
162
            'SELECT 1 FROM ' . prefixTable('misc') . ' WHERE type=%s AND intitule=%s LIMIT 1',
163
            'settings',
164
            $key
165
        );
166
167
        if ((int)$exists === 1) {
168
            DB::update(
169
                prefixTable('misc'),
170
                ['valeur' => $value],
171
                'type=%s AND intitule=%s',
172
                'settings',
173
                $key
174
            );
175
        } else {
176
            DB::insert(
177
                prefixTable('misc'),
178
                ['type' => 'settings', 'intitule' => $key, 'valeur' => $value]
179
            );
180
        }
181
    }
182
183
    /**
184
     * Get TeamPass timezone name from teampass_misc (type='admin', intitule='timezone').
185
     */
186
    function tpGetAdminTimezoneName(): string
187
    {
188
        $tz = DB::queryFirstField(
189
            'SELECT valeur FROM ' . prefixTable('misc') . ' WHERE type=%s AND intitule=%s LIMIT 1',
190
            'admin',
191
            'timezone'
192
        );
193
194
        return (is_string($tz) && $tz !== '') ? $tz : 'UTC';
195
    }
196
197
    function tpFormatBytes(float $bytes): string
198
    {
199
        $units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
200
        $i = 0;
201
        while ($bytes >= 1024 && $i < count($units) - 1) {
202
            $bytes /= 1024;
203
            $i++;
204
        }
205
206
        if ($i === 0) {
207
            return sprintf('%d %s', (int) $bytes, $units[$i]);
208
        }
209
210
        return sprintf('%.1f %s', $bytes, $units[$i]);
211
    }
212
213
// ---------------------------------------------------------------------
214
// Restore compatibility helpers
215
// ---------------------------------------------------------------------
216
// Compatibility is based on schema level (UPGRADE_MIN_DATE).
217
// UI must NOT display schema_level; only TeamPass files version.
218
219
function tpExpectedTpFilesVersion(): string
220
{
221
    if (function_exists('tpGetTpFilesVersion')) {
222
        $v = (string) tpGetTpFilesVersion();
223
        if ($v !== '') return $v;
224
    }
225
    if (defined('TP_VERSION') && defined('TP_VERSION_MINOR')) {
226
        return (string) TP_VERSION . '.' . (string) TP_VERSION_MINOR;
227
    }
228
    return '';
229
}
230
231
function tpCurrentSchemaLevel(): string
232
{
233
    if (function_exists('tpGetSchemaLevel')) {
234
        return (string) tpGetSchemaLevel();
235
    }
236
    if (defined('UPGRADE_MIN_DATE')) {
237
        $v = (string) UPGRADE_MIN_DATE;
238
        if ($v !== '' && preg_match('/^\d+$/', $v) === 1) return $v;
239
    }
240
    return '';
241
}
242
243
/**
244
 * Check restore compatibility (schema level).
245
 * - For server backups: reads schema_level from .meta.json when present, otherwise from "-sl<schema>" in filename.
246
 * - For uploaded restore file: reads schema from "-sl<schema>" preserved in filename stored in teampass_misc.
247
 *
248
 * @return array{is_compatible: bool, reason: string, backup_tp_files_version: ?string, expected_tp_files_version: string}
249
 */
250
function tpCheckRestoreCompatibility(array $SETTINGS, string $serverScope = '', string $serverFile = '', int $operationId = 0): array
251
{
252
    $expectedVersion = tpExpectedTpFilesVersion();
253
    $expectedSchema  = tpCurrentSchemaLevel();
254
255
    $backupVersion = null;
256
    $backupSchema  = '';
257
    $warnings = [];
258
    $mode = ($operationId > 0) ? 'upload_schema_only' : 'server_strict';
259
260
    // Resolve target file path
261
    $targetPath = '';
262
    if ($operationId > 0) {
263
        // Uploaded restore file (temp_file in misc)
264
        $data = DB::queryFirstRow(
265
            'SELECT valeur FROM ' . prefixTable('misc') . ' WHERE increment_id = %i LIMIT 1',
266
            $operationId
267
        );
268
        $val = isset($data['valeur']) ? (string) $data['valeur'] : '';
269
        if ($val === '') {
270
            return [
271
                'is_compatible' => false,
272
                'reason' => 'MISSING_UPLOAD_ENTRY',
273
                'mode' => $mode,
274
                'warnings' => $warnings,
275
                'backup_schema_level' => '',
276
                'expected_schema_level' => $expectedSchema,
277
                'backup_tp_files_version' => null,
278
                'expected_tp_files_version' => $expectedVersion,
279
                'resolved_path' => '',
280
            ];
281
        }
282
283
        $bn = basename($val);
284
        $baseDir = rtrim((string) ($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files')), '/');
285
        $targetPath = $baseDir . '/' . $bn;
286
287
        if (function_exists('tpParseSchemaLevelFromBackupFilename')) {
288
            $backupSchema = (string) tpParseSchemaLevelFromBackupFilename($bn);
289
        }
290
291
        // Upload restore has no meta => version cannot be verified by design
292
        $warnings[] = 'VERSION_NOT_VERIFIED';
293
    } elseif ($serverFile !== '') {
294
        $bn = basename($serverFile);
295
        if ($bn === '' || strtolower(pathinfo($bn, PATHINFO_EXTENSION)) !== 'sql') {
0 ignored issues
show
Bug introduced by
It seems like pathinfo($bn, PATHINFO_EXTENSION) can also be of type array; however, parameter $string of strtolower() does only seem to accept 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

295
        if ($bn === '' || strtolower(/** @scrutinizer ignore-type */ pathinfo($bn, PATHINFO_EXTENSION)) !== 'sql') {
Loading history...
296
            return [
297
                'is_compatible' => false,
298
                'reason' => 'INVALID_FILENAME',
299
                'mode' => $mode,
300
                'warnings' => $warnings,
301
                'backup_schema_level' => '',
302
                'expected_schema_level' => $expectedSchema,
303
                'backup_tp_files_version' => null,
304
                'expected_tp_files_version' => $expectedVersion,
305
                'resolved_path' => '',
306
            ];
307
        }
308
309
        $baseDir = rtrim((string) ($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files')), '/');
310
        if ($serverScope === 'scheduled') {
311
            $baseFilesDir = (string) ($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
312
            $dir = (string) tpGetSettingsValue('bck_scheduled_output_dir', rtrim($baseFilesDir, '/') . '/backups');
313
            $baseDir = rtrim($dir, '/');
314
        }
315
        $targetPath = $baseDir . '/' . $bn;
316
317
        if (function_exists('tpGetBackupTpFilesVersionFromMeta')) {
318
            $v = (string) tpGetBackupTpFilesVersionFromMeta($targetPath);
319
            if ($v !== '') {
320
                $backupVersion = $v;
321
            }
322
        }
323
        if (function_exists('tpGetBackupSchemaLevelFromMetaOrFilename')) {
324
            $backupSchema = (string) tpGetBackupSchemaLevelFromMetaOrFilename($targetPath);
325
        }
326
    } else {
327
        return [
328
            'is_compatible' => false,
329
            'reason' => 'NO_TARGET',
330
            'mode' => $mode,
331
            'warnings' => $warnings,
332
            'backup_schema_level' => '',
333
            'expected_schema_level' => $expectedSchema,
334
            'backup_tp_files_version' => null,
335
            'expected_tp_files_version' => $expectedVersion,
336
            'resolved_path' => '',
337
        ];
338
    }
339
340
    // File existence check (best-effort, for clearer errors)
341
    if ($targetPath !== '' && file_exists($targetPath) === false) {
342
        return [
343
            'is_compatible' => false,
344
            'reason' => 'FILE_NOT_FOUND',
345
            'mode' => $mode,
346
            'warnings' => $warnings,
347
            'backup_schema_level' => $backupSchema,
348
            'expected_schema_level' => $expectedSchema,
349
            'backup_tp_files_version' => $backupVersion,
350
            'expected_tp_files_version' => $expectedVersion,
351
            'resolved_path' => $targetPath,
352
        ];
353
    }
354
355
    // Schema is mandatory in all modes
356
    if ($backupSchema === '' || $expectedSchema === '') {
357
        return [
358
            'is_compatible' => false,
359
            'reason' => 'MISSING_SCHEMA',
360
            'mode' => $mode,
361
            'warnings' => $warnings,
362
            'backup_schema_level' => $backupSchema,
363
            'expected_schema_level' => $expectedSchema,
364
            'backup_tp_files_version' => $backupVersion,
365
            'expected_tp_files_version' => $expectedVersion,
366
            'resolved_path' => $targetPath,
367
        ];
368
    }
369
370
    if ((string) $backupSchema !== (string) $expectedSchema) {
371
        return [
372
            'is_compatible' => false,
373
            'reason' => 'SCHEMA_MISMATCH',
374
            'mode' => $mode,
375
            'warnings' => $warnings,
376
            'backup_schema_level' => $backupSchema,
377
            'expected_schema_level' => $expectedSchema,
378
            'backup_tp_files_version' => $backupVersion,
379
            'expected_tp_files_version' => $expectedVersion,
380
            'resolved_path' => $targetPath,
381
        ];
382
    }
383
384
    // Version check is strict for server-side backups only
385
    if ($mode === 'server_strict') {
386
        if ($expectedVersion === '' || $backupVersion === null || (string) $backupVersion === '') {
387
            return [
388
                'is_compatible' => false,
389
                'reason' => 'MISSING_VERSION_METADATA',
390
                'mode' => $mode,
391
                'warnings' => $warnings,
392
                'backup_schema_level' => $backupSchema,
393
                'expected_schema_level' => $expectedSchema,
394
                'backup_tp_files_version' => $backupVersion,
395
                'expected_tp_files_version' => $expectedVersion,
396
                'resolved_path' => $targetPath,
397
            ];
398
        }
399
400
        if ((string) $backupVersion !== (string) $expectedVersion) {
401
            return [
402
                'is_compatible' => false,
403
                'reason' => 'VERSION_MISMATCH',
404
                'mode' => $mode,
405
                'warnings' => $warnings,
406
                'backup_schema_level' => $backupSchema,
407
                'expected_schema_level' => $expectedSchema,
408
                'backup_tp_files_version' => $backupVersion,
409
                'expected_tp_files_version' => $expectedVersion,
410
                'resolved_path' => $targetPath,
411
            ];
412
        }
413
    }
414
415
    return [
416
        'is_compatible' => true,
417
        'reason' => '',
418
        'mode' => $mode,
419
        'warnings' => $warnings,
420
        'backup_schema_level' => $backupSchema,
421
        'expected_schema_level' => $expectedSchema,
422
        'backup_tp_files_version' => $backupVersion,
423
        'expected_tp_files_version' => $expectedVersion,
424
        'resolved_path' => $targetPath,
425
    ];
426
}
427
    switch ($post_type) {
428
        
429
        case 'scheduled_download_backup':
430
            // Download a scheduled backup file (encrypted) from the server
431
            if ($post_key !== $session->get('key') || (int) $session->get('user-admin') !== 1) {
432
                header('HTTP/1.1 403 Forbidden');
433
                exit;
434
            }
435
436
            $get_key_tmp = filter_input(INPUT_GET, 'key_tmp', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
437
            if (empty($get_key_tmp) === true || $get_key_tmp !== (string) $session->get('user-key_tmp')) {
438
                header('HTTP/1.1 403 Forbidden');
439
                exit;
440
            }
441
442
            $get_file = filter_input(INPUT_GET, 'file', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
443
            $get_file = basename((string) $get_file);
444
445
            // Safety check: only .sql files allowed
446
            $extension = (string) pathinfo($get_file, PATHINFO_EXTENSION);
447
            if (strtolower($extension) !== 'sql') {
448
                header('HTTP/1.1 400 Bad Request');
449
                exit;
450
            }
451
452
            // Safety check: only scheduled-*.sql files allowed
453
            if ($get_file === '' || strpos($get_file, 'scheduled-') !== 0) {
454
                header('HTTP/1.1 400 Bad Request');
455
                exit;
456
            }
457
458
            $baseFilesDir = (string) ($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
459
            $dir = (string) tpGetSettingsValue('bck_scheduled_output_dir', rtrim($baseFilesDir, '/') . '/backups');
460
            $fp = rtrim($dir, '/') . '/' . $get_file;
461
462
            $dirReal = realpath($dir);
463
            $fpReal = realpath($fp);
464
465
            if ($dirReal === false || $fpReal === false || strpos($fpReal, $dirReal . DIRECTORY_SEPARATOR) !== 0 || is_file($fpReal) === false) {
466
                header('HTTP/1.1 404 Not Found');
467
                exit;
468
            }
469
470
            /**
471
             * Stream file with proper error handling
472
             * Removes output buffers, sets execution time limit, and validates file
473
             *
474
             * @param string $fpReal Real file path
475
             * @return int File size in bytes, 0 if file doesn't exist
476
             */
477
            // Set unlimited execution time if function is available and not disabled
478
            if (function_exists('set_time_limit') && !ini_get('safe_mode')) {
479
                set_time_limit(0);
480
            }
481
482
            // Get file size with proper validation
483
            $size = 0;
484
            if (file_exists($fpReal) && is_readable($fpReal)) {
485
                $size = (int) filesize($fpReal);
486
            }
487
488
            // Clear all output buffers
489
            if (function_exists('ob_get_level')) {
490
                while (ob_get_level() > 0) {
491
                    ob_end_clean();
492
                }
493
            }
494
495
            header('Content-Description: File Transfer');
496
            header('Content-Type: application/octet-stream');
497
            header('Content-Disposition: attachment; filename="' . $get_file . '"');
498
            header('Content-Transfer-Encoding: binary');
499
            header('Expires: 0');
500
            header('Cache-Control: private, must-revalidate');
501
            header('Pragma: public');
502
            if ($size > 0) {
503
                header('Content-Length: ' . $size);
504
            }
505
506
            readfile($fpReal);
507
            exit;
508
509
        //CASE adding a new function
510
        case 'onthefly_backup':
511
            // Check KEY
512
            if ($post_key !== $session->get('key')) {
513
                echo prepareExchangedData(
514
                    array(
515
                        'error' => true,
516
                        'message' => $lang->get('key_is_not_correct'),
517
                    ),
518
                    'encode'
519
                );
520
                break;
521
            } elseif ($session->get('user-admin') === 0) {
522
                echo prepareExchangedData(
523
                    array(
524
                        'error' => true,
525
                        'message' => $lang->get('error_not_allowed_to'),
526
                    ),
527
                    'encode'
528
                );
529
                break;
530
            }
531
        
532
            // Decrypt and retrieve data in JSON format
533
            $dataReceived = prepareExchangedData(
534
                $post_data,
535
                'decode'
536
            );
537
        
538
            // Prepare variables
539
            $encryptionKey = filter_var($dataReceived['encryptionKey'] ?? '', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
540
        
541
            require_once __DIR__ . '/backup.functions.php';
542
543
            $backupResult = tpCreateDatabaseBackup($SETTINGS, $encryptionKey);
544
545
            if (($backupResult['success'] ?? false) !== true) {
546
                echo prepareExchangedData(
547
                    array(
548
                        'error' => true,
549
                        'message' => $backupResult['message'] ?? 'Backup failed',
550
                    ),
551
                    'encode'
552
                );
553
                break;
554
            }
555
556
            $filename = $backupResult['filename'];
557
// Write metadata sidecar (<backup>.meta.json) for fast listings / migration safety
558
try {
559
    if (function_exists('tpWriteBackupMetadata') && !empty($backupResult['filepath'])) {
560
        tpWriteBackupMetadata((string)$backupResult['filepath'], '', '', ['source' => 'onthefly']);
561
    }
562
} catch (Throwable $ignored) {
563
    // best effort
564
}
565
        
566
            // Generate 2d key
567
            $session->set('user-key_tmp', GenerateCryptKey(16, false, true, true, false, true));
568
        
569
            // Update LOG
570
            logEvents(
571
                $SETTINGS,
572
                'admin_action',
573
                'dataBase backup',
574
                (string) $session->get('user-id'),
575
                $session->get('user-login'),
576
                $filename
577
            );
578
        
579
            echo prepareExchangedData(
580
                array(
581
                    'error' => false,
582
                    'message' => '',
583
                    'download' => 'sources/downloadFile.php?name=' . urlencode($filename) .
584
                        '&action=backup&file=' . $filename . '&type=sql&key=' . $session->get('key') . '&key_tmp=' .
585
                        $session->get('user-key_tmp') . '&pathIsFiles=1',
586
                ),
587
                'encode'
588
            );
589
            break;
590
        /* ============================================================
591
         * Scheduled backups (UI)
592
         * ============================================================ */
593
594
        case 'scheduled_get_settings':
595
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
596
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
597
                break;
598
            }
599
600
            echo prepareExchangedData([
601
                'error' => false,
602
                'settings' => [
603
                    'enabled' => (int) tpGetSettingsValue('bck_scheduled_enabled', '0'),
604
                    'frequency' => (string) tpGetSettingsValue('bck_scheduled_frequency', 'daily'),
605
                    'time' => (string) tpGetSettingsValue('bck_scheduled_time', '02:00'),
606
                    'dow' => (int) tpGetSettingsValue('bck_scheduled_dow', '1'),
607
                    'dom' => (int) tpGetSettingsValue('bck_scheduled_dom', '1'),
608
                    'output_dir' => (string) tpGetSettingsValue('bck_scheduled_output_dir', ''),
609
                    'retention_days' => (int) tpGetSettingsValue('bck_scheduled_retention_days', '30'),
610
611
                    'next_run_at' => (int) tpGetSettingsValue('bck_scheduled_next_run_at', '0'),
612
                    'last_run_at' => (int) tpGetSettingsValue('bck_scheduled_last_run_at', '0'),
613
                    'last_status' => (string) tpGetSettingsValue('bck_scheduled_last_status', ''),
614
                    'last_message' => (string) tpGetSettingsValue('bck_scheduled_last_message', ''),
615
                    'last_completed_at' => (int) tpGetSettingsValue('bck_scheduled_last_completed_at', '0'),
616
                    'last_purge_at' => (int) tpGetSettingsValue('bck_scheduled_last_purge_at', '0'),
617
                    'last_purge_deleted' => (int) tpGetSettingsValue('bck_scheduled_last_purge_deleted', '0'),
618
619
                    'email_report_enabled' => (int) tpGetSettingsValue('bck_scheduled_email_report_enabled', '0'),
620
                    'email_report_only_failures' => (int) tpGetSettingsValue('bck_scheduled_email_report_only_failures', '0'),
621
622
                    'timezone' => tpGetAdminTimezoneName(),
623
                ],
624
            ], 'encode');
625
            break;
626
627
        case 'disk_usage':
628
            // Provide disk usage information for the storage containing the <files> directory
629
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
630
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
631
                break;
632
            }
633
634
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
635
            $dirReal = realpath($baseFilesDir);
636
637
            if ($dirReal === false) {
638
                echo prepareExchangedData(['error' => true, 'message' => 'Invalid path'], 'encode');
639
                break;
640
            }
641
642
            $total = @disk_total_space($dirReal);
643
            $free = @disk_free_space($dirReal);
644
645
            if ($total === false || $free === false || (float)$total <= 0) {
646
                echo prepareExchangedData(['error' => true, 'message' => 'Unable to read disk usage'], 'encode');
647
                break;
648
            }
649
650
            $used = max(0.0, (float)$total - (float)$free);
651
            $pct = round(($used / (float)$total) * 100, 1);
652
653
            $label = tpFormatBytes($used) . ' / ' . tpFormatBytes((float)$total);
654
            $tooltip = sprintf(
655
                $lang->get('bck_storage_usage_tooltip'),
656
                tpFormatBytes($used),
657
                tpFormatBytes((float)$total),
658
                (string)$pct,
659
                tpFormatBytes((float)$free),
660
                $dirReal
661
            );
662
663
            echo prepareExchangedData(
664
                [
665
                    'error' => false,
666
                    'used_percent' => $pct,
667
                    'label' => $label,
668
                    'tooltip' => $tooltip,
669
                    'path' => $dirReal,
670
                ],
671
                'encode'
672
            );
673
            break;
674
675
        
676
        case 'copy_instance_key':
677
            // Return decrypted instance key (admin only)
678
            if ($post_key !== $session->get('key') || (int) $session->get('user-admin') !== 1) {
679
                echo prepareExchangedData(
680
                    array('error' => true, 'message' => $lang->get('error_not_allowed_to')),
681
                    'encode'
682
                );
683
                break;
684
            }
685
686
            if (empty($SETTINGS['bck_script_passkey'] ?? '') === true) {
687
                echo prepareExchangedData(
688
                    array('error' => true, 'message' => $lang->get('bck_instance_key_not_set')),
689
                    'encode'
690
                );
691
                break;
692
            }
693
694
            $tmp = cryption($SETTINGS['bck_script_passkey'], '', 'decrypt', $SETTINGS);
695
            $instanceKey = isset($tmp['string']) ? (string) $tmp['string'] : '';
696
            if ($instanceKey === '') {
697
                echo prepareExchangedData(
698
                    array('error' => true, 'message' => $lang->get('bck_instance_key_not_set')),
699
                    'encode'
700
                );
701
                break;
702
            }
703
704
            echo prepareExchangedData(
705
                array('error' => false, 'instanceKey' => $instanceKey),
706
                'encode'
707
            );
708
            break;
709
710
        case 'scheduled_save_settings':
711
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
712
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
713
                break;
714
            }
715
716
            $dataReceived = prepareExchangedData($post_data, 'decode');
717
            if (!is_array($dataReceived)) $dataReceived = [];
718
719
            $enabled = (int)($dataReceived['enabled'] ?? 0);
720
            $enabled = ($enabled === 1) ? 1 : 0;
721
722
723
            $emailReportEnabled = (int)($dataReceived['email_report_enabled'] ?? 0);
724
            $emailReportEnabled = ($emailReportEnabled === 1) ? 1 : 0;
725
726
            $emailReportOnlyFailures = (int)($dataReceived['email_report_only_failures'] ?? 0);
727
            $emailReportOnlyFailures = ($emailReportOnlyFailures === 1) ? 1 : 0;
728
729
            if ($emailReportEnabled === 0) {
730
                $emailReportOnlyFailures = 0;
731
            }
732
733
            $frequency = (string)($dataReceived['frequency'] ?? 'daily');
734
            if (!in_array($frequency, ['daily', 'weekly', 'monthly'], true)) {
735
                $frequency = 'daily';
736
            }
737
738
            $timeStr = (string)($dataReceived['time'] ?? '02:00');
739
            if (!preg_match('/^\d{2}:\d{2}$/', $timeStr)) {
740
                $timeStr = '02:00';
741
            } else {
742
                [$hh, $mm] = array_map('intval', explode(':', $timeStr));
743
                if ($hh < 0 || $hh > 23 || $mm < 0 || $mm > 59) {
744
                    $timeStr = '02:00';
745
                }
746
            }
747
748
            $dow = (int)($dataReceived['dow'] ?? 1);
749
            if ($dow < 1 || $dow > 7) $dow = 1;
750
751
            $dom = (int)($dataReceived['dom'] ?? 1);
752
            if ($dom < 1) $dom = 1;
753
            if ($dom > 31) $dom = 31;
754
755
            $retentionDays = (int)($dataReceived['retention_days'] ?? 30);
756
            if ($retentionDays < 1) $retentionDays = 1;
757
            if ($retentionDays > 3650) $retentionDays = 3650;
758
759
            // Output dir: default to <files>/backups
760
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
761
            $defaultDir = rtrim($baseFilesDir, '/') . '/backups';
762
763
            $outputDir = trim((string)($dataReceived['output_dir'] ?? ''));
764
            if ($outputDir === '') $outputDir = $defaultDir;
765
766
            // Safety: prevent path traversal / outside files folder
767
            @mkdir($outputDir, 0770, true);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for mkdir(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

767
            /** @scrutinizer ignore-unhandled */ @mkdir($outputDir, 0770, true);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
768
            $baseReal = realpath($baseFilesDir) ?: $baseFilesDir;
769
            $dirReal = realpath($outputDir);
770
771
            if ($dirReal === false || strpos($dirReal, $baseReal) !== 0) {
772
                echo prepareExchangedData(['error' => true, 'message' => 'Invalid output directory'], 'encode');
773
                break;
774
            }
775
776
            tpUpsertSettingsValue('bck_scheduled_enabled', (string)$enabled);
777
            tpUpsertSettingsValue('bck_scheduled_frequency', $frequency);
778
            tpUpsertSettingsValue('bck_scheduled_time', $timeStr);
779
            tpUpsertSettingsValue('bck_scheduled_dow', (string)$dow);
780
            tpUpsertSettingsValue('bck_scheduled_dom', (string)$dom);
781
            tpUpsertSettingsValue('bck_scheduled_output_dir', $dirReal);
782
            tpUpsertSettingsValue('bck_scheduled_retention_days', (string)$retentionDays);
783
            tpUpsertSettingsValue('bck_scheduled_email_report_enabled', (string)$emailReportEnabled);
784
            tpUpsertSettingsValue('bck_scheduled_email_report_only_failures', (string)$emailReportOnlyFailures);
785
786
787
            // Force re-init of next_run_at so handler recomputes cleanly
788
            tpUpsertSettingsValue('bck_scheduled_next_run_at', '0');
789
790
            echo prepareExchangedData(['error' => false, 'message' => 'Saved'], 'encode');
791
            break;
792
793
        case 'scheduled_list_backups':
794
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
795
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
796
                break;
797
            }
798
799
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
800
            $dir = (string)tpGetSettingsValue('bck_scheduled_output_dir', rtrim($baseFilesDir, '/') . '/backups');
801
            @mkdir($dir, 0770, true);
802
            // Build a relative path from files/ root (output_dir can be a subfolder)
803
            $filesRoot = realpath($baseFilesDir);
804
            $dirReal = realpath($dir);
805
            $relDir = '';
806
            if ($filesRoot !== false && $dirReal !== false && strpos($dirReal, $filesRoot) === 0) {
807
                $relDir = trim(str_replace($filesRoot, '', $dirReal), DIRECTORY_SEPARATOR);
808
                $relDir = str_replace(DIRECTORY_SEPARATOR, '/', $relDir);
809
            }
810
811
            // Ensure we have a temporary key for downloadFile.php
812
            $keyTmp = (string) $session->get('user-key_tmp');
813
            if ($keyTmp === '') {
814
                $keyTmp = GenerateCryptKey(16, false, true, true, false, true);
815
                $session->set('user-key_tmp', $keyTmp);
816
            }
817
818
819
            $files = [];
820
            foreach (glob(rtrim($dir, '/') . '/scheduled-*.sql') ?: [] as $fp) {
821
                $bn = basename($fp);
822
                $files[] = [
823
                    'name' => $bn,
824
                    'size_bytes' => (int)@filesize($fp),
825
                    'mtime' => (int)@filemtime($fp),
826
                    'tp_files_version' => (function_exists('tpGetBackupTpFilesVersionFromMeta') ? ((($v = (string)tpGetBackupTpFilesVersionFromMeta($fp)) !== '') ? $v : null) : null),
827
                    'download' => 'sources/backups.queries.php?type=scheduled_download_backup&file=' . urlencode($bn)
828
                        . '&key=' . urlencode((string) $session->get('key'))
829
                        . '&key_tmp=' . urlencode($keyTmp),
830
                ];
831
            }
832
833
            usort($files, fn($a, $b) => ($b['mtime'] ?? 0) <=> ($a['mtime'] ?? 0));
834
835
            echo prepareExchangedData(['error' => false, 'files' => $files], 'encode');
836
            break;
837
838
        case 'scheduled_delete_backup':
839
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
840
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
841
                break;
842
            }
843
844
            $dataReceived = prepareExchangedData($post_data, 'decode');
845
            if (!is_array($dataReceived)) $dataReceived = [];
846
847
            $file = (string)($dataReceived['file'] ?? '');
848
            $file = basename($file);
849
850
            if ($file === '' || strpos($file, 'scheduled-') !== 0 || !str_ends_with($file, '.sql')) {
851
                echo prepareExchangedData(['error' => true, 'message' => 'Invalid filename'], 'encode');
852
                break;
853
            }
854
855
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
856
            $dir = (string)tpGetSettingsValue('bck_scheduled_output_dir', rtrim($baseFilesDir, '/') . '/backups');
857
            $fp = rtrim($dir, '/') . '/' . $file;
858
859
            /**
860
             * Delete a file and its associated metadata.
861
             * * @param string $fp Full path to the file.
862
             * @return void
863
             */
864
            // Check if file exists and is valid
865
            if (file_exists($fp) === false || is_file($fp) === false) {
866
                echo prepareExchangedData(['error' => false], 'encode');
867
                break;
868
            }
869
870
            // Check permissions
871
            if (is_writable($fp) === false) {
872
                $errorMessage = "File is not writable, cannot delete: " . $fp;
873
                if (WIP === true) error_log("TeamPass - " . $errorMessage);
874
                echo prepareExchangedData(['error' => true, 'message' => $errorMessage], 'encode');
875
                break;
876
            }
877
878
            // Attempt deletion
879
            if (unlink($fp) === false) {
880
                $errorMessage = "Failed to delete file: " . $fp;
881
                if (WIP === true) error_log("TeamPass - " . $errorMessage);
882
                echo prepareExchangedData(['error' => true, 'message' => $errorMessage], 'encode');
883
                break;
884
            }
885
886
            // Cleanup metadata (silent fail for sidecar is acceptable)
887
            if (file_exists($fp . '.meta.json')) {
888
                @unlink($fp . '.meta.json');
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

888
                /** @scrutinizer ignore-unhandled */ @unlink($fp . '.meta.json');

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
889
            }
890
891
            echo prepareExchangedData(['error' => false], 'encode');
892
            break;
893
894
        case 'check_connected_users':
895
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
896
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
897
                break;
898
            }
899
900
            $excludeUserId = (int) filter_input(INPUT_POST, 'exclude_user_id', FILTER_SANITIZE_NUMBER_INT);
901
            if ($excludeUserId === 0 && null !== $session->get('user-id')) {
902
                $excludeUserId = (int) $session->get('user-id');
903
            }
904
905
            $connectedCount = (int) DB::queryFirstField(
906
                'SELECT COUNT(*) FROM ' . prefixTable('users') . ' WHERE session_end >= %i AND id != %i',
907
                time(),
908
                $excludeUserId
909
            );
910
911
            echo prepareExchangedData(['error' => false, 'connected_count' => $connectedCount], 'encode');
912
            break;
913
914
        case 'scheduled_run_now':
915
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
916
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
917
                break;
918
            }
919
920
            $now = time();
921
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
922
            $dir = (string)tpGetSettingsValue('bck_scheduled_output_dir', rtrim($baseFilesDir, '/') . '/backups');
923
            @mkdir($dir, 0770, true);
924
925
            // avoid duplicates
926
            $pending = (int)DB::queryFirstField(
927
                'SELECT COUNT(*) FROM ' . prefixTable('background_tasks') . '
928
                 WHERE process_type=%s AND is_in_progress IN (0,1)
929
                   AND (finished_at IS NULL OR finished_at = "" OR finished_at = 0)',
930
                'database_backup'
931
            );
932
            if ($pending > 0) {
933
                echo prepareExchangedData(['error' => true, 'message' => 'A backup task is already pending/running'], 'encode');
934
                break;
935
            }
936
937
            DB::insert(
938
                prefixTable('background_tasks'),
939
                [
940
                    'created_at' => (string)$now,
941
                    'process_type' => 'database_backup',
942
                    'arguments' => json_encode(['output_dir' => $dir, 'source' => 'scheduler', 'initiator_user_id' => (int) $session->get('user-id')], JSON_UNESCAPED_SLASHES),
943
                    'is_in_progress' => 0,
944
                    'status' => 'new',
945
                ]
946
            );
947
948
            tpUpsertSettingsValue('bck_scheduled_last_run_at', (string)$now);
949
            tpUpsertSettingsValue('bck_scheduled_last_status', 'queued');
950
            tpUpsertSettingsValue('bck_scheduled_last_message', 'Task enqueued by UI');
951
952
            echo prepareExchangedData(['error' => false], 'encode');
953
            break;
954
955
        case 'onthefly_delete_backup':
956
            // Delete an on-the-fly backup file stored in <files> directory
957
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
958
                echo prepareExchangedData(
959
                    array(
960
                        'error' => true,
961
                        'message' => 'Not allowed',
962
                    ),
963
                    'encode'
964
                );
965
                break;
966
            }
967
968
            $dataReceived = prepareExchangedData($post_data, 'decode');
969
            $fileToDelete = isset($dataReceived['file']) === true ? (string)$dataReceived['file'] : '';
970
            $bn = basename($fileToDelete);
971
972
            // Safety checks
973
            if ($bn === '' || strtolower(pathinfo($bn, PATHINFO_EXTENSION)) !== 'sql') {
0 ignored issues
show
Bug introduced by
It seems like pathinfo($bn, PATHINFO_EXTENSION) can also be of type array; however, parameter $string of strtolower() does only seem to accept 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

973
            if ($bn === '' || strtolower(/** @scrutinizer ignore-type */ pathinfo($bn, PATHINFO_EXTENSION)) !== 'sql') {
Loading history...
974
                echo prepareExchangedData(
975
                    array(
976
                        'error' => true,
977
                        'message' => 'Invalid file name',
978
                    ),
979
                    'encode'
980
                );
981
                break;
982
            }
983
984
            // Never allow deleting scheduled backups or temp files
985
            if (strpos($bn, 'scheduled-') === 0 || strpos($bn, 'defuse_temp_') === 0 || strpos($bn, 'defuse_temp_restore_') === 0) {
986
                echo prepareExchangedData(
987
                    array(
988
                        'error' => true,
989
                        'message' => 'Not allowed on this file',
990
                    ),
991
                    'encode'
992
                );
993
                break;
994
            }
995
996
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
997
            $dir = rtrim($baseFilesDir, '/');
998
            $fullPath = $dir . '/' . $bn;
999
1000
            if (file_exists($fullPath) === false) {
1001
                echo prepareExchangedData(
1002
                    array(
1003
                        'error' => true,
1004
                        'message' => 'File not found',
1005
                    ),
1006
                    'encode'
1007
                );
1008
                break;
1009
            }
1010
1011
            // Delete
1012
            $ok = @unlink($fullPath);
1013
1014
            // Also remove metadata sidecar if present
1015
            @unlink($fullPath . '.meta.json');
1016
1017
            if ($ok !== true) {
1018
                echo prepareExchangedData(
1019
                    array(
1020
                        'error' => true,
1021
                        'message' => 'Unable to delete file',
1022
                    ),
1023
                    'encode'
1024
                );
1025
                break;
1026
            }
1027
1028
            echo prepareExchangedData(
1029
                array(
1030
                    'error' => false,
1031
                    'message' => 'Deleted',
1032
                    'file' => $bn,
1033
                ),
1034
                'encode'
1035
            );
1036
            break;
1037
1038
        case 'onthefly_list_backups':
1039
            // List on-the-fly backup files stored directly in <files> directory (not in /backups for scheduled)
1040
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
1041
                echo prepareExchangedData(
1042
                    array(
1043
                        'error' => true,
1044
                        'message' => 'Not allowed',
1045
                    ),
1046
                    'encode'
1047
                );
1048
                break;
1049
            }
1050
1051
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
1052
            $dir = rtrim($baseFilesDir, '/');
1053
1054
            $files = array();
1055
            $paths = glob($dir . '/*.sql');
1056
            if ($paths === false) {
1057
                $paths = array();
1058
            }
1059
1060
            // Ensure we have a temporary key for downloadFile.php
1061
            $keyTmp = (string) $session->get('user-key_tmp');
1062
            if ($keyTmp === '') {
1063
                $keyTmp = GenerateCryptKey(16, false, true, true, false, true);
1064
                $session->set('user-key_tmp', $keyTmp);
1065
            }
1066
1067
            foreach ($paths as $fp) {
1068
                $bn = basename($fp);
1069
1070
                // Skip scheduled backups and temporary files
1071
                if (strpos($bn, 'scheduled-') === 0) {
1072
                    continue;
1073
                }
1074
                if (strpos($bn, 'defuse_temp_') === 0 || strpos($bn, 'defuse_temp_restore_') === 0) {
1075
                    continue;
1076
                }
1077
1078
                $files[] = array(
1079
                    'name' => $bn,
1080
                    'size_bytes' => (int)@filesize($fp),
1081
                    'mtime' => (int)@filemtime($fp),
1082
                    'tp_files_version' => (function_exists('tpGetBackupTpFilesVersionFromMeta') ? ((($v = (string)tpGetBackupTpFilesVersionFromMeta($fp)) !== '') ? $v : null) : null),
1083
                    'download' => 'sources/downloadFile.php?name=' . urlencode($bn) .
1084
                        '&action=backup&file=' . urlencode($bn) .
1085
                        '&type=sql&key=' . $session->get('key') .
1086
                        '&key_tmp=' . $session->get('user-key_tmp') .
1087
                        '&pathIsFiles=1',
1088
                );
1089
            }
1090
1091
            usort($files, static function ($a, $b) {
1092
                return ($b['mtime'] ?? 0) <=> ($a['mtime'] ?? 0);
1093
            });
1094
1095
            echo prepareExchangedData(
1096
                array(
1097
                    'error' => false,
1098
                    'dir' => $dir,
1099
                    'files' => $files,
1100
                ),
1101
                'encode'
1102
            );
1103
            break;
1104
        case 'preflight_restore_compatibility':
1105
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
1106
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
1107
                break;
1108
            }
1109
1110
            $dataReceived = prepareExchangedData($post_data, 'decode');
1111
            if (!is_array($dataReceived)) {
1112
                $dataReceived = [];
1113
            }
1114
1115
            $serverScope = (string) ($dataReceived['serverScope'] ?? '');
1116
            $serverFile  = (string) ($dataReceived['serverFile']  ?? '');
1117
            $operationId = (int)    ($dataReceived['operation_id'] ?? 0);
1118
1119
            $chk = tpCheckRestoreCompatibility($SETTINGS, $serverScope, $serverFile, $operationId);
1120
1121
            echo prepareExchangedData(
1122
                [
1123
                    'error' => false,
1124
                    'is_compatible' => (bool) ($chk['is_compatible'] ?? false),
1125
                    'reason' => (string) ($chk['reason'] ?? ''),
1126
                    'mode' => (string) ($chk['mode'] ?? ''),
1127
                    'warnings' => $chk['warnings'] ?? [],
1128
                    'backup_schema_level' => (string) ($chk['backup_schema_level'] ?? ''),
1129
                    'expected_schema_level' => (string) ($chk['expected_schema_level'] ?? ''),
1130
                    'backup_tp_files_version' => $chk['backup_tp_files_version'] ?? null,
1131
                    'expected_tp_files_version' => (string) ($chk['expected_tp_files_version'] ?? ''),
1132
                ],
1133
                'encode'
1134
            );
1135
            break;
1136
1137
                case 'prepare_restore_cli':
1138
                    // Check KEY + Admin only
1139
                    if ($post_key !== $session->get('key') || (int) $session->get('user-admin') !== 1) {
1140
                        echo prepareExchangedData(
1141
                            [
1142
                                'error' => true,
1143
                                'message' => $lang->get('error_not_allowed_to'),
1144
                            ],
1145
                            'encode'
1146
                        );
1147
                        break;
1148
                    }
1149
1150
                    $dataReceived = prepareExchangedData($post_data, 'decode');
1151
                    if (!is_array($dataReceived)) {
1152
                        $dataReceived = [];
1153
                    }
1154
1155
                    $serverScope = (string) ($dataReceived['serverScope'] ?? '');
1156
                    $serverFile  = (string) ($dataReceived['serverFile']  ?? '');
1157
                    $operationId = (int)    ($dataReceived['operation_id'] ?? 0);
1158
1159
                    $encryptionKey = (string) ($dataReceived['encryptionKey'] ?? '');
1160
                    $overrideKey   = (string) ($dataReceived['overrideKey'] ?? '');
1161
1162
                    // Compatibility check
1163
                    $chk = tpCheckRestoreCompatibility($SETTINGS, $serverScope, $serverFile, $operationId);
1164
                    if (!empty($chk['is_compatible']) === false) {
1165
                        echo prepareExchangedData(
1166
                            [
1167
                                'error' => true,
1168
                                'error_code' => 'INCOMPATIBLE_BACKUP',
1169
                                'reason' => (string) ($chk['reason'] ?? ''),
1170
                                'mode' => (string) ($chk['mode'] ?? ''),
1171
                                'warnings' => $chk['warnings'] ?? [],
1172
                                'backup_schema_level' => (string) ($chk['backup_schema_level'] ?? ''),
1173
                                'expected_schema_level' => (string) ($chk['expected_schema_level'] ?? ''),
1174
                                'backup_tp_files_version' => $chk['backup_tp_files_version'] ?? null,
1175
                                'expected_tp_files_version' => (string) ($chk['expected_tp_files_version'] ?? ''),
1176
                                'message' => $lang->get('bck_restore_incompatible_version_body'),
1177
                            ],
1178
                            'encode'
1179
                        );
1180
                        break;
1181
                    }
1182
1183
                    $resolvedPath = (string) ($chk['resolved_path'] ?? '');
1184
                    if ($resolvedPath === '' || file_exists($resolvedPath) === false) {
1185
                        echo prepareExchangedData(
1186
                            [
1187
                                'error' => true,
1188
                                'error_code' => 'FILE_NOT_FOUND',
1189
                                'message' => 'Restore file not found on server.',
1190
                            ],
1191
                            'encode'
1192
                        );
1193
                        break;
1194
                    }
1195
1196
                    // Validate encryption key by attempting to decrypt to a temporary file
1197
                    $keysToTry = [];
1198
1199
                    // Build candidate keys depending on restore source
1200
                    // - scheduled: uses the instance key (stored in bck_script_passkey) + optional override key
1201
                    // - on-the-fly: uses the key provided by the UI
1202
                    // - upload (serverScope empty): can be either, so also try instance key candidates
1203
                    if ($serverScope === 'scheduled') {
1204
                        if ($overrideKey !== '') {
1205
                            $keysToTry[] = $overrideKey;
1206
                        }
1207
1208
                        if (!empty($SETTINGS['bck_script_passkey'] ?? '')) {
1209
                            $rawInstanceKey = (string) $SETTINGS['bck_script_passkey'];
1210
                            $tmp = cryption($rawInstanceKey, '', 'decrypt', $SETTINGS);
1211
                            $decInstanceKey = isset($tmp['string']) ? (string) $tmp['string'] : '';
1212
1213
                            if ($decInstanceKey !== '') {
1214
                                $keysToTry[] = $decInstanceKey;
1215
                            }
1216
                            // Some environments store bck_script_passkey already decrypted (or in another format)
1217
                            if ($rawInstanceKey !== '' && $rawInstanceKey !== $decInstanceKey) {
1218
                                $keysToTry[] = $rawInstanceKey;
1219
                            }
1220
                        }
1221
                    } else {
1222
                        if ($encryptionKey !== '') {
1223
                            $keysToTry[] = $encryptionKey;
1224
                        }
1225
                        if ($overrideKey !== '') {
1226
                            $keysToTry[] = $overrideKey;
1227
                        }
1228
1229
                        // For uploaded restores (serverScope is empty), also try the instance key candidates.
1230
                        // This allows restoring scheduled backups uploaded manually (they are encrypted using bck_script_passkey).
1231
                        if ($serverScope === '' && !empty($SETTINGS['bck_script_passkey'] ?? '')) {
1232
                            $rawInstanceKey = (string) $SETTINGS['bck_script_passkey'];
1233
                            $tmp = cryption($rawInstanceKey, '', 'decrypt', $SETTINGS);
1234
                            $decInstanceKey = isset($tmp['string']) ? (string) $tmp['string'] : '';
1235
1236
                            if ($decInstanceKey !== '') {
1237
                                $keysToTry[] = $decInstanceKey;
1238
                            }
1239
                            if ($rawInstanceKey !== '' && $rawInstanceKey !== $decInstanceKey) {
1240
                                $keysToTry[] = $rawInstanceKey;
1241
                            }
1242
                        }
1243
                    }
1244
1245
                    $keysToTry = array_values(array_unique(array_filter($keysToTry, function ($v) {
1246
                        return $v !== '';
1247
                    })));
1248
1249
                    if (empty($keysToTry)) {
1250
                        echo prepareExchangedData(
1251
                            [
1252
                                'error' => true,
1253
                                'error_code' => 'MISSING_ENCRYPTION_KEY',
1254
                                'message' => 'Missing encryption key.',
1255
                            ],
1256
                            'encode'
1257
                        );
1258
                        break;
1259
                    }
1260
1261
                    // IMPORTANT: do NOT use tempnam() here. It creates the file and can break Defuse file decrypt.
1262
                    $tmpRand = '';
1263
                    try {
1264
                        $tmpRand = bin2hex(random_bytes(4));
1265
                    } catch (Throwable $ignored) {
1266
                        $tmpRand = uniqid('', true);
1267
                    }
1268
1269
                    $tmpDecryptedSql = rtrim((string) sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR
1270
                        . 'defuse_temp_restore_' . (int) ($session->get('user-id') ?? 0) . '_' . time() . '_' . $tmpRand . '.sql';
1271
1272
                    // Try to decrypt with the available keys
1273
                    $decRet = tpDefuseDecryptWithCandidates($resolvedPath, $tmpDecryptedSql, $keysToTry, $SETTINGS);
1274
                    @unlink($tmpDecryptedSql);
1275
1276
                    if (empty($decRet['success'])) {
1277
                        echo prepareExchangedData(
1278
                            [
1279
                                'error' => true,
1280
                                'error_code' => 'DECRYPT_FAILED',
1281
                                'message' => (string) ($decRet['message'] ?? 'Unable to decrypt backup with provided key(s).'),
1282
                            ],
1283
                            'encode'
1284
                        );
1285
                        break;
1286
                    }
1287
1288
// Create authorization token in DB (teampass_misc)
1289
                    $compat = [
1290
                        'mode' => (string) ($chk['mode'] ?? ''),
1291
                        'warnings' => $chk['warnings'] ?? [],
1292
                        'backup_schema_level' => (string) ($chk['backup_schema_level'] ?? ''),
1293
                        'expected_schema_level' => (string) ($chk['expected_schema_level'] ?? ''),
1294
                        'backup_tp_files_version' => $chk['backup_tp_files_version'] ?? null,
1295
                        'expected_tp_files_version' => (string) ($chk['expected_tp_files_version'] ?? ''),
1296
                    ];
1297
1298
                    $ttl = 3600;
1299
1300
                    $create = tpRestoreAuthorizationCreate(
1301
                        (int) ($session->get('user-id') ?? 0),
1302
                        (string) ($session->get('user-login') ?? ''),
1303
                        $resolvedPath,
1304
                        $serverScope,
1305
                        $serverFile,
1306
                        $operationId,
1307
                        $encryptionKey,
1308
                        $overrideKey,
1309
                        $compat,
1310
                        $ttl,
1311
                        $SETTINGS
1312
                    );
1313
1314
                    if (empty($create['success'])) {
1315
                        echo prepareExchangedData(
1316
                            [
1317
                                'error' => true,
1318
                                'error_code' => 'TOKEN_CREATE_FAILED',
1319
                                'message' => (string) ($create['message'] ?? 'Unable to create authorization token.'),
1320
                            ],
1321
                            'encode'
1322
                        );
1323
                        break;
1324
                    }
1325
1326
                    $token = (string) ($create['token'] ?? '');
1327
                    $expiresAt = (int) ($create['expires_at'] ?? 0);
1328
1329
                    $tpRoot = (string) ($SETTINGS['cpassman_dir'] ?? '');
1330
1331
					// Build a command adapted to the OS and (when possible) running as the same OS user as the web server.
1332
					// This avoids common permission issues on files/ and keeps behavior consistent.
1333
					$osFamily = defined('PHP_OS_FAMILY') ? (string) PHP_OS_FAMILY : (string) PHP_OS;
1334
					$isWindows = (stripos($osFamily, 'Windows') !== false);
1335
					$webUser = '';
1336
					if (!$isWindows && function_exists('posix_getpwuid') && function_exists('posix_geteuid')) {
1337
						$pw = @posix_getpwuid(@posix_geteuid());
1338
						if (is_array($pw) && !empty($pw['name'])) {
1339
							$webUser = (string) $pw['name'];
1340
						}
1341
					}
1342
1343
					$scriptRel = $isWindows ? 'scripts\\restore.php' : 'scripts/restore.php';
1344
					$filePathForCmd = $isWindows ? str_replace('/', '\\', $resolvedPath) : $resolvedPath;
1345
					$rootForCmd = $isWindows ? str_replace('/', '\\', rtrim($tpRoot, '/\\')) : rtrim($tpRoot, '/');
1346
1347
					$sudoPrefix = '';
1348
					if (!$isWindows && $webUser !== '') {
1349
						$sudoPrefix = 'sudo -u ' . escapeshellarg($webUser) . ' ';
1350
					}
1351
1352
					$cmdNoCd = $sudoPrefix . 'php ' . $scriptRel
1353
						. ' --file ' . escapeshellarg($filePathForCmd)
1354
						. ' --auth-token ' . escapeshellarg($token);
1355
					$cmdNoCdForce = $cmdNoCd . ' --force-disconnect';
1356
1357
					$cmd = $cmdNoCd;
1358
					$cmdForce = $cmdNoCdForce;
1359
					if ($tpRoot !== '') {
1360
						if ($isWindows) {
1361
							$cmd = 'cd /d ' . escapeshellarg($rootForCmd) . ' && ' . $cmdNoCd;
1362
							$cmdForce = 'cd /d ' . escapeshellarg($rootForCmd) . ' && ' . $cmdNoCdForce;
1363
						} else {
1364
							$cmd = 'cd ' . escapeshellarg($rootForCmd) . ' && ' . $cmdNoCd;
1365
							$cmdForce = 'cd ' . escapeshellarg($rootForCmd) . ' && ' . $cmdNoCdForce;
1366
						}
1367
					}
1368
1369
					// Provide multiple variants to cover the most common OS families and privilege models.
1370
					$variants = [];
1371
					$variants[] = [
1372
						'label' => $isWindows ? 'Windows (CMD/Powershell)' : 'Recommended',
1373
						'command' => $cmd,
1374
					];
1375
					$variants[] = [
1376
						'label' => $isWindows ? 'Force disconnect (--force-disconnect)' : 'Force disconnect (--force-disconnect)',
1377
						'command' => $cmdForce,
1378
					];
1379
					if (!$isWindows && $webUser !== '') {
1380
						$cmdNoSudo = 'php scripts/restore.php'
1381
							. ' --file ' . escapeshellarg($resolvedPath)
1382
							. ' --auth-token ' . escapeshellarg($token);
1383
						$cmdNoSudoCd = ($tpRoot !== '')
1384
							? ('cd ' . escapeshellarg($rootForCmd) . ' && ' . $cmdNoSudo)
1385
							: $cmdNoSudo;
1386
						$variants[] = [
1387
							'label' => 'Alternative (no sudo) - run as ' . $webUser,
1388
							'command' => $cmdNoSudoCd,
1389
						];
1390
					}
1391
1392
                    echo prepareExchangedData(
1393
                        [
1394
                            'error' => false,
1395
                            'token_expires_at' => $expiresAt,
1396
                            'ttl' => $ttl,
1397
                            'mode' => (string) ($chk['mode'] ?? ''),
1398
                            'warnings' => $chk['warnings'] ?? [],
1399
                            'command' => $cmd,
1400
                            'command_force_disconnect' => $cmdForce,
1401
                            'command_no_cd' => $cmdNoCd,
1402
							'command_variants' => $variants,
1403
							'os_family' => $osFamily,
1404
							'web_user' => $webUser,
1405
                            'resolved_path' => $resolvedPath,
1406
                        ],
1407
                        'encode'
1408
                    );
1409
                    break;
1410
1411
case 'onthefly_restore':
1412
            // Check KEY
1413
            if ($post_key !== $session->get('key')) {
1414
                echo prepareExchangedData(
1415
                    array(
1416
                        'error' => true,
1417
                        'message' => $lang->get('key_is_not_correct'),
1418
                    ),
1419
                    'encode'
1420
                );
1421
                break;
1422
            } elseif ($session->get('user-admin') === 0) {
1423
                echo prepareExchangedData(
1424
                    array(
1425
                        'error' => true,
1426
                        'message' => $lang->get('error_not_allowed_to'),
1427
                    ),
1428
                    'encode'
1429
                );
1430
                break;
1431
            }
1432
            
1433
                                    // Web restore is disabled: the UI only prepares a CLI command.
1434
                        echo prepareExchangedData(
1435
                            array(
1436
                                'error' => true,
1437
                                'error_code' => 'RESTORE_CLI_ONLY',
1438
                                'message' => $lang->get('bck_restore_cli_only'),
1439
                            ),
1440
                            'encode'
1441
                        );
1442
                        break;
1443
1444
// Compatibility check (schema-level) BEFORE maintenance/lock and any destructive action
1445
            $dataEarly = prepareExchangedData($post_data, 'decode');
1446
            if (!is_array($dataEarly)) $dataEarly = [];
1447
1448
            $earlyOffset = (int) ($dataEarly['offset'] ?? 0);
1449
            $earlyClear = (string) ($dataEarly['clearFilename'] ?? '');
1450
            $earlyServerScope = (string) ($dataEarly['serverScope'] ?? '');
1451
            $earlyServerFile  = (string) ($dataEarly['serverFile']  ?? '');
1452
            $earlyBackupFile  = (string) ($dataEarly['backupFile']  ?? '');
1453
            $earlyOperationId = (int) ($dataEarly['operation_id'] ?? 0);
1454
1455
            // If restore is starting (first chunk), enforce schema compatibility.
1456
            if ($earlyOffset === 0 && $earlyClear === '') {
1457
                // Operation id can be passed either as operation_id or as legacy backupFile numeric id
1458
                if ($earlyOperationId === 0 && $earlyBackupFile !== '' && ctype_digit($earlyBackupFile)) {
1459
                    $earlyOperationId = (int) $earlyBackupFile;
1460
                }
1461
1462
                $chk = tpCheckRestoreCompatibility($SETTINGS, $earlyServerScope, $earlyServerFile, $earlyOperationId);
1463
                if (($chk['is_compatible'] ?? false) !== true) {
1464
                    echo prepareExchangedData(
1465
                        [
1466
                            'error' => true,
1467
                            'error_code' => 'INCOMPATIBLE_BACKUP_SCHEMA',
1468
                            'reason' => (string) ($chk['reason'] ?? ''),
1469
                            'backup_tp_files_version' => $chk['backup_tp_files_version'] ?? null,
1470
                            'expected_tp_files_version' => (string) ($chk['expected_tp_files_version'] ?? ''),
1471
                            'message' => $lang->get('bck_restore_incompatible_version_body'),
1472
                        ],
1473
                        'encode'
1474
                    );
1475
                    break;
1476
                }
1477
            }
1478
            // Put TeamPass in maintenance mode for the whole restore workflow.
1479
            // Intentionally NOT disabled at the end: admin must validate the instance after restore.
1480
            try {
1481
                DB::update(
1482
                    prefixTable('misc'),
1483
                    array(
1484
                        'valeur' => '1',
1485
                        'updated_at' => time(),
1486
                    ),
1487
                    'intitule = %s AND type= %s',
1488
                    'maintenance_mode',
1489
                    'admin'
1490
                );
1491
            } catch (Throwable $ignored) {
1492
                // Best effort
1493
            }
1494
1495
            // Decrypt and retrieve data in JSON format
1496
            $dataReceived = prepareExchangedData(
1497
                $post_data,
1498
                'decode'
1499
            );
1500
        
1501
            // Prepare variables (safe defaults for both upload-restore and serverFile-restore)
1502
            $post_encryptionKey = filter_var(($dataReceived['encryptionKey'] ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
1503
1504
            // Optional override key (mainly for scheduled restores in case of migration)
1505
            // This MUST NOT be auto-filled from the on-the-fly key; it is only used when explicitly provided.
1506
            $post_overrideKey = filter_var(($dataReceived['overrideKey'] ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
1507
1508
            $post_backupFile = filter_var(($dataReceived['backupFile'] ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
1509
            $post_clearFilename = filter_var(($dataReceived['clearFilename'] ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
1510
1511
            $post_serverScope = filter_var(($dataReceived['serverScope'] ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
1512
            $post_serverFile  = filter_var(($dataReceived['serverFile']  ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
1513
1514
            // Scheduled backups must always be decrypted with the instance key (server-side).
1515
            // Ignore any key coming from the UI to avoid mismatches.
1516
            if ($post_serverScope === 'scheduled') {
1517
                $post_encryptionKey = '';
1518
            }
1519
            // Ensure all strings we send back through prepareExchangedData() are JSON-safe.
1520
            // This avoids PHP "malformed UTF-8" warnings when restore errors contain binary/latin1 bytes.
1521
            $tpSafeJsonString = static function ($value): string {
1522
                if ($value === null) {
1523
                    return '';
1524
                }
1525
                if (is_bool($value)) {
1526
                    return $value ? '1' : '0';
1527
                }
1528
                if (is_scalar($value) === false) {
1529
                    $value = print_r($value, true);
1530
                }
1531
                $str = (string) $value;
1532
1533
                // If the string isn't valid UTF-8, return a hex dump instead (ASCII-only, safe for JSON).
1534
                $isUtf8 = false;
1535
                if (function_exists('mb_check_encoding')) {
1536
                    $isUtf8 = mb_check_encoding($str, 'UTF-8');
1537
                } else {
1538
                    $isUtf8 = (@preg_match('//u', $str) === 1);
1539
                }
1540
                if ($isUtf8 === false) {
1541
                    return '[hex]' . bin2hex($str);
1542
                }
1543
1544
                // Strip ASCII control chars that could pollute JSON.
1545
                $str = preg_replace("/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/", '', $str) ?? $str;
1546
                return $str;
1547
            };
1548
1549
1550
            // Chunked upload fields (can be absent when restoring from an existing server file)
1551
            $post_offset = (int) ($dataReceived['offset'] ?? 0);
1552
            $post_totalSize = (int) ($dataReceived['totalSize'] ?? 0);
1553
1554
            // Restore session + concurrency lock management.
1555
            // - We keep a token in session to allow chunked restore even while DB is being replaced.
1556
            // - We also block starting a second restore in the same session (double click / 2 tabs).
1557
            $clearRestoreState = static function ($session): void {
1558
                $tmp = (string) ($session->get('restore-temp-file') ?? '');
1559
                if ($tmp !== '' && file_exists($tmp) === true && strpos(basename($tmp), 'defuse_temp_restore_') === 0 && is_file($tmp)) {
1560
                    if (is_writable($tmp)) {
1561
                        if (unlink($tmp) === false && WIP === true) {
1562
                            error_log("TeamPass: Failed to delete file: {$tmp}");
1563
                        }
1564
                    } else if (WIP === true) {
1565
                        error_log("TeamPass: File is not writable, cannot delete: {$tmp}");
1566
                    }
1567
                }
1568
                $session->set('restore-temp-file', '');
1569
                $session->set('restore-token', '');
1570
                $session->set('restore-settings', []);
1571
                $session->set('restore-context', []);
1572
                $session->set('restore-in-progress', false);
1573
                $session->set('restore-in-progress-ts', 0);
1574
                $session->set('restore-start-ts', 0);
1575
            };
1576
1577
            $nowTs = time();
1578
            $inProgress = (bool) ($session->get('restore-in-progress') ?? false);
1579
            $lastTs = (int) ($session->get('restore-in-progress-ts') ?? 0);
1580
1581
            // Auto-release stale lock (e.g. client crashed / browser closed)
1582
            if ($inProgress === true && $lastTs > 0 && ($nowTs - $lastTs) > 3600) {
1583
                $clearRestoreState($session);
1584
                $inProgress = false;
1585
                $lastTs = 0;
1586
            }
1587
1588
            $sessionRestoreToken = (string) ($session->get('restore-token') ?? '');
1589
            $isStartRestore = (empty($post_clearFilename) === true && (int) $post_offset === 0);
1590
1591
            if ($isStartRestore === true) {
1592
                if ($inProgress === true && $sessionRestoreToken !== '') {
1593
                    echo prepareExchangedData(
1594
                        array(
1595
                            'error' => true,
1596
                            'message' => 'A restore is already in progress in this session. Please wait for it to complete (or logout/login to reset).',
1597
                        ),
1598
                        'encode'
1599
                    );
1600
                    break;
1601
                }
1602
1603
                $sessionRestoreToken = bin2hex(random_bytes(16));
1604
                $session->set('restore-token', $sessionRestoreToken);
1605
                $session->set('restore-settings', $SETTINGS);
1606
                $session->set('restore-in-progress', true);
1607
                $session->set('restore-in-progress-ts', $nowTs);
1608
                $session->set('restore-start-ts', $nowTs);
1609
                $session->set('restore-context', []);
1610
            } else {
1611
                // Restore continuation must provide the correct token
1612
                if ($restoreToken === '' || $sessionRestoreToken === '' || !hash_equals($sessionRestoreToken, $restoreToken)) {
1613
                    echo prepareExchangedData(
1614
                        array(
1615
                            'error' => true,
1616
                            'message' => 'Restore session expired. Please restart the restore process.',
1617
                        ),
1618
                        'encode'
1619
                    );
1620
                    break;
1621
                }
1622
1623
                // Update activity timestamp (keeps the lock alive)
1624
                $session->set('restore-in-progress', true);
1625
                $session->set('restore-in-progress-ts', $nowTs);
1626
                if ((int) ($session->get('restore-start-ts') ?? 0) === 0) {
1627
                    $session->set('restore-start-ts', $nowTs);
1628
                }
1629
            }
1630
1631
            $batchSize = 500;
1632
            $errors = array(); // Collect potential errors
1633
        
1634
            // Check if the offset is greater than the total size
1635
            if ($post_offset > 0 && $post_totalSize > 0 && $post_offset >= $post_totalSize) {
1636
                // Defensive: if client asks to continue beyond end, consider restore finished and release lock.
1637
                if (is_string($post_clearFilename) && $post_clearFilename !== '' && file_exists($post_clearFilename) === true
1638
                    && strpos(basename($post_clearFilename), 'defuse_temp_restore_') === 0) {
1639
                    @unlink($post_clearFilename);
1640
                }
1641
                $clearRestoreState($session);
1642
1643
                echo prepareExchangedData(
1644
                    array(
1645
                        'error' => false,
1646
                        'message' => 'operation_finished',
1647
                        'finished' => true,
1648
                        'restore_token' => $sessionRestoreToken,
1649
                    ),
1650
                    'encode'
1651
                );
1652
                break;
1653
            }
1654
            
1655
            // Log debug information if in development mode
1656
            if (defined('WIP') && WIP === true) {
1657
                error_log('DEBUG: Offset -> '.$post_offset.'/'.$post_totalSize.' | File -> '.$post_clearFilename);
1658
            }
1659
        
1660
            include_once $SETTINGS['cpassman_dir'] . '/sources/main.functions.php';
1661
        
1662
            include_once $SETTINGS['cpassman_dir'] . '/sources/main.functions.php';
1663
1664
            /*
1665
             * Restore workflow
1666
             * - 1st call (clearFilename empty): locate encrypted backup, decrypt to a temp file, return its path in clearFilename
1667
             * - next calls: reuse clearFilename as the decrypted file path and continue reading from offset
1668
             */
1669
1670
            if (empty($post_clearFilename) === true) {
1671
                // Default behavior: uploaded on-the-fly backup file (stored in teampass_misc) is removed after decrypt/restore.
1672
                // New behavior: user can select an existing encrypted backup file already present on server (scheduled or on-the-fly stored file).
1673
                $deleteEncryptedAfterDecrypt = true;
1674
1675
                $bn = '';
1676
                $serverPath = '';
1677
                $legacyOperationId = null;
1678
1679
                // NEW: restore from an existing server file (scheduled or on-the-fly stored file)
1680
                if (!empty($post_serverFile)) {
1681
                    $deleteEncryptedAfterDecrypt = false;
1682
1683
                    $bn = basename((string) $post_serverFile);
1684
1685
                    // Safety: allow only *.sql name
1686
                    if ($bn === '' || strtolower(pathinfo($bn, PATHINFO_EXTENSION)) !== 'sql') {
1687
                        $clearRestoreState($session);
1688
                        echo prepareExchangedData(
1689
                            array('error' => true, 'message' => 'Invalid serverFile'),
1690
                            'encode'
1691
                        );
1692
                        break;
1693
                    }
1694
1695
                    $baseDir = rtrim((string) $SETTINGS['path_to_files_folder'], '/');
1696
1697
                    // Scheduled backups are stored in configured output directory
1698
                    if ($post_serverScope === 'scheduled') {
1699
                        $baseFilesDir = (string) ($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
1700
                        $dir = (string) tpGetSettingsValue('bck_scheduled_output_dir', rtrim($baseFilesDir, '/') . '/backups');
1701
                        $baseDir = rtrim($dir, '/');
1702
                    }
1703
1704
                    $serverPath = $baseDir . '/' . $bn;
1705
1706
                    if (file_exists($serverPath) === false) {
1707
                        try {
1708
                            logEvents(
1709
                                $SETTINGS,
1710
                                'admin_action',
1711
                                'dataBase restore failed (file not found)',
1712
                                (string) $session->get('user-id'),
1713
                                $session->get('user-login')
1714
                            );
1715
                        } catch (Throwable $ignored) {
1716
                            // ignore logging errors
1717
                        }
1718
                        $clearRestoreState($session);
1719
                        echo prepareExchangedData(
1720
                            array('error' => true, 'message' => 'Backup file not found on server'),
1721
                            'encode'
1722
                        );
1723
                        break;
1724
                    }
1725
                
1726
                    // Log restore start once (best effort)
1727
                    if ($isStartRestore === true) {
1728
                        $sizeBytes = (int) @filesize($serverPath);
1729
                        $session->set(
1730
                            'restore-context',
1731
                            array(
1732
                                'scope' => $post_serverScope,
1733
                                'backup' => $bn,
1734
                                'size_bytes' => $sizeBytes,
1735
                            )
1736
                        );
1737
1738
                        try {
1739
                            $msg = 'dataBase restore started (scope=' . $post_serverScope . ', file=' . $bn . ')';
1740
                            logEvents(
1741
                                $SETTINGS,
1742
                                'admin_action',
1743
                                $msg,
1744
                                (string) $session->get('user-id'),
1745
                                $session->get('user-login')
1746
                            );
1747
                        } catch (Throwable $ignored) {
1748
                            // ignore logging errors during restore
1749
                        }
1750
                    }
1751
1752
                } else {
1753
                    // LEGACY: restore from uploaded on-the-fly backup identified by its misc.increment_id
1754
                    if (empty($post_backupFile) === true || ctype_digit((string) $post_backupFile) === false) {
1755
                        echo prepareExchangedData(
1756
                            array('error' => true, 'message' => 'No backup selected'),
1757
                            'encode'
1758
                        );
1759
                        break;
1760
                    }
1761
1762
                    $legacyOperationId = (int) $post_backupFile;
1763
1764
                    // Find filename from DB (misc)
1765
                    $data = DB::queryFirstRow(
1766
                        'SELECT valeur FROM ' . prefixTable('misc') . ' WHERE increment_id = %i LIMIT 1',
1767
                        $legacyOperationId
1768
                    );
1769
1770
                    if (empty($data['valeur'])) {
1771
                        try {
1772
                            logEvents(
1773
                                $SETTINGS,
1774
                                'admin_action',
1775
                                'dataBase restore failed (missing misc entry)',
1776
                                (string) $session->get('user-id'),
1777
                                $session->get('user-login')
1778
                            );
1779
                        } catch (Throwable $ignored) {
1780
                            // ignore logging errors
1781
                        }
1782
                        $clearRestoreState($session);
1783
                        echo prepareExchangedData(
1784
                            array('error' => true, 'message' => 'Backup file not found in database'),
1785
                            'encode'
1786
                        );
1787
                        break;
1788
                    }
1789
1790
                    $bn = safeString($data['valeur']);
1791
                    $serverPath = rtrim((string) $SETTINGS['path_to_files_folder'], '/') . '/' . $bn;
1792
1793
                    if (file_exists($serverPath) === false) {
1794
                        try {
1795
                            logEvents(
1796
                                $SETTINGS,
1797
                                'admin_action',
1798
                                'dataBase restore failed (file not found)',
1799
                                (string) $session->get('user-id'),
1800
                                $session->get('user-login')
1801
                            );
1802
                        } catch (Throwable $ignored) {
1803
                            // ignore logging errors
1804
                        }
1805
                        $clearRestoreState($session);
1806
                        echo prepareExchangedData(
1807
                            array('error' => true, 'message' => 'Backup file not found on server'),
1808
                            'encode'
1809
                        );
1810
                        break;
1811
                    }
1812
                }
1813
1814
                // Common checks
1815
                if ($post_serverScope !== 'scheduled' && empty($post_encryptionKey) === true) {
1816
                    echo prepareExchangedData(
1817
                        array('error' => true, 'message' => 'Missing encryption key'),
1818
                        'encode'
1819
                    );
1820
                    break;
1821
                }
1822
1823
                // Decrypt to a dedicated temp file (unique)
1824
                $tmpDecrypted = rtrim((string) $SETTINGS['path_to_files_folder'], '/')
1825
                    . '/defuse_temp_restore_' . (int) $session->get('user-id') . '_' . time() . '_' . $bn;
1826
1827
                // Build the list of keys we can try to decrypt with.
1828
                // - on-the-fly: uses the key provided by the UI
1829
                // - scheduled: uses the instance key (stored in bck_script_passkey)
1830
                $keysToTry = [];
1831
                if ($post_serverScope === 'scheduled') {
1832
                    // Allow an explicit override key (migration use-case)
1833
                    if (!empty($post_overrideKey)) {
1834
                        $keysToTry[] = (string) $post_overrideKey;
1835
                    }
1836
                    if (!empty($SETTINGS['bck_script_passkey'] ?? '')) {
1837
                        $rawInstanceKey = (string) $SETTINGS['bck_script_passkey'];
1838
                        $tmp = cryption($rawInstanceKey, '', 'decrypt', $SETTINGS);
1839
                        $decInstanceKey = isset($tmp['string']) ? (string) $tmp['string'] : '';
1840
1841
                        if ($decInstanceKey !== '') {
1842
                            $keysToTry[] = $decInstanceKey;
1843
                        }
1844
                        if ($rawInstanceKey !== '' && $rawInstanceKey !== $decInstanceKey) {
1845
                            $keysToTry[] = $rawInstanceKey;
1846
                        }
1847
                    }
1848
                } else {
1849
                    if ($post_encryptionKey !== '') {
1850
                        $keysToTry[] = (string) $post_encryptionKey;
1851
                    }
1852
                
1853
1854
                    // For uploaded restores (serverScope is empty), also try the instance key candidates.
1855
                    // This allows restoring scheduled backups uploaded manually (they are encrypted using bck_script_passkey).
1856
                    if ($post_serverScope === '' && !empty($SETTINGS['bck_script_passkey'] ?? '')) {
1857
                        $rawInstanceKey = (string) $SETTINGS['bck_script_passkey'];
1858
                        $tmp = cryption($rawInstanceKey, '', 'decrypt', $SETTINGS);
1859
                        $decInstanceKey = isset($tmp['string']) ? (string) $tmp['string'] : '';
1860
1861
                        if ($decInstanceKey !== '') {
1862
                            $keysToTry[] = $decInstanceKey;
1863
                        }
1864
                        if ($rawInstanceKey !== '' && $rawInstanceKey !== $decInstanceKey) {
1865
                            $keysToTry[] = $rawInstanceKey;
1866
                        }
1867
                    }
1868
}
1869
1870
                // Ensure we have at least one key
1871
                $keysToTry = array_values(array_unique(array_filter($keysToTry, static fn ($v) => $v !== '')));
1872
                if (empty($keysToTry)) {
1873
                    echo prepareExchangedData(
1874
                        array('error' => true, 'message' => 'Missing encryption key'),
1875
                        'encode'
1876
                    );
1877
                    break;
1878
                }
1879
1880
                // Try to decrypt with the available keys (some environments store bck_script_passkey encrypted)
1881
                $decRet = tpDefuseDecryptWithCandidates($serverPath, $tmpDecrypted, $keysToTry, $SETTINGS);
1882
                if (!empty($decRet['success']) === false) {
1883
                    @unlink($tmpDecrypted);
1884
                    try {
1885
                        logEvents(
1886
                            $SETTINGS,
1887
                            'admin_action',
1888
                            'dataBase restore failed (decrypt error)',
1889
                            (string) $session->get('user-id'),
1890
                            $session->get('user-login')
1891
                        );
1892
                    } catch (Throwable $ignored) {
1893
                        // ignore logging errors
1894
                    }
1895
                    $clearRestoreState($session);
1896
                    echo prepareExchangedData(
1897
                        array(
1898
                            'error' => true,
1899
                            'error_code' => 'DECRYPT_FAILED',
1900
                            'message' => 'Unable to decrypt backup: ' . $tpSafeJsonString((string) ($decRet['message'] ?? 'unknown error')),
1901
                        ),
1902
                        'encode'
1903
                    );
1904
                    break;
1905
                }
1906
1907
                if (!is_file($tmpDecrypted) || (int) @filesize($tmpDecrypted) === 0) {
1908
                    @unlink($tmpDecrypted);
1909
                    $clearRestoreState($session);
1910
                    echo prepareExchangedData(
1911
                        array('error' => true, 'message' => 'Decrypted backup is empty or unreadable'),
1912
                        'encode'
1913
                    );
1914
                    break;
1915
                }
1916
1917
                // Remove original encrypted file ONLY for legacy uploaded one-shot restore
1918
                if ($deleteEncryptedAfterDecrypt === true) {
1919
                    fileDelete($serverPath, $SETTINGS);
1920
1921
                    // Delete operation record
1922
                    if ($legacyOperationId !== null) {
1923
                        DB::delete(
1924
                            prefixTable('misc'),
1925
                            'increment_id = %i',
1926
                            $legacyOperationId
1927
                        );
1928
                    }
1929
                }
1930
                // From now, restore uses the decrypted temp file
1931
                $post_backupFile = $tmpDecrypted;
1932
                $session->set('restore-temp-file', $tmpDecrypted);
1933
                $post_clearFilename = $tmpDecrypted;
1934
            } else {
1935
                $post_backupFile = $post_clearFilename;
1936
                $session->set('restore-temp-file', $post_clearFilename);
1937
            }
1938
1939
            // Read sql file
1940
            $handle = fopen($post_backupFile, 'r');
1941
        
1942
            if ($handle === false) {
1943
                if (is_string($post_backupFile) && $post_backupFile !== '' && file_exists($post_backupFile) === true
1944
                    && strpos(basename($post_backupFile), 'defuse_temp_restore_') === 0) {
1945
                    @unlink($post_backupFile);
1946
                }
1947
                $clearRestoreState($session);
1948
                echo prepareExchangedData(
1949
                    array(
1950
                        'error' => true,
1951
                        'message' => 'Unable to open backup file.',
1952
                        'finished' => false,
1953
                        'restore_token' => $sessionRestoreToken,
1954
                    ),
1955
                    'encode'
1956
                );
1957
                break;
1958
            }
1959
        
1960
                        // Get total file size
1961
            if ((int) $post_totalSize === 0) {
1962
                $post_totalSize = filesize($post_backupFile);
1963
            }
1964
1965
            // Validate chunk parameters
1966
            if ((int) $post_totalSize <= 0) {
1967
                // Abort: we cannot safely run a chunked restore without a reliable size
1968
                $clearRestoreState($session);
1969
                echo prepareExchangedData(
1970
                    array(
1971
                        'error' => true,
1972
                        'message' => 'Invalid backup file size (0).',
1973
                        'finished' => true,
1974
                    ),
1975
                    'encode'
1976
                );
1977
                break;
1978
            }
1979
1980
            if ($post_offset < 0) {
1981
                $post_offset = 0;
1982
            }
1983
1984
            if ($post_offset > $post_totalSize) {
1985
                // Abort: invalid offset (prevents instant "success" due to EOF)
1986
                if (is_string($post_backupFile) && $post_backupFile !== '' && file_exists($post_backupFile) === true) {
1987
                    // If it is a temporary decrypted file, cleanup
1988
                    if (strpos(basename($post_backupFile), 'defuse_temp_restore_') === 0) {
1989
                        @unlink($post_backupFile);
1990
                    }
1991
                }
1992
                $clearRestoreState($session);
1993
                echo prepareExchangedData(
1994
                    array(
1995
                        'error' => true,
1996
                        'message' => 'Invalid restore offset.',
1997
                        'finished' => true,
1998
                    ),
1999
                    'encode'
2000
                );
2001
                break;
2002
            }
2003
2004
            // Move the file pointer to the current offset
2005
            fseek($handle, $post_offset);
2006
            $query = '';
2007
            $executedQueries = 0;
2008
            $inMultiLineComment = false;
2009
            
2010
            try {
2011
                // Start transaction to ensure database consistency
2012
                DB::startTransaction();
2013
                DB::query("SET FOREIGN_KEY_CHECKS = 0");
2014
                DB::query("SET UNIQUE_CHECKS = 0");
2015
                
2016
                while (!feof($handle) && $executedQueries < $batchSize) {
2017
                    $line = fgets($handle);
2018
                    
2019
                    // Check if not false
2020
                    if ($line !== false) {
2021
                        $trimmedLine = trim($line);
2022
                        
2023
                        // Skip empty lines or comments
2024
                        if (empty($trimmedLine) || 
2025
                            (strpos($trimmedLine, '--') === 0) || 
2026
                            (strpos($trimmedLine, '#') === 0)) {
2027
                            continue;
2028
                        }
2029
                        
2030
                        // Handle multi-line comments
2031
                        if (strpos($trimmedLine, '/*') === 0 && strpos($trimmedLine, '*/') === false) {
2032
                            $inMultiLineComment = true;
2033
                            continue;
2034
                        }
2035
                        
2036
                        if ($inMultiLineComment) {
2037
                            if (strpos($trimmedLine, '*/') !== false) {
2038
                                $inMultiLineComment = false;
2039
                            }
2040
                            continue;
2041
                        }
2042
                        
2043
                        // Add line to current query
2044
                        $query .= $line;
2045
                        
2046
                        // Execute if this is the end of a statement
2047
                        if (substr($trimmedLine, -1) === ';') {
2048
                            try {
2049
                                DB::query($query);
2050
                                $executedQueries++;
2051
                            } catch (Exception $e) {
2052
                                $snippet = substr($query, 0, 120);
2053
                                $snippet = $tpSafeJsonString($snippet);
2054
                                $errors[] = 'Error executing query: ' . $tpSafeJsonString($e->getMessage()) . ' - Query: ' . $snippet . '...';
2055
                            }
2056
                            $query = '';
2057
                        }
2058
                    }
2059
                }
2060
2061
                // Set default settings back
2062
                DB::query("SET FOREIGN_KEY_CHECKS = 1");
2063
                DB::query("SET UNIQUE_CHECKS = 1");
2064
                
2065
                // Commit the transaction if no errors
2066
                if (empty($errors)) {
2067
                    DB::commit();
2068
                } else {
2069
                    DB::rollback();
2070
                }
2071
            } catch (Exception $e) {
2072
                try {
2073
                    DB::query("SET FOREIGN_KEY_CHECKS = 1");
2074
                    DB::query("SET UNIQUE_CHECKS = 1");
2075
                } catch (Exception $ignored) {
2076
                    // Ignore further exceptions
2077
                }
2078
                // Rollback transaction on any exception
2079
                DB::rollback();
2080
                $errors[] = 'Transaction failed: ' . $tpSafeJsonString($e->getMessage());
2081
            }
2082
        
2083
            // Calculate the new offset
2084
            $newOffset = ftell($handle);
2085
        
2086
            // Check if the end of the file has been reached
2087
            $isEndOfFile = feof($handle);
2088
            fclose($handle);
2089
        
2090
            // Handle errors if any
2091
            if (!empty($errors)) {
2092
                // Abort restore: cleanup temp file and release session lock
2093
                if (is_string($post_backupFile) && $post_backupFile !== '' && file_exists($post_backupFile) === true
2094
                    && strpos(basename($post_backupFile), 'defuse_temp_restore_') === 0) {
2095
                    @unlink($post_backupFile);
2096
                }
2097
2098
                $tokenForResponse = $sessionRestoreToken;
2099
2100
                // Best-effort log
2101
                try {
2102
                    $ctx = $session->get('restore-context');
2103
                    $scope = is_array($ctx) ? (string) ($ctx['scope'] ?? '') : '';
2104
                    logEvents(
2105
                        $SETTINGS,
2106
                        'admin_action',
2107
                        'dataBase restore failed' . ($scope !== '' ? ' (scope=' . $scope . ')' : ''),
2108
                        (string) $session->get('user-id'),
2109
                        $session->get('user-login')
2110
                    );
2111
                } catch (Throwable $ignored) {
2112
                    // ignore logging errors during restore
2113
                }
2114
2115
                $clearRestoreState($session);
2116
2117
                echo prepareExchangedData(
2118
                    array(
2119
                        'error' => true,
2120
                        'message' => 'Errors occurred during import: ' . implode('; ', ($post_serverScope === 'scheduled' ? array_map($tpSafeJsonString, $errors) : $errors)),
2121
                        'newOffset' => $newOffset,
2122
                        'totalSize' => $post_totalSize,
2123
                        'clearFilename' => $post_backupFile,
2124
                        'finished' => true,
2125
                        'restore_token' => $tokenForResponse,
2126
                    ),
2127
                    'encode'
2128
                );
2129
                break;
2130
            }
2131
2132
            // Determine if restore is complete
2133
            $finished = ($isEndOfFile === true) || ($post_totalSize > 0 && $newOffset >= $post_totalSize);
2134
2135
            // Respond with the new offset
2136
            echo prepareExchangedData(
2137
                array(
2138
                    'error' => false,
2139
                    'newOffset' => $newOffset,
2140
                    'totalSize' => $post_totalSize,
2141
                    'clearFilename' => $post_backupFile,
2142
                    'finished' => $finished,
2143
                    'restore_token' => $sessionRestoreToken,
2144
                ),
2145
                'encode'
2146
            );
2147
2148
            // Check if the end of the file has been reached to delete the file
2149
            if ($finished) {
2150
                if (defined('WIP') && WIP === true) {
2151
                    error_log('DEBUG: End of file reached. Deleting file '.$post_backupFile);
2152
                }
2153
2154
                if (is_string($post_backupFile) && $post_backupFile !== '' && file_exists($post_backupFile) === true) {
2155
                    @unlink($post_backupFile);
2156
                }
2157
2158
                // Ensure maintenance mode stays enabled after restore (dump may have restored it to 0).
2159
                try {
2160
                    DB::update(
2161
                        prefixTable('misc'),
2162
                        array(
2163
                            'valeur' => '1',
2164
                            'updated_at' => time(),
2165
                        ),
2166
                        'intitule = %s AND type= %s',
2167
                        'maintenance_mode',
2168
                        'admin'
2169
                    );
2170
                } catch (Throwable $ignored) {
2171
                    // Best effort
2172
                }
2173
2174
                // Cleanup: after a DB restore, the SQL dump may re-import a running database_backup task
2175
                // (is_in_progress=1) that becomes a "ghost" in Task Manager.
2176
                try {
2177
                    DB::delete(
2178
                        prefixTable('background_tasks'),
2179
                        'process_type=%s AND is_in_progress=%i',
2180
                        'database_backup',
2181
                        1
2182
                    );
2183
                } catch (Throwable $ignored) {
2184
                    // Best effort: ignore if table does not exist yet / partial restore / schema mismatch
2185
                }
2186
2187
                // Finalize: clear lock/session state and log duration (best effort)
2188
                $ctx = $session->get('restore-context');
2189
                $scope = is_array($ctx) ? (string) ($ctx['scope'] ?? '') : '';
2190
                $fileLabel = is_array($ctx) ? (string) ($ctx['backup'] ?? '') : '';
2191
                $startTs = (int) ($session->get('restore-start-ts') ?? 0);
2192
                $duration = ($startTs > 0) ? (time() - $startTs) : 0;
2193
2194
                $clearRestoreState($session);
2195
2196
                try {
2197
                    $msg = 'dataBase restore completed';
2198
                    if ($scope !== '' || $fileLabel !== '' || $duration > 0) {
2199
                        $parts = array();
2200
                        if ($scope !== '') {
2201
                            $parts[] = 'scope=' . $scope;
2202
                        }
2203
                        if ($fileLabel !== '') {
2204
                            $parts[] = 'file=' . $fileLabel;
2205
                        }
2206
                        if ($duration > 0) {
2207
                            $parts[] = 'duration=' . $duration . 's';
2208
                        }
2209
                        $msg .= ' (' . implode(', ', $parts) . ')';
2210
                    }
2211
2212
                    logEvents(
2213
                        $SETTINGS,
2214
                        'admin_action',
2215
                        $msg,
2216
                        (string) $session->get('user-id'),
2217
                        $session->get('user-login')
2218
                    );
2219
                } catch (Throwable $ignored) {
2220
                    // ignore logging errors during restore
2221
                }
2222
            }
2223
            break;
2224
    }
2225
}
2226