Passed
Push — master ( 87b47c...561fa9 )
by Nils
06:37
created

tpGetTpFilesVersion()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 3
c 1
b 0
f 0
nc 2
nop 0
dl 0
loc 6
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      backup.functions.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
/**
33
 * Teampass - Backup helper functions
34
 * This file provides reusable functions for database backup creation
35
 * (manual UI + scheduled/background tasks).
36
 */
37
38
if (!function_exists('tpCreateDatabaseBackup')) {
39
    /**
40
     * Create a Teampass database backup file (optionally encrypted) in files folder.
41
     *
42
     * @param array  $SETTINGS      Teampass settings array (must include path_to_files_folder)
43
     * @param string $encryptionKey Encryption key (Defuse). If empty => no encryption
44
     * @param array  $options       Optional:
45
     *                              - output_dir (string) default: $SETTINGS['path_to_files_folder']
46
     *                              - filename_prefix (string) default: '' (ex: 'scheduled-')
47
     *                              - chunk_rows (int) default: 1000
48
     *                              - flush_every_inserts (int) default: 200
49
     *                              - include_tables (array<string>) default: [] (empty => all)
50
     *                              - exclude_tables (array<string>) default: [] (empty => none)
51
     * @return array
52
     * @psalm-return array{success: bool, filename: string, filepath: string, encrypted: bool, size_bytes: int, message: string}
53
     */
54
    function tpCreateDatabaseBackup(array $SETTINGS, string $encryptionKey = '', array $options = []): array
55
    {
56
        // Ensure required dependencies are loaded
57
        $mainFunctionsPath = __DIR__ . '/main.functions.php';
58
        if ((!function_exists('GenerateCryptKey') || !function_exists('prefixTable')) && is_file($mainFunctionsPath)) {
59
            require_once $mainFunctionsPath;
60
        }
61
        if (function_exists('loadClasses') && !class_exists('DB')) {
62
            loadClasses('DB');
63
        }
64
65
        // Enable maintenance mode for the whole backup operation, then restore previous value at the end.
66
        // This is best-effort: a failure to toggle maintenance must not break the backup itself.
67
        /** @scrutinizer ignore-unused */
68
        $__tpMaintenanceGuard = new class() {
0 ignored issues
show
Unused Code introduced by
The assignment to $__tpMaintenanceGuard is dead and can be removed.
Loading history...
69
            private $prev = null;
70
            private $changed = false;
71
72
            public function __construct()
73
            {
74
                try {
75
                    $row = DB::queryFirstRow(
76
                        'SELECT valeur FROM ' . prefixTable('misc') . ' WHERE intitule=%s AND type=%s',
77
                        'maintenance_mode',
78
                        'admin'
79
                    );
80
81
                    if (is_array($row) && array_key_exists('valeur', $row)) {
82
                        $this->prev = (string) $row['valeur'];
83
                    }
84
85
                    // Only toggle if it was not already enabled
86
                    if ($this->prev !== '1') {
87
                        DB::update(
88
                            prefixTable('misc'),
89
                            array(
90
                                'valeur' => '1',
91
                                'updated_at' => time(),
92
                            ),
93
                            'intitule = %s AND type= %s',
94
                            'maintenance_mode',
95
                            'admin'
96
                        );
97
                        $this->changed = true;
98
                    }
99
                } catch (Throwable $ignored) {
100
                    // ignore
101
                }
102
            }
103
104
            public function __destruct()
105
            {
106
                if ($this->changed !== true) {
107
                    return;
108
                }
109
110
                try {
111
                    DB::update(
112
                        prefixTable('misc'),
113
                        array(
114
                            'valeur' => (string) ($this->prev ?? '0'),
115
                            'updated_at' => time(),
116
                        ),
117
                        'intitule = %s AND type= %s',
118
                        'maintenance_mode',
119
                        'admin'
120
                    );
121
                } catch (Throwable $ignored) {
122
                    // ignore
123
                }
124
            }
125
        };
126
127
        $outputDir = $options['output_dir'] ?? ($SETTINGS['path_to_files_folder'] ?? '');
128
        $prefix = (string)($options['filename_prefix'] ?? '');
129
        $chunkRows = (int)($options['chunk_rows'] ?? 1000);
130
        $flushEvery = (int)($options['flush_every_inserts'] ?? 200);
131
        $includeTables = $options['include_tables'] ?? [];
132
        $excludeTables = $options['exclude_tables'] ?? [];
133
134
        if ($outputDir === '' || !is_dir($outputDir) || !is_writable($outputDir)) {
135
            return [
136
                'success' => false,
137
                'filename' => '',
138
                'filepath' => '',
139
                'encrypted' => false,
140
                'size_bytes' => 0,
141
                'message' => 'Backup folder is not writable or not found: ' . $outputDir,
142
            ];
143
        }
144
145
        // Generate filename
146
        $token = function_exists('GenerateCryptKey')
147
            ? GenerateCryptKey(20, false, true, true, false, true)
148
            : bin2hex(random_bytes(10));
149
150
        // Schema level token in filename (used for compatibility checks during migrations)
151
$schemaLevel = '';
152
if (defined('UPGRADE_MIN_DATE')) {
153
    $schemaLevel = (string) UPGRADE_MIN_DATE;
154
}
155
if ($schemaLevel !== '' && preg_match('/^\d+$/', $schemaLevel) !== 1) {
156
    $schemaLevel = '';
157
}
158
$schemaSuffix = ($schemaLevel !== '') ? ('-sl' . $schemaLevel) : '';
159
$filename = $prefix . time() . '-' . $token . $schemaSuffix . '.sql';
160
        $filepath = rtrim($outputDir, '/') . '/' . $filename;
161
162
        $handle = @fopen($filepath, 'w+');
163
        if ($handle === false) {
164
            return [
165
                'success' => false,
166
                'filename' => $filename,
167
                'filepath' => $filepath,
168
                'encrypted' => false,
169
                'size_bytes' => 0,
170
                'message' => 'Could not create backup file: ' . $filepath,
171
            ];
172
        }
173
174
        $insertCount = 0;
175
176
        try {
177
            // Get all tables
178
            $tables = [];
179
            $result = DB::query('SHOW TABLES');
180
            foreach ($result as $row) {
181
                // SHOW TABLES returns key like 'Tables_in_<DB_NAME>'
182
                foreach ($row as $v) {
183
                    $tables[] = (string) $v;
184
                    break;
185
                }
186
            }
187
188
            // Filter tables if requested
189
            if (!empty($includeTables) && is_array($includeTables)) {
190
                $tables = array_values(array_intersect($tables, $includeTables));
191
            }
192
            if (!empty($excludeTables) && is_array($excludeTables)) {
193
                $tables = array_values(array_diff($tables, $excludeTables));
194
            }
195
196
            foreach ($tables as $tableName) {
197
                // Safety: only allow typical MySQL table identifiers
198
                if (!preg_match('/^[a-zA-Z0-9_]+$/', $tableName)) {
199
                    continue;
200
                }
201
202
                // Write drop and creation
203
                fwrite($handle, 'DROP TABLE IF EXISTS `' . $tableName . "`;\n");
204
205
                $row2 = DB::queryFirstRow('SHOW CREATE TABLE `' . $tableName . '`');
206
                if (!is_array($row2) || empty($row2['Create Table'])) {
207
                    // Skip table if structure cannot be fetched
208
                    fwrite($handle, "\n");
209
                    continue;
210
                }
211
212
                fwrite($handle, $row2['Create Table'] . ";\n\n");
213
214
                // Process table data in chunks to reduce memory usage
215
                $offset = 0;
216
                while (true) {
217
                    $rows = DB::query(
218
                        'SELECT * FROM `' . $tableName . '` LIMIT %i OFFSET %i',
219
                        $chunkRows,
220
                        $offset
221
                    );
222
223
                    if (empty($rows)) {
224
                        break;
225
                    }
226
227
                    foreach ($rows as $record) {
228
                        $values = [];
229
                        foreach ($record as $value) {
230
                            if ($value === null) {
231
                                $values[] = 'NULL';
232
                                continue;
233
                            }
234
235
                            // Force scalar/string
236
                            if (is_bool($value)) {
237
                                $value = $value ? '1' : '0';
238
                            } elseif (is_numeric($value)) {
239
                                // keep numeric as string but quoted (safe & consistent)
240
                                $value = (string) $value;
241
                            } else {
242
                                $value = (string) $value;
243
                            }
244
245
                            // Escape and keep newlines
246
                            $value = addslashes(preg_replace("/\n/", '\\n', $value));
247
                            $values[] = '"' . $value . '"';
248
                        }
249
250
                        $insertQuery = 'INSERT INTO `' . $tableName . '` VALUES(' . implode(',', $values) . ");\n";
251
                        fwrite($handle, $insertQuery);
252
253
                        $insertCount++;
254
                        if ($flushEvery > 0 && ($insertCount % $flushEvery) === 0) {
255
                            fflush($handle);
256
                        }
257
                    }
258
259
                    $offset += $chunkRows;
260
                    fflush($handle);
261
                }
262
263
                fwrite($handle, "\n\n");
264
                fflush($handle);
265
            }
266
        } catch (Throwable $e) {
267
            if (is_resource($handle)) {
268
                fclose($handle);
269
            }
270
271
            $errorMessage = 'Backup failed: ' . $e->getMessage();
272
273
            // Suppression sécurisée sans @
274
            if (file_exists($filepath)) {
275
                $deleted = unlink($filepath);
276
                if ($deleted === false) {
277
                    $errorMessage .= ' (Note: Temporary backup file could not be deleted from disk)';
278
                }
279
            }
280
281
            return [
282
                'success' => false,
283
                'filename' => $filename,
284
                'filepath' => $filepath,
285
                'encrypted' => false,
286
                'size_bytes' => 0,
287
                'message' => $errorMessage,
288
            ];
289
        }
290
291
        fclose($handle);
292
293
        // Encrypt the file if key provided
294
        $encrypted = false;
295
        if ($encryptionKey !== '') {
296
            $tmpPath = rtrim($outputDir, '/') . '/defuse_temp_' . $filename;
297
298
            if (!function_exists('prepareFileWithDefuse')) {
299
                if (file_exists($filepath)) {
300
                    unlink($filepath);
301
                }
302
                return [
303
                    'success' => false,
304
                    'filename' => $filename,
305
                    'filepath' => $filepath,
306
                    'encrypted' => false,
307
                    'size_bytes' => 0,
308
                    'message' => 'Missing prepareFileWithDefuse() dependency (main.functions.php not loaded?)',
309
                ];
310
            }
311
312
            $ret = prepareFileWithDefuse('encrypt', $filepath, $tmpPath, $encryptionKey);
313
314
            if ($ret !== true) {
315
                if (file_exists($filepath)) {
316
                    unlink($filepath);
317
                }
318
                if (file_exists($tmpPath)) {
319
                    unlink($tmpPath);
320
                }
321
                return [
322
                    'success' => false,
323
                    'filename' => $filename,
324
                    'filepath' => $filepath,
325
                    'encrypted' => false,
326
                    'size_bytes' => 0,
327
                    'message' => 'Encryption failed: ' . (is_string($ret) ? $ret : 'unknown error'),
0 ignored issues
show
introduced by
The condition is_string($ret) is always true.
Loading history...
328
                ];
329
            }
330
331
            // Replace original with encrypted version
332
            if (file_exists($filepath)) {
333
                unlink($filepath);
334
            }
335
            
336
            // On vérifie le succès de rename() sans @
337
            if (is_file($tmpPath) && !rename($tmpPath, $filepath)) {
338
                if (file_exists($tmpPath)) {
339
                    unlink($tmpPath);
340
                }
341
                return [
342
                    'success' => false,
343
                    'filename' => $filename,
344
                    'filepath' => $filepath,
345
                    'encrypted' => false,
346
                    'size_bytes' => 0,
347
                    'message' => 'Encryption succeeded but could not finalize file (rename failed)',
348
                ];
349
            }
350
351
            $encrypted = true;
352
        }
353
354
        // Gestion de filesize sans @
355
        $size = 0;
356
        if (is_file($filepath)) {
357
            $size = filesize($filepath);
358
            if ($size === false) {
359
                $size = 0;
360
            }
361
        }
362
363
        return [
364
            'success' => true,
365
            'filename' => $filename,
366
            'filepath' => $filepath,
367
            'encrypted' => $encrypted,
368
            'size_bytes' => (int) $size,
369
            'message' => '',
370
        ];
371
    }
372
}
373
374
375
// -----------------------------------------------------------------------------
376
// Helpers for restore logic (used by backups.queries.php)
377
// -----------------------------------------------------------------------------
378
379
if (function_exists('tpSafeUtf8String') === false) {
380
    /**
381
     * Ensure the returned string is valid UTF-8 and JSON-safe.
382
     *
383
     * Some crypto libraries can return messages containing non-UTF8 bytes.
384
     * Those would break json_encode() / prepareExchangedData().
385
     */
386
    function tpSafeUtf8String($value): string
387
    {
388
        if ($value === null) {
389
            return '';
390
        }
391
        if (is_bool($value)) {
392
            return $value ? '1' : '0';
393
        }
394
        if (is_scalar($value) === false) {
395
            $value = print_r($value, true);
396
        }
397
398
        $str = (string) $value;
399
400
        $isUtf8 = false;
401
        if (function_exists('mb_check_encoding')) {
402
            $isUtf8 = mb_check_encoding($str, 'UTF-8');
403
        } else {
404
            $isUtf8 = (@preg_match('//u', $str) === 1);
405
        }
406
407
        if ($isUtf8 === false) {
408
            // ASCII safe fallback
409
            return '[hex]' . bin2hex($str);
410
        }
411
412
        // Strip ASCII control chars
413
        $str = preg_replace("/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/", '', $str) ?? $str;
414
        return $str;
415
    }
416
}
417
if (function_exists('tpPrepareFileWithDefuseNormalized') === false) {
418
    /**
419
     * Wrapper around prepareFileWithDefuse() that normalizes return values across TeamPass versions.
420
     *
421
     * @return array{success: bool, message: string}
422
     */
423
    function tpPrepareFileWithDefuseNormalized(
424
        string $mode,
425
        string $sourceFile,
426
        string $destFile,
427
        string $encryptionKey
428
    ): array {
429
        if (function_exists('prepareFileWithDefuse') === false) {
430
            return ['success' => false, 'message' => 'prepareFileWithDefuse() is not available'];
431
        }
432
433
        try {
434
            $ret = prepareFileWithDefuse($mode, $sourceFile, $destFile, $encryptionKey);
435
436
            if ($ret === true) {
437
                return ['success' => true, 'message' => ''];
438
            }
439
440
            if (is_string($ret)) {
0 ignored issues
show
introduced by
The condition is_string($ret) is always true.
Loading history...
441
                return ['success' => false, 'message' => tpSafeUtf8String($ret)];
442
            }
443
444
            return ['success' => false, 'message' => 'Unknown error'];
445
        } catch (\Throwable $e) {
446
            return ['success' => false, 'message' => tpSafeUtf8String($e->getMessage())];
447
        }
448
    }
449
}
450
451
if (function_exists('tpDefuseDecryptWithCandidates') === false) {
452
    /**
453
     * Try to decrypt a file using Defuse with multiple candidate keys.
454
     *
455
     * @param array<int,string> $candidateKeys
456
     * @return array{success: bool, message: string, key_used?: string}
457
     */
458
    function tpDefuseDecryptWithCandidates(
459
        string $encryptedFile,
460
        string $decryptedFile,
461
        array $candidateKeys,
462
        array $SETTINGS = []
0 ignored issues
show
Unused Code introduced by
The parameter $SETTINGS is not used and could be removed. ( Ignorable by Annotation )

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

462
        /** @scrutinizer ignore-unused */ array $SETTINGS = []

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
463
    ): array {
464
        $lastMsg = '';
465
        foreach ($candidateKeys as $k) {
466
            $k = (string)$k;
467
            if ($k === '') {
468
                continue;
469
            }
470
471
            // Ensure we start from a clean slate.
472
            if (is_file($decryptedFile) && !unlink($decryptedFile)) {
473
                // Nothing to do, try next key
474
            }
475
476
            $r = tpPrepareFileWithDefuseNormalized('decrypt', $encryptedFile, $decryptedFile, $k);
477
            if (!empty($r['success'])) {
478
                return ['success' => true, 'message' => '', 'key_used' => $k];
479
            }
480
            $lastMsg = tpSafeUtf8String((string)($r['message'] ?? ''));
481
        }
482
483
        return ['success' => false, 'message' => ($lastMsg !== '' ? $lastMsg : 'Unable to decrypt')];
484
    }
485
}
486
487
// -----------------------------------------------------------------------------
488
// Backup metadata helpers (.meta.json sidecar) and schema token parsing
489
// -----------------------------------------------------------------------------
490
// NOTE: schema_level is stored for internal checks only. UI must never display schema_level.
491
492
if (function_exists('tpGetTpFilesVersion') === false) {
493
    function tpGetTpFilesVersion(): string
494
    {
495
        if (defined('TP_VERSION') && defined('TP_VERSION_MINOR')) {
496
            return (string) TP_VERSION . '.' . (string) TP_VERSION_MINOR;
497
        }
498
        return '';
499
    }
500
}
501
502
if (function_exists('tpGetSchemaLevel') === false) {
503
    function tpGetSchemaLevel(): string
504
    {
505
        if (defined('UPGRADE_MIN_DATE')) {
506
            $v = (string) UPGRADE_MIN_DATE;
507
            if ($v !== '' && preg_match('/^\d+$/', $v) === 1) {
508
                return $v;
509
            }
510
        }
511
        return '';
512
    }
513
}
514
515
if (function_exists('tpGetBackupMetadataPath') === false) {
516
    function tpGetBackupMetadataPath(string $backupFilePath): string
517
    {
518
        return $backupFilePath . '.meta.json';
519
    }
520
}
521
522
if (function_exists('tpWriteBackupMetadata') === false) {
523
    /**
524
     * Write backup metadata sidecar file (<backup>.meta.json).
525
     *
526
     * @return array{success: bool, message: string, meta_path: string}
527
     */
528
    function tpWriteBackupMetadata(string $backupFilePath, string $tpFilesVersion = '', string $schemaLevel = '', array $extra = []): array
529
    {
530
        $metaPath = tpGetBackupMetadataPath($backupFilePath);
531
532
        if ($tpFilesVersion === '') {
533
            $tpFilesVersion = tpGetTpFilesVersion();
534
        }
535
        if ($schemaLevel === '') {
536
            $schemaLevel = tpGetSchemaLevel();
537
        }
538
539
        $payload = array_merge(
540
            [
541
                'tp_files_version' => $tpFilesVersion !== '' ? $tpFilesVersion : null,
542
                'schema_level' => $schemaLevel !== '' ? $schemaLevel : null,
543
                'created_at' => gmdate('c'),
544
            ],
545
            $extra
546
        );
547
548
        $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
549
        if ($json === false) {
550
            return ['success' => false, 'message' => 'Unable to encode metadata as JSON', 'meta_path' => $metaPath];
551
        }
552
553
        $ok = @file_put_contents($metaPath, $json, LOCK_EX);
554
        if ($ok === false) {
555
            return ['success' => false, 'message' => 'Unable to write metadata file', 'meta_path' => $metaPath];
556
        }
557
558
        return ['success' => true, 'message' => '', 'meta_path' => $metaPath];
559
    }
560
}
561
562
if (function_exists('tpReadBackupMetadata') === false) {
563
    function tpReadBackupMetadata(string $backupFilePath): array
564
    {
565
        $metaPath = tpGetBackupMetadataPath($backupFilePath);
566
        if (!is_file($metaPath)) {
567
            return [];
568
        }
569
        $raw = @file_get_contents($metaPath);
570
        if ($raw === false || trim($raw) === '') {
571
            return [];
572
        }
573
        $data = json_decode($raw, true);
574
        return is_array($data) ? $data : [];
575
    }
576
}
577
578
if (function_exists('tpParseSchemaLevelFromBackupFilename') === false) {
579
    function tpParseSchemaLevelFromBackupFilename(string $filename): string
580
    {
581
        $bn = basename($filename);
582
        if (preg_match('/-sl(\d+)(?:\D|$)/', $bn, $m) === 1) {
583
            return (string) $m[1];
584
        }
585
        return '';
586
    }
587
}
588
589
if (function_exists('tpGetBackupSchemaLevelFromMetaOrFilename') === false) {
590
    function tpGetBackupSchemaLevelFromMetaOrFilename(string $backupFilePath): string
591
    {
592
        $meta = tpReadBackupMetadata($backupFilePath);
593
        if (!empty($meta['schema_level']) && is_scalar($meta['schema_level'])) {
594
            $v = (string) $meta['schema_level'];
595
            if ($v !== '' && preg_match('/^\d+$/', $v) === 1) {
596
                return $v;
597
            }
598
        }
599
        $v = tpParseSchemaLevelFromBackupFilename($backupFilePath);
600
        return ($v !== '' && preg_match('/^\d+$/', $v) === 1) ? $v : '';
601
    }
602
}
603
604
if (function_exists('tpGetBackupTpFilesVersionFromMeta') === false) {
605
    function tpGetBackupTpFilesVersionFromMeta(string $backupFilePath): string
606
    {
607
        $meta = tpReadBackupMetadata($backupFilePath);
608
        if (!empty($meta['tp_files_version']) && is_scalar($meta['tp_files_version'])) {
609
            return (string) $meta['tp_files_version'];
610
        }
611
        return '';
612
    }
613
}
614