Issues (48)

Security Analysis    no vulnerabilities found

This project does not seem to handle request data directly as such no vulnerable execution paths were found.

  File Inclusion
File Inclusion enables an attacker to inject custom files into PHP's file loading mechanism, either explicitly passed to include, or for example via PHP's auto-loading mechanism.
  Regex Injection
Regex Injection enables an attacker to execute arbitrary code in your PHP process.
  SQL Injection
SQL Injection enables an attacker to execute arbitrary SQL code on your database server gaining access to user data, or manipulating user data.
  Response Splitting
Response Splitting can be used to send arbitrary responses.
  File Manipulation
File Manipulation enables an attacker to write custom data to files. This potentially leads to injection of arbitrary code on the server.
  Object Injection
Object Injection enables an attacker to inject an object into PHP code, and can lead to arbitrary code execution, file exposure, or file manipulation attacks.
  File Exposure
File Exposure allows an attacker to gain access to local files that he should not be able to access. These files can for example include database credentials, or other configuration files.
  XML Injection
XML Injection enables an attacker to read files on your local filesystem including configuration files, or can be abused to freeze your web-server process.
  Code Injection
Code Injection enables an attacker to execute arbitrary code on the server.
  Variable Injection
Variable Injection enables an attacker to overwrite program variables with custom data, and can lead to further vulnerabilities.
  XPath Injection
XPath Injection enables an attacker to modify the parts of XML document that are read. If that XML document is for example used for authentication, this can lead to further vulnerabilities similar to SQL Injection.
  Other Vulnerability
This category comprises other attack vectors such as manipulating the PHP runtime, loading custom extensions, freezing the runtime, or similar.
  Command Injection
Command Injection enables an attacker to inject a shell command that is execute with the privileges of the web-server. This can be used to expose sensitive data, or gain access of your server.
  LDAP Injection
LDAP Injection enables an attacker to inject LDAP statements potentially granting permission to run unauthorized queries, or modify content inside the LDAP tree.
  Cross-Site Scripting
Cross-Site Scripting enables an attacker to inject code into the response of a web-request that is viewed by other users. It can for example be used to bypass access controls, or even to take over other users' accounts.
  Header Injection
Unfortunately, the security analysis is currently not available for your project. If you are a non-commercial open-source project, please contact support to gain access.

scripts/restore.php (6 issues)

1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * Teampass - a collaborative passwords manager.
7
 * ---
8
 * This file is part of the TeamPass project.
9
 * 
10
 * TeamPass is free software: you can redistribute it and/or modify it
11
 * under the terms of the GNU General Public License as published by
12
 * the Free Software Foundation, version 3 of the License.
13
 * 
14
 * TeamPass is distributed in the hope that it will be useful,
15
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17
 * GNU General Public License for more details.
18
 * 
19
 * You should have received a copy of the GNU General Public License
20
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
21
 * 
22
 * Certain components of this file may be under different licenses. For
23
 * details, see the `licenses` directory or individual file headers.
24
 * ---
25
 * @file      restore.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\ConfigManager\ConfigManager;
33
34
require_once __DIR__ . '/../sources/main.functions.php';
35
require_once __DIR__ . '/../sources/backup.functions.php';
36
37
loadClasses('DB');
38
39
function tpCliOut(string $msg): void
40
{
41
    fwrite(STDOUT, $msg . PHP_EOL);
42
}
43
44
function tpCliErr(string $msg): void
45
{
46
    fwrite(STDERR, $msg . PHP_EOL);
47
}
48
49
function tpCliHelp(): void
50
{
51
    tpCliOut('Usage: php scripts/restore.php --file "/path/to/backup.sql" --auth-token "TOKEN" [--force-disconnect]');
52
}
53
54
function tpCliIsInteractive(): bool
55
{
56
    try {
57
        if (function_exists('stream_isatty')) {
58
            return @stream_isatty(STDERR);
59
        }
60
        if (function_exists('posix_isatty')) {
61
            return @posix_isatty(STDERR);
62
        }
63
    } catch (Throwable $e) {
64
        // ignore
65
    }
66
    return false;
67
}
68
69
/**
70
 * Render a single-line progress bar on STDERR.
71
 * - Interactive terminals: updates in place (\r)
72
 * - Non-interactive: prints a line every 5% (and at 100%)
73
 */
