Passed
Pull Request — master (#5027)
by
unknown
07:11
created

tpCheckRestoreCompatibility()   C

Complexity

Conditions 16
Paths 68

Size

Total Lines 100
Code Lines 69

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 16
eloc 69
c 1
b 0
f 0
nc 68
nop 4
dl 0
loc 100
rs 5.5666

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
    $schema = '';
257
258
    // Resolve target file path
259
    $targetPath = '';
260
    if ($operationId > 0) {
261
        // Uploaded restore file (temp_file in misc)
262
        $data = DB::queryFirstRow(
263
            'SELECT valeur FROM ' . prefixTable('misc') . ' WHERE increment_id = %i LIMIT 1',
264
            $operationId
265
        );
266
        $val = isset($data['valeur']) ? (string)$data['valeur'] : '';
267
        if ($val === '') {
268
            return [
269
                'is_compatible' => false,
270
                'reason' => 'MISSING_UPLOAD_ENTRY',
271
                'backup_tp_files_version' => null,
272
                'expected_tp_files_version' => $expectedVersion,
273
            ];
274
        }
275
        $bn = basename($val);
276
        $baseDir = rtrim((string) ($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files')), '/');
277
        $targetPath = $baseDir . '/' . $bn;
0 ignored issues
show
Unused Code introduced by
The assignment to $targetPath is dead and can be removed.
Loading history...
278
279
        if (function_exists('tpParseSchemaLevelFromBackupFilename')) {
280
            $schema = (string) tpParseSchemaLevelFromBackupFilename($bn);
281
        }
282
    } elseif ($serverFile !== '') {
283
        $bn = basename($serverFile);
284
        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

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

692
            /** @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...
693
            $baseReal = realpath($baseFilesDir) ?: $baseFilesDir;
694
            $dirReal = realpath($outputDir);
695
696
            if ($dirReal === false || strpos($dirReal, $baseReal) !== 0) {
697
                echo prepareExchangedData(['error' => true, 'message' => 'Invalid output directory'], 'encode');
698
                break;
699
            }
700
701
            tpUpsertSettingsValue('bck_scheduled_enabled', (string)$enabled);
702
            tpUpsertSettingsValue('bck_scheduled_frequency', $frequency);
703
            tpUpsertSettingsValue('bck_scheduled_time', $timeStr);
704
            tpUpsertSettingsValue('bck_scheduled_dow', (string)$dow);
705
            tpUpsertSettingsValue('bck_scheduled_dom', (string)$dom);
706
            tpUpsertSettingsValue('bck_scheduled_output_dir', $dirReal);
707
            tpUpsertSettingsValue('bck_scheduled_retention_days', (string)$retentionDays);
708
            tpUpsertSettingsValue('bck_scheduled_email_report_enabled', (string)$emailReportEnabled);
709
            tpUpsertSettingsValue('bck_scheduled_email_report_only_failures', (string)$emailReportOnlyFailures);
710
711
712
            // Force re-init of next_run_at so handler recomputes cleanly
713
            tpUpsertSettingsValue('bck_scheduled_next_run_at', '0');
714
715
            echo prepareExchangedData(['error' => false, 'message' => 'Saved'], 'encode');
716
            break;
717
718
        case 'scheduled_list_backups':
719
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
720
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
721
                break;
722
            }
723
724
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
725
            $dir = (string)tpGetSettingsValue('bck_scheduled_output_dir', rtrim($baseFilesDir, '/') . '/backups');
726
            @mkdir($dir, 0770, true);
727
            // Build a relative path from files/ root (output_dir can be a subfolder)
728
            $filesRoot = realpath($baseFilesDir);
729
            $dirReal = realpath($dir);
730
            $relDir = '';
731
            if ($filesRoot !== false && $dirReal !== false && strpos($dirReal, $filesRoot) === 0) {
732
                $relDir = trim(str_replace($filesRoot, '', $dirReal), DIRECTORY_SEPARATOR);
733
                $relDir = str_replace(DIRECTORY_SEPARATOR, '/', $relDir);
734
            }
735
736
            // Ensure we have a temporary key for downloadFile.php
737
            $keyTmp = (string) $session->get('user-key_tmp');
738
            if ($keyTmp === '') {
739
                $keyTmp = GenerateCryptKey(16, false, true, true, false, true);
740
                $session->set('user-key_tmp', $keyTmp);
741
            }
742
743
744
            $files = [];
745
            foreach (glob(rtrim($dir, '/') . '/scheduled-*.sql') ?: [] as $fp) {
746
                $bn = basename($fp);
747
                $files[] = [
748
                    'name' => $bn,
749
                    'size_bytes' => (int)@filesize($fp),
750
                    'mtime' => (int)@filemtime($fp),
751
                    'tp_files_version' => (function_exists('tpGetBackupTpFilesVersionFromMeta') ? ((($v = (string)tpGetBackupTpFilesVersionFromMeta($fp)) !== '') ? $v : null) : null),
752
                    'download' => 'sources/backups.queries.php?type=scheduled_download_backup&file=' . urlencode($bn)
753
                        . '&key=' . urlencode((string) $session->get('key'))
754
                        . '&key_tmp=' . urlencode($keyTmp),
755
                ];
756
            }
757
758
            usort($files, fn($a, $b) => ($b['mtime'] ?? 0) <=> ($a['mtime'] ?? 0));
759
760
            echo prepareExchangedData(['error' => false, 'files' => $files], 'encode');
761
            break;
762
763
        case 'scheduled_delete_backup':
764
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
765
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
766
                break;
767
            }
768
769
            $dataReceived = prepareExchangedData($post_data, 'decode');
770
            if (!is_array($dataReceived)) $dataReceived = [];
771
772
            $file = (string)($dataReceived['file'] ?? '');
773
            $file = basename($file);
774
775
            if ($file === '' || strpos($file, 'scheduled-') !== 0 || !str_ends_with($file, '.sql')) {
776
                echo prepareExchangedData(['error' => true, 'message' => 'Invalid filename'], 'encode');
777
                break;
778
            }
779
780
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
781
            $dir = (string)tpGetSettingsValue('bck_scheduled_output_dir', rtrim($baseFilesDir, '/') . '/backups');
782
            $fp = rtrim($dir, '/') . '/' . $file;
783
784
785
            if (file_exists($fp) && is_file($fp)) {
786
                if (is_writable($fp)) {
787
                    if (unlink($fp) === false) {
788
                        error_log("TeamPass - Failed to delete file: {$fp}");
789
                    }
790
                    // Also remove metadata sidecar if present
791
                    @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

791
                    /** @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...
792
                } else {
793
                    error_log("TeamPass - File is not writable, cannot delete: {$fp}");
794
                }
795
            }
796
797
            echo prepareExchangedData(['error' => false], 'encode');
798
            break;
799
800
        case 'check_connected_users':
801
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
802
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
803
                break;
804
            }
805
806
            $excludeUserId = (int) filter_input(INPUT_POST, 'exclude_user_id', FILTER_SANITIZE_NUMBER_INT);
807
            if ($excludeUserId === 0 && null !== $session->get('user-id')) {
808
                $excludeUserId = (int) $session->get('user-id');
809
            }
810
811
            $connectedCount = (int) DB::queryFirstField(
812
                'SELECT COUNT(*) FROM ' . prefixTable('users') . ' WHERE session_end >= %i AND id != %i',
813
                time(),
814
                $excludeUserId
815
            );
816
817
            echo prepareExchangedData(['error' => false, 'connected_count' => $connectedCount], 'encode');
818
            break;
819
820
        case 'scheduled_run_now':
821
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
822
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
823
                break;
824
            }
825
826
            $now = time();
827
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
828
            $dir = (string)tpGetSettingsValue('bck_scheduled_output_dir', rtrim($baseFilesDir, '/') . '/backups');
829
            @mkdir($dir, 0770, true);
830
831
            // avoid duplicates
832
            $pending = (int)DB::queryFirstField(
833
                'SELECT COUNT(*) FROM ' . prefixTable('background_tasks') . '
834
                 WHERE process_type=%s AND is_in_progress IN (0,1)
835
                   AND (finished_at IS NULL OR finished_at = "" OR finished_at = 0)',
836
                'database_backup'
837
            );
838
            if ($pending > 0) {
839
                echo prepareExchangedData(['error' => true, 'message' => 'A backup task is already pending/running'], 'encode');
840
                break;
841
            }
842
843
            DB::insert(
844
                prefixTable('background_tasks'),
845
                [
846
                    'created_at' => (string)$now,
847
                    'process_type' => 'database_backup',
848
                    'arguments' => json_encode(['output_dir' => $dir, 'source' => 'scheduler', 'initiator_user_id' => (int) $session->get('user-id')], JSON_UNESCAPED_SLASHES),
849
                    'is_in_progress' => 0,
850
                    'status' => 'new',
851
                ]
852
            );
853
854
            tpUpsertSettingsValue('bck_scheduled_last_run_at', (string)$now);
855
            tpUpsertSettingsValue('bck_scheduled_last_status', 'queued');
856
            tpUpsertSettingsValue('bck_scheduled_last_message', 'Task enqueued by UI');
857
858
            echo prepareExchangedData(['error' => false], 'encode');
859
            break;
860
861
        case 'onthefly_delete_backup':
862
            // Delete an on-the-fly backup file stored in <files> directory
863
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
864
                echo prepareExchangedData(
865
                    array(
866
                        'error' => true,
867
                        'message' => 'Not allowed',
868
                    ),
869
                    'encode'
870
                );
871
                break;
872
            }
873
874
            $dataReceived = prepareExchangedData($post_data, 'decode');
875
            $fileToDelete = isset($dataReceived['file']) === true ? (string)$dataReceived['file'] : '';
876
            $bn = basename($fileToDelete);
877
878
            // Safety checks
879
            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

879
            if ($bn === '' || strtolower(/** @scrutinizer ignore-type */ pathinfo($bn, PATHINFO_EXTENSION)) !== 'sql') {
Loading history...
880
                echo prepareExchangedData(
881
                    array(
882
                        'error' => true,
883
                        'message' => 'Invalid file name',
884
                    ),
885
                    'encode'
886
                );
887
                break;
888
            }
889
890
            // Never allow deleting scheduled backups or temp files
891
            if (strpos($bn, 'scheduled-') === 0 || strpos($bn, 'defuse_temp_') === 0 || strpos($bn, 'defuse_temp_restore_') === 0) {
892
                echo prepareExchangedData(
893
                    array(
894
                        'error' => true,
895
                        'message' => 'Not allowed on this file',
896
                    ),
897
                    'encode'
898
                );
899
                break;
900
            }
901
902
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
903
            $dir = rtrim($baseFilesDir, '/');
904
            $fullPath = $dir . '/' . $bn;
905
906
            if (file_exists($fullPath) === false) {
907
                echo prepareExchangedData(
908
                    array(
909
                        'error' => true,
910
                        'message' => 'File not found',
911
                    ),
912
                    'encode'
913
                );
914
                break;
915
            }
916
917
            // Delete
918
            $ok = @unlink($fullPath);
919
920
            // Also remove metadata sidecar if present
921
            @unlink($fullPath . '.meta.json');
922
923
            if ($ok !== true) {
924
                echo prepareExchangedData(
925
                    array(
926
                        'error' => true,
927
                        'message' => 'Unable to delete file',
928
                    ),
929
                    'encode'
930
                );
931
                break;
932
            }
933
934
            echo prepareExchangedData(
935
                array(
936
                    'error' => false,
937
                    'message' => 'Deleted',
938
                    'file' => $bn,
939
                ),
940
                'encode'
941
            );
942
            break;
943
944
        case 'onthefly_list_backups':
945
            // List on-the-fly backup files stored directly in <files> directory (not in /backups for scheduled)
946
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
947
                echo prepareExchangedData(
948
                    array(
949
                        'error' => true,
950
                        'message' => 'Not allowed',
951
                    ),
952
                    'encode'
953
                );
954
                break;
955
            }
956
957
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
958
            $dir = rtrim($baseFilesDir, '/');
959
960
            $files = array();
961
            $paths = glob($dir . '/*.sql');
962
            if ($paths === false) {
963
                $paths = array();
964
            }
965
966
            // Ensure we have a temporary key for downloadFile.php
967
            $keyTmp = (string) $session->get('user-key_tmp');
968
            if ($keyTmp === '') {
969
                $keyTmp = GenerateCryptKey(16, false, true, true, false, true);
970
                $session->set('user-key_tmp', $keyTmp);
971
            }
972
973
            foreach ($paths as $fp) {
974
                $bn = basename($fp);
975
976
                // Skip scheduled backups and temporary files
977
                if (strpos($bn, 'scheduled-') === 0) {
978
                    continue;
979
                }
980
                if (strpos($bn, 'defuse_temp_') === 0 || strpos($bn, 'defuse_temp_restore_') === 0) {
981
                    continue;
982
                }
983
984
                $files[] = array(
985
                    'name' => $bn,
986
                    'size_bytes' => (int)@filesize($fp),
987
                    'mtime' => (int)@filemtime($fp),
988
                    'tp_files_version' => (function_exists('tpGetBackupTpFilesVersionFromMeta') ? ((($v = (string)tpGetBackupTpFilesVersionFromMeta($fp)) !== '') ? $v : null) : null),
989
                    'download' => 'sources/downloadFile.php?name=' . urlencode($bn) .
990
                        '&action=backup&file=' . urlencode($bn) .
991
                        '&type=sql&key=' . $session->get('key') .
992
                        '&key_tmp=' . $session->get('user-key_tmp') .
993
                        '&pathIsFiles=1',
994
                );
995
            }
996
997
            usort($files, static function ($a, $b) {
998
                return ($b['mtime'] ?? 0) <=> ($a['mtime'] ?? 0);
999
            });
1000
1001
            echo prepareExchangedData(
1002
                array(
1003
                    'error' => false,
1004
                    'dir' => $dir,
1005
                    'files' => $files,
1006
                ),
1007
                'encode'
1008
            );
1009
            break;
1010
1011
case 'preflight_restore_compatibility':
1012
    if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
1013
        echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
1014
        break;
1015
    }
