tpCreateDatabaseBackup()   F
last analyzed

Complexity

Conditions 45
Paths > 20000

Size

Total Lines 307
Code Lines 185

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 45
eloc 185
nc 178796
nop 3
dl 0
loc 307
rs 0
c 2
b 0
f 0

2 Methods

Rating   Name   Duplication   Size   Complexity  
A backup.functions.php$0 ➔ __destruct() 0 18 3
A backup.functions.php$0 ➔ __construct() 0 28 5

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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.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
        $filename = $prefix . time() . '-' . $token . '.sql';
151
        $filepath = rtrim($outputDir, '/') . '/' . $filename;
152
153
        $handle = @fopen($filepath, 'w+');
154
        if ($handle === false) {
155
            return [
156
                'success' => false,
157
                'filename' => $filename,
158
                'filepath' => $filepath,
159
                'encrypted' => false,
160
                'size_bytes' => 0,
161
                'message' => 'Could not create backup file: ' . $filepath,
162
            ];
163
        }
164
165
        $insertCount = 0;
166
167
        try {
168
            // Get all tables
169
            $tables = [];
170
            $result = DB::query('SHOW TABLES');
171
            foreach ($result as $row) {
172
                // SHOW TABLES returns key like 'Tables_in_<DB_NAME>'
173
                foreach ($row as $v) {
174
                    $tables[] = (string) $v;
175
                    break;
176
                }
177
            }
178
179
            // Filter tables if requested
180
            if (!empty($includeTables) && is_array($includeTables)) {
181
                $tables = array_values(array_intersect($tables, $includeTables));
182
            }
183
            if (!empty($excludeTables) && is_array($excludeTables)) {
184
                $tables = array_values(array_diff($tables, $excludeTables));
185
            }
186
187
            foreach ($tables as $tableName) {
188
                // Safety: only allow typical MySQL table identifiers
189
                if (!preg_match('/^[a-zA-Z0-9_]+$/', $tableName)) {
190
                    continue;
191
                }
192
193
                // Write drop and creation
194
                fwrite($handle, 'DROP TABLE IF EXISTS `' . $tableName . "`;\n");
195
196
                $row2 = DB::queryFirstRow('SHOW CREATE TABLE `' . $tableName . '`');
197
                if (!is_array($row2) || empty($row2['Create Table'])) {
198
                    // Skip table if structure cannot be fetched
199
                    fwrite($handle, "\n");
200
                    continue;
201
                }
202
203
                fwrite($handle, $row2['Create Table'] . ";\n\n");
204
205
                // Process table data in chunks to reduce memory usage
206
                $offset = 0;
207
                while (true) {
208
                    $rows = DB::query(
209
                        'SELECT * FROM `' . $tableName . '` LIMIT %i OFFSET %i',
210
                        $chunkRows,
211
                        $offset
212
                    );
213
214
                    if (empty($rows)) {
215
                        break;
216
                    }
217
218
                    foreach ($rows as $record) {
219
                        $values = [];
220
                        foreach ($record as $value) {
221
                            if ($value === null) {
222
                                $values[] = 'NULL';
223
                                continue;
224
                            }
225
226
                            // Force scalar/string
227
                            if (is_bool($value)) {
228
                                $value = $value ? '1' : '0';
229
                            } elseif (is_numeric($value)) {
230
                                // keep numeric as string but quoted (safe & consistent)
231
                                $value = (string) $value;
232
                            } else {
233
                                $value = (string) $value;
234
                            }
235
236
                            // Escape and keep newlines
237
                            $value = addslashes(preg_replace("/\n/", '\\n', $value));
238
                            $values[] = '"' . $value . '"';
239
                        }
240
241
                        $insertQuery = 'INSERT INTO `' . $tableName . '` VALUES(' . implode(',', $values) . ");\n";
242
                        fwrite($handle, $insertQuery);
243
244
                        $insertCount++;
245
                        if ($flushEvery > 0 && ($insertCount % $flushEvery) === 0) {
246
                            fflush($handle);
247
                        }
248
                    }
249
250
                    $offset += $chunkRows;
251
                    fflush($handle);
252
                }
253
254
                fwrite($handle, "\n\n");
255
                fflush($handle);
256
            }
257
        } catch (Throwable $e) {
258
            if (is_resource($handle)) {
259
                fclose($handle);
260
            }
261
262
            $errorMessage = 'Backup failed: ' . $e->getMessage();
263
264
            // Suppression sécurisée sans @
265
            if (file_exists($filepath)) {
266
                $deleted = unlink($filepath);
267
                if ($deleted === false) {
268
                    $errorMessage .= ' (Note: Temporary backup file could not be deleted from disk)';
269
                }
270
            }
271
272
            return [
273
                'success' => false,
274
                'filename' => $filename,
275
                'filepath' => $filepath,
276
                'encrypted' => false,
277
                'size_bytes' => 0,
278
                'message' => $errorMessage,
279
            ];
280
        }
