Passed
Pull Request — master (#5017)
by
unknown
06:15
created

tpCreateDatabaseBackup()   F

Complexity

Conditions 37
Paths > 20000

Size

Total Lines 216
Code Lines 138

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 37
eloc 138
c 1
b 0
f 0
nc 44092
nop 3
dl 0
loc 216
rs 0

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      french.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
if (!function_exists('tpCreateDatabaseBackup')) {
33
    /**
34
     * Create a Teampass database backup file (optionally encrypted) in files folder.
35
     *
36
     * @param array  $SETTINGS      Teampass settings array (must include path_to_files_folder)
37
     * @param string $encryptionKey Encryption key (Defuse). If empty => no encryption
38
     * @param array  $options       Optional:
39
     *                              - output_dir (string) default: $SETTINGS['path_to_files_folder']
40
     *                              - filename_prefix (string) default: '' (ex: 'scheduled-')
41
     *                              - chunk_rows (int) default: 1000
42
     *                              - flush_every_inserts (int) default: 200
43
     *                              - include_tables (array<string>) default: [] (empty => all)
44
     *                              - exclude_tables (array<string>) default: [] (empty => none)
45
     *
46
     * @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...
47
     *   success: bool,
48
     *   filename: string,
49
     *   filepath: string,
50
     *   encrypted: bool,
51
     *   size_bytes: int,
52
     *   message: string
53
     * }
54
     */
55
    function tpCreateDatabaseBackup(array $SETTINGS, string $encryptionKey = '', array $options = []): array
56
    {
57
        // Ensure required dependencies are loaded
58
        $mainFunctionsPath = __DIR__ . '/main.functions.php';
59
        if (!function_exists('GenerateCryptKey') && is_file($mainFunctionsPath)) {
60
            require_once $mainFunctionsPath;
61
        }
62
        if (function_exists('loadClasses') && !class_exists('DB')) {
63
            loadClasses('DB');
64
        }
65
66
        $outputDir = $options['output_dir'] ?? ($SETTINGS['path_to_files_folder'] ?? '');
67
        $prefix = (string)($options['filename_prefix'] ?? '');
68
        $chunkRows = (int)($options['chunk_rows'] ?? 1000);
69
        $flushEvery = (int)($options['flush_every_inserts'] ?? 200);
70
        $includeTables = $options['include_tables'] ?? [];
71
        $excludeTables = $options['exclude_tables'] ?? [];
72
73
        if ($outputDir === '' || !is_dir($outputDir) || !is_writable($outputDir)) {
74
            return [
75
                'success' => false,
76
                'filename' => '',
77
                'filepath' => '',
78
                'encrypted' => false,
79
                'size_bytes' => 0,
80
                'message' => 'Backup folder is not writable or not found: ' . $outputDir,
81
            ];
82
        }
83
84
        // Generate filename
85
        $token = function_exists('GenerateCryptKey')
86
            ? GenerateCryptKey(20, false, true, true, false, true)
87
            : bin2hex(random_bytes(10));
88
89
        $filename = $prefix . time() . '-' . $token . '.sql';
90
        $filepath = rtrim($outputDir, '/') . '/' . $filename;
91
92
        $handle = @fopen($filepath, 'w+');
93
        if ($handle === false) {
94
            return [
95
                'success' => false,
96
                'filename' => $filename,
97
                'filepath' => $filepath,
98
                'encrypted' => false,
99
                'size_bytes' => 0,
100
                'message' => 'Could not create backup file: ' . $filepath,
101
            ];
102
        }
103
104
        $insertCount = 0;
105
106
        try {
107
            // Get all tables
108
            $tables = [];
109
            $result = DB::query('SHOW TABLES');
110
            foreach ($result as $row) {
111
                // SHOW TABLES returns key like 'Tables_in_<DB_NAME>'
112
                foreach ($row as $v) {
113
                    $tables[] = (string) $v;
114
                    break;
115
                }
116
            }
117
118
            // Filter tables if requested
119
            if (!empty($includeTables) && is_array($includeTables)) {
120
                $tables = array_values(array_intersect($tables, $includeTables));
121
            }
122
            if (!empty($excludeTables) && is_array($excludeTables)) {
123
                $tables = array_values(array_diff($tables, $excludeTables));
124
            }
125
126
            foreach ($tables as $tableName) {
127
                // Safety: only allow typical MySQL table identifiers
128
                if (!preg_match('/^[a-zA-Z0-9_]+$/', $tableName)) {
129
                    continue;
130
                }
131
132
                // Write drop and creation
133
                fwrite($handle, 'DROP TABLE IF EXISTS `' . $tableName . "`;\n");
134
135
                $row2 = DB::queryFirstRow('SHOW CREATE TABLE `' . $tableName . '`');
136
                if (!is_array($row2) || empty($row2['Create Table'])) {
137
                    // Skip table if structure cannot be fetched
138
                    fwrite($handle, "\n");
139
                    continue;
140
                }
141
142
                fwrite($handle, $row2['Create Table'] . ";\n\n");
143
144
                // Process table data in chunks to reduce memory usage
145
                $offset = 0;
146
                while (true) {
147
                    $rows = DB::query(
148
                        'SELECT * FROM `' . $tableName . '` LIMIT %i OFFSET %i',
149
                        $chunkRows,
150
                        $offset
151
                    );
152
153
                    if (empty($rows)) {
154
                        break;
155
                    }
156
157
                    foreach ($rows as $record) {
158
                        $values = [];
159
                        foreach ($record as $value) {
160
                            if ($value === null) {
161
                                $values[] = 'NULL';
162
                                continue;
163
                            }
164
165
                            // Force scalar/string
166
                            if (is_bool($value)) {
167
                                $value = $value ? '1' : '0';
168
                            } elseif (is_numeric($value)) {
169
                                // keep numeric as string but quoted (safe & consistent)
170
                                $value = (string) $value;
171
                            } else {
172
                                $value = (string) $value;
173
                            }
174
175
                            // Escape and keep newlines
176
                            $value = addslashes(preg_replace("/\n/", '\\n', $value));
177
                            $values[] = '"' . $value . '"';
178
                        }
179
180
                        $insertQuery = 'INSERT INTO `' . $tableName . '` VALUES(' . implode(',', $values) . ");\n";
181
                        fwrite($handle, $insertQuery);
182
183
                        $insertCount++;
184
                        if ($flushEvery > 0 && ($insertCount % $flushEvery) === 0) {
185
                            fflush($handle);
186
                        }
187
                    }
188
189
                    $offset += $chunkRows;
190
                    fflush($handle);
191
                }
192
193
                fwrite($handle, "\n\n");
194
                fflush($handle);
195
            }
196
        } catch (Throwable $e) {
197
            fclose($handle);
198
            @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

198
            /** @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...
199
200
            return [
201
                'success' => false,
202
                'filename' => $filename,
203
                'filepath' => $filepath,
204
                'encrypted' => false,
205
                'size_bytes' => 0,
206
                'message' => 'Backup failed: ' . $e->getMessage(),
207
            ];
208
        }
209
210
        fclose($handle);
211
212
        // Encrypt the file if key provided
213
        $encrypted = false;
214
        if ($encryptionKey !== '') {
215
            $tmpPath = rtrim($outputDir, '/') . '/defuse_temp_' . $filename;
216
217
            if (!function_exists('prepareFileWithDefuse')) {
218
                @unlink($filepath);
219
                return [
220
                    'success' => false,
221
                    'filename' => $filename,
222
                    'filepath' => $filepath,
223
                    'encrypted' => false,
224
                    'size_bytes' => 0,
225
                    'message' => 'Missing prepareFileWithDefuse() dependency (main.functions.php not loaded?)',
226
                ];
227
            }
228
229
            $ret = prepareFileWithDefuse('encrypt', $filepath, $tmpPath, $encryptionKey);
230
231
            // prepareFileWithDefuse usually returns true on success, otherwise message/false
232
            if ($ret !== true) {
233
                @unlink($filepath);
234
                @unlink($tmpPath);
235
                return [
236
                    'success' => false,
237
                    'filename' => $filename,
238
                    'filepath' => $filepath,
239
                    'encrypted' => false,
240
                    'size_bytes' => 0,
241
                    '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...
242
                ];
243
            }
244
245
            // Replace original with encrypted version
246
            @unlink($filepath);
247
            if (!@rename($tmpPath, $filepath)) {
248
                @unlink($tmpPath);
249
                return [
250
                    'success' => false,
251
                    'filename' => $filename,
252
                    'filepath' => $filepath,
253
                    'encrypted' => false,
254
                    'size_bytes' => 0,
255
                    'message' => 'Encryption succeeded but could not finalize file (rename failed)',
256
                ];
257
            }
258
259
            $encrypted = true;
260
        }
261
262
        $size = (int) (@filesize($filepath) ?: 0);
263
264
        return [
265
            'success' => true,
266
            'filename' => $filename,
267
            'filepath' => $filepath,
268
            'encrypted' => $encrypted,
269
            'size_bytes' => $size,
270
            'message' => '',
271
        ];
272
    }
273
}
274
275
276
// -----------------------------------------------------------------------------
277
// Helpers for restore logic (used by backups.queries.php)
278
// -----------------------------------------------------------------------------
279
280
if (function_exists('tpSafeUtf8String') === false) {
281
    /**
282
     * Ensure the returned string is valid UTF-8 and JSON-safe.
283
     *
284
     * Some crypto libraries can return messages containing non-UTF8 bytes.
285
     * Those would break json_encode() / prepareExchangedData().
286
     */
287
    function tpSafeUtf8String($value): string
288
    {
289
        if ($value === null) {
290
            return '';
291
        }
292
        if (is_bool($value)) {
293
            return $value ? '1' : '0';
294
        }
295
        if (is_scalar($value) === false) {
296
            $value = print_r($value, true);
297
        }
298
299
        $str = (string) $value;
300
301
        $isUtf8 = false;
302
        if (function_exists('mb_check_encoding')) {
303
            $isUtf8 = mb_check_encoding($str, 'UTF-8');
304
        } else {
305
            $isUtf8 = (@preg_match('//u', $str) === 1);
306
        }
307
308
        if ($isUtf8 === false) {
309
            // ASCII safe fallback
310
            return '[hex]' . bin2hex($str);
311
        }
312
313
        // Strip ASCII control chars
314
        $str = preg_replace("/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/", '', $str) ?? $str;
315
        return $str;
316
    }
317
}
318
if (function_exists('tpPrepareFileWithDefuseNormalized') === false) {
319
    /**
320
     * Wrapper around prepareFileWithDefuse() that normalizes return values across TeamPass versions.
321
     *
322
     * @return array{success: bool, message: string}
323
     */
324
    function tpPrepareFileWithDefuseNormalized(
325
        string $mode,
326
        string $sourceFile,
327
        string $destFile,
328
        string $encryptionKey,
329
        array $SETTINGS = []
330
    ): array {
331
        if (function_exists('prepareFileWithDefuse') === false) {
332
            return ['success' => false, 'message' => 'prepareFileWithDefuse() is not available'];
333
        }
334
335
        try {
336
            // Some versions accept $SETTINGS as 5th parameter, others don't.
337
            try {
338
                $ret = prepareFileWithDefuse($mode, $sourceFile, $destFile, $encryptionKey, $SETTINGS);
0 ignored issues
show
Unused Code introduced by
The call to prepareFileWithDefuse() has too many arguments starting with $SETTINGS. ( Ignorable by Annotation )

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

338
                $ret = /** @scrutinizer ignore-call */ prepareFileWithDefuse($mode, $sourceFile, $destFile, $encryptionKey, $SETTINGS);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
339
            } catch (\ArgumentCountError $e) {
340
                $ret = prepareFileWithDefuse($mode, $sourceFile, $destFile, $encryptionKey);
341
            }
342
343
            if ($ret === true) {
344
                return ['success' => true, 'message' => ''];
345
            }
346
347
            if (is_array($ret)) {
0 ignored issues
show
introduced by
The condition is_array($ret) is always false.
Loading history...
348
                $hasError = !empty($ret['error']);
349
                $msg = tpSafeUtf8String((string)($ret['message'] ?? $ret['details'] ?? $ret['error'] ?? ''));
350
                return ['success' => !$hasError, 'message' => $msg];
351
            }
352
353
            if (is_string($ret)) {
0 ignored issues
show
introduced by
The condition is_string($ret) is always true.
Loading history...
354
                return ['success' => false, 'message' => tpSafeUtf8String($ret)];
355
            }
356
357
            return ['success' => false, 'message' => 'Unknown error'];
358
        } catch (\Throwable $e) {
359
            return ['success' => false, 'message' => tpSafeUtf8String($e->getMessage())];
360
        }
361
    }
362
}
363
364
if (function_exists('tpDefuseDecryptWithCandidates') === false) {
365
    /**
366
     * Try to decrypt a file using Defuse with multiple candidate keys.
367
     *
368
     * @param array<int,string> $candidateKeys
369
     * @return array{success: bool, message: string, key_used?: string}
370
     */
371
    function tpDefuseDecryptWithCandidates(
372
        string $encryptedFile,
373
        string $decryptedFile,
374
        array $candidateKeys,
375
        array $SETTINGS = []
376
    ): array {
377
        $lastMsg = '';
378
        foreach ($candidateKeys as $k) {
379
            $k = (string)$k;
380
            if ($k === '') {
381
                continue;
382
            }
383
384
            // Ensure we start from a clean slate.
385
            if (is_file($decryptedFile)) {
386
                @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

386
                /** @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...
387
            }
388
389
            $r = tpPrepareFileWithDefuseNormalized('decrypt', $encryptedFile, $decryptedFile, $k, $SETTINGS);
390
            if (!empty($r['success'])) {
391
                return ['success' => true, 'message' => '', 'key_used' => $k];
392
            }
393
            $lastMsg = tpSafeUtf8String((string)($r['message'] ?? ''));
394
        }
395
396
        return ['success' => false, 'message' => ($lastMsg !== '' ? $lastMsg : 'Unable to decrypt')];
397
    }
398
}
399