1016
1017
    $dataReceived = prepareExchangedData($post_data, 'decode');
1018
    if (!is_array($dataReceived)) $dataReceived = [];
1019
1020
    $serverScope = (string) ($dataReceived['serverScope'] ?? '');
1021
    $serverFile  = (string) ($dataReceived['serverFile']  ?? '');
1022
    $operationId = (int)    ($dataReceived['operation_id'] ?? 0);
1023
1024
    $chk = tpCheckRestoreCompatibility($SETTINGS, $serverScope, $serverFile, $operationId);
1025
1026
    echo prepareExchangedData(
1027
        [
1028
            'error' => false,
1029
            'is_compatible' => (bool) ($chk['is_compatible'] ?? false),
1030
            'reason' => (string) ($chk['reason'] ?? ''),
1031
            'backup_tp_files_version' => $chk['backup_tp_files_version'] ?? null,
1032
            'expected_tp_files_version' => (string) ($chk['expected_tp_files_version'] ?? ''),
1033
        ],
1034
        'encode'
1035
    );
1036
    break;
1037
        case 'onthefly_restore':
1038
            // Check KEY
1039
            if ($post_key !== $session->get('key')) {
1040
                echo prepareExchangedData(
1041
                    array(
1042
                        'error' => true,
1043
                        'message' => $lang->get('key_is_not_correct'),
1044
                    ),
1045
                    'encode'
1046
                );
1047
                break;
1048
            } elseif ($session->get('user-admin') === 0) {
1049
                echo prepareExchangedData(
1050
                    array(
1051
                        'error' => true,
1052
                        'message' => $lang->get('error_not_allowed_to'),
1053
                    ),
1054
                    'encode'
1055
                );
1056
                break;
1057
            }