281
282
        fclose($handle);
283
284
        // Encrypt the file if key provided
285
        $encrypted = false;
286
        if ($encryptionKey !== '') {
287
            $tmpPath = rtrim($outputDir, '/') . '/defuse_temp_' . $filename;
288
289
            if (!function_exists('prepareFileWithDefuse')) {
290
                if (file_exists($filepath)) {
291
                    unlink($filepath);
292
                }
293
                return [
294
                    'success' => false,
295
                    'filename' => $filename,
296
                    'filepath' => $filepath,
297
                    'encrypted' => false,
298
                    'size_bytes' => 0,
299
                    'message' => 'Missing prepareFileWithDefuse() dependency (main.functions.php not loaded?)',
300
                ];
301
            }
302
303
            $ret = prepareFileWithDefuse('encrypt', $filepath, $tmpPath, $encryptionKey);
304
305
            if ($ret !== true) {
306
                if (file_exists($filepath)) {
307
                    unlink($filepath);
308
                }
309
                if (file_exists($tmpPath)) {
310
                    unlink($tmpPath);
311
                }
312
                return [
313
                    'success' => false,
314
                    'filename' => $filename,
315
                    'filepath' => $filepath,
316
                    'encrypted' => false,
317
                    'size_bytes' => 0,
318
                    '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...
319
                ];
320
            }
321
322
            // Replace original with encrypted version
323
            if (file_exists($filepath)) {
324
                unlink($filepath);
325
            }
326
            
327
            // On vérifie le succès de rename() sans @
328
            if (is_file($tmpPath) && !rename($tmpPath, $filepath)) {
329
                if (file_exists($tmpPath)) {
330
                    unlink($tmpPath);
331
                }
332
                return [
333
                    'success' => false,
334
                    'filename' => $filename,
335
                    'filepath' => $filepath,
336
                    'encrypted' => false,
337
                    'size_bytes' => 0,
338
                    'message' => 'Encryption succeeded but could not finalize file (rename failed)',
339
                ];
340
            }
341
342
            $encrypted = true;
343
        }
344
345
        // Gestion de filesize sans @
346
        $size = 0;
347
        if (is_file($filepath)) {
348
            $size = filesize($filepath);
349
            if ($size === false) {
350
                $size = 0;
351
            }
352
        }
353
354
        return [
355
            'success' => true,
356
            'filename' => $filename,
357
            'filepath' => $filepath,
358
            'encrypted' => $encrypted,
359
            'size_bytes' => (int) $size,
360
            'message' => '',
361
        ];
362
    }
