Issues (42)

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 (3 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-2025 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
    switch ($post_type) {
214
        
215
        case 'scheduled_download_backup':
216
            // Download a scheduled backup file (encrypted) from the server
217
            if ($post_key !== $session->get('key') || (int) $session->get('user-admin') !== 1) {
218
                header('HTTP/1.1 403 Forbidden');
219
                exit;
220
            }
221
222
            $get_key_tmp = filter_input(INPUT_GET, 'key_tmp', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
223
            if (empty($get_key_tmp) === true || $get_key_tmp !== (string) $session->get('user-key_tmp')) {
224
                header('HTTP/1.1 403 Forbidden');
225
                exit;
226
            }
227
228
            $get_file = filter_input(INPUT_GET, 'file', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
229
            $get_file = basename((string) $get_file);
230
231
            // Safety check: only .sql files allowed
232
            $extension = pathinfo($get_file, PATHINFO_EXTENSION);
233
            if (is_string($extension) === false || strtolower($extension) !== 'sql') {
0 ignored issues
show
$extension of type array is incompatible with the type string expected by parameter $string of strtolower(). ( Ignorable by Annotation )

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

233
            if (is_string($extension) === false || strtolower(/** @scrutinizer ignore-type */ $extension) !== 'sql') {
Loading history...
234
                header('HTTP/1.1 400 Bad Request');
235
                exit;
236
            }
237
238
            // Safety check: only scheduled-*.sql files allowed
239
            if ($get_file === '' || strpos($get_file, 'scheduled-') !== 0) {
240
                header('HTTP/1.1 400 Bad Request');
241
                exit;
242
            }
243
244
            $baseFilesDir = (string) ($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
245
            $dir = (string) tpGetSettingsValue('bck_scheduled_output_dir', rtrim($baseFilesDir, '/') . '/backups');
246
            $fp = rtrim($dir, '/') . '/' . $get_file;
247
248
            $dirReal = realpath($dir);
249
            $fpReal = realpath($fp);
250
251
            if ($dirReal === false || $fpReal === false || strpos($fpReal, $dirReal . DIRECTORY_SEPARATOR) !== 0 || is_file($fpReal) === false) {
252
                header('HTTP/1.1 404 Not Found');
253
                exit;
254
            }
255
256
            /**
257
             * Stream file with proper error handling
258
             * Removes output buffers, sets execution time limit, and validates file
259
             *
260
             * @param string $fpReal Real file path
261
             * @return int File size in bytes, 0 if file doesn't exist
262
             */
263
            // Set unlimited execution time if function is available and not disabled
264
            if (function_exists('set_time_limit') && !ini_get('safe_mode')) {
265
                set_time_limit(0);
266
            }
267
268
            // Get file size with proper validation
269
            $size = 0;
270
            if (file_exists($fpReal) && is_readable($fpReal)) {
271
                $size = (int) filesize($fpReal);
272
            }
273
274
            // Clear all output buffers
275
            if (function_exists('ob_get_level')) {
276
                while (ob_get_level() > 0) {
277
                    ob_end_clean();
278
                }
279
            }
280
281
            header('Content-Description: File Transfer');
282
            header('Content-Type: application/octet-stream');
283
            header('Content-Disposition: attachment; filename="' . $get_file . '"');
284
            header('Content-Transfer-Encoding: binary');
285
            header('Expires: 0');
286
            header('Cache-Control: private, must-revalidate');
287
            header('Pragma: public');
288
            if ($size > 0) {
289
                header('Content-Length: ' . $size);
290
            }
291
292
            readfile($fpReal);
293
            exit;
294
295
        //CASE adding a new function
296
        case 'onthefly_backup':
297
            // Check KEY
298
            if ($post_key !== $session->get('key')) {
299
                echo prepareExchangedData(
300
                    array(
301
                        'error' => true,
302
                        'message' => $lang->get('key_is_not_correct'),
303
                    ),
304
                    'encode'
305
                );
306
                break;
307
            } elseif ($session->get('user-admin') === 0) {
308
                echo prepareExchangedData(
309
                    array(
310
                        'error' => true,
311
                        'message' => $lang->get('error_not_allowed_to'),
312
                    ),
313
                    'encode'
314
                );
315
                break;
316
            }
317
        
318
            // Decrypt and retrieve data in JSON format
319
            $dataReceived = prepareExchangedData(
320
                $post_data,
321
                'decode'
322
            );
323
        
324
            // Prepare variables
325
            $encryptionKey = filter_var($dataReceived['encryptionKey'] ?? '', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
326
        
327
            require_once __DIR__ . '/backup.functions.php';
328
329
            $backupResult = tpCreateDatabaseBackup($SETTINGS, $encryptionKey);
330
331
            if (($backupResult['success'] ?? false) !== true) {
332
                echo prepareExchangedData(
333
                    array(
334
                        'error' => true,
335
                        'message' => $backupResult['message'] ?? 'Backup failed',
336
                    ),
337
                    'encode'
338
                );
339
                break;
340
            }
341
342
            $filename = $backupResult['filename'];
343
        
344
            // Generate 2d key
345
            $session->set('user-key_tmp', GenerateCryptKey(16, false, true, true, false, true));
346
        
347
            // Update LOG
348
            logEvents(
349
                $SETTINGS,
350
                'admin_action',
351
                'dataBase backup',
352
                (string) $session->get('user-id'),
353
                $session->get('user-login'),
354
                $filename
355
            );
356
        
357
            echo prepareExchangedData(
358
                array(
359
                    'error' => false,
360
                    'message' => '',
361
                    'download' => 'sources/downloadFile.php?name=' . urlencode($filename) .
362
                        '&action=backup&file=' . $filename . '&type=sql&key=' . $session->get('key') . '&key_tmp=' .
363
                        $session->get('user-key_tmp') . '&pathIsFiles=1',
364
                ),
365
                'encode'
366
            );
367
            break;
368
        /* ============================================================
369
         * Scheduled backups (UI)
370
         * ============================================================ */
371
372
        case 'scheduled_get_settings':
373
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
374
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
375
                break;
376
            }
377
378
            echo prepareExchangedData([
379
                'error' => false,
380
                'settings' => [
381
                    'enabled' => (int) tpGetSettingsValue('bck_scheduled_enabled', '0'),
382
                    'frequency' => (string) tpGetSettingsValue('bck_scheduled_frequency', 'daily'),
383
                    'time' => (string) tpGetSettingsValue('bck_scheduled_time', '02:00'),
384
                    'dow' => (int) tpGetSettingsValue('bck_scheduled_dow', '1'),
385
                    'dom' => (int) tpGetSettingsValue('bck_scheduled_dom', '1'),
386
                    'output_dir' => (string) tpGetSettingsValue('bck_scheduled_output_dir', ''),
387
                    'retention_days' => (int) tpGetSettingsValue('bck_scheduled_retention_days', '30'),
388
389
                    'next_run_at' => (int) tpGetSettingsValue('bck_scheduled_next_run_at', '0'),
390
                    'last_run_at' => (int) tpGetSettingsValue('bck_scheduled_last_run_at', '0'),
391
                    'last_status' => (string) tpGetSettingsValue('bck_scheduled_last_status', ''),
392
                    'last_message' => (string) tpGetSettingsValue('bck_scheduled_last_message', ''),
393
                    'last_completed_at' => (int) tpGetSettingsValue('bck_scheduled_last_completed_at', '0'),
394
                    'last_purge_at' => (int) tpGetSettingsValue('bck_scheduled_last_purge_at', '0'),
395
                    'last_purge_deleted' => (int) tpGetSettingsValue('bck_scheduled_last_purge_deleted', '0'),
396
397
                    'timezone' => tpGetAdminTimezoneName(),
398
                ],
399
            ], 'encode');
400
            break;
401
402
        case 'disk_usage':
403
            // Provide disk usage information for the storage containing the <files> directory
404
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
405
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
406
                break;
407
            }
408
409
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
410
            $dirReal = realpath($baseFilesDir);
411
412
            if ($dirReal === false) {
413
                echo prepareExchangedData(['error' => true, 'message' => 'Invalid path'], 'encode');
414
                break;
415
            }
416
417
            $total = @disk_total_space($dirReal);
418
            $free = @disk_free_space($dirReal);
419
420
            if ($total === false || $free === false || (float)$total <= 0) {
421
                echo prepareExchangedData(['error' => true, 'message' => 'Unable to read disk usage'], 'encode');
422
                break;
423
            }
424
425
            $used = max(0.0, (float)$total - (float)$free);
426
            $pct = round(($used / (float)$total) * 100, 1);
427
428
            $label = tpFormatBytes($used) . ' / ' . tpFormatBytes((float)$total);
429
            $tooltip = sprintf(
430
                $lang->get('bck_storage_usage_tooltip'),
431
                tpFormatBytes($used),
432
                tpFormatBytes((float)$total),
433
                (string)$pct,
434
                tpFormatBytes((float)$free),
435
                $dirReal
436
            );
437
438
            echo prepareExchangedData(
439
                [
440
                    'error' => false,
441
                    'used_percent' => $pct,
442
                    'label' => $label,
443
                    'tooltip' => $tooltip,
444
                    'path' => $dirReal,
445
                ],
446
                'encode'
447
            );
448
            break;
449
450
        
451
        case 'copy_instance_key':
452
            // Return decrypted instance key (admin only)
453
            if ($post_key !== $session->get('key') || (int) $session->get('user-admin') !== 1) {
454
                echo prepareExchangedData(
455
                    array('error' => true, 'message' => $lang->get('error_not_allowed_to')),
456
                    'encode'
457
                );
458
                break;
459
            }
460
461
            if (empty($SETTINGS['bck_script_passkey'] ?? '') === true) {
462
                echo prepareExchangedData(
463
                    array('error' => true, 'message' => $lang->get('bck_instance_key_not_set')),
464
                    'encode'
465
                );
466
                break;
467
            }
468
469
            $tmp = cryption($SETTINGS['bck_script_passkey'], '', 'decrypt', $SETTINGS);
470
            $instanceKey = isset($tmp['string']) ? (string) $tmp['string'] : '';
471
            if ($instanceKey === '') {
472
                echo prepareExchangedData(
473
                    array('error' => true, 'message' => $lang->get('bck_instance_key_not_set')),
474
                    'encode'
475
                );
476
                break;
477
            }
478
479
            echo prepareExchangedData(
480
                array('error' => false, 'instanceKey' => $instanceKey),
481
                'encode'
482
            );
483
            break;
484
485
        case 'scheduled_save_settings':
486
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
487
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
488
                break;
489
            }
490
491
            $dataReceived = prepareExchangedData($post_data, 'decode');
492
            if (!is_array($dataReceived)) $dataReceived = [];
493
494
            $enabled = (int)($dataReceived['enabled'] ?? 0);
495
            $enabled = ($enabled === 1) ? 1 : 0;
496
497
            $frequency = (string)($dataReceived['frequency'] ?? 'daily');
498
            if (!in_array($frequency, ['daily', 'weekly', 'monthly'], true)) {
499
                $frequency = 'daily';
500
            }
501
502
            $timeStr = (string)($dataReceived['time'] ?? '02:00');
503
            if (!preg_match('/^\d{2}:\d{2}$/', $timeStr)) {
504
                $timeStr = '02:00';
505
            } else {
506
                [$hh, $mm] = array_map('intval', explode(':', $timeStr));
507
                if ($hh < 0 || $hh > 23 || $mm < 0 || $mm > 59) {
508
                    $timeStr = '02:00';
509
                }
510
            }
511
512
            $dow = (int)($dataReceived['dow'] ?? 1);
513
            if ($dow < 1 || $dow > 7) $dow = 1;
514
515
            $dom = (int)($dataReceived['dom'] ?? 1);
516
            if ($dom < 1) $dom = 1;
517
            if ($dom > 31) $dom = 31;
518
519
            $retentionDays = (int)($dataReceived['retention_days'] ?? 30);
520
            if ($retentionDays < 1) $retentionDays = 1;
521
            if ($retentionDays > 3650) $retentionDays = 3650;
522
523
            // Output dir: default to <files>/backups
524
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
525
            $defaultDir = rtrim($baseFilesDir, '/') . '/backups';
526
527
            $outputDir = trim((string)($dataReceived['output_dir'] ?? ''));
528
            if ($outputDir === '') $outputDir = $defaultDir;
529
530
            // Safety: prevent path traversal / outside files folder
531
            @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

531
            /** @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...
532
            $baseReal = realpath($baseFilesDir) ?: $baseFilesDir;
533
            $dirReal = realpath($outputDir);
534
535
            if ($dirReal === false || strpos($dirReal, $baseReal) !== 0) {
536
                echo prepareExchangedData(['error' => true, 'message' => 'Invalid output directory'], 'encode');
537
                break;
538
            }
539
540
            tpUpsertSettingsValue('bck_scheduled_enabled', (string)$enabled);
541
            tpUpsertSettingsValue('bck_scheduled_frequency', $frequency);
542
            tpUpsertSettingsValue('bck_scheduled_time', $timeStr);
543
            tpUpsertSettingsValue('bck_scheduled_dow', (string)$dow);
544
            tpUpsertSettingsValue('bck_scheduled_dom', (string)$dom);
545
            tpUpsertSettingsValue('bck_scheduled_output_dir', $dirReal);
546
            tpUpsertSettingsValue('bck_scheduled_retention_days', (string)$retentionDays);
547
548
            // Force re-init of next_run_at so handler recomputes cleanly
549
            tpUpsertSettingsValue('bck_scheduled_next_run_at', '0');
550
551
            echo prepareExchangedData(['error' => false, 'message' => 'Saved'], 'encode');
552
            break;
553
554
        case 'scheduled_list_backups':
555
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
556
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
557
                break;
558
            }
559
560
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
561
            $dir = (string)tpGetSettingsValue('bck_scheduled_output_dir', rtrim($baseFilesDir, '/') . '/backups');
562
            @mkdir($dir, 0770, true);
563
            // Build a relative path from files/ root (output_dir can be a subfolder)
564
            $filesRoot = realpath($baseFilesDir);
565
            $dirReal = realpath($dir);
566
            $relDir = '';
567
            if ($filesRoot !== false && $dirReal !== false && strpos($dirReal, $filesRoot) === 0) {
568
                $relDir = trim(str_replace($filesRoot, '', $dirReal), DIRECTORY_SEPARATOR);
569
                $relDir = str_replace(DIRECTORY_SEPARATOR, '/', $relDir);
570
            }
571
572
            // Ensure we have a temporary key for downloadFile.php
573
            $keyTmp = (string) $session->get('user-key_tmp');
574
            if ($keyTmp === '') {
575
                $keyTmp = GenerateCryptKey(16, false, true, true, false, true);
576
                $session->set('user-key_tmp', $keyTmp);
577
            }
578
579
580
            $files = [];
581
            foreach (glob(rtrim($dir, '/') . '/scheduled-*.sql') ?: [] as $fp) {
582
                $bn = basename($fp);
583
                $files[] = [
584
                    'name' => $bn,
585
                    'size_bytes' => (int)@filesize($fp),
586
                    'mtime' => (int)@filemtime($fp),
587
                    'download' => 'sources/backups.queries.php?type=scheduled_download_backup&file=' . urlencode($bn)
588
                        . '&key=' . urlencode((string) $session->get('key'))
589
                        . '&key_tmp=' . urlencode($keyTmp),
590
                ];
591
            }
592
593
            usort($files, fn($a, $b) => ($b['mtime'] ?? 0) <=> ($a['mtime'] ?? 0));
594
595
            echo prepareExchangedData(['error' => false, 'files' => $files], 'encode');
596
            break;
597
598
        case 'scheduled_delete_backup':
599
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
600
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
601
                break;
602
            }
603
604
            $dataReceived = prepareExchangedData($post_data, 'decode');
605
            if (!is_array($dataReceived)) $dataReceived = [];
606
607
            $file = (string)($dataReceived['file'] ?? '');
608
            $file = basename($file);
609
610
            if ($file === '' || strpos($file, 'scheduled-') !== 0 || !str_ends_with($file, '.sql')) {
611
                echo prepareExchangedData(['error' => true, 'message' => 'Invalid filename'], 'encode');
612
                break;
613
            }
614
615
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
616
            $dir = (string)tpGetSettingsValue('bck_scheduled_output_dir', rtrim($baseFilesDir, '/') . '/backups');
617
            $fp = rtrim($dir, '/') . '/' . $file;
618
619
620
            if (file_exists($fp) && is_file($fp)) {
621
                if (is_writable($fp)) {
622
                    if (unlink($fp) === false) {
623
                        error_log("TeamPass - Failed to delete file: {$fp}");
624
                    }
625
                } else {
626
                    error_log("TeamPass - File is not writable, cannot delete: {$fp}");
627
                }
628
            }
629
630
            echo prepareExchangedData(['error' => false], 'encode');
631
            break;
632
633
        case 'scheduled_run_now':
634
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
635
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
636
                break;
637
            }
638
639
            $now = time();
640
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
641
            $dir = (string)tpGetSettingsValue('bck_scheduled_output_dir', rtrim($baseFilesDir, '/') . '/backups');
642
            @mkdir($dir, 0770, true);
643
644
            // avoid duplicates
645
            $pending = (int)DB::queryFirstField(
646
                'SELECT COUNT(*) FROM ' . prefixTable('background_tasks') . '
647
                 WHERE process_type=%s AND is_in_progress IN (0,1)
648
                   AND (finished_at IS NULL OR finished_at = "" OR finished_at = 0)',
649
                'database_backup'
650
            );
651
            if ($pending > 0) {
652
                echo prepareExchangedData(['error' => true, 'message' => 'A backup task is already pending/running'], 'encode');
653
                break;
654
            }
655
656
            DB::insert(
657
                prefixTable('background_tasks'),
658
                [
659
                    'created_at' => (string)$now,
660
                    'process_type' => 'database_backup',
661
                    'arguments' => json_encode(['output_dir' => $dir, 'source' => 'scheduler'], JSON_UNESCAPED_SLASHES),
662
                    'is_in_progress' => 0,
663
                    'status' => 'new',
664
                ]
665
            );
666
667
            tpUpsertSettingsValue('bck_scheduled_last_run_at', (string)$now);
668
            tpUpsertSettingsValue('bck_scheduled_last_status', 'queued');
669
            tpUpsertSettingsValue('bck_scheduled_last_message', 'Task enqueued by UI');
670
671
            echo prepareExchangedData(['error' => false], 'encode');
672
            break;
673
674
        case 'onthefly_delete_backup':
675
            // Delete an on-the-fly backup file stored in <files> directory
676
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
677
                echo prepareExchangedData(
678
                    array(
679
                        'error' => true,
680
                        'message' => 'Not allowed',
681
                    ),
682
                    'encode'
683
                );
684
                break;
685
            }
686
687
            $dataReceived = prepareExchangedData($post_data, 'decode');
688
            $fileToDelete = isset($dataReceived['file']) === true ? (string)$dataReceived['file'] : '';
689
            $bn = basename($fileToDelete);
690
691
            // Safety checks
692
            if ($bn === '' || strtolower(pathinfo($bn, PATHINFO_EXTENSION)) !== 'sql') {
693
                echo prepareExchangedData(
694
                    array(
695
                        'error' => true,
696
                        'message' => 'Invalid file name',
697
                    ),
698
                    'encode'
699
                );
700
                break;
701
            }
702
703
            // Never allow deleting scheduled backups or temp files
704
            if (strpos($bn, 'scheduled-') === 0 || strpos($bn, 'defuse_temp_') === 0 || strpos($bn, 'defuse_temp_restore_') === 0) {
705
                echo prepareExchangedData(
706
                    array(
707
                        'error' => true,
708
                        'message' => 'Not allowed on this file',
709
                    ),
710
                    'encode'
711
                );
712
                break;
713
            }
714
715
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
716
            $dir = rtrim($baseFilesDir, '/');
717
            $fullPath = $dir . '/' . $bn;
718
719
            if (file_exists($fullPath) === false) {
720
                echo prepareExchangedData(
721
                    array(
722
                        'error' => true,
723
                        'message' => 'File not found',
724
                    ),
725
                    'encode'
726
                );
727
                break;
728
            }
729
730
            // Delete
731
            $ok = @unlink($fullPath);
732
733
            if ($ok !== true) {
734
                echo prepareExchangedData(
735
                    array(
736
                        'error' => true,
737
                        'message' => 'Unable to delete file',
738
                    ),
739
                    'encode'
740
                );
741
                break;
742
            }
743
744
            echo prepareExchangedData(
745
                array(
746
                    'error' => false,
747
                    'message' => 'Deleted',
748
                    'file' => $bn,
749
                ),
750
                'encode'
751
            );
752
            break;
753
754
        case 'onthefly_list_backups':
755
            // List on-the-fly backup files stored directly in <files> directory (not in /backups for scheduled)
756
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
757
                echo prepareExchangedData(
758
                    array(
759
                        'error' => true,
760
                        'message' => 'Not allowed',
761
                    ),
762
                    'encode'
763
                );
764
                break;
765
            }
766
767
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
768
            $dir = rtrim($baseFilesDir, '/');
769
770
            $files = array();
771
            $paths = glob($dir . '/*.sql');
772
            if ($paths === false) {
773
                $paths = array();
774
            }
775
776
            // Ensure we have a temporary key for downloadFile.php
777
            $keyTmp = (string) $session->get('user-key_tmp');
778
            if ($keyTmp === '') {
779
                $keyTmp = GenerateCryptKey(16, false, true, true, false, true);
780
                $session->set('user-key_tmp', $keyTmp);
781
            }
782
783
            foreach ($paths as $fp) {
784
                $bn = basename($fp);
785
786
                // Skip scheduled backups and temporary files
787
                if (strpos($bn, 'scheduled-') === 0) {
788
                    continue;
789
                }
790
                if (strpos($bn, 'defuse_temp_') === 0 || strpos($bn, 'defuse_temp_restore_') === 0) {
791
                    continue;
792
                }
793
794
                $files[] = array(
795
                    'name' => $bn,
796
                    'size_bytes' => (int)@filesize($fp),
797
                    'mtime' => (int)@filemtime($fp),
798
                    'download' => 'sources/downloadFile.php?name=' . urlencode($bn) .
799
                        '&action=backup&file=' . urlencode($bn) .
800
                        '&type=sql&key=' . $session->get('key') .
801
                        '&key_tmp=' . $session->get('user-key_tmp') .
802
                        '&pathIsFiles=1',
803
                );
804
            }
805
806
            usort($files, static function ($a, $b) {
807
                return ($b['mtime'] ?? 0) <=> ($a['mtime'] ?? 0);
808
            });
809
810
            echo prepareExchangedData(
811
                array(
812
                    'error' => false,
813
                    'dir' => $dir,
814
                    'files' => $files,
815
                ),
816
                'encode'
817
            );
818
            break;
819
820
        case 'onthefly_restore':
821
            // Check KEY
822
            if ($post_key !== $session->get('key')) {
823
                echo prepareExchangedData(
824
                    array(
825
                        'error' => true,
826
                        'message' => $lang->get('key_is_not_correct'),
827
                    ),
828
                    'encode'
829
                );
830
                break;
831
            } elseif ($session->get('user-admin') === 0) {
832
                echo prepareExchangedData(
833
                    array(
834
                        'error' => true,
835
                        'message' => $lang->get('error_not_allowed_to'),
836
                    ),
837
                    'encode'
838
                );
839
                break;
840
            }
841
            
842
            // Put TeamPass in maintenance mode for the whole restore workflow.
843
            // Intentionally NOT disabled at the end: admin must validate the instance after restore.
844
            try {
845
                DB::update(
846
                    prefixTable('misc'),
847
                    array(
848
                        'valeur' => '1',
849
                        'updated_at' => time(),
850
                    ),
851
                    'intitule = %s AND type= %s',
852
                    'maintenance_mode',
853
                    'admin'
854
                );
855
            } catch (Throwable $ignored) {
856
                // Best effort
857
            }
858
859
            // Decrypt and retrieve data in JSON format
860
            $dataReceived = prepareExchangedData(
861
                $post_data,
862
                'decode'
863
            );
864
        
865
            // Prepare variables (safe defaults for both upload-restore and serverFile-restore)
866
            $post_encryptionKey = filter_var(($dataReceived['encryptionKey'] ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
867
868
            // Optional override key (mainly for scheduled restores in case of migration)
869
            // This MUST NOT be auto-filled from the on-the-fly key; it is only used when explicitly provided.
870
            $post_overrideKey = filter_var(($dataReceived['overrideKey'] ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
871
872
            $post_backupFile = filter_var(($dataReceived['backupFile'] ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
873
            $post_clearFilename = filter_var(($dataReceived['clearFilename'] ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
874
875
            $post_serverScope = filter_var(($dataReceived['serverScope'] ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
876
            $post_serverFile  = filter_var(($dataReceived['serverFile']  ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
877
878
            // Scheduled backups must always be decrypted with the instance key (server-side).
879
            // Ignore any key coming from the UI to avoid mismatches.
880
            if ($post_serverScope === 'scheduled') {
881
                $post_encryptionKey = '';
882
            }
883
            // Ensure all strings we send back through prepareExchangedData() are JSON-safe.
884
            // This avoids PHP "malformed UTF-8" warnings when restore errors contain binary/latin1 bytes.
885
            $tpSafeJsonString = static function ($value): string {
886
                if ($value === null) {
887
                    return '';
888
                }
889
                if (is_bool($value)) {
890
                    return $value ? '1' : '0';
891
                }
892
                if (is_scalar($value) === false) {
893
                    $value = print_r($value, true);
894
                }
895
                $str = (string) $value;
896
897
                // If the string isn't valid UTF-8, return a hex dump instead (ASCII-only, safe for JSON).
898
                $isUtf8 = false;
899
                if (function_exists('mb_check_encoding')) {
900
                    $isUtf8 = mb_check_encoding($str, 'UTF-8');
901
                } else {
902
                    $isUtf8 = (@preg_match('//u', $str) === 1);
903
                }
904
                if ($isUtf8 === false) {
905
                    return '[hex]' . bin2hex($str);
906
                }
907
908
                // Strip ASCII control chars that could pollute JSON.
909
                $str = preg_replace("/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/", '', $str) ?? $str;
910
                return $str;
911
            };
912
913
914
            // Chunked upload fields (can be absent when restoring from an existing server file)
915
            $post_offset = (int) ($dataReceived['offset'] ?? 0);
916
            $post_totalSize = (int) ($dataReceived['totalSize'] ?? 0);
917
918
            // Restore session + concurrency lock management.
919
            // - We keep a token in session to allow chunked restore even while DB is being replaced.
920
            // - We also block starting a second restore in the same session (double click / 2 tabs).
921
            $clearRestoreState = static function ($session): void {
922
                            $tmp = (string) ($session->get('restore-temp-file') ?? '');
923
                            if ($tmp !== '' && file_exists($tmp) === true && strpos(basename($tmp), 'defuse_temp_restore_') === 0 && is_file($tmp)) {
924
                                if (is_writable($tmp)) {
925
                                    if (unlink($tmp) === false) {
926
                                        error_log("TeamPass: Failed to delete file: {$tmp}");
927
                                    }
928
                                } else {
929
                                    error_log("TeamPass: File is not writable, cannot delete: {$tmp}");
930
                                }
931
                            }
932
                            $session->set('restore-temp-file', '');
933
                $session->set('restore-token', '');
934
                $session->set('restore-settings', []);
935
                $session->set('restore-context', []);
936
                $session->set('restore-in-progress', false);
937
                $session->set('restore-in-progress-ts', 0);
938
                $session->set('restore-start-ts', 0);
939
            };
940
941
            $nowTs = time();
942
            $inProgress = (bool) ($session->get('restore-in-progress') ?? false);
943
            $lastTs = (int) ($session->get('restore-in-progress-ts') ?? 0);
944
945
            // Auto-release stale lock (e.g. client crashed / browser closed)
946
            if ($inProgress === true && $lastTs > 0 && ($nowTs - $lastTs) > 3600) {
947
                $clearRestoreState($session);
948
                $inProgress = false;
949
                $lastTs = 0;
950
            }
951
952
            $sessionRestoreToken = (string) ($session->get('restore-token') ?? '');
953
            $isStartRestore = (empty($post_clearFilename) === true && (int) $post_offset === 0);
954
955
            if ($isStartRestore === true) {
956
                if ($inProgress === true && $sessionRestoreToken !== '') {
957
                    echo prepareExchangedData(
958
                        array(
959
                            'error' => true,
960
                            'message' => 'A restore is already in progress in this session. Please wait for it to complete (or logout/login to reset).',
961
                        ),
962
                        'encode'
963
                    );
964
                    break;
965
                }
966
967
                $sessionRestoreToken = bin2hex(random_bytes(16));
968
                $session->set('restore-token', $sessionRestoreToken);
969
                $session->set('restore-settings', $SETTINGS);
970
                $session->set('restore-in-progress', true);
971
                $session->set('restore-in-progress-ts', $nowTs);
972
                $session->set('restore-start-ts', $nowTs);
973
                $session->set('restore-context', []);
974
            } else {
975
                // Restore continuation must provide the correct token
976
                if ($restoreToken === '' || $sessionRestoreToken === '' || !hash_equals($sessionRestoreToken, $restoreToken)) {
977
                    echo prepareExchangedData(
978
                        array(
979
                            'error' => true,
980
                            'message' => 'Restore session expired. Please restart the restore process.',
981
                        ),
982
                        'encode'
983
                    );
984
                    break;
985
                }
986
987
                // Update activity timestamp (keeps the lock alive)
988
                $session->set('restore-in-progress', true);
989
                $session->set('restore-in-progress-ts', $nowTs);
990
                if ((int) ($session->get('restore-start-ts') ?? 0) === 0) {
991
                    $session->set('restore-start-ts', $nowTs);
992
                }
993
            }
994
995
            $batchSize = 500;
996
            $errors = array(); // Collect potential errors
997
        
998
            // Check if the offset is greater than the total size
999
            if ($post_offset > 0 && $post_totalSize > 0 && $post_offset >= $post_totalSize) {
1000
                // Defensive: if client asks to continue beyond end, consider restore finished and release lock.
1001
                if (is_string($post_clearFilename) && $post_clearFilename !== '' && file_exists($post_clearFilename) === true
1002
                    && strpos(basename($post_clearFilename), 'defuse_temp_restore_') === 0) {
1003
                    @unlink($post_clearFilename);
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

1003
                    /** @scrutinizer ignore-unhandled */ @unlink($post_clearFilename);

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...
1004
                }
1005
                $clearRestoreState($session);
1006
1007
                echo prepareExchangedData(
1008
                    array(
1009
                        'error' => false,
1010
                        'message' => 'operation_finished',
1011
                        'finished' => true,
1012
                        'restore_token' => $sessionRestoreToken,
1013
                    ),
1014
                    'encode'
1015
                );
1016
                break;
1017
            }
1018
            
1019
            // Log debug information if in development mode
1020
            if (defined('WIP') && WIP === true) {
1021
                error_log('DEBUG: Offset -> '.$post_offset.'/'.$post_totalSize.' | File -> '.$post_clearFilename);
1022
            }
1023
        
1024
            include_once $SETTINGS['cpassman_dir'] . '/sources/main.functions.php';
1025
        
1026
            include_once $SETTINGS['cpassman_dir'] . '/sources/main.functions.php';
1027
1028
            /*
1029
             * Restore workflow
1030
             * - 1st call (clearFilename empty): locate encrypted backup, decrypt to a temp file, return its path in clearFilename
1031
             * - next calls: reuse clearFilename as the decrypted file path and continue reading from offset
1032
             */
1033
1034
            if (empty($post_clearFilename) === true) {
1035
                // Default behavior: uploaded on-the-fly backup file (stored in teampass_misc) is removed after decrypt/restore.
1036
                // New behavior: user can select an existing encrypted backup file already present on server (scheduled or on-the-fly stored file).
1037
                $deleteEncryptedAfterDecrypt = true;
1038
1039
                $bn = '';
1040
                $serverPath = '';
1041
                $legacyOperationId = null;
1042
1043
                // NEW: restore from an existing server file (scheduled or on-the-fly stored file)
1044
                if (!empty($post_serverFile)) {
1045
                    $deleteEncryptedAfterDecrypt = false;
1046
1047
                    $bn = basename((string) $post_serverFile);
1048
1049
                    // Safety: allow only *.sql name
1050
                    if ($bn === '' || strtolower(pathinfo($bn, PATHINFO_EXTENSION)) !== 'sql') {
1051
                        $clearRestoreState($session);
1052
                        echo prepareExchangedData(
1053
                            array('error' => true, 'message' => 'Invalid serverFile'),
1054
                            'encode'
1055
                        );
1056
                        break;
1057
                    }
1058
1059
                    $baseDir = rtrim((string) $SETTINGS['path_to_files_folder'], '/');
1060
1061
                    // Scheduled backups are stored in configured output directory
1062
                    if ($post_serverScope === 'scheduled') {
1063
                        $baseFilesDir = (string) ($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
1064
                        $dir = (string) tpGetSettingsValue('bck_scheduled_output_dir', rtrim($baseFilesDir, '/') . '/backups');
1065
                        $baseDir = rtrim($dir, '/');
1066
                    }
1067
1068
                    $serverPath = $baseDir . '/' . $bn;
1069
1070
                    if (file_exists($serverPath) === false) {
1071
                        try {
1072
                            logEvents(
1073
                                $SETTINGS,
1074
                                'admin_action',
1075
                                'dataBase restore failed (file not found)',
1076
                                (string) $session->get('user-id'),
1077
                                $session->get('user-login')
1078
                            );
1079
                        } catch (Throwable $ignored) {
1080
                            // ignore logging errors
1081
                        }
1082
                        $clearRestoreState($session);
1083
                        echo prepareExchangedData(
1084
                            array('error' => true, 'message' => 'Backup file not found on server'),
1085
                            'encode'
1086
                        );
1087
                        break;
1088
                    }
1089
                
1090
                    // Log restore start once (best effort)
1091
                    if ($isStartRestore === true) {
1092
                        $sizeBytes = (int) @filesize($serverPath);
1093
                        $session->set(
1094
                            'restore-context',
1095
                            array(
1096
                                'scope' => $post_serverScope,
1097
                                'backup' => $bn,
1098
                                'size_bytes' => $sizeBytes,
1099
                            )
1100
                        );
1101
1102
                        try {
1103
                            $msg = 'dataBase restore started (scope=' . $post_serverScope . ', file=' . $bn . ')';
1104
                            logEvents(
1105
                                $SETTINGS,
1106
                                'admin_action',
1107
                                $msg,
1108
                                (string) $session->get('user-id'),
1109
                                $session->get('user-login')
1110
                            );
1111
                        } catch (Throwable $ignored) {
1112
                            // ignore logging errors during restore
1113
                        }
1114
                    }
1115
1116
                } else {
1117
                    // LEGACY: restore from uploaded on-the-fly backup identified by its misc.increment_id
1118
                    if (empty($post_backupFile) === true || ctype_digit((string) $post_backupFile) === false) {
1119
                        echo prepareExchangedData(
1120
                            array('error' => true, 'message' => 'No backup selected'),
1121
                            'encode'
1122
                        );
1123
                        break;
1124
                    }
1125
1126
                    $legacyOperationId = (int) $post_backupFile;
1127
1128
                    // Find filename from DB (misc)
1129
                    $data = DB::queryFirstRow(
1130
                        'SELECT valeur FROM ' . prefixTable('misc') . ' WHERE increment_id = %i LIMIT 1',
1131
                        $legacyOperationId
1132
                    );
1133
1134
                    if (empty($data['valeur'])) {
1135
                        try {
1136
                            logEvents(
1137
                                $SETTINGS,
1138
                                'admin_action',
1139
                                'dataBase restore failed (missing misc entry)',
1140
                                (string) $session->get('user-id'),
1141
                                $session->get('user-login')
1142
                            );
1143
                        } catch (Throwable $ignored) {
1144
                            // ignore logging errors
1145
                        }
1146
                        $clearRestoreState($session);
1147
                        echo prepareExchangedData(
1148
                            array('error' => true, 'message' => 'Backup file not found in database'),
1149
                            'encode'
1150
                        );
1151
                        break;
1152
                    }
1153
1154
                    $bn = safeString($data['valeur']);
1155
                    $serverPath = rtrim((string) $SETTINGS['path_to_files_folder'], '/') . '/' . $bn;
1156
1157
                    if (file_exists($serverPath) === false) {
1158
                        try {
1159
                            logEvents(
1160
                                $SETTINGS,
1161
                                'admin_action',
1162
                                'dataBase restore failed (file not found)',
1163
                                (string) $session->get('user-id'),
1164
                                $session->get('user-login')
1165
                            );
1166
                        } catch (Throwable $ignored) {
1167
                            // ignore logging errors
1168
                        }
1169
                        $clearRestoreState($session);
1170
                        echo prepareExchangedData(
1171
                            array('error' => true, 'message' => 'Backup file not found on server'),
1172
                            'encode'
1173
                        );
1174
                        break;
1175
                    }
1176
                }
1177
1178
                // Common checks
1179
                if ($post_serverScope !== 'scheduled' && empty($post_encryptionKey) === true) {
1180
                    echo prepareExchangedData(
1181
                        array('error' => true, 'message' => 'Missing encryption key'),
1182
                        'encode'
1183
                    );
1184
                    break;
1185
                }
1186
1187
                // Decrypt to a dedicated temp file (unique)
1188
                $tmpDecrypted = rtrim((string) $SETTINGS['path_to_files_folder'], '/')
1189
                    . '/defuse_temp_restore_' . (int) $session->get('user-id') . '_' . time() . '_' . $bn;
1190
1191
                // Build the list of keys we can try to decrypt with.
1192
                // - on-the-fly: uses the key provided by the UI
1193
                // - scheduled: uses the instance key (stored in bck_script_passkey)
1194
                $keysToTry = [];
1195
                if ($post_serverScope === 'scheduled') {
1196
                    // Allow an explicit override key (migration use-case)
1197
                    if (!empty($post_overrideKey)) {
1198
                        $keysToTry[] = (string) $post_overrideKey;
1199
                    }
1200
                    if (!empty($SETTINGS['bck_script_passkey'] ?? '')) {
1201
                        $rawInstanceKey = (string) $SETTINGS['bck_script_passkey'];
1202
                        $tmp = cryption($rawInstanceKey, '', 'decrypt', $SETTINGS);
1203
                        $decInstanceKey = isset($tmp['string']) ? (string) $tmp['string'] : '';
1204
1205
                        if ($decInstanceKey !== '') {
1206
                            $keysToTry[] = $decInstanceKey;
1207
                        }
1208
                        if ($rawInstanceKey !== '' && $rawInstanceKey !== $decInstanceKey) {
1209
                            $keysToTry[] = $rawInstanceKey;
1210
                        }
1211
                    }
1212
                } else {
1213
                    if ($post_encryptionKey !== '') {
1214
                        $keysToTry[] = (string) $post_encryptionKey;
1215
                    }
1216
                }
1217
1218
                // Ensure we have at least one key
1219
                $keysToTry = array_values(array_unique(array_filter($keysToTry, static fn ($v) => $v !== '')));
1220
                if (empty($keysToTry)) {
1221
                    echo prepareExchangedData(
1222
                        array('error' => true, 'message' => 'Missing encryption key'),
1223
                        'encode'
1224
                    );
1225
                    break;
1226
                }
1227
1228
                // Try to decrypt with the available keys (some environments store bck_script_passkey encrypted)
1229
                $decRet = tpDefuseDecryptWithCandidates($serverPath, $tmpDecrypted, $keysToTry, $SETTINGS);
1230
                if (!empty($decRet['success']) === false) {
1231
                    @unlink($tmpDecrypted);
1232
                    try {
1233
                        logEvents(
1234
                            $SETTINGS,
1235
                            'admin_action',
1236
                            'dataBase restore failed (decrypt error)',
1237
                            (string) $session->get('user-id'),
1238
                            $session->get('user-login')
1239
                        );
1240
                    } catch (Throwable $ignored) {
1241
                        // ignore logging errors
1242
                    }
1243
                    $clearRestoreState($session);
1244
                    echo prepareExchangedData(
1245
                        array(
1246
                            'error' => true,
1247
                            'error_code' => 'DECRYPT_FAILED',
1248
                            'message' => 'Unable to decrypt backup: ' . $tpSafeJsonString((string) ($decRet['message'] ?? 'unknown error')),
1249
                        ),
1250
                        'encode'
1251
                    );
1252
                    break;
1253
                }
1254
1255
                if (!is_file($tmpDecrypted) || (int) @filesize($tmpDecrypted) === 0) {
1256
                    @unlink($tmpDecrypted);
1257
                    $clearRestoreState($session);
1258
                    echo prepareExchangedData(
1259
                        array('error' => true, 'message' => 'Decrypted backup is empty or unreadable'),
1260
                        'encode'
1261
                    );
1262
                    break;
1263
                }
1264
1265
                // Remove original encrypted file ONLY for legacy uploaded one-shot restore
1266
                if ($deleteEncryptedAfterDecrypt === true) {
1267
                    fileDelete($serverPath, $SETTINGS);
1268
1269
                    // Delete operation record
1270
                    if ($legacyOperationId !== null) {
1271
                        DB::delete(
1272
                            prefixTable('misc'),
1273
                            'increment_id = %i',
1274
                            $legacyOperationId
1275
                        );
1276
                    }
1277
                }
1278
// From now, restore uses the decrypted temp file
1279
                $post_backupFile = $tmpDecrypted;
1280
                $session->set('restore-temp-file', $tmpDecrypted);
1281
                $post_clearFilename = $tmpDecrypted;
1282
            } else {
1283
                $post_backupFile = $post_clearFilename;
1284
                $session->set('restore-temp-file', $post_clearFilename);
1285
            }
1286
1287
            // Read sql file
1288
            $handle = fopen($post_backupFile, 'r');
1289
        
1290
            if ($handle === false) {
1291
                if (is_string($post_backupFile) && $post_backupFile !== '' && file_exists($post_backupFile) === true
1292
                    && strpos(basename($post_backupFile), 'defuse_temp_restore_') === 0) {
1293
                    @unlink($post_backupFile);
1294
                }
1295
                $clearRestoreState($session);
1296
                echo prepareExchangedData(
1297
                    array(
1298
                        'error' => true,
1299
                        'message' => 'Unable to open backup file.',
1300
                        'finished' => false,
1301
                        'restore_token' => $sessionRestoreToken,
1302
                    ),
1303
                    'encode'
1304
                );
1305
                break;
1306
            }
1307
        
1308
                        // Get total file size
1309
            if ((int) $post_totalSize === 0) {
1310
                $post_totalSize = filesize($post_backupFile);
1311
            }
1312
1313
            // Validate chunk parameters
1314
            if ((int) $post_totalSize <= 0) {
1315
                // Abort: we cannot safely run a chunked restore without a reliable size
1316
                $clearRestoreState($session);
1317
                echo prepareExchangedData(
1318
                    array(
1319
                        'error' => true,
1320
                        'message' => 'Invalid backup file size (0).',
1321
                        'finished' => true,
1322
                    ),
1323
                    'encode'
1324
                );
1325
                break;
1326
            }
1327
1328
            if ($post_offset < 0) {
1329
                $post_offset = 0;
1330
            }
1331
1332
            if ($post_offset > $post_totalSize) {
1333
                // Abort: invalid offset (prevents instant "success" due to EOF)
1334
                if (is_string($post_backupFile) && $post_backupFile !== '' && file_exists($post_backupFile) === true) {
1335
                    // If it is a temporary decrypted file, cleanup
1336
                    if (strpos(basename($post_backupFile), 'defuse_temp_restore_') === 0) {
1337
                        @unlink($post_backupFile);
1338
                    }
1339
                }
1340
                $clearRestoreState($session);
1341
                echo prepareExchangedData(
1342
                    array(
1343
                        'error' => true,
1344
                        'message' => 'Invalid restore offset.',
1345
                        'finished' => true,
1346
                    ),
1347
                    'encode'
1348
                );
1349
                break;
1350
            }
1351
1352
            // Move the file pointer to the current offset
1353
            fseek($handle, $post_offset);
1354
            $query = '';
1355
            $executedQueries = 0;
1356
            $inMultiLineComment = false;
1357
            
1358
            try {
1359
                // Start transaction to ensure database consistency
1360
                DB::startTransaction();
1361
                DB::query("SET FOREIGN_KEY_CHECKS = 0");
1362
                DB::query("SET UNIQUE_CHECKS = 0");
1363
                
1364
                while (!feof($handle) && $executedQueries < $batchSize) {
1365
                    $line = fgets($handle);
1366
                    
1367
                    // Check if not false
1368
                    if ($line !== false) {
1369
                        $trimmedLine = trim($line);
1370
                        
1371
                        // Skip empty lines or comments
1372
                        if (empty($trimmedLine) || 
1373
                            (strpos($trimmedLine, '--') === 0) || 
1374
                            (strpos($trimmedLine, '#') === 0)) {
1375
                            continue;
1376
                        }
1377
                        
1378
                        // Handle multi-line comments
1379
                        if (strpos($trimmedLine, '/*') === 0 && strpos($trimmedLine, '*/') === false) {
1380
                            $inMultiLineComment = true;
1381
                            continue;
1382
                        }
1383
                        
1384
                        if ($inMultiLineComment) {
1385
                            if (strpos($trimmedLine, '*/') !== false) {
1386
                                $inMultiLineComment = false;
1387
                            }
1388
                            continue;
1389
                        }
1390
                        
1391
                        // Add line to current query
1392
                        $query .= $line;
1393
                        
1394
                        // Execute if this is the end of a statement
1395
                        if (substr($trimmedLine, -1) === ';') {
1396
                            try {
1397
                                DB::query($query);
1398
                                $executedQueries++;
1399
                            } catch (Exception $e) {
1400
                                $snippet = substr($query, 0, 120);
1401
                                $snippet = $tpSafeJsonString($snippet);
1402
                                $errors[] = 'Error executing query: ' . $tpSafeJsonString($e->getMessage()) . ' - Query: ' . $snippet . '...';
1403
                            }
1404
                            $query = '';
1405
                        }
1406
                    }
1407
                }
1408
1409
                // Set default settings back
1410
                DB::query("SET FOREIGN_KEY_CHECKS = 1");
1411
                DB::query("SET UNIQUE_CHECKS = 1");
1412
                
1413
                // Commit the transaction if no errors
1414
                if (empty($errors)) {
1415
                    DB::commit();
1416
                } else {
1417
                    DB::rollback();
1418
                }
1419
            } catch (Exception $e) {
1420
                try {
1421
                    DB::query("SET FOREIGN_KEY_CHECKS = 1");
1422
                    DB::query("SET UNIQUE_CHECKS = 1");
1423
                } catch (Exception $ignored) {
1424
                    // Ignore further exceptions
1425
                }
1426
                // Rollback transaction on any exception
1427
                DB::rollback();
1428
                $errors[] = 'Transaction failed: ' . $tpSafeJsonString($e->getMessage());
1429
            }
1430
        
1431
            // Calculate the new offset
1432
            $newOffset = ftell($handle);
1433
        
1434
            // Check if the end of the file has been reached
1435
            $isEndOfFile = feof($handle);
1436
            fclose($handle);
1437
        
1438
            // Handle errors if any
1439
if (!empty($errors)) {
1440
    // Abort restore: cleanup temp file and release session lock
1441
    if (is_string($post_backupFile) && $post_backupFile !== '' && file_exists($post_backupFile) === true
1442
        && strpos(basename($post_backupFile), 'defuse_temp_restore_') === 0) {
1443
        @unlink($post_backupFile);
1444
    }
1445
1446
    $tokenForResponse = $sessionRestoreToken;
1447
1448
    // Best-effort log
1449
    try {
1450
        $ctx = $session->get('restore-context');
1451
        $scope = is_array($ctx) ? (string) ($ctx['scope'] ?? '') : '';
1452
        logEvents(
1453
            $SETTINGS,
1454
            'admin_action',
1455
            'dataBase restore failed' . ($scope !== '' ? ' (scope=' . $scope . ')' : ''),
1456
            (string) $session->get('user-id'),
1457
            $session->get('user-login')
1458
        );
1459
    } catch (Throwable $ignored) {
1460
        // ignore logging errors during restore
1461
    }
1462
1463
    $clearRestoreState($session);
1464
1465
    echo prepareExchangedData(
1466
        array(
1467
            'error' => true,
1468
            'message' => 'Errors occurred during import: ' . implode('; ', ($post_serverScope === 'scheduled' ? array_map($tpSafeJsonString, $errors) : $errors)),
1469
            'newOffset' => $newOffset,
1470
            'totalSize' => $post_totalSize,
1471
            'clearFilename' => $post_backupFile,
1472
            'finished' => true,
1473
            'restore_token' => $tokenForResponse,
1474
        ),
1475
        'encode'
1476
    );
1477
    break;
1478
}
1479
1480
// Determine if restore is complete
1481
            $finished = ($isEndOfFile === true) || ($post_totalSize > 0 && $newOffset >= $post_totalSize);
1482
1483
            // Respond with the new offset
1484
            echo prepareExchangedData(
1485
                array(
1486
                    'error' => false,
1487
                    'newOffset' => $newOffset,
1488
                    'totalSize' => $post_totalSize,
1489
                    'clearFilename' => $post_backupFile,
1490
                    'finished' => $finished,
1491
                        'restore_token' => $sessionRestoreToken,
1492
                ),
1493
                'encode'
1494
            );
1495
1496
            // Check if the end of the file has been reached to delete the file
1497
            if ($finished) {
1498
                if (defined('WIP') && WIP === true) {
1499
                    error_log('DEBUG: End of file reached. Deleting file '.$post_backupFile);
1500
                }
1501
1502
                if (is_string($post_backupFile) && $post_backupFile !== '' && file_exists($post_backupFile) === true) {
1503
                    @unlink($post_backupFile);
1504
                }
1505
1506
            // Ensure maintenance mode stays enabled after restore (dump may have restored it to 0).
1507
            try {
1508
                DB::update(
1509
                    prefixTable('misc'),
1510
                    array(
1511
                        'valeur' => '1',
1512
                        'updated_at' => time(),
1513
                    ),
1514
                    'intitule = %s AND type= %s',
1515
                    'maintenance_mode',
1516
                    'admin'
1517
                );
1518
            } catch (Throwable $ignored) {
1519
                // Best effort
1520
            }
1521
1522
// Cleanup: after a DB restore, the SQL dump may re-import a running database_backup task
1523
// (is_in_progress=1) that becomes a "ghost" in Task Manager.
1524
try {
1525
    DB::delete(
1526
        prefixTable('background_tasks'),
1527
        'process_type=%s AND is_in_progress=%i',
1528
        'database_backup',
1529
        1
1530
    );
1531
} catch (Throwable $ignored) {
1532
    // Best effort: ignore if table does not exist yet / partial restore / schema mismatch
1533
}
1534
1535
// Finalize: clear lock/session state and log duration (best effort)
1536
$ctx = $session->get('restore-context');
1537
$scope = is_array($ctx) ? (string) ($ctx['scope'] ?? '') : '';
1538
$fileLabel = is_array($ctx) ? (string) ($ctx['backup'] ?? '') : '';
1539
$startTs = (int) ($session->get('restore-start-ts') ?? 0);
1540
$duration = ($startTs > 0) ? (time() - $startTs) : 0;
1541
1542
$clearRestoreState($session);
1543
1544
try {
1545
    $msg = 'dataBase restore completed';
1546
    if ($scope !== '' || $fileLabel !== '' || $duration > 0) {
1547
        $parts = array();
1548
        if ($scope !== '') {
1549
            $parts[] = 'scope=' . $scope;
1550
        }
1551
        if ($fileLabel !== '') {
1552
            $parts[] = 'file=' . $fileLabel;
1553
        }
1554
        if ($duration > 0) {
1555
            $parts[] = 'duration=' . $duration . 's';
1556
        }
1557
        $msg .= ' (' . implode(', ', $parts) . ')';
1558
    }
1559
1560
    logEvents(
1561
        $SETTINGS,
1562
        'admin_action',
1563
        $msg,
1564
        (string) $session->get('user-id'),
1565
        $session->get('user-login')
1566
    );
1567
} catch (Throwable $ignored) {
1568
    // ignore logging errors during restore
1569
}
1570
1571
            }
1572
            break;
1573
    }
1574
}
1575