Passed
Pull Request — master (#5017)
by
unknown
06:15
created

tpUpsertSettingsValue()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 20
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 15
c 1
b 0
f 0
nc 2
nop 2
dl 0
loc 20
rs 9.7666
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
            if ($get_file === '' || strpos($get_file, 'scheduled-') !== 0 || strtolower(pathinfo($get_file, PATHINFO_EXTENSION)) !== 'sql') {
0 ignored issues
show
Bug introduced by
It seems like pathinfo($get_file, 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

231
            if ($get_file === '' || strpos($get_file, 'scheduled-') !== 0 || strtolower(/** @scrutinizer ignore-type */ pathinfo($get_file, PATHINFO_EXTENSION)) !== 'sql') {
Loading history...
232
                header('HTTP/1.1 400 Bad Request');
233
                exit;
234
            }
235
236
            $baseFilesDir = (string) ($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
237
            $dir = (string) tpGetSettingsValue('bck_scheduled_output_dir', rtrim($baseFilesDir, '/') . '/backups');
238
            $fp = rtrim($dir, '/') . '/' . $get_file;
239
240
            $dirReal = realpath($dir);
241
            $fpReal = realpath($fp);
242
243
            if ($dirReal === false || $fpReal === false || strpos($fpReal, $dirReal . DIRECTORY_SEPARATOR) !== 0 || is_file($fpReal) === false) {
244
                header('HTTP/1.1 404 Not Found');
245
                exit;
246
            }
247
248
            // Stream file
249
            @set_time_limit(0);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for set_time_limit(). 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

249
            /** @scrutinizer ignore-unhandled */ @set_time_limit(0);

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...
250
            $size = (int) @filesize($fpReal);
251
            if (function_exists('ob_get_level')) {
252
                while (ob_get_level() > 0) {
253
                    @ob_end_clean();
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for ob_end_clean(). 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

253
                    /** @scrutinizer ignore-unhandled */ @ob_end_clean();

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

507
            /** @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...
508
            $baseReal = realpath($baseFilesDir) ?: $baseFilesDir;
509
            $dirReal = realpath($outputDir);
510
511
            if ($dirReal === false || strpos($dirReal, $baseReal) !== 0) {
512
                echo prepareExchangedData(['error' => true, 'message' => 'Invalid output directory'], 'encode');
513
                break;
514
            }
515
516
            tpUpsertSettingsValue('bck_scheduled_enabled', (string)$enabled);
517
            tpUpsertSettingsValue('bck_scheduled_frequency', $frequency);
518
            tpUpsertSettingsValue('bck_scheduled_time', $timeStr);
519
            tpUpsertSettingsValue('bck_scheduled_dow', (string)$dow);
520
            tpUpsertSettingsValue('bck_scheduled_dom', (string)$dom);
521
            tpUpsertSettingsValue('bck_scheduled_output_dir', $dirReal);
522
            tpUpsertSettingsValue('bck_scheduled_retention_days', (string)$retentionDays);
523
524
            // Force re-init of next_run_at so handler recomputes cleanly
525
            tpUpsertSettingsValue('bck_scheduled_next_run_at', '0');
526
527
            echo prepareExchangedData(['error' => false, 'message' => 'Saved'], 'encode');
528
            break;
529
530
        case 'scheduled_list_backups':
531
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
532
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
533
                break;
534
            }
535
536
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
537
            $dir = (string)tpGetSettingsValue('bck_scheduled_output_dir', rtrim($baseFilesDir, '/') . '/backups');
538
            @mkdir($dir, 0770, true);
539
            // Build a relative path from files/ root (output_dir can be a subfolder)
540
            $filesRoot = realpath($baseFilesDir);
541
            $dirReal = realpath($dir);
542
            $relDir = '';
543
            if ($filesRoot !== false && $dirReal !== false && strpos($dirReal, $filesRoot) === 0) {
544
                $relDir = trim(str_replace($filesRoot, '', $dirReal), DIRECTORY_SEPARATOR);
545
                $relDir = str_replace(DIRECTORY_SEPARATOR, '/', $relDir);
546
            }
547
548
            // Ensure we have a temporary key for downloadFile.php
549
            $keyTmp = (string) $session->get('user-key_tmp');
550
            if ($keyTmp === '') {
551
                $keyTmp = GenerateCryptKey(16, false, true, true, false, true);
552
                $session->set('user-key_tmp', $keyTmp);
553
            }
554
555
556
            $files = [];
557
            foreach (glob(rtrim($dir, '/') . '/scheduled-*.sql') ?: [] as $fp) {
558
                $bn = basename($fp);
559
                $files[] = [
560
                    'name' => $bn,
561
                    'size_bytes' => (int)@filesize($fp),
562
                    'mtime' => (int)@filemtime($fp),
563
                    'download' => 'sources/backups.queries.php?type=scheduled_download_backup&file=' . urlencode($bn)
564
                        . '&key=' . urlencode((string) $session->get('key'))
565
                        . '&key_tmp=' . urlencode($keyTmp),
566
                ];
567
            }
568
569
            usort($files, fn($a, $b) => ($b['mtime'] ?? 0) <=> ($a['mtime'] ?? 0));
570
571
            echo prepareExchangedData(['error' => false, 'files' => $files], 'encode');
572
            break;
573
574
        case 'scheduled_delete_backup':
575
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
576
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
577
                break;
578
            }
579
580
            $dataReceived = prepareExchangedData($post_data, 'decode');
581
            if (!is_array($dataReceived)) $dataReceived = [];
582
583
            $file = (string)($dataReceived['file'] ?? '');
584
            $file = basename($file);
585
586
            if ($file === '' || strpos($file, 'scheduled-') !== 0 || !str_ends_with($file, '.sql')) {
587
                echo prepareExchangedData(['error' => true, 'message' => 'Invalid filename'], 'encode');
588
                break;
589
            }
590
591
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
592
            $dir = (string)tpGetSettingsValue('bck_scheduled_output_dir', rtrim($baseFilesDir, '/') . '/backups');
593
            $fp = rtrim($dir, '/') . '/' . $file;
594
595
            if (is_file($fp)) {
596
                @unlink($fp);
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

596
                /** @scrutinizer ignore-unhandled */ @unlink($fp);

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...
597
            }
598
599
            echo prepareExchangedData(['error' => false], 'encode');
600
            break;
601
602
        case 'scheduled_run_now':
603
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
604
                echo prepareExchangedData(['error' => true, 'message' => 'Not allowed'], 'encode');
605
                break;
606
            }
607
608
            $now = time();
609
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
610
            $dir = (string)tpGetSettingsValue('bck_scheduled_output_dir', rtrim($baseFilesDir, '/') . '/backups');
611
            @mkdir($dir, 0770, true);
612
613
            // avoid duplicates
614
            $pending = (int)DB::queryFirstField(
615
                'SELECT COUNT(*) FROM ' . prefixTable('background_tasks') . '
616
                 WHERE process_type=%s AND is_in_progress IN (0,1)
617
                   AND (finished_at IS NULL OR finished_at = "" OR finished_at = 0)',
618
                'database_backup'
619
            );
620
            if ($pending > 0) {
621
                echo prepareExchangedData(['error' => true, 'message' => 'A backup task is already pending/running'], 'encode');
622
                break;
623
            }
624
625
            DB::insert(
626
                prefixTable('background_tasks'),
627
                [
628
                    'created_at' => (string)$now,
629
                    'process_type' => 'database_backup',
630
                    'arguments' => json_encode(['output_dir' => $dir, 'source' => 'scheduler'], JSON_UNESCAPED_SLASHES),
631
                    'is_in_progress' => 0,
632
                    'status' => 'new',
633
                ]
634
            );
635
636
            tpUpsertSettingsValue('bck_scheduled_last_run_at', (string)$now);
637
            tpUpsertSettingsValue('bck_scheduled_last_status', 'queued');
638
            tpUpsertSettingsValue('bck_scheduled_last_message', 'Task enqueued by UI');
639
640
            echo prepareExchangedData(['error' => false], 'encode');
641
            break;
642
643
        case 'onthefly_delete_backup':
644
            // Delete an on-the-fly backup file stored in <files> directory
645
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
646
                echo prepareExchangedData(
647
                    array(
648
                        'error' => true,
649
                        'message' => 'Not allowed',
650
                    ),
651
                    'encode'
652
                );
653
                break;
654
            }
655
656
            $dataReceived = prepareExchangedData($post_data, 'decode');
657
            $fileToDelete = isset($dataReceived['file']) === true ? (string)$dataReceived['file'] : '';
658
            $bn = basename($fileToDelete);
659
660
            // Safety checks
661
            if ($bn === '' || strtolower(pathinfo($bn, PATHINFO_EXTENSION)) !== 'sql') {
662
                echo prepareExchangedData(
663
                    array(
664
                        'error' => true,
665
                        'message' => 'Invalid file name',
666
                    ),
667
                    'encode'
668
                );
669
                break;
670
            }
671
672
            // Never allow deleting scheduled backups or temp files
673
            if (strpos($bn, 'scheduled-') === 0 || strpos($bn, 'defuse_temp_') === 0 || strpos($bn, 'defuse_temp_restore_') === 0) {
674
                echo prepareExchangedData(
675
                    array(
676
                        'error' => true,
677
                        'message' => 'Not allowed on this file',
678
                    ),
679
                    'encode'
680
                );
681
                break;
682
            }
683
684
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
685
            $dir = rtrim($baseFilesDir, '/');
686
            $fullPath = $dir . '/' . $bn;
687
688
            if (file_exists($fullPath) === false) {
689
                echo prepareExchangedData(
690
                    array(
691
                        'error' => true,
692
                        'message' => 'File not found',
693
                    ),
694
                    'encode'
695
                );
696
                break;
697
            }
698
699
            // Delete
700
            $ok = @unlink($fullPath);
701
702
            if ($ok !== true) {
703
                echo prepareExchangedData(
704
                    array(
705
                        'error' => true,
706
                        'message' => 'Unable to delete file',
707
                    ),
708
                    'encode'
709
                );
710
                break;
711
            }
712
713
            echo prepareExchangedData(
714
                array(
715
                    'error' => false,
716
                    'message' => 'Deleted',
717
                    'file' => $bn,
718
                ),
719
                'encode'
720
            );
721
            break;
722
723
        case 'onthefly_list_backups':
724
            // List on-the-fly backup files stored directly in <files> directory (not in /backups for scheduled)
725
            if ($post_key !== $session->get('key') || (int)$session->get('user-admin') !== 1) {
726
                echo prepareExchangedData(
727
                    array(
728
                        'error' => true,
729
                        'message' => 'Not allowed',
730
                    ),
731
                    'encode'
732
                );
733
                break;
734
            }
735
736
            $baseFilesDir = (string)($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
737
            $dir = rtrim($baseFilesDir, '/');
738
739
            $files = array();
740
            $paths = glob($dir . '/*.sql');
741
            if ($paths === false) {
742
                $paths = array();
743
            }
744
745
            // Ensure we have a temporary key for downloadFile.php
746
            $keyTmp = (string) $session->get('user-key_tmp');
747
            if ($keyTmp === '') {
748
                $keyTmp = GenerateCryptKey(16, false, true, true, false, true);
749
                $session->set('user-key_tmp', $keyTmp);
750
            }
751
752
            foreach ($paths as $fp) {
753
                $bn = basename($fp);
754
755
                // Skip scheduled backups and temporary files
756
                if (strpos($bn, 'scheduled-') === 0) {
757
                    continue;
758
                }
759
                if (strpos($bn, 'defuse_temp_') === 0 || strpos($bn, 'defuse_temp_restore_') === 0) {
760
                    continue;
761
                }
762
763
                $files[] = array(
764
                    'name' => $bn,
765
                    'size_bytes' => (int)@filesize($fp),
766
                    'mtime' => (int)@filemtime($fp),
767
                    'download' => 'sources/downloadFile.php?name=' . urlencode($bn) .
768
                        '&action=backup&file=' . urlencode($bn) .
769
                        '&type=sql&key=' . $session->get('key') .
770
                        '&key_tmp=' . $session->get('user-key_tmp') .
771
                        '&pathIsFiles=1',
772
                );
773
            }
774
775
            usort($files, static function ($a, $b) {
776
                return ($b['mtime'] ?? 0) <=> ($a['mtime'] ?? 0);
777
            });
778
779
            echo prepareExchangedData(
780
                array(
781
                    'error' => false,
782
                    'dir' => $dir,
783
                    'files' => $files,
784
                ),
785
                'encode'
786
            );
787
            break;
788
789
        case 'onthefly_restore':
790
            // Check KEY
791
            if ($post_key !== $session->get('key')) {
792
                echo prepareExchangedData(
793
                    array(
794
                        'error' => true,
795
                        'message' => $lang->get('key_is_not_correct'),
796
                    ),
797
                    'encode'
798
                );
799
                break;
800
            } elseif ($session->get('user-admin') === 0) {
801
                echo prepareExchangedData(
802
                    array(
803
                        'error' => true,
804
                        'message' => $lang->get('error_not_allowed_to'),
805
                    ),
806
                    'encode'
807
                );
808
                break;
809
            }
810
        
811
            // Decrypt and retrieve data in JSON format
812
            $dataReceived = prepareExchangedData(
813
                $post_data,
814
                'decode'
815
            );
816
        
817
            // Prepare variables (safe defaults for both upload-restore and serverFile-restore)
818
            $post_encryptionKey = filter_var(($dataReceived['encryptionKey'] ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
819
820
            // Optional override key (mainly for scheduled restores in case of migration)
821
            // This MUST NOT be auto-filled from the on-the-fly key; it is only used when explicitly provided.
822
            $post_overrideKey = filter_var(($dataReceived['overrideKey'] ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
823
824
            $post_backupFile = filter_var(($dataReceived['backupFile'] ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
825
            $post_clearFilename = filter_var(($dataReceived['clearFilename'] ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
826
827
            $post_serverScope = filter_var(($dataReceived['serverScope'] ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
828
            $post_serverFile  = filter_var(($dataReceived['serverFile']  ?? ''), FILTER_SANITIZE_FULL_SPECIAL_CHARS);
829
830
            // Scheduled backups must always be decrypted with the instance key (server-side).
831
            // Ignore any key coming from the UI to avoid mismatches.
832
            if ($post_serverScope === 'scheduled') {
833
                $post_encryptionKey = '';
834
            }
835
            // Ensure all strings we send back through prepareExchangedData() are JSON-safe.
836
            // This avoids PHP "malformed UTF-8" warnings when restore errors contain binary/latin1 bytes.
837
            $tpSafeJsonString = static function ($value): string {
838
                if ($value === null) {
839
                    return '';
840
                }
841
                if (is_bool($value)) {
842
                    return $value ? '1' : '0';
843
                }
844
                if (is_scalar($value) === false) {
845
                    $value = print_r($value, true);
846
                }
847
                $str = (string) $value;
848
849
                // If the string isn't valid UTF-8, return a hex dump instead (ASCII-only, safe for JSON).
850
                $isUtf8 = false;
851
                if (function_exists('mb_check_encoding')) {
852
                    $isUtf8 = mb_check_encoding($str, 'UTF-8');
853
                } else {
854
                    $isUtf8 = (@preg_match('//u', $str) === 1);
855
                }
856
                if ($isUtf8 === false) {
857
                    return '[hex]' . bin2hex($str);
858
                }
859
860
                // Strip ASCII control chars that could pollute JSON.
861
                $str = preg_replace("/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/", '', $str) ?? $str;
862
                return $str;
863
            };
864
865
866
            // Chunked upload fields (can be absent when restoring from an existing server file)
867
            $post_offset = (int) ($dataReceived['offset'] ?? 0);
868
            $post_totalSize = (int) ($dataReceived['totalSize'] ?? 0);
869
// Restore session + concurrency lock management.
870
// - We keep a token in session to allow chunked restore even while DB is being replaced.
871
// - We also block starting a second restore in the same session (double click / 2 tabs).
872
$clearRestoreState = static function ($session): void {
873
                $tmp = (string) ($session->get('restore-temp-file') ?? '');
874
                if ($tmp !== '' && file_exists($tmp) === true && strpos(basename($tmp), 'defuse_temp_restore_') === 0) {
875
                    @unlink($tmp);
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

875
                    /** @scrutinizer ignore-unhandled */ @unlink($tmp);

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...
876
                }
877
                $session->set('restore-temp-file', '');
878
    $session->set('restore-token', '');
879
    $session->set('restore-settings', []);
880
    $session->set('restore-context', []);
881
    $session->set('restore-in-progress', false);
882
    $session->set('restore-in-progress-ts', 0);
883
    $session->set('restore-start-ts', 0);
884
};
885
886
$nowTs = time();
887
$inProgress = (bool) ($session->get('restore-in-progress') ?? false);
888
$lastTs = (int) ($session->get('restore-in-progress-ts') ?? 0);
889
890
// Auto-release stale lock (e.g. client crashed / browser closed)
891
if ($inProgress === true && $lastTs > 0 && ($nowTs - $lastTs) > 3600) {
892
    $clearRestoreState($session);
893
    $inProgress = false;
894
    $lastTs = 0;
895
}
896
897
$sessionRestoreToken = (string) ($session->get('restore-token') ?? '');
898
$isStartRestore = (empty($post_clearFilename) === true && (int) $post_offset === 0);
899
900
if ($isStartRestore === true) {
901
    if ($inProgress === true && $sessionRestoreToken !== '') {
902
        echo prepareExchangedData(
903
            array(
904
                'error' => true,
905
                'message' => 'A restore is already in progress in this session. Please wait for it to complete (or logout/login to reset).',
906
            ),
907
            'encode'
908
        );
909
        break;
910
    }
911
912
    $sessionRestoreToken = bin2hex(random_bytes(16));
913
    $session->set('restore-token', $sessionRestoreToken);
914
    $session->set('restore-settings', $SETTINGS);
915
    $session->set('restore-in-progress', true);
916
    $session->set('restore-in-progress-ts', $nowTs);
917
    $session->set('restore-start-ts', $nowTs);
918
    $session->set('restore-context', []);
919
} else {
920
    // Restore continuation must provide the correct token
921
    if ($restoreToken === '' || $sessionRestoreToken === '' || !hash_equals($sessionRestoreToken, $restoreToken)) {
922
        echo prepareExchangedData(
923
            array(
924
                'error' => true,
925
                'message' => 'Restore session expired. Please restart the restore process.',
926
            ),
927
            'encode'
928
        );
929
        break;
930
    }
931
932
    // Update activity timestamp (keeps the lock alive)
933
    $session->set('restore-in-progress', true);
934
    $session->set('restore-in-progress-ts', $nowTs);
935
    if ((int) ($session->get('restore-start-ts') ?? 0) === 0) {
936
        $session->set('restore-start-ts', $nowTs);
937
    }
938
}
939
940
$batchSize = 500;
941
            $errors = array(); // Collect potential errors
942
        
943
            // Check if the offset is greater than the total size
944
            if ($post_offset > 0 && $post_totalSize > 0 && $post_offset >= $post_totalSize) {
945
                // Defensive: if client asks to continue beyond end, consider restore finished and release lock.
946
                if (is_string($post_clearFilename) && $post_clearFilename !== '' && file_exists($post_clearFilename) === true
947
                    && strpos(basename($post_clearFilename), 'defuse_temp_restore_') === 0) {
948
                    @unlink($post_clearFilename);
949
                }
950
                $clearRestoreState($session);
951
952
                echo prepareExchangedData(
953
                    array(
954
                        'error' => false,
955
                        'message' => 'operation_finished',
956
                        'finished' => true,
957
                        'restore_token' => $sessionRestoreToken,
958
                    ),
959
                    'encode'
960
                );
961
                break;
962
            }
963
            
964
            // Log debug information if in development mode
965
            if (defined('WIP') && WIP === true) {
966
                error_log('DEBUG: Offset -> '.$post_offset.'/'.$post_totalSize.' | File -> '.$post_clearFilename);
967
            }
968
        
969
            include_once $SETTINGS['cpassman_dir'] . '/sources/main.functions.php';
970
        
971
            include_once $SETTINGS['cpassman_dir'] . '/sources/main.functions.php';
972
973
            /*
974
             * Restore workflow
975
             * - 1st call (clearFilename empty): locate encrypted backup, decrypt to a temp file, return its path in clearFilename
976
             * - next calls: reuse clearFilename as the decrypted file path and continue reading from offset
977
             */
978
979
            if (empty($post_clearFilename) === true) {
980
                // Default behavior: uploaded on-the-fly backup file (stored in teampass_misc) is removed after decrypt/restore.
981
                // New behavior: user can select an existing encrypted backup file already present on server (scheduled or on-the-fly stored file).
982
                $deleteEncryptedAfterDecrypt = true;
983
984
                $bn = '';
985
                $serverPath = '';
986
                $legacyOperationId = null;
987
988
                // NEW: restore from an existing server file (scheduled or on-the-fly stored file)
989
                if (!empty($post_serverFile)) {
990
                    $deleteEncryptedAfterDecrypt = false;
991
992
                    $bn = basename((string) $post_serverFile);
993
994
                    // Safety: allow only *.sql name
995
                    if ($bn === '' || strtolower(pathinfo($bn, PATHINFO_EXTENSION)) !== 'sql') {
996
                        $clearRestoreState($session);
997
                        echo prepareExchangedData(
998
                            array('error' => true, 'message' => 'Invalid serverFile'),
999
                            'encode'
1000
                        );
1001
                        break;
1002
                    }
1003
1004
                    $baseDir = rtrim((string) $SETTINGS['path_to_files_folder'], '/');
1005
1006
                    // Scheduled backups are stored in configured output directory
1007
                    if ($post_serverScope === 'scheduled') {
1008
                        $baseFilesDir = (string) ($SETTINGS['path_to_files_folder'] ?? (__DIR__ . '/../files'));
1009
                        $dir = (string) tpGetSettingsValue('bck_scheduled_output_dir', rtrim($baseFilesDir, '/') . '/backups');
1010
                        $baseDir = rtrim($dir, '/');
1011
                    }
1012
1013
                    $serverPath = $baseDir . '/' . $bn;
1014
1015
                    if (file_exists($serverPath) === false) {
1016
                        try {
1017
                            logEvents(
1018
                                $SETTINGS,
1019
                                'admin_action',
1020
                                'dataBase restore failed (file not found)',
1021
                                (string) $session->get('user-id'),
1022
                                $session->get('user-login')
1023
                            );
1024
                        } catch (Throwable $ignored) {
1025
                            // ignore logging errors
1026
                        }
1027
                        $clearRestoreState($session);
1028
                        echo prepareExchangedData(
1029
                            array('error' => true, 'message' => 'Backup file not found on server'),
1030
                            'encode'
1031
                        );
1032
                        break;
1033
                    }
1034
                
1035
// Log restore start once (best effort)
1036
if ($isStartRestore === true) {
1037
    $sizeBytes = (int) @filesize($serverPath);
1038
    $session->set(
1039
        'restore-context',
1040
        array(
1041
            'scope' => $post_serverScope,
1042
            'backup' => $bn,
1043
            'size_bytes' => $sizeBytes,
1044
        )
1045
    );
1046
1047
    try {
1048
        $msg = 'dataBase restore started (scope=' . $post_serverScope . ', file=' . $bn . ')';
1049
        logEvents(
1050
            $SETTINGS,
1051
            'admin_action',
1052
            $msg,
1053
            (string) $session->get('user-id'),
1054
            $session->get('user-login')
1055
        );
1056
    } catch (Throwable $ignored) {
1057
        // ignore logging errors during restore
1058
    }
1059
}
1060
1061
} else {
1062
                    // LEGACY: restore from uploaded on-the-fly backup identified by its misc.increment_id
1063
                    if (empty($post_backupFile) === true || ctype_digit((string) $post_backupFile) === false) {
1064
                        echo prepareExchangedData(
1065
                            array('error' => true, 'message' => 'No backup selected'),
1066
                            'encode'
1067
                        );
1068
                        break;
1069
                    }
1070
1071
                    $legacyOperationId = (int) $post_backupFile;
1072
1073
                    // Find filename from DB (misc)
1074
                    $data = DB::queryFirstRow(
1075
                        'SELECT valeur FROM ' . prefixTable('misc') . ' WHERE increment_id = %i LIMIT 1',
1076
                        $legacyOperationId
1077
                    );
1078
1079
                    if (empty($data['valeur'])) {
1080
                        try {
1081
                            logEvents(
1082
                                $SETTINGS,
1083
                                'admin_action',
1084
                                'dataBase restore failed (missing misc entry)',
1085
                                (string) $session->get('user-id'),
1086
                                $session->get('user-login')
1087
                            );
1088
                        } catch (Throwable $ignored) {
1089
                            // ignore logging errors
1090
                        }
1091
                        $clearRestoreState($session);
1092
                        echo prepareExchangedData(
1093
                            array('error' => true, 'message' => 'Backup file not found in database'),
1094
                            'encode'
1095
                        );
1096
                        break;
1097
                    }
1098
1099
                    $bn = safeString($data['valeur']);
1100
                    $serverPath = rtrim((string) $SETTINGS['path_to_files_folder'], '/') . '/' . $bn;
1101
1102
                    if (file_exists($serverPath) === false) {
1103
                        try {
1104
                            logEvents(
1105
                                $SETTINGS,
1106
                                'admin_action',
1107
                                'dataBase restore failed (file not found)',
1108
                                (string) $session->get('user-id'),
1109
                                $session->get('user-login')
1110
                            );
1111
                        } catch (Throwable $ignored) {
1112
                            // ignore logging errors
1113
                        }
1114
                        $clearRestoreState($session);
1115
                        echo prepareExchangedData(
1116
                            array('error' => true, 'message' => 'Backup file not found on server'),
1117
                            'encode'
1118
                        );
1119
                        break;
1120
                    }
1121
                }
1122
1123
                // Common checks
1124
                if ($post_serverScope !== 'scheduled' && empty($post_encryptionKey) === true) {
1125
                    echo prepareExchangedData(
1126
                        array('error' => true, 'message' => 'Missing encryption key'),
1127
                        'encode'
1128
                    );
1129
                    break;
1130
                }
1131
1132
                // Decrypt to a dedicated temp file (unique)
1133
                $tmpDecrypted = rtrim((string) $SETTINGS['path_to_files_folder'], '/')
1134
                    . '/defuse_temp_restore_' . (int) $session->get('user-id') . '_' . time() . '_' . $bn;
1135
1136
                // Build the list of keys we can try to decrypt with.
1137
                // - on-the-fly: uses the key provided by the UI
1138
                // - scheduled: uses the instance key (stored in bck_script_passkey)
1139
                $keysToTry = [];
1140
                if ($post_serverScope === 'scheduled') {
1141
                    // Allow an explicit override key (migration use-case)
1142
                    if (!empty($post_overrideKey)) {
1143
                        $keysToTry[] = (string) $post_overrideKey;
1144
                    }
1145
                    if (!empty($SETTINGS['bck_script_passkey'] ?? '')) {
1146
                        $rawInstanceKey = (string) $SETTINGS['bck_script_passkey'];
1147
                        $tmp = cryption($rawInstanceKey, '', 'decrypt', $SETTINGS);
1148
                        $decInstanceKey = isset($tmp['string']) ? (string) $tmp['string'] : '';
1149
1150
                        if ($decInstanceKey !== '') {
1151
                            $keysToTry[] = $decInstanceKey;
1152
                        }
1153
                        if ($rawInstanceKey !== '' && $rawInstanceKey !== $decInstanceKey) {
1154
                            $keysToTry[] = $rawInstanceKey;
1155
                        }
1156
                    }
1157
                } else {
1158
                    if ($post_encryptionKey !== '') {
1159
                        $keysToTry[] = (string) $post_encryptionKey;
1160
                    }
1161
                }
1162
1163
                // Ensure we have at least one key
1164
                $keysToTry = array_values(array_unique(array_filter($keysToTry, static fn ($v) => $v !== '')));
1165
                if (empty($keysToTry)) {
1166
                    echo prepareExchangedData(
1167
                        array('error' => true, 'message' => 'Missing encryption key'),
1168
                        'encode'
1169
                    );
1170
                    break;
1171
                }
1172
1173
                // Try to decrypt with the available keys (some environments store bck_script_passkey encrypted)
1174
                $decRet = tpDefuseDecryptWithCandidates($serverPath, $tmpDecrypted, $keysToTry, $SETTINGS);
1175
                if (!empty($decRet['success']) === false) {
1176
                    @unlink($tmpDecrypted);
1177
                    try {
1178
                        logEvents(
1179
                            $SETTINGS,
1180
                            'admin_action',
1181
                            'dataBase restore failed (decrypt error)',
1182
                            (string) $session->get('user-id'),
1183
                            $session->get('user-login')
1184
                        );
1185
                    } catch (Throwable $ignored) {
1186
                        // ignore logging errors
1187
                    }
1188
                    $clearRestoreState($session);
1189
                    echo prepareExchangedData(
1190
                        array(
1191
                            'error' => true,
1192
                            'error_code' => 'DECRYPT_FAILED',
1193
                            'message' => 'Unable to decrypt backup: ' . $tpSafeJsonString((string) ($decRet['message'] ?? 'unknown error')),
1194
                        ),
1195
                        'encode'
1196
                    );
1197
                    break;
1198
                }
1199
1200
                if (!is_file($tmpDecrypted) || (int) @filesize($tmpDecrypted) === 0) {
1201
                    @unlink($tmpDecrypted);
1202
                    $clearRestoreState($session);
1203
                    echo prepareExchangedData(
1204
                        array('error' => true, 'message' => 'Decrypted backup is empty or unreadable'),
1205
                        'encode'
1206
                    );
1207
                    break;
1208
                }
1209
1210
                // Remove original encrypted file ONLY for legacy uploaded one-shot restore
1211
                if ($deleteEncryptedAfterDecrypt === true) {
1212
                    fileDelete($serverPath);
0 ignored issues
show
Bug introduced by
The call to fileDelete() has too few arguments starting with SETTINGS. ( Ignorable by Annotation )

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

1212
                    /** @scrutinizer ignore-call */ 
1213
                    fileDelete($serverPath);

This check compares calls to functions or methods with their respective definitions. If the call has less arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
1213
1214
                    // Delete operation record
1215
                    if ($legacyOperationId !== null) {
1216
                        DB::delete(
1217
                            prefixTable('misc'),
1218
                            'increment_id = %i',
1219
                            $legacyOperationId
1220
                        );
1221
                    }
1222
                }
1223
// From now, restore uses the decrypted temp file
1224
                $post_backupFile = $tmpDecrypted;
1225
                $session->set('restore-temp-file', $tmpDecrypted);
1226
                $post_clearFilename = $tmpDecrypted;
1227
            } else {
1228
                $post_backupFile = $post_clearFilename;
1229
                $session->set('restore-temp-file', $post_clearFilename);
1230
            }
1231
1232
            // Read sql file
1233
            $handle = fopen($post_backupFile, 'r');
1234
        
1235
            if ($handle === false) {
1236
                if (is_string($post_backupFile) && $post_backupFile !== '' && file_exists($post_backupFile) === true
1237
                    && strpos(basename($post_backupFile), 'defuse_temp_restore_') === 0) {
1238
                    @unlink($post_backupFile);
1239
                }
1240
                $clearRestoreState($session);
1241
                echo prepareExchangedData(
1242
                    array(
1243
                        'error' => true,
1244
                        'message' => 'Unable to open backup file.',
1245
                        'finished' => false,
1246
                        'restore_token' => $sessionRestoreToken,
1247
                    ),
1248
                    'encode'
1249
                );
1250
                break;
1251
            }
1252
        
1253
                        // Get total file size
1254
            if ((int) $post_totalSize === 0) {
1255
                $post_totalSize = filesize($post_backupFile);
1256
            }
1257
1258
            // Validate chunk parameters
1259
            if ((int) $post_totalSize <= 0) {
1260
                // Abort: we cannot safely run a chunked restore without a reliable size
1261
                $clearRestoreState($session);
1262
                echo prepareExchangedData(
1263
                    array(
1264
                        'error' => true,
1265
                        'message' => 'Invalid backup file size (0).',
1266
                        'finished' => true,
1267
                    ),
1268
                    'encode'
1269
                );
1270
                break;
1271
            }
1272
1273
            if ($post_offset < 0) {
1274
                $post_offset = 0;
1275
            }
1276
1277
            if ($post_offset > $post_totalSize) {
1278
                // Abort: invalid offset (prevents instant "success" due to EOF)
1279
                if (is_string($post_backupFile) && $post_backupFile !== '' && file_exists($post_backupFile) === true) {
1280
                    // If it is a temporary decrypted file, cleanup
1281
                    if (strpos(basename($post_backupFile), 'defuse_temp_restore_') === 0) {
1282
                        @unlink($post_backupFile);
1283
                    }
1284
                }
1285
                $clearRestoreState($session);
1286
                echo prepareExchangedData(
1287
                    array(
1288
                        'error' => true,
1289
                        'message' => 'Invalid restore offset.',
1290
                        'finished' => true,
1291
                    ),
1292
                    'encode'
1293
                );
1294
                break;
1295
            }
1296
1297
            // Move the file pointer to the current offset
1298
            fseek($handle, $post_offset);
1299
            $query = '';
1300
            $executedQueries = 0;
1301
            $inMultiLineComment = false;
1302
            
1303
            try {
1304
                // Start transaction to ensure database consistency
1305
                DB::startTransaction();
1306
                DB::query("SET FOREIGN_KEY_CHECKS = 0");
1307
                DB::query("SET UNIQUE_CHECKS = 0");
1308
                
1309
                while (!feof($handle) && $executedQueries < $batchSize) {
1310
                    $line = fgets($handle);
1311
                    
1312
                    // Check if not false
1313
                    if ($line !== false) {
1314
                        $trimmedLine = trim($line);
1315
                        
1316
                        // Skip empty lines or comments
1317
                        if (empty($trimmedLine) || 
1318
                            (strpos($trimmedLine, '--') === 0) || 
1319
                            (strpos($trimmedLine, '#') === 0)) {
1320
                            continue;
1321
                        }
1322
                        
1323
                        // Handle multi-line comments
1324
                        if (strpos($trimmedLine, '/*') === 0 && strpos($trimmedLine, '*/') === false) {
1325
                            $inMultiLineComment = true;
1326
                            continue;
1327
                        }
1328
                        
1329
                        if ($inMultiLineComment) {
1330
                            if (strpos($trimmedLine, '*/') !== false) {
1331
                                $inMultiLineComment = false;
1332
                            }
1333
                            continue;
1334
                        }
1335
                        
1336
                        // Add line to current query
1337
                        $query .= $line;
1338
                        
1339
                        // Execute if this is the end of a statement
1340
                        if (substr($trimmedLine, -1) === ';') {
1341
                            try {
1342
                                DB::query($query);
1343
                                $executedQueries++;
1344
                            } catch (Exception $e) {
1345
                                $snippet = substr($query, 0, 120);
1346
                                $snippet = $tpSafeJsonString($snippet);
1347
                                $errors[] = 'Error executing query: ' . $tpSafeJsonString($e->getMessage()) . ' - Query: ' . $snippet . '...';
1348
                            }
1349
                            $query = '';
1350
                        }
1351
                    }
1352
                }
1353
1354
                // Set default settings back
1355
                DB::query("SET FOREIGN_KEY_CHECKS = 1");
1356
                DB::query("SET UNIQUE_CHECKS = 1");
1357
                
1358
                // Commit the transaction if no errors
1359
                if (empty($errors)) {
1360
                    DB::commit();
1361
                } else {
1362
                    DB::rollback();
1363
                }
1364
            } catch (Exception $e) {
1365
                try {
1366
                    DB::query("SET FOREIGN_KEY_CHECKS = 1");
1367
                    DB::query("SET UNIQUE_CHECKS = 1");
1368
                } catch (Exception $ignored) {
1369
                    // Ignore further exceptions
1370
                }
1371
                // Rollback transaction on any exception
1372
                DB::rollback();
1373
                $errors[] = 'Transaction failed: ' . $tpSafeJsonString($e->getMessage());
1374
            }
1375
        
1376
            // Calculate the new offset
1377
            $newOffset = ftell($handle);
1378
        
1379
            // Check if the end of the file has been reached
1380
            $isEndOfFile = feof($handle);
1381
            fclose($handle);
1382
        
1383
            // Handle errors if any
1384
if (!empty($errors)) {
1385
    // Abort restore: cleanup temp file and release session lock
1386
    if (is_string($post_backupFile) && $post_backupFile !== '' && file_exists($post_backupFile) === true
1387
        && strpos(basename($post_backupFile), 'defuse_temp_restore_') === 0) {
1388
        @unlink($post_backupFile);
1389
    }
1390
1391
    $tokenForResponse = $sessionRestoreToken;
1392
1393
    // Best-effort log
1394
    try {
1395
        $ctx = $session->get('restore-context');
1396
        $scope = is_array($ctx) ? (string) ($ctx['scope'] ?? '') : '';
1397
        logEvents(
1398
            $SETTINGS,
1399
            'admin_action',
1400
            'dataBase restore failed' . ($scope !== '' ? ' (scope=' . $scope . ')' : ''),
1401
            (string) $session->get('user-id'),
1402
            $session->get('user-login')
1403
        );
1404
    } catch (Throwable $ignored) {
1405
        // ignore logging errors during restore
1406
    }
1407
1408
    $clearRestoreState($session);
1409
1410
    echo prepareExchangedData(
1411
        array(
1412
            'error' => true,
1413
            'message' => 'Errors occurred during import: ' . implode('; ', ($post_serverScope === 'scheduled' ? array_map($tpSafeJsonString, $errors) : $errors)),
1414
            'newOffset' => $newOffset,
1415
            'totalSize' => $post_totalSize,
1416
            'clearFilename' => $post_backupFile,
1417
            'finished' => true,
1418
            'restore_token' => $tokenForResponse,
1419
        ),
1420
        'encode'
1421
    );
1422
    break;
1423
}
1424
1425
// Determine if restore is complete
1426
            $finished = ($isEndOfFile === true) || ($post_totalSize > 0 && $newOffset >= $post_totalSize);
1427
1428
            // Respond with the new offset
1429
            echo prepareExchangedData(
1430
                array(
1431
                    'error' => false,
1432
                    'newOffset' => $newOffset,
1433
                    'totalSize' => $post_totalSize,
1434
                    'clearFilename' => $post_backupFile,
1435
                    'finished' => $finished,
1436
                        'restore_token' => $sessionRestoreToken,
1437
                ),
1438
                'encode'
1439
            );
1440
1441
            // Check if the end of the file has been reached to delete the file
1442
            if ($finished) {
1443
                if (defined('WIP') && WIP === true) {
1444
                    error_log('DEBUG: End of file reached. Deleting file '.$post_backupFile);
1445
                }
1446
1447
                if (is_string($post_backupFile) && $post_backupFile !== '' && file_exists($post_backupFile) === true) {
1448
                    @unlink($post_backupFile);
1449
                }
1450
// Finalize: clear lock/session state and log duration (best effort)
1451
$ctx = $session->get('restore-context');
1452
$scope = is_array($ctx) ? (string) ($ctx['scope'] ?? '') : '';
1453
$fileLabel = is_array($ctx) ? (string) ($ctx['backup'] ?? '') : '';
1454
$startTs = (int) ($session->get('restore-start-ts') ?? 0);
1455
$duration = ($startTs > 0) ? (time() - $startTs) : 0;
1456
1457
$clearRestoreState($session);
1458
1459
try {
1460
    $msg = 'dataBase restore completed';
1461
    if ($scope !== '' || $fileLabel !== '' || $duration > 0) {
1462
        $parts = array();
1463
        if ($scope !== '') {
1464
            $parts[] = 'scope=' . $scope;
1465
        }
1466
        if ($fileLabel !== '') {
1467
            $parts[] = 'file=' . $fileLabel;
1468
        }
1469
        if ($duration > 0) {
1470
            $parts[] = 'duration=' . $duration . 's';
1471
        }
1472
        $msg .= ' (' . implode(', ', $parts) . ')';
1473
    }
1474
1475
    logEvents(
1476
        $SETTINGS,
1477
        'admin_action',
1478
        $msg,
1479
        (string) $session->get('user-id'),
1480
        $session->get('user-login')
1481
    );
1482
} catch (Throwable $ignored) {
1483
    // ignore logging errors during restore
1484
}
1485
1486
            }
1487
            break;
1488
    }
1489
}
1490