1058
            
1059
// Compatibility check (schema-level) BEFORE maintenance/lock and any destructive action
1060
$dataEarly = prepareExchangedData($post_data, 'decode');
1061
if (!is_array($dataEarly)) $dataEarly = [];
1062
1063
$earlyOffset = (int) ($dataEarly['offset'] ?? 0);
1064
$earlyClear = (string) ($dataEarly['clearFilename'] ?? '');
1065
$earlyServerScope = (string) ($dataEarly['serverScope'] ?? '');
1066
$earlyServerFile  = (string) ($dataEarly['serverFile']  ?? '');
1067
$earlyBackupFile  = (string) ($dataEarly['backupFile']  ?? '');
1068
$earlyOperationId = (int) ($dataEarly['operation_id'] ?? 0);
1069
1070
// If restore is starting (first chunk), enforce schema compatibility.
1071
if ($earlyOffset === 0 && $earlyClear === '') {
1072
    // Operation id can be passed either as operation_id or as legacy backupFile numeric id
1073
    if ($earlyOperationId === 0 && $earlyBackupFile !== '' && ctype_digit($earlyBackupFile)) {
1074
        $earlyOperationId = (int) $earlyBackupFile;
1075
    }
1076
1077
    $chk = tpCheckRestoreCompatibility($SETTINGS, $earlyServerScope, $earlyServerFile, $earlyOperationId);
1078
    if (($chk['is_compatible'] ?? false) !== true) {
1079
        echo prepareExchangedData(
1080
            [
1081
                'error' => true,
1082
                'error_code' => 'INCOMPATIBLE_BACKUP_SCHEMA',
1083
                'reason' => (string) ($chk['reason'] ?? ''),
1084
                'backup_tp_files_version' => $chk['backup_tp_files_version'] ?? null,
1085
                'expected_tp_files_version' => (string) ($chk['expected_tp_files_version'] ?? ''),
1086
                'message' => $lang->get('bck_restore_incompatible_version_body'),
1087
            ],
1088
            'encode'
1089
        );
1090
        break;
1091
    }
1092
}
1093
            // Put TeamPass in maintenance mode for the whole restore workflow.
1094
            // Intentionally NOT disabled at the end: admin must validate the instance after restore.
1095
            try {
1096
                DB::update(
1097
                    prefixTable('misc'),
1098
                    array(
1099
                        'valeur' => '1',
1100
                        'updated_at' => time(),
1101
                    ),
1102
                    'intitule = %s AND type= %s',
1103
                    'maintenance_mode',
1104
                    'admin'
1105
                );
1106
            } catch (Throwable $ignored) {
1107
                // Best effort
1108
            }
