Issues (48)

Security Analysis    no vulnerabilities found

This project does not seem to handle request data directly as such no vulnerable execution paths were found.

  File Inclusion
File Inclusion enables an attacker to inject custom files into PHP's file loading mechanism, either explicitly passed to include, or for example via PHP's auto-loading mechanism.
  Regex Injection
Regex Injection enables an attacker to execute arbitrary code in your PHP process.
  SQL Injection
SQL Injection enables an attacker to execute arbitrary SQL code on your database server gaining access to user data, or manipulating user data.
  Response Splitting
Response Splitting can be used to send arbitrary responses.
  File Manipulation
File Manipulation enables an attacker to write custom data to files. This potentially leads to injection of arbitrary code on the server.
  Object Injection
Object Injection enables an attacker to inject an object into PHP code, and can lead to arbitrary code execution, file exposure, or file manipulation attacks.
  File Exposure
File Exposure allows an attacker to gain access to local files that he should not be able to access. These files can for example include database credentials, or other configuration files.
  XML Injection
XML Injection enables an attacker to read files on your local filesystem including configuration files, or can be abused to freeze your web-server process.
  Code Injection
Code Injection enables an attacker to execute arbitrary code on the server.
  Variable Injection
Variable Injection enables an attacker to overwrite program variables with custom data, and can lead to further vulnerabilities.
  XPath Injection
XPath Injection enables an attacker to modify the parts of XML document that are read. If that XML document is for example used for authentication, this can lead to further vulnerabilities similar to SQL Injection.
  Other Vulnerability
This category comprises other attack vectors such as manipulating the PHP runtime, loading custom extensions, freezing the runtime, or similar.
  Command Injection
Command Injection enables an attacker to inject a shell command that is execute with the privileges of the web-server. This can be used to expose sensitive data, or gain access of your server.
  LDAP Injection
LDAP Injection enables an attacker to inject LDAP statements potentially granting permission to run unauthorized queries, or modify content inside the LDAP tree.
  Cross-Site Scripting
Cross-Site Scripting enables an attacker to inject code into the response of a web-request that is viewed by other users. It can for example be used to bypass access controls, or even to take over other users' accounts.
  Header Injection
Unfortunately, the security analysis is currently not available for your project. If you are a non-commercial open-source project, please contact support to gain access.

sources/backups.queries.php (2 issues)

1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * Teampass - a collaborative passwords manager.
7
 * ---
8
 * This file is part of the TeamPass project.
9
 * 
10
 * TeamPass is free software: you can redistribute it and/or modify it
11
 * under the terms of the GNU General Public License as published by
12
 * the Free Software Foundation, version 3 of the License.
13
 * 
14
 * TeamPass is distributed in the hope that it will be useful,
15
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17
 * GNU General Public License for more details.
18
 * 
19
 * You should have received a copy of the GNU General Public License
20
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
21
 * 
22
 * Certain components of this file may be under different licenses. For
23
 * details, see the `licenses` directory or individual file headers.
24
 * ---
25
 * @file      backups.queries.php
26
 * @author    Nils Laumaillé ([email protected])
27
 * @copyright 2009-2026 Teampass.net
28
 * @license   GPL-3.0
29
 * @see       https://www.teampass.net
30
 */
