tpFormatBytes()   A
last analyzed

Complexity

Conditions 4
Paths 4

Size

Total Lines 14
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 8
c 1
b 0
f 0
nc 4
nop 1
dl 0
loc 14
rs 10
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
Bug introduced by
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