Issues (40)

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-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
    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 = (string) pathinfo($get_file, PATHINFO_EXTENSION);
233
            if (strtolower($extension) !== 'sql') {
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 'check_connected_users':
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
            $excludeUserId = (int) filter_input(INPUT_POST, 'exclude_user_id', FILTER_SANITIZE_NUMBER_INT);
640
            if ($excludeUserId === 0 && null !== $session->get('user-id')) {
641
                $excludeUserId = (int) $session->get('user-id');
642
            }
643
644
            $connectedCount = (int) DB::queryFirstField(
645
                'SELECT COUNT(*) FROM ' . prefixTable('users') . ' WHERE session_end >= %i AND id != %i',
646
                time(),
647
                $excludeUserId
648
            );
649
650
            echo prepareExchangedData(['error' => false, 'connected_count' => $connectedCount], 'encode');
651
            break;
652
653
        case 'scheduled_run_now':
654
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
655
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
656
                break;
657
            }
658
659
            $now = time();
660
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
661
            $dir = (string)tpGetSettingsValue('bck_scheduled_output_dir', rtrim($baseFilesDir, '/') . '/backups');
662
            @mkdir($dir, 0770, true);
663
664
            // avoid duplicates
665
            $pending = (int)DB::queryFirstField(
666
                'SELECT COUNT(*) FROM ' . prefixTable('background_tasks') . '
667
                 WHERE process_type=%s AND is_in_progress IN (0,1)
668
                   AND (finished_at IS NULL OR finished_at = "" OR finished_at = 0)',
669
                'database_backup'
670
            );
671
            if ($pending > 0) {
672
                echo prepareExchangedData(['error' => true, 'message' => 'A backup task is already pending/running'], 'encode');
673
                break;
674
            }
675
676
            DB::insert(
677
                prefixTable('background_tasks'),
678
                [
679
                    'created_at' => (string)$now,
680
                    'process_type' => 'database_backup',
681
                    'arguments' => json_encode(['output_dir' => $dir, 'source' => 'scheduler', 'initiator_user_id' => (int) $session->get('user-id')], JSON_UNESCAPED_SLASHES),
682
                    'is_in_progress' => 0,
683
                    'status' => 'new',
684
                ]
685
            );
686
687
            tpUpsertSettingsValue('bck_scheduled_last_run_at', (string)$now);
688
            tpUpsertSettingsValue('bck_scheduled_last_status', 'queued');
689
            tpUpsertSettingsValue('bck_scheduled_last_message', 'Task enqueued by UI');
690
691
            echo prepareExchangedData(['error' => false], 'encode');
692
            break;
693
694
        case 'onthefly_delete_backup':
695
            // Delete an on-the-fly backup file stored in <files> directory
696
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
697
                echo prepareExchangedData(
698
                    array(
699
                        'error' => true,
700
                        'message' => 'Not allowed',
701
                    ),
702
                    'encode'
703
                );
704
                break;
705
            }
706
707
            $dataReceived = prepareExchangedData($post_data, 'decode');
708
            $fileToDelete = isset($dataReceived['file']) === true ? (string)$dataReceived['file'] : '';
709
            $bn = basename($fileToDelete);
710
711
            // Safety checks
712
            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

712
            if ($bn === '' || strtolower(/** @scrutinizer ignore-type */ pathinfo($bn, PATHINFO_EXTENSION)) !== 'sql') {
Loading history...
713
                echo prepareExchangedData(
714
                    array(
715
                        'error' => true,
716
                        'message' => 'Invalid file name',
717
                    ),
718
                    'encode'
719
                );
720
                break;
721
            }
722
723
            // Never allow deleting scheduled backups or temp files
724
            if (strpos($bn, 'scheduled-') === 0 || strpos($bn, 'defuse_temp_') === 0 || strpos($bn, 'defuse_temp_restore_') === 0) {
725
                echo prepareExchangedData(
726
                    array(
727
                        'error' => true,
728
                        'message' => 'Not allowed on this file',
729
                    ),
730
                    'encode'
731
                );
732
                break;
733
            }
734
735
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
736
            $dir = rtrim($baseFilesDir, '/');
737
            $fullPath = $dir . '/' . $bn;
738
739
            if (file_exists($fullPath) === false) {
740
                echo prepareExchangedData(
741
                    array(
742
                        'error' => true,
743
                        'message' => 'File not found',
744
                    ),
745
                    'encode'
746
                );
747
                break;
748
            }
749
750
            // Delete
751
            $ok = @unlink($fullPath);
752
753
            if ($ok !== true) {
754
                echo prepareExchangedData(
755
                    array(
756
                        'error' => true,
757
                        'message' => 'Unable to delete file',
758
                    ),
759
                    'encode'
760
                );
761
                break;
762
            }
763
764
            echo prepareExchangedData(
765
                array(
766
                    'error' => false,
767
                    'message' => 'Deleted',
768
                    'file' => $bn,
769
                ),
770
                'encode'
771
            );
772
            break;
773
774
        case 'onthefly_list_backups':
