Issues (43)

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.

sources/backup.functions.php (4 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      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
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
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
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
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