31
32
use TeampassClasses\NestedTree\NestedTree;
33
use TeampassClasses\SessionManager\SessionManager;
34
use Symfony\Component\HttpFoundation\Request as SymfonyRequest;
35
use TeampassClasses\Language\Language;
36
use EZimuel\PHPSecureSession;
37
use TeampassClasses\PerformChecks\PerformChecks;
38
use TeampassClasses\ConfigManager\ConfigManager;
39
40
41
// Load functions
42
require_once 'main.functions.php';
43
require_once __DIR__ . '/backup.functions.php';
44
$session = SessionManager::getSession();
45
46
47
// init
48
loadClasses('DB');
49
$session = SessionManager::getSession();
50
$request = SymfonyRequest::createFromGlobals();
51
// Detect if this is a restore continuation call.
52
// During a database restore, tables used for session/access checks can temporarily disappear.
53
// We allow continuation calls when they present a valid restore token stored in PHP session.
54
$earlyType = (string) $request->request->get('type', '');
55
if ($earlyType === '') {
56
    $earlyType = (string) $request->query->get('type', '');
57
}
58
$restoreToken = (string) $request->request->get('restore_token', '');
59
$restoreTokenSession = (string) ($session->get('restore-token') ?? '');
60
$isRestoreContinuation = (
61
    $earlyType === 'onthefly_restore'
62
    && $restoreToken !== ''
63
    && $restoreTokenSession !== ''
64
    && hash_equals($restoreTokenSession, $restoreToken)
65
);
66
67
$lang = new Language($session->get('user-language') ?? 'english');
68
69
// Load config (for restore continuations, prefer the snapshot stored in session)
70
$configManager = new ConfigManager();
71
$SETTINGS = [];
72
if ($isRestoreContinuation === true) {
73
    $tmpSettings = $session->get('restore-settings');
74
    if (is_array($tmpSettings) && !empty($tmpSettings)) {
75
        $SETTINGS = $tmpSettings;
76
    }
77
}
78
if (empty($SETTINGS)) {
79
    $SETTINGS = $configManager->getAllSettings();
80
}
81
82
// Do checks (skip for restore continuation calls, as the DB can be temporarily inconsistent)
83
if ($isRestoreContinuation === false) {
84
    // Do checks
85
    // Instantiate the class with posted data
86
    $checkUserAccess = new PerformChecks(
87
        dataSanitizer(
88
            [
89
                'type' => htmlspecialchars($request->request->get('type', ''), ENT_QUOTES, 'UTF-8'),
90
            ],
91
            [
92
                'type' => 'trim|escape',
93
            ],
94
        ),
95
        [
96
            'user_id' => returnIfSet($session->get('user-id'), null),
97
            'user_key' => returnIfSet($session->get('key'), null),
98
        ]
99
    );
100
    // Handle the case
101
    echo $checkUserAccess->caseHandler();
102
    if (
103
        $checkUserAccess->userAccessPage('backups') === false ||
104
        $checkUserAccess->checkSession() === false
105
    ) {
106
        // Not allowed page
107
        $session->set('system-error_code', ERR_NOT_ALLOWED);
108
        include $SETTINGS['cpassman_dir'] . '/error.php';
109
        exit;
110
    }
111
}
112
113
// Define Timezone
114
date_default_timezone_set($SETTINGS['timezone'] ?? 'UTC');
115
116
// Set header properties
117
header('Content-type: text/html; charset=utf-8');
118
header('Cache-Control: no-cache, no-store, must-revalidate');
119
120
// --------------------------------- //
121
122
123
// Prepare POST variables
124
$post_type = filter_input(INPUT_POST, 'type', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
125
if (empty($post_type) === true) {
126
    $post_type = filter_input(INPUT_GET, 'type', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
127
}
128
129
$post_key = filter_input(INPUT_POST, 'key', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
130
if (empty($post_key) === true) {
131
    $post_key = filter_input(INPUT_GET, 'key', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
132
}
133
$post_data = filter_input(
134
    INPUT_POST,
135
    'data',
136
    FILTER_SANITIZE_FULL_SPECIAL_CHARS,
137
    FILTER_FLAG_NO_ENCODE_QUOTES
138
);
139
140
// manage action required
141
    if (null !== $post_type) {
142
    /**
143
     * Read a setting from teampass_misc (type='settings', intitule=key).
144
     */
145
    function tpGetSettingsValue(string $key, string $default = ''): string
146
    {
147
        $val = DB::queryFirstField(
148
            'SELECT valeur FROM ' . prefixTable('misc') . ' WHERE type=%s AND intitule=%s LIMIT 1',
149
            'settings',
150
            $key
151
        );
152
153
        return ($val === null || $val === false || $val === '') ? $default : (string) $val;
154
    }
155
156
    /**
157
     * Upsert a setting into teampass_misc (type='settings', intitule=key).
158
     */
159
    function tpUpsertSettingsValue(string $key, string $value): void
160
    {
161
        $exists = DB::queryFirstField(
162
            'SELECT 1 FROM ' . prefixTable('misc') . ' WHERE type=%s AND intitule=%s LIMIT 1',
163
            'settings',
164
            $key
165
        );
166
167
        if ((int)$exists === 1) {
168
            DB::update(
169
                prefixTable('misc'),
170
                ['valeur' => $value],
171
                'type=%s AND intitule=%s',
172
                'settings',
173
                $key
174
            );
175
        } else {
176
            DB::insert(
177
                prefixTable('misc'),
178
                ['type' => 'settings', 'intitule' => $key, 'valeur' => $value]
179
            );
180
        }
181
    }
182
183
    /**
184
     * Get TeamPass timezone name from teampass_misc (type='admin', intitule='timezone').
185
     */
186
    function tpGetAdminTimezoneName(): string
187
    {
188
        $tz = DB::queryFirstField(
189
            'SELECT valeur FROM ' . prefixTable('misc') . ' WHERE type=%s AND intitule=%s LIMIT 1',
190
            'admin',
191
            'timezone'
192
        );
193
194
        return (is_string($tz) && $tz !== '') ? $tz : 'UTC';
195
    }
196
197
    function tpFormatBytes(float $bytes): string
198
    {
199
        $units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
200
        $i = 0;
201
        while ($bytes >= 1024 && $i < count($units) - 1) {
202
            $bytes /= 1024;
203
            $i++;
204
        }
205
206
        if ($i === 0) {
207
            return sprintf('%d %s', (int) $bytes, $units[$i]);
208
        }
209
210
        return sprintf('%.1f %s', $bytes, $units[$i]);
211
    }
212
213
// ---------------------------------------------------------------------
214
// Restore compatibility helpers
215
// ---------------------------------------------------------------------
216
// Compatibility is based on schema level (UPGRADE_MIN_DATE).
217
// UI must NOT display schema_level; only TeamPass files version.
218
219
function tpExpectedTpFilesVersion(): string
220
{
221
    if (function_exists('tpGetTpFilesVersion')) {
222
        $v = (string) tpGetTpFilesVersion();
223
        if ($v !== '') return $v;
224
    }
225
    if (defined('TP_VERSION') && defined('TP_VERSION_MINOR')) {
226
        return (string) TP_VERSION . '.' . (string) TP_VERSION_MINOR;
227
    }
228
    return '';
229
}
230
231
function tpCurrentSchemaLevel(): string
232
{
233
    if (function_exists('tpGetSchemaLevel')) {
234
        return (string) tpGetSchemaLevel();
235
    }
236
    if (defined('UPGRADE_MIN_DATE')) {
237
        $v = (string) UPGRADE_MIN_DATE;
238
        if ($v !== '' && preg_match('/^\d+$/', $v) === 1) return $v;
239
    }
240
    return '';
241
}
242
243
/**
244
 * Check restore compatibility (schema level).
245
 * - For server backups: reads schema_level from .meta.json when present, otherwise from "-sl<schema>" in filename.
246
 * - For uploaded restore file: reads schema from "-sl<schema>" preserved in filename stored in teampass_misc.
247
 *
248
 * @return array{is_compatible: bool, reason: string, backup_tp_files_version: ?string, expected_tp_files_version: string}
249
 */
250
function tpCheckRestoreCompatibility(array $SETTINGS, string $serverScope = '', string $serverFile = '', int $operationId = 0): array
251
{
252
    $expectedVersion = tpExpectedTpFilesVersion();
253
    $expectedSchema  = tpCurrentSchemaLevel();
254
255
    $backupVersion = null;
256
    $backupSchema  = '';
257
    $warnings = [];
258
    $mode = ($operationId > 0) ? 'upload_schema_only' : 'server_strict';
259
260
    // Resolve target file path
261
    $targetPath = '';
262
    if ($operationId > 0) {
263
        // Uploaded restore file (temp_file in misc)
264
        $data = DB::queryFirstRow(
265
            'SELECT valeur FROM ' . prefixTable('misc') . ' WHERE increment_id = %i LIMIT 1',
266
            $operationId
267
        );
268
        $val = isset($data['valeur']) ? (string) $data['valeur'] : '';
269
        if ($val === '') {
270
            return [
271
                'is_compatible' => false,
272
                'reason' => 'MISSING_UPLOAD_ENTRY',
273
                'mode' => $mode,
274
                'warnings' => $warnings,
275
                'backup_schema_level' => '',
276
                'expected_schema_level' => $expectedSchema,
277
                'backup_tp_files_version' => null,
278
                'expected_tp_files_version' => $expectedVersion,
279
                'resolved_path' => '',
280
            ];
281
        }
282
283
        $bn = basename($val);
284
        $baseDir = rtrim((string) ($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files')), '/');
285
        $targetPath = $baseDir . '/' . $bn;
286
287
        if (function_exists('tpParseSchemaLevelFromBackupFilename')) {
288
            $backupSchema = (string) tpParseSchemaLevelFromBackupFilename($bn);
289
        }
290
291
        // Upload restore has no meta => version cannot be verified by design
292
        $warnings[] = 'VERSION_NOT_VERIFIED';
293
    } elseif ($serverFile !== '') {
294
        $bn = basename($serverFile);
295
        if ($bn === '' || strtolower(pathinfo($bn, PATHINFO_EXTENSION)) !== 'sql') {
296
            return [
297
                'is_compatible' => false,
298
                'reason' => 'INVALID_FILENAME',
299
                'mode' => $mode,
300
                'warnings' => $warnings,
301
                'backup_schema_level' => '',
302
                'expected_schema_level' => $expectedSchema,
303
                'backup_tp_files_version' => null,
304
                'expected_tp_files_version' => $expectedVersion,
305
                'resolved_path' => '',
306
            ];
307
        }
308
309
        $baseDir = rtrim((string) ($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files')), '/');
310
        if ($serverScope === 'scheduled') {
311
            $baseFilesDir = (string) ($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
312
            $dir = (string) tpGetSettingsValue('bck_scheduled_output_dir', rtrim($baseFilesDir, '/') . '/backups');
313
            $baseDir = rtrim($dir, '/');
314
        }
315
        $targetPath = $baseDir . '/' . $bn;
316
317
        if (function_exists('tpGetBackupTpFilesVersionFromMeta')) {
318
            $v = (string) tpGetBackupTpFilesVersionFromMeta($targetPath);
319
            if ($v !== '') {
320
                $backupVersion = $v;
321
            }
322
        }
323
        if (function_exists('tpGetBackupSchemaLevelFromMetaOrFilename')) {
324
            $backupSchema = (string) tpGetBackupSchemaLevelFromMetaOrFilename($targetPath);
325
        }
326
    } else {
327
        return [
328
            'is_compatible' => false,
329
            'reason' => 'NO_TARGET',
330
            'mode' => $mode,
331
            'warnings' => $warnings,
332
            'backup_schema_level' => '',
333
            'expected_schema_level' => $expectedSchema,
334
            'backup_tp_files_version' => null,
335
            'expected_tp_files_version' => $expectedVersion,
336
            'resolved_path' => '',
337
        ];
338
    }
339
340
    // File existence check (best-effort, for clearer errors)
341
    if ($targetPath !== '' && file_exists($targetPath) === false) {
342
        return [
343
            'is_compatible' => false,
344
            'reason' => 'FILE_NOT_FOUND',
345
            'mode' => $mode,
346
            'warnings' => $warnings,
347
            'backup_schema_level' => $backupSchema,
348
            'expected_schema_level' => $expectedSchema,
349
            'backup_tp_files_version' => $backupVersion,
350
            'expected_tp_files_version' => $expectedVersion,
351
            'resolved_path' => $targetPath,
352
        ];
353
    }
354
355
    // Schema is mandatory in all modes
356
    if ($backupSchema === '' || $expectedSchema === '') {
357
        return [
358
            'is_compatible' => false,
359
            'reason' => 'MISSING_SCHEMA',
360
            'mode' => $mode,
361
            'warnings' => $warnings,
362
            'backup_schema_level' => $backupSchema,
363
            'expected_schema_level' => $expectedSchema,
364
            'backup_tp_files_version' => $backupVersion,
365
            'expected_tp_files_version' => $expectedVersion,
366
            'resolved_path' => $targetPath,
367
        ];
368
    }
369
370
    if ((string) $backupSchema !== (string) $expectedSchema) {
371
        return [
372
            'is_compatible' => false,
373
            'reason' => 'SCHEMA_MISMATCH',
374
            'mode' => $mode,
375
            'warnings' => $warnings,
376
            'backup_schema_level' => $backupSchema,
377
            'expected_schema_level' => $expectedSchema,
378
            'backup_tp_files_version' => $backupVersion,
379
            'expected_tp_files_version' => $expectedVersion,
380
            'resolved_path' => $targetPath,
381
        ];
382
    }
383
384
    // Version check is strict for server-side backups only
385
    if ($mode === 'server_strict') {
386
        if ($expectedVersion === '' || $backupVersion === null || (string) $backupVersion === '') {
387
            return [
388
                'is_compatible' => false,
389
                'reason' => 'MISSING_VERSION_METADATA',
390
                'mode' => $mode,
391
                'warnings' => $warnings,
392
                'backup_schema_level' => $backupSchema,
393
                'expected_schema_level' => $expectedSchema,
394
                'backup_tp_files_version' => $backupVersion,
395
                'expected_tp_files_version' => $expectedVersion,
396
                'resolved_path' => $targetPath,
397
            ];
398
        }
399
400
        if ((string) $backupVersion !== (string) $expectedVersion) {
401
            return [
402
                'is_compatible' => false,
403
                'reason' => 'VERSION_MISMATCH',
404
                'mode' => $mode,
405
                'warnings' => $warnings,
406
                'backup_schema_level' => $backupSchema,
407
                'expected_schema_level' => $expectedSchema,
408
                'backup_tp_files_version' => $backupVersion,
409
                'expected_tp_files_version' => $expectedVersion,
410
                'resolved_path' => $targetPath,
411
            ];
412
        }
413
    }
414
415
    return [
416
        'is_compatible' => true,
417
        'reason' => '',
418
        'mode' => $mode,
419
        'warnings' => $warnings,
420
        'backup_schema_level' => $backupSchema,
421
        'expected_schema_level' => $expectedSchema,
422
        'backup_tp_files_version' => $backupVersion,
423
        'expected_tp_files_version' => $expectedVersion,
424
        'resolved_path' => $targetPath,
425
    ];
426
}
427
    switch ($post_type) {
428
        
429
        case 'scheduled_download_backup':
430
            // Download a scheduled backup file (encrypted) from the server
431
            if ($post_key !== $session->get('key') || (int) $session->get('user-admin') !== 1) {
432
                header('HTTP/1.1 403 Forbidden');
433
                exit;
434
            }
435
436
            $get_key_tmp = filter_input(INPUT_GET, 'key_tmp', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
437
            if (empty($get_key_tmp) === true || $get_key_tmp !== (string) $session->get('user-key_tmp')) {
438
                header('HTTP/1.1 403 Forbidden');
439
                exit;
440
            }
441
442
            $get_file = filter_input(INPUT_GET, 'file', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
443
            $get_file = basename((string) $get_file);
444
445
            // Safety check: only .sql files allowed
446
            $extension = (string) pathinfo($get_file, PATHINFO_EXTENSION);
447
            if (strtolower($extension) !== 'sql') {
448
                header('HTTP/1.1 400 Bad Request');
449
                exit;
450
            }
451
452
            // Safety check: only scheduled-*.sql files allowed
453
            if ($get_file === '' || strpos($get_file, 'scheduled-') !== 0) {
454
                header('HTTP/1.1 400 Bad Request');
455
                exit;
456
            }
457
458
            $baseFilesDir = (string) ($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
459
            $dir = (string) tpGetSettingsValue('bck_scheduled_output_dir', rtrim($baseFilesDir, '/') . '/backups');
460
            $fp = rtrim($dir, '/') . '/' . $get_file;
461
462
            $dirReal = realpath($dir);
463
            $fpReal = realpath($fp);
464
465
            if ($dirReal === false || $fpReal === false || strpos($fpReal, $dirReal . DIRECTORY_SEPARATOR) !== 0 || is_file($fpReal) === false) {
466
                header('HTTP/1.1 404 Not Found');
467
                exit;
468
            }
469
470
            /**
471
             * Stream file with proper error handling
472
             * Removes output buffers, sets execution time limit, and validates file
473
             *
474
             * @param string $fpReal Real file path
475
             * @return int File size in bytes, 0 if file doesn't exist
476
             */
477
            // Set unlimited execution time if function is available and not disabled
478
            if (function_exists('set_time_limit') && !ini_get('safe_mode')) {
479
                set_time_limit(0);
480
            }
481
482
            // Get file size with proper validation
483
            $size = 0;
484
            if (file_exists($fpReal) && is_readable($fpReal)) {
485
                $size = (int) filesize($fpReal);
486
            }
487
488
            // Clear all output buffers
489
            if (function_exists('ob_get_level')) {
490
                while (ob_get_level() > 0) {
491
                    ob_end_clean();
492
                }
493
            }
494
495
            header('Content-Description: File Transfer');
496
            header('Content-Type: application/octet-stream');
497
            header('Content-Disposition: attachment; filename="' . $get_file . '"');
498
            header('Content-Transfer-Encoding: binary');
499
            header('Expires: 0');
500
            header('Cache-Control: private, must-revalidate');
501
            header('Pragma: public');
502
            if ($size > 0) {
503
                header('Content-Length: ' . $size);
504
            }
505
506
            readfile($fpReal);
507
            exit;
508
509
        //CASE adding a new function
510
        case 'onthefly_backup':
511
            // Check KEY
512
            if ($post_key !== $session->get('key')) {
513
                echo prepareExchangedData(
514
                    array(
515
                        'error' => true,
516
                        'message' => $lang->get('key_is_not_correct'),
517
                    ),
518
                    'encode'
519
                );
520
                break;
521
            } elseif ($session->get('user-admin') === 0) {
522
                echo prepareExchangedData(
523
                    array(
524
                        'error' => true,
525
                        'message' => $lang->get('error_not_allowed_to'),
526
                    ),
527
                    'encode'
528
                );
529
                break;
530
            }
531
        
532
            // Decrypt and retrieve data in JSON format
533
            $dataReceived = prepareExchangedData(
534
                $post_data,
535
                'decode'
536
            );
537
        
538
            // Prepare variables
539
            $encryptionKey = filter_var($dataReceived['encryptionKey'] ?? '', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
540
        
541
            require_once __DIR__ . '/backup.functions.php';
542
543
            $backupResult = tpCreateDatabaseBackup($SETTINGS, $encryptionKey);
544
545
            if (($backupResult['success'] ?? false) !== true) {
546
                echo prepareExchangedData(
547
                    array(
548
                        'error' => true,
549
                        'message' => $backupResult['message'] ?? 'Backup failed',
550
                    ),
551
                    'encode'
552
                );
553
                break;
554
            }
555
556
            $filename = $backupResult['filename'];
557
// Write metadata sidecar (<backup>.meta.json) for fast listings / migration safety
558
try {
559
    if (function_exists('tpWriteBackupMetadata') && !empty($backupResult['filepath'])) {
560
        tpWriteBackupMetadata((string)$backupResult['filepath'], '', '', ['source' => 'onthefly']);
561
    }
562
} catch (Throwable $ignored) {
563
    // best effort
564
}
565
        
566
            // Generate 2d key
567
            $session->set('user-key_tmp', GenerateCryptKey(16, false, true, true, false, true));
568
        
569
            // Update LOG
570
            logEvents(
571
                $SETTINGS,
572
                'admin_action',
573
                'dataBase backup',
574
                (string) $session->get('user-id'),
575
                $session->get('user-login'),
576
                $filename
577
            );
578
        
579
            echo prepareExchangedData(
580
                array(
581
                    'error' => false,
582
                    'message' => '',
583
                    'download' => 'sources/downloadFile.php?name=' . urlencode($filename) .
584
                        '&action=backup&file=' . $filename . '&type=sql&key=' . $session->get('key') . '&key_tmp=' .
585
                        $session->get('user-key_tmp') . '&pathIsFiles=1',
586
                ),
587
                'encode'
588
            );
589
            break;
590
        /* ============================================================
591
         * Scheduled backups (UI)
592
         * ============================================================ */
593
594
        case 'scheduled_get_settings':
595
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
596
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
597
                break;
598
            }
599
600
            echo prepareExchangedData([
601
                'error' => false,
602
                'settings' => [
603
                    'enabled' => (int) tpGetSettingsValue('bck_scheduled_enabled', '0'),
604
                    'frequency' => (string) tpGetSettingsValue('bck_scheduled_frequency', 'daily'),
605
                    'time' => (string) tpGetSettingsValue('bck_scheduled_time', '02:00'),
606
                    'dow' => (int) tpGetSettingsValue('bck_scheduled_dow', '1'),
607
                    'dom' => (int) tpGetSettingsValue('bck_scheduled_dom', '1'),
608
                    'output_dir' => (string) tpGetSettingsValue('bck_scheduled_output_dir', ''),
609
                    'retention_days' => (int) tpGetSettingsValue('bck_scheduled_retention_days', '30'),
610
611
                    'next_run_at' => (int) tpGetSettingsValue('bck_scheduled_next_run_at', '0'),
612
                    'last_run_at' => (int) tpGetSettingsValue('bck_scheduled_last_run_at', '0'),
613
                    'last_status' => (string) tpGetSettingsValue('bck_scheduled_last_status', ''),
614
                    'last_message' => (string) tpGetSettingsValue('bck_scheduled_last_message', ''),
615
                    'last_completed_at' => (int) tpGetSettingsValue('bck_scheduled_last_completed_at', '0'),
616
                    'last_purge_at' => (int) tpGetSettingsValue('bck_scheduled_last_purge_at', '0'),
617
                    'last_purge_deleted' => (int) tpGetSettingsValue('bck_scheduled_last_purge_deleted', '0'),
618
619
                    'email_report_enabled' => (int) tpGetSettingsValue('bck_scheduled_email_report_enabled', '0'),
620
                    'email_report_only_failures' => (int) tpGetSettingsValue('bck_scheduled_email_report_only_failures', '0'),
621
622
                    'timezone' => tpGetAdminTimezoneName(),
623
                ],
624
            ], 'encode');
625
            break;
626
627
        case 'disk_usage':
628
            // Provide disk usage information for the storage containing the <files> directory
629
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
630
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
631
                break;
632
            }
633
634
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
635
            $dirReal = realpath($baseFilesDir);
636
637
            if ($dirReal === false) {
638
                echo prepareExchangedData(['error' => true, 'message' => 'Invalid path'], 'encode');
639
                break;
640
            }
641
642
            $total = @disk_total_space($dirReal);
643
            $free = @disk_free_space($dirReal);
644
645
            if ($total === false || $free === false || (float)$total <= 0) {
646
                echo prepareExchangedData(['error' => true, 'message' => 'Unable to read disk usage'], 'encode');
647
                break;
648
            }
649
650
            $used = max(0.0, (float)$total - (float)$free);
651
            $pct = round(($used / (float)$total) * 100, 1);
652
653
            $label = tpFormatBytes($used) . ' / ' . tpFormatBytes((float)$total);
654
            $tooltip = sprintf(
655
                $lang->get('bck_storage_usage_tooltip'),
656
                tpFormatBytes($used),
657
                tpFormatBytes((float)$total),
658
                (string)$pct,
659
                tpFormatBytes((float)$free),
660
                $dirReal
661
            );
662
663
            echo prepareExchangedData(
664
                [
665
                    'error' => false,
666
                    'used_percent' => $pct,
667
                    'label' => $label,
668
                    'tooltip' => $tooltip,
669
                    'path' => $dirReal,
670
                ],
671
                'encode'
672
            );
673
            break;
674
675
        
676
        case 'copy_instance_key':
677
            // Return decrypted instance key (admin only)
678
            if ($post_key !== $session->get('key') || (int) $session->get('user-admin') !== 1) {
679
                echo prepareExchangedData(
680
                    array('error' => true, 'message' => $lang->get('error_not_allowed_to')),
681
                    'encode'
682
                );
683
                break;
684
            }
685
686
            if (empty($SETTINGS['bck_script_passkey'] ?? '') === true) {
687
                echo prepareExchangedData(
688
                    array('error' => true, 'message' => $lang->get('bck_instance_key_not_set')),
689
                    'encode'
690
                );
691
                break;
692
            }
693
694
            $tmp = cryption($SETTINGS['bck_script_passkey'], '', 'decrypt', $SETTINGS);
695
            $instanceKey = isset($tmp['string']) ? (string) $tmp['string'] : '';
696
            if ($instanceKey === '') {
697
                echo prepareExchangedData(
698
                    array('error' => true, 'message' => $lang->get('bck_instance_key_not_set')),
699
                    'encode'
700
                );
701
                break;
702
            }
703
704
            echo prepareExchangedData(
705
                array('error' => false, 'instanceKey' => $instanceKey),
706
                'encode'
707
            );
708
            break;
709
710
        case 'scheduled_save_settings':
711
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
712
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
713
                break;
714
            }
715
716
            $dataReceived = prepareExchangedData($post_data, 'decode');
717
            if (!is_array($dataReceived)) $dataReceived = [];
718
719
            $enabled = (int)($dataReceived['enabled'] ?? 0);
720
            $enabled = ($enabled === 1) ? 1 : 0;
721
722
723
            $emailReportEnabled = (int)($dataReceived['email_report_enabled'] ?? 0);
724
            $emailReportEnabled = ($emailReportEnabled === 1) ? 1 : 0;
725
726
            $emailReportOnlyFailures = (int)($dataReceived['email_report_only_failures'] ?? 0);
727
            $emailReportOnlyFailures = ($emailReportOnlyFailures === 1) ? 1 : 0;
728
729
            if ($emailReportEnabled === 0) {
730
                $emailReportOnlyFailures = 0;
731
            }
732
733
            $frequency = (string)($dataReceived['frequency'] ?? 'daily');
734
            if (!in_array($frequency, ['daily', 'weekly', 'monthly'], true)) {
735
                $frequency = 'daily';
736
            }
737
738
            $timeStr = (string)($dataReceived['time'] ?? '02:00');
739
            if (!preg_match('/^\d{2}:\d{2}$/', $timeStr)) {
740
                $timeStr = '02:00';
741
            } else {
742
                [$hh, $mm] = array_map('intval', explode(':', $timeStr));
743
                if ($hh < 0 || $hh > 23 || $mm < 0 || $mm > 59) {
744
                    $timeStr = '02:00';
745
                }
746
            }
747
748
            $dow = (int)($dataReceived['dow'] ?? 1);
749
            if ($dow < 1 || $dow > 7) $dow = 1;
750
751
            $dom = (int)($dataReceived['dom'] ?? 1);
752
            if ($dom < 1) $dom = 1;
753
            if ($dom > 31) $dom = 31;
754
755
            $retentionDays = (int)($dataReceived['retention_days'] ?? 30);
756
            if ($retentionDays < 1) $retentionDays = 1;
757
            if ($retentionDays > 3650) $retentionDays = 3650;
758
759
            // Output dir: default to <files>/backups
760
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
761
            $defaultDir = rtrim($baseFilesDir, '/') . '/backups';
762
763
            $outputDir = trim((string)($dataReceived['output_dir'] ?? ''));
764
            if ($outputDir === '') $outputDir = $defaultDir;
765
766
            // Safety: prevent path traversal / outside files folder
767
            @mkdir($outputDir, 0770, true);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for mkdir(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

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

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

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

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

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

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

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

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

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