775
            // List on-the-fly backup files stored directly in <files> directory (not in /backups for scheduled)
776
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
777
                echo prepareExchangedData(
778
                    array(
779
                        'error' => true,
780
                        'message' => 'Not allowed',
781
                    ),
782
                    'encode'
783
                );
784
                break;
785
            }
786
787
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
788
            $dir = rtrim($baseFilesDir, '/');
789
790
            $files = array();
791
            $paths = glob($dir . '/*.sql');
792
            if ($paths === false) {
793
                $paths = array();
794
            }
795
796
            // Ensure we have a temporary key for downloadFile.php
797
            $keyTmp = (string) $session->get('user-key_tmp');
798
            if ($keyTmp === '') {
799
                $keyTmp = GenerateCryptKey(16, false, true, true, false, true);
800
                $session->set('user-key_tmp', $keyTmp);
801
            }
802
803
            foreach ($paths as $fp) {
804
                $bn = basename($fp);
805
806
                // Skip scheduled backups and temporary files
807
                if (strpos($bn, 'scheduled-') === 0) {
808
                    continue;
809
                }
810
                if (strpos($bn, 'defuse_temp_') === 0 || strpos($bn, 'defuse_temp_restore_') === 0) {
811
                    continue;
812
                }
813
814
                $files[] = array(
815
                    'name' => $bn,
816
                    'size_bytes' => (int)@filesize($fp),
817
                    'mtime' => (int)@filemtime($fp),
818
                    'download' => 'sources/downloadFile.php?name=' . urlencode($bn) .
819
                        '&action=backup&file=' . urlencode($bn) .
820
                        '&type=sql&key=' . $session->get('key') .
821
                        '&key_tmp=' . $session->get('user-key_tmp') .
822
                        '&pathIsFiles=1',
823
                );
824
            }
825
826
            usort($files, static function ($a, $b) {
827
                return ($b['mtime'] ?? 0) <=> ($a['mtime'] ?? 0);
828
            });
829
830
            echo prepareExchangedData(
831
                array(
832
                    'error' => false,
833
                    'dir' => $dir,
834
                    'files' => $files,
835
                ),
836
                'encode'
837
            );
838
            break;
839
840
        case 'onthefly_restore':
841
            // Check KEY
842
            if ($post_key !== $session->get('key')) {
843
                echo prepareExchangedData(
844
                    array(
845
                        'error' => true,
846
                        'message' => $lang->get('key_is_not_correct'),
847
                    ),
848
                    'encode'
849
                );
850
                break;
851
            } elseif ($session->get('user-admin') === 0) {
852
                echo prepareExchangedData(
853
                    array(
854
                        'error' => true,
855
                        'message' => $lang->get('error_not_allowed_to'),
856
                    ),
857
                    'encode'
858
                );
859
                break;
860
            }
861
            
862
            // Put TeamPass in maintenance mode for the whole restore workflow.
863
            // Intentionally NOT disabled at the end: admin must validate the instance after restore.
864
            try {
865
                DB::update(
866
                    prefixTable('misc'),
867
                    array(
868
                        'valeur' => '1',
869
                        'updated_at' => time(),
870
                    ),
871
                    'intitule = %s AND type= %s',
872
                    'maintenance_mode',
873
                    'admin'
874
                );
875
            } catch (Throwable $ignored) {
876
                // Best effort
877
            }
878
879
            // Decrypt and retrieve data in JSON format
880
            $dataReceived = prepareExchangedData(
881
                $post_data,
882
                'decode'
883
            );
884
        
885
            // Prepare variables (safe defaults for both upload-restore and serverFile-restore)
