Issues (43)

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

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

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

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

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

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

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

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
693
            $baseReal = realpath($baseFilesDir) ?: $baseFilesDir;
694
            $dirReal = realpath($outputDir);
695
696
            if ($dirReal === false || strpos($dirReal, $baseReal) !== 0) {
697
                echo prepareExchangedData(['error' => true, 'message' => 'Invalid output directory'], 'encode');
698
                break;
699
            }
700
701
            tpUpsertSettingsValue('bck_scheduled_enabled', (string)$enabled);
702
            tpUpsertSettingsValue('bck_scheduled_frequency', $frequency);
703
            tpUpsertSettingsValue('bck_scheduled_time', $timeStr);
704
            tpUpsertSettingsValue('bck_scheduled_dow', (string)$dow);
705
            tpUpsertSettingsValue('bck_scheduled_dom', (string)$dom);
706
            tpUpsertSettingsValue('bck_scheduled_output_dir', $dirReal);
707
            tpUpsertSettingsValue('bck_scheduled_retention_days', (string)$retentionDays);
708
            tpUpsertSettingsValue('bck_scheduled_email_report_enabled', (string)$emailReportEnabled);
709
            tpUpsertSettingsValue('bck_scheduled_email_report_only_failures', (string)$emailReportOnlyFailures);
710
711
712
            // Force re-init of next_run_at so handler recomputes cleanly
713
            tpUpsertSettingsValue('bck_scheduled_next_run_at', '0');
714
715
            echo prepareExchangedData(['error' => false, 'message' => 'Saved'], 'encode');
716
            break;
717
718
        case 'scheduled_list_backups':
719
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
720
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
721
                break;
722
            }
723
724
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
725
            $dir = (string)tpGetSettingsValue('bck_scheduled_output_dir', rtrim($baseFilesDir, '/') . '/backups');
726
            @mkdir($dir, 0770, true);
727
            // Build a relative path from files/ root (output_dir can be a subfolder)
728
            $filesRoot = realpath($baseFilesDir);
729
            $dirReal = realpath($dir);
730
            $relDir = '';
731
            if ($filesRoot !== false && $dirReal !== false && strpos($dirReal, $filesRoot) === 0) {
732
                $relDir = trim(str_replace($filesRoot, '', $dirReal), DIRECTORY_SEPARATOR);
733
                $relDir = str_replace(DIRECTORY_SEPARATOR, '/', $relDir);
734
            }
735
736
            // Ensure we have a temporary key for downloadFile.php
737
            $keyTmp = (string) $session->get('user-key_tmp');
738
            if ($keyTmp === '') {
739
                $keyTmp = GenerateCryptKey(16, false, true, true, false, true);
740
                $session->set('user-key_tmp', $keyTmp);
741
            }
742
743
744
            $files = [];
745
            foreach (glob(rtrim($dir, '/') . '/scheduled-*.sql') ?: [] as $fp) {
746
                $bn = basename($fp);
747
                $files[] = [
748
                    'name' => $bn,
749
                    'size_bytes' => (int)@filesize($fp),
750
                    'mtime' => (int)@filemtime($fp),
751
                    'tp_files_version' => (function_exists('tpGetBackupTpFilesVersionFromMeta') ? ((($v = (string)tpGetBackupTpFilesVersionFromMeta($fp)) !== '') ? $v : null) : null),
752
                    'download' => 'sources/backups.queries.php?type=scheduled_download_backup&file=' . urlencode($bn)
753
                        . '&key=' . urlencode((string) $session->get('key'))
754
                        . '&key_tmp=' . urlencode($keyTmp),
755
                ];
756
            }
757
758
            usort($files, fn($a, $b) => ($b['mtime'] ?? 0) <=> ($a['mtime'] ?? 0));
759
760
            echo prepareExchangedData(['error' => false, 'files' => $files], 'encode');
761
            break;
762
763
        case 'scheduled_delete_backup':
764
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
765
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
766
                break;
767
            }
768
769
            $dataReceived = prepareExchangedData($post_data, 'decode');
770
            if (!is_array($dataReceived)) $dataReceived = [];
771
772
            $file = (string)($dataReceived['file'] ?? '');
773
            $file = basename($file);
774
775
            if ($file === '' || strpos($file, 'scheduled-') !== 0 || !str_ends_with($file, '.sql')) {
776
                echo prepareExchangedData(['error' => true, 'message' => 'Invalid filename'], 'encode');
777
                break;
778
            }
779
780
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
781
            $dir = (string)tpGetSettingsValue('bck_scheduled_output_dir', rtrim($baseFilesDir, '/') . '/backups');
782
            $fp = rtrim($dir, '/') . '/' . $file;
783
784
            /**
785
             * Delete a file and its associated metadata.
786
             * * @param string $fp Full path to the file.
787
             * @return void
788
             */
789
            // Check if file exists and is valid
790
            if (file_exists($fp) === false || is_file($fp) === false) {
791
                echo prepareExchangedData(['error' => false], 'encode');
792
                break;
793
            }
794
795
            // Check permissions
796
            if (is_writable($fp) === false) {
797
                $errorMessage = "File is not writable, cannot delete: " . $fp;
798
                if (WIP === true) error_log("TeamPass - " . $errorMessage);
799
                echo prepareExchangedData(['error' => true, 'message' => $errorMessage], 'encode');
800
                break;
801
            }
802
803
            // Attempt deletion
804
            if (unlink($fp) === false) {
805
                $errorMessage = "Failed to delete file: " . $fp;
806
                if (WIP === true) error_log("TeamPass - " . $errorMessage);
807
                echo prepareExchangedData(['error' => true, 'message' => $errorMessage], 'encode');
808
                break;
809
            }
810
811
            // Cleanup metadata (silent fail for sidecar is acceptable)
812
            if (file_exists($fp . '.meta.json')) {
813
                @unlink($fp . '.meta.json');
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

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

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

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

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

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

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