74
function tpCliProgressBar(int $pct, int $totalExecuted, bool $interactive): void
75
{
76
    static $lastPct = -1;
77
    static $lastLen = 0;
78
    static $lastTs = 0.0;
79
80
    $pct = max(0, min(100, $pct));
81
82
    if (!$interactive) {
83
        if ($pct !== $lastPct && ($pct % 5 === 0 || $pct === 100)) {
84
            fwrite(STDERR, 'Progress: ' . $pct . '% (statements executed: ' . $totalExecuted . ')' . PHP_EOL);
85
            $lastPct = $pct;
86
        }
87
        return;
88
    }
89
90
    $now = microtime(true);
91
    if ($pct === $lastPct && ($now - $lastTs) < 0.20) {
92
        return;
93
    }
94
95
    $lastTs = $now;
96
    $lastPct = $pct;
97
98
    $width = 30;
99
    $filled = (int) round(($pct / 100) * $width);
100
    $bar = str_repeat('#', $filled) . str_repeat('-', $width - $filled);
101
    $msg = sprintf("\r[%s] %3d%% | statements: %d", $bar, $pct, $totalExecuted);
102
103
    $pad = max(0, $lastLen - strlen($msg));
104
    fwrite(STDERR, $msg . str_repeat(' ', $pad));
105
    $lastLen = strlen($msg);
106
107
    if ($pct === 100) {
108
        fwrite(STDERR, PHP_EOL);
109
    }
110
}
111
112
if (PHP_SAPI !== 'cli') {
113
    tpCliErr('ERROR: This script must be executed in CLI.');
114
    exit(2);
115
}
116
117
// Parse CLI options
118
$options = getopt('', ['file:', 'auth-token:', 'force-disconnect', 'help']);
119
if (isset($options['help']) || empty($options['file']) || empty($options['auth-token'])) {
120
    tpCliHelp();
121
    exit(isset($options['help']) ? 0 : 2);
122
}
123
124
$file = (string) $options['file'];
125
$authToken = (string) $options['auth-token'];
126
$forceDisconnect = array_key_exists('force-disconnect', $options);
127
128
$interactive = tpCliIsInteractive();
129
130
// Load settings
131
$configManager = new ConfigManager();
132
$SETTINGS = $configManager->getAllSettings();
133
134
// Logging
135
$filesDir = rtrim((string) ($SETTINGS['path_to_files_folder'] ?? sys_get_temp_dir()), '/');
136
$logDir = $filesDir . '/restore_cli_logs';
137
if (!is_dir($logDir)) {
138
    @mkdir($logDir, 0700, 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

138
    /** @scrutinizer ignore-unhandled */ @mkdir($logDir, 0700, 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...
139
}
140
$logFile = $logDir . '/restore_' . date('Ymd_His') . '.log';
141
142
$log = function (string $level, string $message) use ($logFile): void {
143
    $line = '[' . date('c') . '][' . $level . '] ' . $message . PHP_EOL;
144
    @file_put_contents($logFile, $line, FILE_APPEND);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for file_put_contents(). 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

144
    /** @scrutinizer ignore-unhandled */ @file_put_contents($logFile, $line, FILE_APPEND);

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...
145
    if ($level === 'ERROR') {
146
        tpCliErr($message);
147
    } else {
148
        tpCliOut($message);
149
    }
150
};
151
152
// Lock (prevent concurrent restores)
153
// Prefer the TeamPass files folder, but fallback to system temp if not writable for the current user.
154
$lockFile = '';
155
$lockHandle = false;
156
$lockCandidates = [
157
    $filesDir . '/restore_cli.lock',
158
    rtrim((string) sys_get_temp_dir(), '/\\') . '/teampass_restore_cli_' . substr(md5($filesDir), 0, 12) . '.lock',
159
];
160
161
foreach ($lockCandidates as $candidate) {
162
    $h = @fopen($candidate, 'c');
163
    if ($h !== false) {
164
        $lockFile = $candidate;
165
        $lockHandle = $h;
166
        break;
167
    }
168
}
169
170
if ($lockHandle === false) {
171
    $log('ERROR', 'Unable to open any lock file. Tried: ' . implode(', ', $lockCandidates));
172
    exit(20);
173
}
174
175
if (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
176
    $log('ERROR', 'Another restore seems to be in progress (lock: ' . $lockFile . ').');
177
    exit(20);
178
}
179
180
@ftruncate($lockHandle, 0);
181
@fwrite($lockHandle, (string) getmypid() . ' ' . date('c') . PHP_EOL);
182
183
// Normalize file path
184
$realFile = realpath($file);
185
if ($realFile !== false) {
186
    $file = $realFile;
187
}
188
189
if (!is_file($file)) {
190
    $log('ERROR', 'Backup file not found: ' . $file);
191
    exit(3);
192
}
193
194
$tokenHash = hash('sha256', $authToken);
195
196
// Token verification (DB)
197
$fetch = tpRestoreAuthorizationFetchByHash($tokenHash);
198
if (empty($fetch['success'])) {
199
    $log('ERROR', 'Authorization token not found or invalid.');
200
    exit(30);
201
}
202
203
$authId = (int) ($fetch['id'] ?? 0);
204
$payload = $fetch['payload'] ?? [];
205
if (!is_array($payload)) {
206
    $log('ERROR', 'Authorization payload is invalid.');
207
    exit(30);
208
}
209
210
$now = time();
211
$status = (string) ($payload['status'] ?? '');
212
$expiresAt = (int) ($payload['expires_at'] ?? 0);
213
$payloadFilePath = (string) (($payload['file']['path'] ?? ''));
214
215
if ($status !== 'pending') {
216
    $log('ERROR', 'Authorization token is not pending (status=' . $status . ').');
217
    exit(31);
218
}
219
220
if ($expiresAt > 0 && $expiresAt < $now) {
221
    $log('ERROR', 'Authorization token is expired.');
222
    exit(32);
223
}
224
225
// Ensure the token matches the file we are about to restore
226
$cmp1 = $payloadFilePath;
227
$cmp2 = $file;
228
$cmp1Real = ($cmp1 !== '') ? realpath($cmp1) : false;
229
if ($cmp1Real !== false) {
230
    $cmp1 = $cmp1Real;
231
}
232
$cmp2Real = realpath($cmp2);
233
if ($cmp2Real !== false) {
234
    $cmp2 = $cmp2Real;
235
}
236
237
if ($cmp1 === '' || $cmp1 !== $cmp2) {
238
    $log('ERROR', 'Authorization token does not match the provided --file path.');
239
    $log('ERROR', 'Expected: ' . $payloadFilePath);
240
    $log('ERROR', 'Provided: ' . $file);
241
    exit(33);
242
}
243
244
// Always disconnect the restore initiator right away (so the admin can safely run the CLI).
245
$initiatorId = (int) (($payload['initiator']['id'] ?? 0));
246
if ($initiatorId > 0) {
247
    try {
248
        DB::update(
249
            prefixTable('users'),
250
            [
251
                'timestamp' => '',
252
                'key_tempo' => '',
253
                'session_end' => '',
254
            ],
255
            'id = %i',
256
            $initiatorId
257
        );
258
        $log('INFO', 'Disconnected restore initiator (user id=' . $initiatorId . ').');
259
    } catch (Throwable $e) {
260
        $log('WARN', 'Unable to disconnect restore initiator: ' . $e->getMessage());
261
    }
262
}
263
264
// Connected users detection (web + API if possible)
265
$webUsers = [];
266
$apiUsers = [];
267
try {
268
    if ($initiatorId > 0) {
269
        $webUsers = DB::query(
270
            'SELECT id, login, name, lastname, session_end
271
             FROM ' . prefixTable('users') . '
272
             WHERE session_end >= %i AND id != %i',
273
            $now,
274
            $initiatorId
275
        );
276
    } else {
277
        $webUsers = DB::query(
278
            'SELECT id, login, name, lastname, session_end
279
             FROM ' . prefixTable('users') . '
280
             WHERE session_end >= %i',
281
            $now
282
        );
283
    }
284
} catch (Throwable $e) {
285
	// Ensure any in-place progress bar line is terminated before printing errors
286
	if ($interactive) {
287
	    @fwrite(STDERR, PHP_EOL);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for fwrite(). 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

287
	    /** @scrutinizer ignore-unhandled */ @fwrite(STDERR, PHP_EOL);

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...
288
	}
289
    $log('WARN', 'Unable to check web sessions: ' . $e->getMessage());
290
}
291
292
try {
293
    $apiTokenDuration = 3600;
294
    if (isset($SETTINGS['api_token_duration']) && is_numeric($SETTINGS['api_token_duration'])) {
295
        $apiTokenDuration = (int) $SETTINGS['api_token_duration'];
296
    }
297
    if ($apiTokenDuration <= 0) {
298
        $apiTokenDuration = 3600;
299
    }
300
    $apiConnectedAfter = $now - ($apiTokenDuration + 600);
301
302
    $apiUsers = DB::query(
303
        'SELECT u.id, u.login, u.name, u.lastname, api_conn.last_api_date AS last_api
304
         FROM ' . prefixTable('users') . ' u
305
         INNER JOIN (
306
            SELECT qui, MAX(date) AS last_api_date
307
            FROM ' . prefixTable('log_system') . '
308
            WHERE type = %s AND label = %s AND date >= %i
309
            GROUP BY qui
310
         ) api_conn ON api_conn.qui = u.id',
311
        'api',
312
        'user_connection',
313
        $apiConnectedAfter
314
    );
315
} catch (Throwable $e) {
316
    // Optional: ignore API checks if unavailable
317
    $log('WARN', 'Unable to check API activity: ' . $e->getMessage());
318
}
319
320
// Enforce disconnect policy for web sessions
321
if (!empty($webUsers) && $forceDisconnect === false) {
322
    $log('ERROR', 'Active web sessions detected. Re-run with --force-disconnect or disconnect users first.');
323
    foreach ($webUsers as $u) {
324
        $log('ERROR', 'WEB user connected: ' . ($u['login'] ?? '') . ' (' . ($u['name'] ?? '') . ' ' . ($u['lastname'] ?? '') . ')');
325
    }
326
    if (!empty($apiUsers)) {
327
        $log('WARN', 'API activity detected as well (best-effort detection):');
328
        foreach ($apiUsers as $u) {
329
            $log('WARN', 'API activity: ' . ($u['login'] ?? '') . ' (' . ($u['name'] ?? '') . ' ' . ($u['lastname'] ?? '') . ')');
330
        }
331
    }
332
    // IMPORTANT: keep token pending so the operator can retry after disconnecting users.
333
    $payload['last_error'] = 'active_web_sessions';
334
    $payload['last_error_at'] = time();
335
    tpRestoreAuthorizationUpdatePayload($authId, $payload);
336
337
    exit(10);
338
}
339
340
if (!empty($webUsers) && $forceDisconnect === true) {
341
    $log('INFO', 'Active web sessions detected: forcing disconnection.');
342
    try {
343
        DB::update(
344
            prefixTable('users'),
345
            [
346
                'timestamp' => '',
347
                'key_tempo' => '',
348
                'session_end' => '',
349
            ],
350
            'session_end >= %i',
351
            $now
352
        );
353
    } catch (Throwable $e) {
354
        $log('WARN', 'Failed to force-disconnect web users: ' . $e->getMessage());
355
    }
356
}
357
358
// From this point, we consider the restore authorized to start.
359
$payload['status'] = 'in_progress';
360
$payload['started_at'] = time();
361
tpRestoreAuthorizationUpdatePayload($authId, $payload);
362
363
if (!empty($apiUsers)) {
364
    $log('WARN', 'API activity detected (best-effort): those sessions may not be revocable. Proceeding.');
365
    foreach ($apiUsers as $u) {
366
        $log('WARN', 'API activity: ' . ($u['login'] ?? '') . ' (' . ($u['name'] ?? '') . ' ' . ($u['lastname'] ?? '') . ')');
367
    }
368
}
369
370
// Maintenance mode toggle (best-effort)
371
// Keep maintenance enabled at the end of the restore (same behavior as the legacy web restore),
372
// because the SQL dump may restore maintenance_mode to 0.
373
try {
374
    $affected = DB::update(
375
        prefixTable('misc'),
376
        [
377
            'valeur' => '1',
378
            'updated_at' => time(),
379
        ],
380
        'intitule = %s AND type = %s',
381
        'maintenance_mode',
382
        'admin'
383
    );
384
    if ($affected === 0) {
385
        DB::insert(
386
            prefixTable('misc'),
387
            [
388
                'intitule' => 'maintenance_mode',
389
                'type' => 'admin',
390
                'valeur' => '1',
391
                'updated_at' => time(),
392
            ]
393
        );
394
    }
395
} catch (Throwable $e) {
396
    // Fallback without updated_at
397
    try {
398
        $affected = DB::update(
399
            prefixTable('misc'),
400
            [
401
                'valeur' => '1',
402
            ],
403
            'intitule = %s AND type = %s',
404
            'maintenance_mode',
405
            'admin'
406
        );
407
        if ($affected === 0) {
408
            DB::insert(
409
                prefixTable('misc'),
410
                [
411
                    'intitule' => 'maintenance_mode',
412
                    'type' => 'admin',
413
                    'valeur' => '1',
414
                ]
415
            );
416
        }
417
    } catch (Throwable $e2) {
418
        $log('WARN', 'Unable to enable maintenance mode: ' . $e2->getMessage());
419
    }
420
}
421
422
$log('INFO', 'Starting restore from: ' . $file);
423
$log('INFO', 'Log file: ' . $logFile);
424
425
// Build decryption keys to try
426
$keysToTry = [];
427
428
// Secrets from payload (encrypted in DB)
429
try {
430
    $secrets = $payload['secrets'] ?? [];
431
    if (is_array($secrets)) {
432
        if (!empty($secrets['encryptionKey'])) {
433
            $tmp = cryption((string) $secrets['encryptionKey'], '', 'decrypt', $SETTINGS);
434
            $k = isset($tmp['string']) ? (string) $tmp['string'] : '';
435
            if ($k !== '') $keysToTry[] = $k;
436
        }
437
        if (!empty($secrets['overrideKey'])) {
438
            $tmp = cryption((string) $secrets['overrideKey'], '', 'decrypt', $SETTINGS);
439
            $k = isset($tmp['string']) ? (string) $tmp['string'] : '';
440
            if ($k !== '') $keysToTry[] = $k;
441
        }
442
    }
443
} catch (Throwable $e) {
444
    $log('WARN', 'Unable to decrypt stored secrets: ' . $e->getMessage());
445
}
446
447
// Instance key (scheduled backups)
448
try {
449
    if (!empty($SETTINGS['bck_script_passkey'] ?? '') === true) {
450
        $rawInstanceKey = (string) $SETTINGS['bck_script_passkey'];
451
        $tmp = cryption($rawInstanceKey, '', 'decrypt', $SETTINGS);
452
        $decInstanceKey = isset($tmp['string']) ? (string) $tmp['string'] : '';
453
454
        if ($decInstanceKey !== '') {
455
            $keysToTry[] = $decInstanceKey;
456
        }
457
        // Some environments may store bck_script_passkey already in clear
458
        if ($rawInstanceKey !== '' && $rawInstanceKey !== $decInstanceKey) {
459
            $keysToTry[] = $rawInstanceKey;
460
        }
461
    }
462
} catch (Throwable $e) {
463
    $log('WARN', 'Unable to decrypt instance backup key: ' . $e->getMessage());
464
}
465
466
$keysToTry = array_values(array_unique(array_filter($keysToTry, function ($v) { return $v !== ''; })));
467
468
// Decrypt backup file to temp SQL
469
// IMPORTANT: do NOT use tempnam() here. It creates the file and can break Defuse file decrypt.
470
$tmpRand = '';
471
try {
472
    $tmpRand = bin2hex(random_bytes(4));
473
} catch (Throwable $ignored) {
474
    $tmpRand = uniqid('', true);
475
}
476
477
$tmpSql = rtrim((string) sys_get_temp_dir(), '/\\') . '/defuse_temp_restore_' . getmypid() . '_' . time() . '_' . $tmpRand . '.sql';
478
479
$dec = tpDefuseDecryptWithCandidates($file, $tmpSql, $keysToTry, $SETTINGS);
480
if (empty($dec['success'])) {
481
    @unlink($tmpSql);
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

481
    /** @scrutinizer ignore-unhandled */ @unlink($tmpSql);

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...
482
    $log('ERROR', 'Decrypt failed: ' . (string) ($dec['message'] ?? ''));
483
    $payload['status'] = 'failed';
484
    $payload['finished_at'] = time();
485
    $payload['error'] = 'decrypt_failed';
486
    tpRestoreAuthorizationUpdatePayload($authId, $payload);
487
    exit(41);
488
}
489
490
$log('INFO', 'Decrypt OK. Importing SQL...');
491
492
// Restore SQL (chunked)
493
$totalSize = filesize($tmpSql);
494
if ($totalSize === false) {
495
    $totalSize = 0;
496
}
497
$offset = 0;
498
$batchSize = 500;
499
$totalExecuted = 0;
500
501
$handle = fopen($tmpSql, 'r');
502
if ($handle === false) {
503
    @unlink($tmpSql);
504
    $log('ERROR', 'Unable to open decrypted SQL file.');
505
    exit(42);
506
}
507
508
try {
509
    while (true) {
510
        if ($offset > 0) {
511
            fseek($handle, $offset);
512
        }
513
514
        $query = '';
515
        $executed = 0;
516
        $inMultiLineComment = false;
517
        $errors = [];
518
519
        try {
520
            DB::startTransaction();
521
            DB::query("SET FOREIGN_KEY_CHECKS = 0");
522
            DB::query("SET UNIQUE_CHECKS = 0");
523
524
            while (!feof($handle) && $executed < $batchSize) {
525
                $line = fgets($handle);
526
                if ($line === false) {
527
                    break;
528
                }
529
530
                $trimmedLine = trim($line);
531
532
                // Skip empty lines or comments
533
                if (
534
                    $trimmedLine === ''
535
                    || strpos($trimmedLine, '--') === 0
536
                    || strpos($trimmedLine, '#') === 0
537
                ) {
538
                    continue;
539
                }
540
541
                // Handle multi-line comments
542
                if (strpos($trimmedLine, '/*') === 0 && strpos($trimmedLine, '*/') === false) {
543
                    $inMultiLineComment = true;
544
                    continue;
545
                }
546
                if ($inMultiLineComment) {
547
                    if (strpos($trimmedLine, '*/') !== false) {
548
                        $inMultiLineComment = false;
549
                    }
550
                    continue;
551
                }
552
553
                $query .= $line;
554
555
                // Execute query if it ends with semicolon
556
                if (substr(rtrim($query), -1) === ';') {
557
                    try {
558
                        DB::query($query);
559
                        $executed++;
560
                        $totalExecuted++;
561
                    } catch (Throwable $e) {
562
                        $errors[] = $e->getMessage();
563
                        $log('ERROR', 'SQL error: ' . $e->getMessage());
564
                        // Stop early on first error
565
                        break;
566
                    }
567
                    $query = '';
568
                }
569
            }
570
571
            if (empty($errors)) {
572
                DB::commit();
573
            } else {
574
                DB::rollback();
575
            }
576
        } catch (Throwable $e) {
577
            try {
578
                DB::query("SET FOREIGN_KEY_CHECKS = 1");
579
                DB::query("SET UNIQUE_CHECKS = 1");
580
            } catch (Throwable $ignored) {
581
                // ignore
582
            }
583
            DB::rollback();
584
            $errors[] = $e->getMessage();
585
            $log('ERROR', 'Restore chunk failed: ' . $e->getMessage());
586
        } finally {
587
            try {
588
                DB::query("SET FOREIGN_KEY_CHECKS = 1");
589
                DB::query("SET UNIQUE_CHECKS = 1");
590
            } catch (Throwable $ignored) {
591
                // ignore
592
            }
593
        }
594
595
        $newOffset = ftell($handle);
596
        if ($newOffset === false) {
597
            $newOffset = $offset;
598
        }
599
600
601
	    // Progress (single-line bar on STDERR to avoid flooding the terminal)
602
	    if ($totalSize > 0) {
603
	        $pct = (int) floor(($newOffset / $totalSize) * 100);
604
	        if ($pct > 100) {
605
	            $pct = 100;
606
	        }
607
	        tpCliProgressBar($pct, $totalExecuted, $interactive);
608
609
	        // Log progress in file every 10%
610
	        static $lastLoggedPct = -1;
611
	        if ($pct !== $lastLoggedPct && ($pct % 10 === 0 || $pct === 100)) {
612
	            $lastLoggedPct = $pct;
613
	            @file_put_contents(
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for file_put_contents(). 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

613
	            /** @scrutinizer ignore-unhandled */ @file_put_contents(

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...
614
	                $logFile,
615
	                '[' . date('c') . '][INFO] Progress: ' . $pct . '% (statements executed: ' . $totalExecuted . ')' . PHP_EOL,
616
	                FILE_APPEND
617
	            );
618
	        }
619
	    }
620
621
        if (!empty($errors)) {
622
            throw new RuntimeException('SQL restore failed: ' . implode(' | ', $errors));
623
        }
624
625
        // End conditions
626
        if (feof($handle) || $newOffset >= $totalSize) {
627
            break;
628
        }
629
630
        // Continue with next offset
631
        $offset = $newOffset;
632
    }
633
634
    $log('INFO', 'SQL import completed. Total statements executed: ' . $totalExecuted);
635
636
    $payload['status'] = 'success';
637
    $payload['finished_at'] = time();
638
    tpRestoreAuthorizationUpdatePayload($authId, $payload);
639
640
    $exitCode = 0;
641
} catch (Throwable $e) {
642
    $payload['status'] = 'failed';
643
    $payload['finished_at'] = time();
644
    $payload['error'] = $e->getMessage();
645
    tpRestoreAuthorizationUpdatePayload($authId, $payload);
646
647
    $log('ERROR', 'Restore failed: ' . $e->getMessage());
648
    $exitCode = 50;
649
} finally {
650
    if (is_resource($handle)) {
651
        fclose($handle);
652
    }
653
    @unlink($tmpSql);
654
655
    // Re-force maintenance mode ON at the end (dump may have restored it to 0).
656
    try {
657
        DB::update(
658
            prefixTable('misc'),
659
            [
660
                'valeur' => '1',
661
                'updated_at' => time(),
662
            ],
663
            'intitule = %s AND type = %s',
664
            'maintenance_mode',
665
            'admin'
666
        );
667
    } catch (Throwable $e) {
668
        try {
669
            DB::update(
670
                prefixTable('misc'),
671
                [
672
                    'valeur' => '1',
673
                ],
674
                'intitule = %s AND type = %s',
675
                'maintenance_mode',
676
                'admin'
677
            );
678
        } catch (Throwable $ignored) {
679
            // ignore
680
        }
681
    }
682
683
    // Release lock
684
    try {
685
        flock($lockHandle, LOCK_UN);
686
    } catch (Throwable $ignored) {
687
        // ignore
688
    }
689
    @fclose($lockHandle);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for fclose(). 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

689
    /** @scrutinizer ignore-unhandled */ @fclose($lockHandle);

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...
690
}
691
692
exit($exitCode);
693