886
            $post_encryptionKey = filter_var(($dataReceived['encryptionKey'] ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
887
888
            // Optional override key (mainly for scheduled restores in case of migration)
889
            // This MUST NOT be auto-filled from the on-the-fly key; it is only used when explicitly provided.
890
            $post_overrideKey = filter_var(($dataReceived['overrideKey'] ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
891
892
            $post_backupFile = filter_var(($dataReceived['backupFile'] ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
893
            $post_clearFilename = filter_var(($dataReceived['clearFilename'] ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
894
895
            $post_serverScope = filter_var(($dataReceived['serverScope'] ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
896
            $post_serverFile  = filter_var(($dataReceived['serverFile']  ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
897
898
            // Scheduled backups must always be decrypted with the instance key (server-side).
899
            // Ignore any key coming from the UI to avoid mismatches.
900
            if ($post_serverScope === 'scheduled') {
901
                $post_encryptionKey = '';
902
            }
903
            // Ensure all strings we send back through prepareExchangedData() are JSON-safe.
904
            // This avoids PHP "malformed UTF-8" warnings when restore errors contain binary/latin1 bytes.
905
            $tpSafeJsonString = static function ($value): string {
906
                if ($value === null) {
907
                    return '';
908
                }
909
                if (is_bool($value)) {
910
                    return $value ? '1' : '0';
911
                }
912
                if (is_scalar($value) === false) {
913
                    $value = print_r($value, true);
914
                }
915
                $str = (string) $value;
916
917
                // If the string isn't valid UTF-8, return a hex dump instead (ASCII-only, safe for JSON).
918
                $isUtf8 = false;
919
                if (function_exists('mb_check_encoding')) {
920
                    $isUtf8 = mb_check_encoding($str, 'UTF-8');
921
                } else {
922
                    $isUtf8 = (@preg_match('//u', $str) === 1);
923
                }
924
                if ($isUtf8 === false) {
925
                    return '[hex]' . bin2hex($str);
926
                }
927
928
                // Strip ASCII control chars that could pollute JSON.
929
                $str = preg_replace("/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/", '', $str) ?? $str;
930
                return $str;
931
            };
932
933
934
            // Chunked upload fields (can be absent when restoring from an existing server file)
935
            $post_offset = (int) ($dataReceived['offset'] ?? 0);
936
            $post_totalSize = (int) ($dataReceived['totalSize'] ?? 0);
937
938
            // Restore session + concurrency lock management.
939
            // - We keep a token in session to allow chunked restore even while DB is being replaced.
940
            // - We also block starting a second restore in the same session (double click / 2 tabs).
941
            $clearRestoreState = static function ($session): void {
942
                            $tmp = (string) ($session->get('restore-temp-file') ?? '');
943
                            if ($tmp !== '' && file_exists($tmp) === true && strpos(basename($tmp), 'defuse_temp_restore_') === 0 && is_file($tmp)) {
944
                                if (is_writable($tmp)) {
945
                                    if (unlink($tmp) === false) {
946
                                        error_log("TeamPass: Failed to delete file: {$tmp}");
947
                                    }
948
                                } else {
949
                                    error_log("TeamPass: File is not writable, cannot delete: {$tmp}");
950
                                }
951
                            }
952
                            $session->set('restore-temp-file', '');
953
                $session->set('restore-token', '');
954
                $session->set('restore-settings', []);
955
                $session->set('restore-context', []);
956
                $session->set('restore-in-progress', false);
957
                $session->set('restore-in-progress-ts', 0);
958
                $session->set('restore-start-ts', 0);
959
            };
960
961
            $nowTs = time();
962
            $inProgress = (bool) ($session->get('restore-in-progress') ?? false);
963
            $lastTs = (int) ($session->get('restore-in-progress-ts') ?? 0);
964
965
            // Auto-release stale lock (e.g. client crashed / browser closed)
966
            if ($inProgress === true && $lastTs > 0 && ($nowTs - $lastTs) > 3600) {
967
                $clearRestoreState($session);
968
                $inProgress = false;
969
                $lastTs = 0;
970
            }
971
972
            $sessionRestoreToken = (string) ($session->get('restore-token') ?? '');
973
            $isStartRestore = (empty($post_clearFilename) === true && (int) $post_offset === 0);
974
975
            if ($isStartRestore === true) {
976
                if ($inProgress === true && $sessionRestoreToken !== '') {
977
                    echo prepareExchangedData(
978
                        array(
979
                            'error' => true,
980
                            'message' => 'A restore is already in progress in this session. Please wait for it to complete (or logout/login to reset).',
981
                        ),
982
                        'encode'
983
                    );
984
                    break;
985
                }
986
987
                $sessionRestoreToken = bin2hex(random_bytes(16));
988
                $session->set('restore-token', $sessionRestoreToken);
989
                $session->set('restore-settings', $SETTINGS);
990
                $session->set('restore-in-progress', true);
991
                $session->set('restore-in-progress-ts', $nowTs);
992
                $session->set('restore-start-ts', $nowTs);
993
                $session->set('restore-context', []);
994
            } else {
995
                // Restore continuation must provide the correct token
996
                if ($restoreToken === '' || $sessionRestoreToken === '' || !hash_equals($sessionRestoreToken, $restoreToken)) {
997
                    echo prepareExchangedData(
998
                        array(
999
                            'error' => true,
1000
                            'message' => 'Restore session expired. Please restart the restore process.',
1001
                        ),
1002
                        'encode'
1003
                    );
1004
                    break;
1005
                }
1006
1007
                // Update activity timestamp (keeps the lock alive)
1008
                $session->set('restore-in-progress', true);
1009
                $session->set('restore-in-progress-ts', $nowTs);
1010
                if ((int) ($session->get('restore-start-ts') ?? 0) === 0) {
1011
                    $session->set('restore-start-ts', $nowTs);
1012
                }
1013
            }
1014
1015
            $batchSize = 500;
1016
            $errors = array(); // Collect potential errors
1017
        
1018
            // Check if the offset is greater than the total size
1019
            if ($post_offset > 0 && $post_totalSize > 0 && $post_offset >= $post_totalSize) {
1020
                // Defensive: if client asks to continue beyond end, consider restore finished and release lock.
1021
                if (is_string($post_clearFilename) && $post_clearFilename !== '' && file_exists($post_clearFilename) === true
1022
                    && strpos(basename($post_clearFilename), 'defuse_temp_restore_') === 0) {
1023
                    @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

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