363
}
364
365
366
// -----------------------------------------------------------------------------
367
// Helpers for restore logic (used by backups.queries.php)
368
// -----------------------------------------------------------------------------
369
370
if (function_exists('tpSafeUtf8String') === false) {
371
    /**
372
     * Ensure the returned string is valid UTF-8 and JSON-safe.
373
     *
374
     * Some crypto libraries can return messages containing non-UTF8 bytes.
375
     * Those would break json_encode() / prepareExchangedData().
376
     */
377
    function tpSafeUtf8String($value): string
378
    {
379
        if ($value === null) {
380
            return '';
381
        }
382
        if (is_bool($value)) {
383
            return $value ? '1' : '0';
384
        }
385
        if (is_scalar($value) === false) {
386
            $value = print_r($value, true);
387
        }
388
389
        $str = (string) $value;
390
391
        $isUtf8 = false;
392
        if (function_exists('mb_check_encoding')) {
393
            $isUtf8 = mb_check_encoding($str, 'UTF-8');
394
        } else {
395
            $isUtf8 = (@preg_match('//u', $str) === 1);
396
        }
397
398
        if ($isUtf8 === false) {
399
            // ASCII safe fallback
400
            return '[hex]' . bin2hex($str);
401
        }
402
403
        // Strip ASCII control chars
404
        $str = preg_replace("/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/", '', $str) ?? $str;
405
        return $str;
406
    }
407
}
408
if (function_exists('tpPrepareFileWithDefuseNormalized') === false) {
409
    /**
410
     * Wrapper around prepareFileWithDefuse() that normalizes return values across TeamPass versions.
411
     *
412
     * @return array{success: bool, message: string}
413
     */
414
    function tpPrepareFileWithDefuseNormalized(
415
        string $mode,
416
        string $sourceFile,
417
        string $destFile,
418
        string $encryptionKey
419
    ): array {
420
        if (function_exists('prepareFileWithDefuse') === false) {
421
            return ['success' => false, 'message' => 'prepareFileWithDefuse() is not available'];
422
        }
423
424
        try {
425
            $ret = prepareFileWithDefuse($mode, $sourceFile, $destFile, $encryptionKey);
426
427
            if ($ret === true) {
428
                return ['success' => true, 'message' => ''];
429
            }
430
431
            if (is_string($ret)) {
0 ignored issues
show
introduced by
The condition is_string($ret) is always true.
Loading history...
432
                return ['success' => false, 'message' => tpSafeUtf8String($ret)];
433
            }
434
435
            return ['success' => false, 'message' => 'Unknown error'];
436
        } catch (\Throwable $e) {
437
            return ['success' => false, 'message' => tpSafeUtf8String($e->getMessage())];
438
        }
439
    }
440
}
441
442
if (function_exists('tpDefuseDecryptWithCandidates') === false) {
443
    /**
444
     * Try to decrypt a file using Defuse with multiple candidate keys.
445
     *
446
     * @param array<int,string> $candidateKeys
447
     * @return array{success: bool, message: string, key_used?: string}
448
     */
449
    function tpDefuseDecryptWithCandidates(
450
        string $encryptedFile,
451
        string $decryptedFile,
452
        array $candidateKeys,
453
        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

453
        /** @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...
454
    ): array {
455
        $lastMsg = '';
456
        foreach ($candidateKeys as $k) {
457
            $k = (string)$k;
458
            if ($k === '') {
459
                continue;
460
            }
461
462
            // Ensure we start from a clean slate.
463
            if (is_file($decryptedFile) && !unlink($decryptedFile)) {
464
                // Nothing to do, try next key
465
            }
466
467
            $r = tpPrepareFileWithDefuseNormalized('decrypt', $encryptedFile, $decryptedFile, $k);
468
            if (!empty($r['success'])) {
469
                return ['success' => true, 'message' => '', 'key_used' => $k];
470
            }
471
            $lastMsg = tpSafeUtf8String((string)($r['message'] ?? ''));
472
        }
473
474
        return ['success' => false, 'message' => ($lastMsg !== '' ? $lastMsg : 'Unable to decrypt')];
475
    }
476
}
477