tpExpectedTpFilesVersion()   A
last analyzed

Complexity

Conditions 5
Paths 5

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 6
c 1
b 0
f 0
nc 5
nop 0
dl 0
loc 10
rs 9.6111
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
             * Delete a file and its associated metadata.
786
             * * @param string $fp Full path to the file.
787
             * @return void
788
             */
789
            // Check if file exists and is valid
790
            if (file_exists($fp) === false || is_file($fp) === false) {
791
                echo prepareExchangedData(['error' => false], 'encode');
792
                break;
793
            }
794
795
            // Check permissions
796
            if (is_writable($fp) === false) {
797
                $errorMessage = "File is not writable, cannot delete: " . $fp;
798
                if (WIP === true) error_log("TeamPass - " . $errorMessage);
799
                echo prepareExchangedData(['error' => true, 'message' => $errorMessage], 'encode');
800
                break;
801
            }
802
803
            // Attempt deletion
804
            if (unlink($fp) === false) {
805
                $errorMessage = "Failed to delete file: " . $fp;
806
                if (WIP === true) error_log("TeamPass - " . $errorMessage);
807
                echo prepareExchangedData(['error' => true, 'message' => $errorMessage], 'encode');
808
                break;
809
            }
810
811
            // Cleanup metadata (silent fail for sidecar is acceptable)
812
            if (file_exists($fp . '.meta.json')) {
813
                @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

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

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