Passed
Push — master ( 1229ae...3254be )
by Nils
06:09
created

tpPrepareFileWithDefuseNormalized()   A

Complexity

Conditions 6
Paths 11

Size

Total Lines 31
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 15
c 1
b 0
f 0
nc 11
nop 5
dl 0
loc 31
rs 9.2222
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-2025 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
     *
52
     * @return array{
0 ignored issues
show
Documentation Bug introduced by
The doc comment array{ at position 2 could not be parsed: the token is null at position 2.
Loading history...
53
     *   success: bool,
54
     *   filename: string,
55
     *   filepath: string,
56
     *   encrypted: bool,
57
     *   size_bytes: int,
58
     *   message: string
59
     * }
60
     */
61
    function tpCreateDatabaseBackup(array $SETTINGS, string $encryptionKey = '', array $options = []): array
62
    {
63
        // Ensure required dependencies are loaded
64
        $mainFunctionsPath = __DIR__ . '/main.functions.php';
65
        if ((!function_exists('GenerateCryptKey') || !function_exists('prefixTable')) && is_file($mainFunctionsPath)) {
66
            require_once $mainFunctionsPath;
67
        }
68
        if (function_exists('loadClasses') && !class_exists('DB')) {
69
            loadClasses('DB');
70
        }
71
72
        // Enable maintenance mode for the whole backup operation, then restore previous value at the end.
73
        // This is best-effort: a failure to toggle maintenance must not break the backup itself.
74
        $__tpMaintenanceGuard = new class() {
0 ignored issues
show
Unused Code introduced by
The assignment to $__tpMaintenanceGuard is dead and can be removed.
Loading history...
75
            private $prev = null;
76
            private $changed = false;
77
78
            public function __construct()
79
            {
80
                try {
81
                    $row = DB::queryFirstRow(
82
                        'SELECT valeur FROM ' . prefixTable('misc') . ' WHERE intitule=%s AND type=%s',
83
                        'maintenance_mode',
84
                        'admin'
85
                    );
86
87
                    if (is_array($row) && array_key_exists('valeur', $row)) {
88
                        $this->prev = (string) $row['valeur'];
89
                    }
90
91
                    // Only toggle if it was not already enabled
92
                    if ($this->prev !== '1') {
93
                        DB::update(
94
                            prefixTable('misc'),
95
                            array(
96
                                'valeur' => '1',
97
                                'updated_at' => time(),
98
                            ),
99
                            'intitule = %s AND type= %s',
100
                            'maintenance_mode',
101
                            'admin'
102
                        );
103
                        $this->changed = true;
104
                    }
105
                } catch (Throwable $ignored) {
106
                    // ignore
107
                }
108
            }
109
110
            public function __destruct()
111
            {
112
                if ($this->changed !== true) {
113
                    return;
114
                }
115
116
                try {
117
                    DB::update(
118
                        prefixTable('misc'),
119
                        array(
120
                            'valeur' => (string) ($this->prev ?? '0'),
121
                            'updated_at' => time(),
122
                        ),
123
                        'intitule = %s AND type= %s',
124
                        'maintenance_mode',
125
                        'admin'
126
                    );
127
                } catch (Throwable $ignored) {
128
                    // ignore
129
                }
130
            }
131
        };
132
133
        $outputDir = $options['output_dir'] ?? ($SETTINGS['path_to_files_folder'] ?? '');
134
        $prefix = (string)($options['filename_prefix'] ?? '');
135
        $chunkRows = (int)($options['chunk_rows'] ?? 1000);
136
        $flushEvery = (int)($options['flush_every_inserts'] ?? 200);
137
        $includeTables = $options['include_tables'] ?? [];
138
        $excludeTables = $options['exclude_tables'] ?? [];
139
140
        if ($outputDir === '' || !is_dir($outputDir) || !is_writable($outputDir)) {
141
            return [
142
                'success' => false,
143
                'filename' => '',
144
                'filepath' => '',
145
                'encrypted' => false,
146
                'size_bytes' => 0,
147
                'message' => 'Backup folder is not writable or not found: ' . $outputDir,
148
            ];
149
        }
150
151
        // Generate filename
152
        $token = function_exists('GenerateCryptKey')
153
            ? GenerateCryptKey(20, false, true, true, false, true)
154
            : bin2hex(random_bytes(10));
155
156
        $filename = $prefix . time() . '-' . $token . '.sql';
157
        $filepath = rtrim($outputDir, '/') . '/' . $filename;
158
159
        $handle = @fopen($filepath, 'w+');
160
        if ($handle === false) {
161
            return [
162
                'success' => false,
163
                'filename' => $filename,
164
                'filepath' => $filepath,
165
                'encrypted' => false,
166
                'size_bytes' => 0,
167
                'message' => 'Could not create backup file: ' . $filepath,
168
            ];
169
        }
170
171
        $insertCount = 0;
172
173
        try {
174
            // Get all tables
175
            $tables = [];
176
            $result = DB::query('SHOW TABLES');
177
            foreach ($result as $row) {
178
                // SHOW TABLES returns key like 'Tables_in_<DB_NAME>'
179
                foreach ($row as $v) {
180
                    $tables[] = (string) $v;
181
                    break;
182
                }
183
            }
184
185
            // Filter tables if requested
186
            if (!empty($includeTables) && is_array($includeTables)) {
187
                $tables = array_values(array_intersect($tables, $includeTables));
188
            }
189
            if (!empty($excludeTables) && is_array($excludeTables)) {
190
                $tables = array_values(array_diff($tables, $excludeTables));
191
            }
192
193
            foreach ($tables as $tableName) {
194
                // Safety: only allow typical MySQL table identifiers
195
                if (!preg_match('/^[a-zA-Z0-9_]+$/', $tableName)) {
196
                    continue;
197
                }
198
199
                // Write drop and creation
200
                fwrite($handle, 'DROP TABLE IF EXISTS `' . $tableName . "`;\n");
201
202
                $row2 = DB::queryFirstRow('SHOW CREATE TABLE `' . $tableName . '`');
203
                if (!is_array($row2) || empty($row2['Create Table'])) {
204
                    // Skip table if structure cannot be fetched
205
                    fwrite($handle, "\n");
206
                    continue;
207
                }
208
209
                fwrite($handle, $row2['Create Table'] . ";\n\n");
210
211
                // Process table data in chunks to reduce memory usage
212
                $offset = 0;
213
                while (true) {
214
                    $rows = DB::query(
215
                        'SELECT * FROM `' . $tableName . '` LIMIT %i OFFSET %i',
216
                        $chunkRows,
217
                        $offset
218
                    );
219
220
                    if (empty($rows)) {
221
                        break;
222
                    }
223
224
                    foreach ($rows as $record) {
225
                        $values = [];
226
                        foreach ($record as $value) {
227
                            if ($value === null) {
228
                                $values[] = 'NULL';
229
                                continue;
230
                            }
231
232
                            // Force scalar/string
233
                            if (is_bool($value)) {
234
                                $value = $value ? '1' : '0';
235
                            } elseif (is_numeric($value)) {
236
                                // keep numeric as string but quoted (safe & consistent)
237
                                $value = (string) $value;
238
                            } else {
239
                                $value = (string) $value;
240
                            }
241
242
                            // Escape and keep newlines
243
                            $value = addslashes(preg_replace("/\n/", '\\n', $value));
244
                            $values[] = '"' . $value . '"';
245
                        }
246
247
                        $insertQuery = 'INSERT INTO `' . $tableName . '` VALUES(' . implode(',', $values) . ");\n";
248
                        fwrite($handle, $insertQuery);
249
250
                        $insertCount++;
251
                        if ($flushEvery > 0 && ($insertCount % $flushEvery) === 0) {
252
                            fflush($handle);
253
                        }
254
                    }
255
256
                    $offset += $chunkRows;
257
                    fflush($handle);
258
                }
259
260
                fwrite($handle, "\n\n");
261
                fflush($handle);
262
            }
263
        } catch (Throwable $e) {
264
            fclose($handle);
265
            @unlink($filepath);
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

265
            /** @scrutinizer ignore-unhandled */ @unlink($filepath);

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...
266
267
            return [
268
                'success' => false,
269
                'filename' => $filename,
270
                'filepath' => $filepath,
271
                'encrypted' => false,
272
                'size_bytes' => 0,
273
                'message' => 'Backup failed: ' . $e->getMessage(),
274
            ];
275
        }
276
277
        fclose($handle);
278
279
        // Encrypt the file if key provided
280
        $encrypted = false;
281
        if ($encryptionKey !== '') {
282
            $tmpPath = rtrim($outputDir, '/') . '/defuse_temp_' . $filename;
283
284
            if (!function_exists('prepareFileWithDefuse')) {
285
                @unlink($filepath);
286
                return [
287
                    'success' => false,
288
                    'filename' => $filename,
289
                    'filepath' => $filepath,
290
                    'encrypted' => false,
291
                    'size_bytes' => 0,
292
                    'message' => 'Missing prepareFileWithDefuse() dependency (main.functions.php not loaded?)',
293
                ];
294
            }
295
296
            $ret = prepareFileWithDefuse('encrypt', $filepath, $tmpPath, $encryptionKey);
297
298
            // prepareFileWithDefuse usually returns true on success, otherwise message/false
299
            if ($ret !== true) {
300
                @unlink($filepath);
301
                @unlink($tmpPath);
302
                return [
303
                    'success' => false,
304
                    'filename' => $filename,
305
                    'filepath' => $filepath,
306
                    'encrypted' => false,
307
                    'size_bytes' => 0,
308
                    '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...
309
                ];
310
            }
311
312
            // Replace original with encrypted version
313
            @unlink($filepath);
314
            if (!@rename($tmpPath, $filepath)) {
315
                @unlink($tmpPath);
316
                return [
317
                    'success' => false,
318
                    'filename' => $filename,
319
                    'filepath' => $filepath,
320
                    'encrypted' => false,
321
                    'size_bytes' => 0,
322
                    'message' => 'Encryption succeeded but could not finalize file (rename failed)',
323
                ];
324
            }
325
326
            $encrypted = true;
327
        }
328
329
        $size = (int) (@filesize($filepath) ?: 0);
330
331
        return [
332
            'success' => true,
333
            'filename' => $filename,
334
            'filepath' => $filepath,
335
            'encrypted' => $encrypted,
336
            'size_bytes' => $size,
337
            'message' => '',
338
        ];
339
    }
340
}
341
342
343
// -----------------------------------------------------------------------------
344
// Helpers for restore logic (used by backups.queries.php)
345
// -----------------------------------------------------------------------------
346
347
if (function_exists('tpSafeUtf8String') === false) {
348
    /**
349
     * Ensure the returned string is valid UTF-8 and JSON-safe.
350
     *
351
     * Some crypto libraries can return messages containing non-UTF8 bytes.
352
     * Those would break json_encode() / prepareExchangedData().
353
     */
354
    function tpSafeUtf8String($value): string
355
    {
356
        if ($value === null) {
357
            return '';
358
        }
359
        if (is_bool($value)) {
360
            return $value ? '1' : '0';
361
        }
362
        if (is_scalar($value) === false) {
363
            $value = print_r($value, true);
364
        }
365
366
        $str = (string) $value;
367
368
        $isUtf8 = false;
369
        if (function_exists('mb_check_encoding')) {
370
            $isUtf8 = mb_check_encoding($str, 'UTF-8');
371
        } else {
372
            $isUtf8 = (@preg_match('//u', $str) === 1);
373
        }
374
375
        if ($isUtf8 === false) {
376
            // ASCII safe fallback
377
            return '[hex]' . bin2hex($str);
378
        }
379
380
        // Strip ASCII control chars
381
        $str = preg_replace("/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/", '', $str) ?? $str;
382
        return $str;
383
    }
384
}
385
if (function_exists('tpPrepareFileWithDefuseNormalized') === false) {
386
    /**
387
     * Wrapper around prepareFileWithDefuse() that normalizes return values across TeamPass versions.
388
     *
389
     * @return array{success: bool, message: string}
390
     */
391
    function tpPrepareFileWithDefuseNormalized(
392
        string $mode,
393
        string $sourceFile,
394
        string $destFile,
395
        string $encryptionKey,
396
        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

396
        /** @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...
397
    ): array {
398
        if (function_exists('prepareFileWithDefuse') === false) {
399
            return ['success' => false, 'message' => 'prepareFileWithDefuse() is not available'];
400
        }
401
402
        try {
403
            $ret = prepareFileWithDefuse($mode, $sourceFile, $destFile, $encryptionKey);
404
405
            if ($ret === true) {
406
                return ['success' => true, 'message' => ''];
407
            }
408
409
            if (is_array($ret)) {
0 ignored issues
show
introduced by
The condition is_array($ret) is always false.
Loading history...
410
                $hasError = !empty($ret['error']);
411
                $msg = tpSafeUtf8String((string)($ret['message'] ?? $ret['details'] ?? $ret['error'] ?? ''));
412
                return ['success' => !$hasError, 'message' => $msg];
413
            }
414
415
            if (is_string($ret)) {
0 ignored issues
show
introduced by
The condition is_string($ret) is always true.
Loading history...
416
                return ['success' => false, 'message' => tpSafeUtf8String($ret)];
417
            }
418
419
            return ['success' => false, 'message' => 'Unknown error'];
420
        } catch (\Throwable $e) {
421
            return ['success' => false, 'message' => tpSafeUtf8String($e->getMessage())];
422
        }
423
    }
424
}
425
426
if (function_exists('tpDefuseDecryptWithCandidates') === false) {
427
    /**
428
     * Try to decrypt a file using Defuse with multiple candidate keys.
429
     *
430
     * @param array<int,string> $candidateKeys
431
     * @return array{success: bool, message: string, key_used?: string}
432
     */
433
    function tpDefuseDecryptWithCandidates(
434
        string $encryptedFile,
435
        string $decryptedFile,
436
        array $candidateKeys,
437
        array $SETTINGS = []
438
    ): array {
439
        $lastMsg = '';
440
        foreach ($candidateKeys as $k) {
441
            $k = (string)$k;
442
            if ($k === '') {
443
                continue;
444
            }
445
446
            // Ensure we start from a clean slate.
447
            if (is_file($decryptedFile)) {
448
                @unlink($decryptedFile);
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

448
                /** @scrutinizer ignore-unhandled */ @unlink($decryptedFile);

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...
449
            }
450
451
            $r = tpPrepareFileWithDefuseNormalized('decrypt', $encryptedFile, $decryptedFile, $k, $SETTINGS);
452
            if (!empty($r['success'])) {
453
                return ['success' => true, 'message' => '', 'key_used' => $k];
454
            }
455
            $lastMsg = tpSafeUtf8String((string)($r['message'] ?? ''));
456
        }
457
458
        return ['success' => false, 'message' => ($lastMsg !== '' ? $lastMsg : 'Unable to decrypt')];
459
    }
460
}
461