1109
1110
            // Decrypt and retrieve data in JSON format
1111
            $dataReceived = prepareExchangedData(
1112
                $post_data,
1113
                'decode'
1114
            );
1115
        
1116
            // Prepare variables (safe defaults for both upload-restore and serverFile-restore)
1117
            $post_encryptionKey = filter_var(($dataReceived['encryptionKey'] ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
1118
1119
            // Optional override key (mainly for scheduled restores in case of migration)
1120
            // This MUST NOT be auto-filled from the on-the-fly key; it is only used when explicitly provided.
1121
            $post_overrideKey = filter_var(($dataReceived['overrideKey'] ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
1122
1123
            $post_backupFile = filter_var(($dataReceived['backupFile'] ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
1124
            $post_clearFilename = filter_var(($dataReceived['clearFilename'] ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
1125
1126
            $post_serverScope = filter_var(($dataReceived['serverScope'] ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
1127
            $post_serverFile  = filter_var(($dataReceived['serverFile']  ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
1128
1129
            // Scheduled backups must always be decrypted with the instance key (server-side).
1130
            // Ignore any key coming from the UI to avoid mismatches.
1131
            if ($post_serverScope === 'scheduled') {
1132
                $post_encryptionKey = '';
1133
            }
1134
            // Ensure all strings we send back through prepareExchangedData() are JSON-safe.
1135
            // This avoids PHP "malformed UTF-8" warnings when restore errors contain binary/latin1 bytes.
1136
            $tpSafeJsonString = static function ($value): string {
1137
                if ($value === null) {
1138
                    return '';
1139
                }
1140
                if (is_bool($value)) {
1141
                    return $value ? '1' : '0';
1142
                }
1143
                if (is_scalar($value) === false) {
1144
                    $value = print_r($value, true);
1145
                }
1146
                $str = (string) $value;
1147
1148
                // If the string isn't valid UTF-8, return a hex dump instead (ASCII-only, safe for JSON).
1149
                $isUtf8 = false;
1150
                if (function_exists('mb_check_encoding')) {
1151
                    $isUtf8 = mb_check_encoding($str, 'UTF-8');
1152
                } else {
1153
                    $isUtf8 = (@preg_match('//u', $str) === 1);
1154
                }
1155
                if ($isUtf8 === false) {
1156
                    return '[hex]' . bin2hex($str);
1157
                }
1158
1159
                // Strip ASCII control chars that could pollute JSON.
1160
                $str = preg_replace("/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/", '', $str) ?? $str;
1161
                return $str;
1162
            };
1163
1164
1165
            // Chunked upload fields (can be absent when restoring from an existing server file)
1166
            $post_offset = (int) ($dataReceived['offset'] ?? 0);
1167
            $post_totalSize = (int) ($dataReceived['totalSize'] ?? 0);
1168
1169
            // Restore session + concurrency lock management.
1170
            // - We keep a token in session to allow chunked restore even while DB is being replaced.
1171
            // - We also block starting a second restore in the same session (double click / 2 tabs).
1172
            $clearRestoreState = static function ($session): void {
1173
                            $tmp = (string) ($session->get('restore-temp-file') ?? '');
1174
                            if ($tmp !== '' && file_exists($tmp) === true && strpos(basename($tmp), 'defuse_temp_restore_') === 0 && is_file($tmp)) {
1175
                                if (is_writable($tmp)) {
1176
                                    if (unlink($tmp) === false) {
1177
                                        error_log("TeamPass: Failed to delete file: {$tmp}");
1178
                                    }
1179
                                } else {
1180
                                    error_log("TeamPass: File is not writable, cannot delete: {$tmp}");
1181
                                }
1182
                            }
1183
                            $session->set('restore-temp-file', '');
1184
                $session->set('restore-token', '');
1185
                $session->set('restore-settings', []);
1186
                $session->set('restore-context', []);
1187
                $session->set('restore-in-progress', false);
1188
                $session->set('restore-in-progress-ts', 0);
1189
                $session->set('restore-start-ts', 0);
1190
            };
1191
1192
            $nowTs = time();
1193
            $inProgress = (bool) ($session->get('restore-in-progress') ?? false);
1194
            $lastTs = (int) ($session->get('restore-in-progress-ts') ?? 0);
1195
1196
            // Auto-release stale lock (e.g. client crashed / browser closed)
1197
            if ($inProgress === true && $lastTs > 0 && ($nowTs - $lastTs) > 3600) {
1198
                $clearRestoreState($session);
1199
                $inProgress = false;
1200
                $lastTs = 0;
1201
            }
1202
1203
            $sessionRestoreToken = (string) ($session->get('restore-token') ?? '');
1204
            $isStartRestore = (empty($post_clearFilename) === true && (int) $post_offset === 0);
1205
1206
            if ($isStartRestore === true) {
1207
                if ($inProgress === true && $sessionRestoreToken !== '') {
1208
                    echo prepareExchangedData(
1209
                        array(
1210
                            'error' => true,
1211
                            'message' => 'A restore is already in progress in this session. Please wait for it to complete (or logout/login to reset).',
1212
                        ),
1213
                        'encode'
1214
                    );
1215
                    break;
1216
                }
1217
1218
                $sessionRestoreToken = bin2hex(random_bytes(16));
1219
                $session->set('restore-token', $sessionRestoreToken);
1220
                $session->set('restore-settings', $SETTINGS);
1221
                $session->set('restore-in-progress', true);
1222
                $session->set('restore-in-progress-ts', $nowTs);
1223
                $session->set('restore-start-ts', $nowTs);
1224
                $session->set('restore-context', []);
1225
            } else {
1226
                // Restore continuation must provide the correct token
1227
                if ($restoreToken === '' || $sessionRestoreToken === '' || !hash_equals($sessionRestoreToken, $restoreToken)) {
1228
                    echo prepareExchangedData(
1229
                        array(
1230
                            'error' => true,
1231
                            'message' => 'Restore session expired. Please restart the restore process.',
1232
                        ),
1233
                        'encode'
1234
                    );
1235
                    break;
1236
                }
1237
1238
                // Update activity timestamp (keeps the lock alive)
1239
                $session->set('restore-in-progress', true);
1240
                $session->set('restore-in-progress-ts', $nowTs);
1241
                if ((int) ($session->get('restore-start-ts') ?? 0) === 0) {
1242
                    $session->set('restore-start-ts', $nowTs);
1243
                }
1244
            }
1245
1246
            $batchSize = 500;
1247
            $errors = array(); // Collect potential errors
1248
        
1249
            // Check if the offset is greater than the total size
1250
            if ($post_offset > 0 && $post_totalSize > 0 && $post_offset >= $post_totalSize) {
1251
                // Defensive: if client asks to continue beyond end, consider restore finished and release lock.
1252
                if (is_string($post_clearFilename) && $post_clearFilename !== '' && file_exists($post_clearFilename) === true
1253
                    && strpos(basename($post_clearFilename), 'defuse_temp_restore_') === 0) {
1254
                    @unlink($post_clearFilename);
1255
                }
1256
                $clearRestoreState($session);
1257
1258
                echo prepareExchangedData(
1259
                    array(
1260
                        'error' => false,
1261
                        'message' => 'operation_finished',
1262
                        'finished' => true,
1263
                        'restore_token' => $sessionRestoreToken,
1264
                    ),
1265
                    'encode'
1266
                );
1267
                break;
1268
            }
1269
            
1270
            // Log debug information if in development mode
1271
            if (defined('WIP') && WIP === true) {
1272
                error_log('DEBUG: Offset -> '.$post_offset.'/'.$post_totalSize.' | File -> '.$post_clearFilename);
1273
            }
1274
        
1275
            include_once $SETTINGS['cpassman_dir'] . '/sources/main.functions.php';
1276
        
1277
            include_once $SETTINGS['cpassman_dir'] . '/sources/main.functions.php';
1278
1279
            /*
1280
             * Restore workflow
1281
             * - 1st call (clearFilename empty): locate encrypted backup, decrypt to a temp file, return its path in clearFilename
1282
             * - next calls: reuse clearFilename as the decrypted file path and continue reading from offset
1283
             */
1284
1285
            if (empty($post_clearFilename) === true) {
1286
                // Default behavior: uploaded on-the-fly backup file (stored in teampass_misc) is removed after decrypt/restore.
1287
                // New behavior: user can select an existing encrypted backup file already present on server (scheduled or on-the-fly stored file).
1288
                $deleteEncryptedAfterDecrypt = true;
1289
1290
                $bn = '';
1291
                $serverPath = '';
1292
                $legacyOperationId = null;
1293
1294
                // NEW: restore from an existing server file (scheduled or on-the-fly stored file)
1295
                if (!empty($post_serverFile)) {
1296
                    $deleteEncryptedAfterDecrypt = false;
1297
1298
                    $bn = basename((string) $post_serverFile);
1299
1300
                    // Safety: allow only *.sql name
1301
                    if ($bn === '' || strtolower(pathinfo($bn, PATHINFO_EXTENSION)) !== 'sql') {
1302
                        $clearRestoreState($session);
1303
                        echo prepareExchangedData(
1304
                            array('error' => true, 'message' => 'Invalid serverFile'),
1305
                            'encode'
1306
                        );
1307
                        break;
1308
                    }
1309
1310
                    $baseDir = rtrim((string) $SETTINGS['path_to_files_folder'], '/');
1311
1312
                    // Scheduled backups are stored in configured output directory
1313
                    if ($post_serverScope === 'scheduled') {
1314
                        $baseFilesDir = (string) ($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
1315
                        $dir = (string) tpGetSettingsValue('bck_scheduled_output_dir', rtrim($baseFilesDir, '/') . '/backups');
1316
                        $baseDir = rtrim($dir, '/');
1317
                    }
1318
1319
                    $serverPath = $baseDir . '/' . $bn;
1320
1321
                    if (file_exists($serverPath) === false) {
1322
                        try {
1323
                            logEvents(
1324
                                $SETTINGS,
1325
                                'admin_action',
1326
                                'dataBase restore failed (file not found)',
1327
                                (string) $session->get('user-id'),
1328
                                $session->get('user-login')
1329
                            );
1330
                        } catch (Throwable $ignored) {
1331
                            // ignore logging errors
1332
                        }
1333
                        $clearRestoreState($session);
1334
                        echo prepareExchangedData(
1335
                            array('error' => true, 'message' => 'Backup file not found on server'),
1336
                            'encode'
1337
                        );
1338
                        break;
1339
                    }
1340
                
1341
                    // Log restore start once (best effort)
1342
                    if ($isStartRestore === true) {
1343
                        $sizeBytes = (int) @filesize($serverPath);
1344
                        $session->set(
1345
                            'restore-context',
1346
                            array(
1347
                                'scope' => $post_serverScope,
1348
                                'backup' => $bn,
1349
                                'size_bytes' => $sizeBytes,
1350
                            )
1351
                        );
1352
1353
                        try {
1354
                            $msg = 'dataBase restore started (scope=' . $post_serverScope . ', file=' . $bn . ')';
1355
                            logEvents(
1356
                                $SETTINGS,
1357
                                'admin_action',
1358
                                $msg,
1359
                                (string) $session->get('user-id'),
1360
                                $session->get('user-login')
1361
                            );
1362
                        } catch (Throwable $ignored) {
1363
                            // ignore logging errors during restore
1364
                        }
1365
                    }
1366
1367
                } else {
1368
                    // LEGACY: restore from uploaded on-the-fly backup identified by its misc.increment_id
1369
                    if (empty($post_backupFile) === true || ctype_digit((string) $post_backupFile) === false) {
1370
                        echo prepareExchangedData(
1371
                            array('error' => true, 'message' => 'No backup selected'),
1372
                            'encode'
1373
                        );
1374
                        break;
1375
                    }
1376
1377
                    $legacyOperationId = (int) $post_backupFile;
1378
1379
                    // Find filename from DB (misc)
1380
                    $data = DB::queryFirstRow(
1381
                        'SELECT valeur FROM ' . prefixTable('misc') . ' WHERE increment_id = %i LIMIT 1',
1382
                        $legacyOperationId
1383
                    );
1384
1385
                    if (empty($data['valeur'])) {
1386
                        try {
1387
                            logEvents(
1388
                                $SETTINGS,
1389
                                'admin_action',
1390
                                'dataBase restore failed (missing misc entry)',
1391
                                (string) $session->get('user-id'),
1392
                                $session->get('user-login')
1393
                            );
1394
                        } catch (Throwable $ignored) {
1395
                            // ignore logging errors
1396
                        }
1397
                        $clearRestoreState($session);
1398
                        echo prepareExchangedData(
1399
                            array('error' => true, 'message' => 'Backup file not found in database'),
1400
                            'encode'
1401
                        );
1402
                        break;
1403
                    }
1404
1405
                    $bn = safeString($data['valeur']);
1406
                    $serverPath = rtrim((string) $SETTINGS['path_to_files_folder'], '/') . '/' . $bn;
1407
1408
                    if (file_exists($serverPath) === false) {
1409
                        try {
1410
                            logEvents(
1411
                                $SETTINGS,
1412
                                'admin_action',
1413
                                'dataBase restore failed (file not found)',
1414
                                (string) $session->get('user-id'),
1415
                                $session->get('user-login')
1416
                            );
1417
                        } catch (Throwable $ignored) {
1418
                            // ignore logging errors
1419
                        }
1420
                        $clearRestoreState($session);
1421
                        echo prepareExchangedData(
1422
                            array('error' => true, 'message' => 'Backup file not found on server'),
1423
                            'encode'
1424
                        );
1425
                        break;
1426
                    }
1427
                }
1428
1429
                // Common checks
1430
                if ($post_serverScope !== 'scheduled' && empty($post_encryptionKey) === true) {
1431
                    echo prepareExchangedData(
1432
                        array('error' => true, 'message' => 'Missing encryption key'),
1433
                        'encode'
1434
                    );
1435
                    break;
1436
                }
1437
1438
                // Decrypt to a dedicated temp file (unique)
1439
                $tmpDecrypted = rtrim((string) $SETTINGS['path_to_files_folder'], '/')
1440
                    . '/defuse_temp_restore_' . (int) $session->get('user-id') . '_' . time() . '_' . $bn;
1441
1442
                // Build the list of keys we can try to decrypt with.
1443
                // - on-the-fly: uses the key provided by the UI
1444
                // - scheduled: uses the instance key (stored in bck_script_passkey)
1445
                $keysToTry = [];
1446
                if ($post_serverScope === 'scheduled') {
1447
                    // Allow an explicit override key (migration use-case)
1448
                    if (!empty($post_overrideKey)) {
1449
                        $keysToTry[] = (string) $post_overrideKey;
1450
                    }
1451
                    if (!empty($SETTINGS['bck_script_passkey'] ?? '')) {
1452
                        $rawInstanceKey = (string) $SETTINGS['bck_script_passkey'];
1453
                        $tmp = cryption($rawInstanceKey, '', 'decrypt', $SETTINGS);
1454
                        $decInstanceKey = isset($tmp['string']) ? (string) $tmp['string'] : '';
1455
1456
                        if ($decInstanceKey !== '') {
1457
                            $keysToTry[] = $decInstanceKey;
1458
                        }
1459
                        if ($rawInstanceKey !== '' && $rawInstanceKey !== $decInstanceKey) {
1460
                            $keysToTry[] = $rawInstanceKey;
1461
                        }
1462
                    }
1463
                } else {
1464
                    if ($post_encryptionKey !== '') {
1465
                        $keysToTry[] = (string) $post_encryptionKey;
1466
                    }
1467
                
1468
1469
                    // For uploaded restores (serverScope is empty), also try the instance key candidates.
1470
                    // This allows restoring scheduled backups uploaded manually (they are encrypted using bck_script_passkey).
1471
                    if ($post_serverScope === '' && !empty($SETTINGS['bck_script_passkey'] ?? '')) {
1472
                        $rawInstanceKey = (string) $SETTINGS['bck_script_passkey'];
1473
                        $tmp = cryption($rawInstanceKey, '', 'decrypt', $SETTINGS);
1474
                        $decInstanceKey = isset($tmp['string']) ? (string) $tmp['string'] : '';
1475
1476
                        if ($decInstanceKey !== '') {
1477
                            $keysToTry[] = $decInstanceKey;
1478
                        }
1479
                        if ($rawInstanceKey !== '' && $rawInstanceKey !== $decInstanceKey) {
1480
                            $keysToTry[] = $rawInstanceKey;
1481
                        }
1482
                    }
1483
}
1484
1485
                // Ensure we have at least one key
1486
                $keysToTry = array_values(array_unique(array_filter($keysToTry, static fn ($v) => $v !== '')));
1487
                if (empty($keysToTry)) {
1488
                    echo prepareExchangedData(
1489
                        array('error' => true, 'message' => 'Missing encryption key'),
1490
                        'encode'
1491
                    );
1492
                    break;
1493
                }
1494
1495
                // Try to decrypt with the available keys (some environments store bck_script_passkey encrypted)
1496
                $decRet = tpDefuseDecryptWithCandidates($serverPath, $tmpDecrypted, $keysToTry, $SETTINGS);
1497
                if (!empty($decRet['success']) === false) {
1498
                    @unlink($tmpDecrypted);
1499
                    try {
1500
                        logEvents(
1501
                            $SETTINGS,
1502
                            'admin_action',
1503
                            'dataBase restore failed (decrypt error)',
1504
                            (string) $session->get('user-id'),
1505
                            $session->get('user-login')
1506
                        );
1507
                    } catch (Throwable $ignored) {
1508
                        // ignore logging errors
1509
                    }
1510
                    $clearRestoreState($session);
1511
                    echo prepareExchangedData(
1512
                        array(
1513
                            'error' => true,
1514
                            'error_code' => 'DECRYPT_FAILED',
1515
                            'message' => 'Unable to decrypt backup: ' . $tpSafeJsonString((string) ($decRet['message'] ?? 'unknown error')),
1516
                        ),
1517
                        'encode'
1518
                    );
1519
                    break;
1520
                }
1521
1522
                if (!is_file($tmpDecrypted) || (int) @filesize($tmpDecrypted) === 0) {
1523
                    @unlink($tmpDecrypted);
1524
                    $clearRestoreState($session);
1525
                    echo prepareExchangedData(
1526
                        array('error' => true, 'message' => 'Decrypted backup is empty or unreadable'),
1527
                        'encode'
1528
                    );
1529
                    break;
1530
                }
1531
1532
                // Remove original encrypted file ONLY for legacy uploaded one-shot restore
1533
                if ($deleteEncryptedAfterDecrypt === true) {
1534
                    fileDelete($serverPath, $SETTINGS);
1535
1536
                    // Delete operation record
1537
                    if ($legacyOperationId !== null) {
1538
                        DB::delete(
1539
                            prefixTable('misc'),
1540
                            'increment_id = %i',
1541
                            $legacyOperationId
1542
                        );
1543
                    }
1544
                }
1545
// From now, restore uses the decrypted temp file
1546
                $post_backupFile = $tmpDecrypted;
1547
                $session->set('restore-temp-file', $tmpDecrypted);
1548
                $post_clearFilename = $tmpDecrypted;
1549
            } else {
1550
                $post_backupFile = $post_clearFilename;
1551
                $session->set('restore-temp-file', $post_clearFilename);
1552
            }
1553
1554
            // Read sql file
1555
            $handle = fopen($post_backupFile, 'r');
1556
        
1557
            if ($handle === false) {
1558
                if (is_string($post_backupFile) && $post_backupFile !== '' && file_exists($post_backupFile) === true
1559
                    && strpos(basename($post_backupFile), 'defuse_temp_restore_') === 0) {
1560
                    @unlink($post_backupFile);
1561
                }
1562
                $clearRestoreState($session);
1563
                echo prepareExchangedData(
1564
                    array(
1565
                        'error' => true,
1566
                        'message' => 'Unable to open backup file.',
1567
                        'finished' => false,
1568
                        'restore_token' => $sessionRestoreToken,
1569
                    ),
1570
                    'encode'
1571
                );
1572
                break;
1573
            }
1574
        
1575
                        // Get total file size
1576
            if ((int) $post_totalSize === 0) {
1577
                $post_totalSize = filesize($post_backupFile);
1578
            }
1579
1580
            // Validate chunk parameters
1581
            if ((int) $post_totalSize <= 0) {
1582
                // Abort: we cannot safely run a chunked restore without a reliable size
1583
                $clearRestoreState($session);
1584
                echo prepareExchangedData(
1585
                    array(
1586
                        'error' => true,
1587
                        'message' => 'Invalid backup file size (0).',
1588
                        'finished' => true,
1589
                    ),
1590
                    'encode'
1591
                );
1592
                break;
1593
            }
1594
1595
            if ($post_offset < 0) {
1596
                $post_offset = 0;
1597
            }
1598
1599
            if ($post_offset > $post_totalSize) {
1600
                // Abort: invalid offset (prevents instant "success" due to EOF)
1601
                if (is_string($post_backupFile) && $post_backupFile !== '' && file_exists($post_backupFile) === true) {
1602
                    // If it is a temporary decrypted file, cleanup
1603
                    if (strpos(basename($post_backupFile), 'defuse_temp_restore_') === 0) {
1604
                        @unlink($post_backupFile);
1605
                    }
1606
                }
1607
                $clearRestoreState($session);
1608
                echo prepareExchangedData(
1609
                    array(
1610
                        'error' => true,
1611
                        'message' => 'Invalid restore offset.',
1612
                        'finished' => true,
1613
                    ),
1614
                    'encode'
1615
                );
1616
                break;
1617
            }
1618
1619
            // Move the file pointer to the current offset
1620
            fseek($handle, $post_offset);
1621
            $query = '';
1622
            $executedQueries = 0;
1623
            $inMultiLineComment = false;
1624
            
1625
            try {
1626
                // Start transaction to ensure database consistency
1627
                DB::startTransaction();
1628
                DB::query("SET FOREIGN_KEY_CHECKS = 0");
1629
                DB::query("SET UNIQUE_CHECKS = 0");
1630
                
1631
                while (!feof($handle) && $executedQueries < $batchSize) {
1632
                    $line = fgets($handle);
1633
                    
1634
                    // Check if not false
1635
                    if ($line !== false) {
1636
                        $trimmedLine = trim($line);
1637
                        
1638
                        // Skip empty lines or comments
1639
                        if (empty($trimmedLine) || 
1640
                            (strpos($trimmedLine, '--') === 0) || 
1641
                            (strpos($trimmedLine, '#') === 0)) {
1642
                            continue;
1643
                        }
1644
                        
1645
                        // Handle multi-line comments
1646
                        if (strpos($trimmedLine, '/*') === 0 && strpos($trimmedLine, '*/') === false) {
1647
                            $inMultiLineComment = true;
1648
                            continue;
1649
                        }
1650
                        
1651
                        if ($inMultiLineComment) {
1652
                            if (strpos($trimmedLine, '*/') !== false) {
1653
                                $inMultiLineComment = false;
1654
                            }
1655
                            continue;
1656
                        }
1657
                        
1658
                        // Add line to current query
1659
                        $query .= $line;
1660
                        
1661
                        // Execute if this is the end of a statement
1662
                        if (substr($trimmedLine, -1) === ';') {
1663
                            try {
1664
                                DB::query($query);
1665
                                $executedQueries++;
1666
                            } catch (Exception $e) {
1667
                                $snippet = substr($query, 0, 120);
1668
                                $snippet = $tpSafeJsonString($snippet);
1669
                                $errors[] = 'Error executing query: ' . $tpSafeJsonString($e->getMessage()) . ' - Query: ' . $snippet . '...';
1670
                            }
1671
                            $query = '';
1672
                        }
1673
                    }
1674
                }
1675
1676
                // Set default settings back
1677
                DB::query("SET FOREIGN_KEY_CHECKS = 1");
1678
                DB::query("SET UNIQUE_CHECKS = 1");
1679
                
1680
                // Commit the transaction if no errors
1681
                if (empty($errors)) {
1682
                    DB::commit();
1683
                } else {
1684
                    DB::rollback();
1685
                }
1686
            } catch (Exception $e) {
1687
                try {
1688
                    DB::query("SET FOREIGN_KEY_CHECKS = 1");
1689
                    DB::query("SET UNIQUE_CHECKS = 1");
1690
                } catch (Exception $ignored) {
1691
                    // Ignore further exceptions
1692
                }
1693
                // Rollback transaction on any exception
1694
                DB::rollback();
1695
                $errors[] = 'Transaction failed: ' . $tpSafeJsonString($e->getMessage());
1696
            }
1697
        
1698
            // Calculate the new offset
1699
            $newOffset = ftell($handle);
1700
        
1701
            // Check if the end of the file has been reached
1702
            $isEndOfFile = feof($handle);
1703
            fclose($handle);
1704
        
1705
            // Handle errors if any
1706
if (!empty($errors)) {
1707
    // Abort restore: cleanup temp file and release session lock
1708
    if (is_string($post_backupFile) && $post_backupFile !== '' && file_exists($post_backupFile) === true
1709
        && strpos(basename($post_backupFile), 'defuse_temp_restore_') === 0) {
1710
        @unlink($post_backupFile);
1711
    }
1712
1713
    $tokenForResponse = $sessionRestoreToken;
1714
1715
    // Best-effort log
1716
    try {
1717
        $ctx = $session->get('restore-context');
1718
        $scope = is_array($ctx) ? (string) ($ctx['scope'] ?? '') : '';
1719
        logEvents(
1720
            $SETTINGS,
1721
            'admin_action',
1722
            'dataBase restore failed' . ($scope !== '' ? ' (scope=' . $scope . ')' : ''),
1723
            (string) $session->get('user-id'),
1724
            $session->get('user-login')
1725
        );
1726
    } catch (Throwable $ignored) {
1727
        // ignore logging errors during restore
1728
    }
1729
1730
    $clearRestoreState($session);
1731
1732
    echo prepareExchangedData(
1733
        array(
1734
            'error' => true,
1735
            'message' => 'Errors occurred during import: ' . implode('; ', ($post_serverScope === 'scheduled' ? array_map($tpSafeJsonString, $errors) : $errors)),
1736
            'newOffset' => $newOffset,
1737
            'totalSize' => $post_totalSize,
1738
            'clearFilename' => $post_backupFile,
1739
            'finished' => true,
1740
            'restore_token' => $tokenForResponse,
1741
        ),
1742
        'encode'
1743
    );
1744
    break;
1745
}
1746
1747
// Determine if restore is complete
1748
            $finished = ($isEndOfFile === true) || ($post_totalSize > 0 && $newOffset >= $post_totalSize);
1749
1750
            // Respond with the new offset
1751
            echo prepareExchangedData(
1752
                array(
1753
                    'error' => false,
1754
                    'newOffset' => $newOffset,
1755
                    'totalSize' => $post_totalSize,
1756
                    'clearFilename' => $post_backupFile,
1757
                    'finished' => $finished,
1758
                        'restore_token' => $sessionRestoreToken,
1759
                ),
1760
                'encode'
1761
            );
1762
1763
            // Check if the end of the file has been reached to delete the file
1764
            if ($finished) {
1765
                if (defined('WIP') && WIP === true) {
1766
                    error_log('DEBUG: End of file reached. Deleting file '.$post_backupFile);
1767
                }
1768
1769
                if (is_string($post_backupFile) && $post_backupFile !== '' && file_exists($post_backupFile) === true) {
1770
                    @unlink($post_backupFile);
1771
                }
1772
1773
            // Ensure maintenance mode stays enabled after restore (dump may have restored it to 0).
1774
            try {
1775
                DB::update(
1776
                    prefixTable('misc'),
1777
                    array(
1778
                        'valeur' => '1',
1779
                        'updated_at' => time(),
1780
                    ),
1781
                    'intitule = %s AND type= %s',
1782
                    'maintenance_mode',
1783
                    'admin'
1784
                );
1785
            } catch (Throwable $ignored) {
1786
                // Best effort
1787
            }
1788
1789
// Cleanup: after a DB restore, the SQL dump may re-import a running database_backup task
1790
// (is_in_progress=1) that becomes a "ghost" in Task Manager.
1791
try {
1792
    DB::delete(
1793
        prefixTable('background_tasks'),
1794
        'process_type=%s AND is_in_progress=%i',
1795
        'database_backup',
1796
        1
1797
    );
1798
} catch (Throwable $ignored) {
1799
    // Best effort: ignore if table does not exist yet / partial restore / schema mismatch
1800
}
1801
1802
// Finalize: clear lock/session state and log duration (best effort)
1803
$ctx = $session->get('restore-context');
1804
$scope = is_array($ctx) ? (string) ($ctx['scope'] ?? '') : '';
1805
$fileLabel = is_array($ctx) ? (string) ($ctx['backup'] ?? '') : '';
1806
$startTs = (int) ($session->get('restore-start-ts') ?? 0);
1807
$duration = ($startTs > 0) ? (time() - $startTs) : 0;
1808
1809
$clearRestoreState($session);
1810
1811
try {
1812
    $msg = 'dataBase restore completed';
1813
    if ($scope !== '' || $fileLabel !== '' || $duration > 0) {
1814
        $parts = array();
1815
        if ($scope !== '') {
1816
            $parts[] = 'scope=' . $scope;
1817
        }
1818
        if ($fileLabel !== '') {
1819
            $parts[] = 'file=' . $fileLabel;
1820
        }
1821
        if ($duration > 0) {
1822
            $parts[] = 'duration=' . $duration . 's';
1823
        }
1824
        $msg .= ' (' . implode(', ', $parts) . ')';
1825
    }
1826
1827
    logEvents(
1828
        $SETTINGS,
1829
        'admin_action',
1830
        $msg,
1831
        (string) $session->get('user-id'),
1832
        $session->get('user-login')
1833
    );
1834
} catch (Throwable $ignored) {
1835
    // ignore logging errors during restore
1836
}
1837
1838
            }
1839
            break;
1840
    }
1841
}
1842