CVE-2022-3608: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: Cross-site Scripting (XSS) - Stored in GitHub repository thorsten/phpmyfaq prior to 3.2.0-alpha.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 8.4
Published: 2022-10-19T13:15Z
Repository: https://github.com/thorsten/phpmyfaq
Language: PHP
Files changed: CHANGELOG.md, composer.json, composer.lock, phpmyfaq/admin/backup.export.php, phpmyfaq/admin/backup.import.php, phpmyfaq/assets/themes/default/scss/_mixins.scss, phpmyfaq/assets/themes/default/scss/_theme.scss, phpmyfaq/assets/themes/default/scss/layout/_startpage.scss, phpmyfaq/setup/update.php, phpmyfaq/src/phpMyFAQ/Backup.php, phpmyfaq/src/phpMyFAQ/Database.php, phpmyfaq/src/phpMyFAQ/Database/DatabaseHelper.php, phpmyfaq/src/phpMyFAQ/Export.php, phpmyfaq/src/phpMyFAQ/Filesystem.php, phpmyfaq/src/phpMyFAQ/Helper/CategoryHelper.php, phpmyfaq/src/phpMyFAQ/Instance/Database/Mysqli.php, phpmyfaq/src/phpMyFAQ/Instance/Database/Pgsql.php, phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlite3.php, phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlsrv.php, phpmyfaq/src/phpMyFAQ/Instance/Setup.php, tests/phpMyFAQ/ApiTest.php, tests/phpMyFAQ/BackupTest.php, tests/phpMyFAQ/Database/DatabaseHelperTest.php
Commit date: 2022-10-09 17:52:48 +0200
Commit message: feat(backup): added verification of backup files

Diff: diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5bd30f95d..e138680f0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,10 +13,11 @@ This is a log of major user-visible changes in each phpMyFAQ release.
 - changed MySQL / MariaDB / Percona Server / Galera Cluster to InnoDB only (Thorsten)
 - added experimental Azure AD login (Thorsten)
 - added HTTPS support for local Docker development (Thorsten)
 - added Monolog v2 as logging solution (Thorsten)
 - added REST API v2.2 to fetch groups (Thorsten)
+- added verification of backup files (Thorsten)
 - migrated from SwiftMailer to Symfony Mailer (Thorsten)
 - updated to Bootstrap v5.1 (Thorsten)
 - updated to TinyMCE v5.10 (Thorsten)
 - updated Japanese translation (Advanced Bear)
 
diff --git a/composer.json b/composer.json
index 0d9ef6770..e1104ad9b 100644
--- a/composer.json
+++ b/composer.json
@@ -27,10 +27,11 @@
     "ext-curl": "*",
     "ext-fileinfo": "*",
     "ext-filter": "*",
     "ext-gd": "*",
     "ext-json": "*",
+    "ext-sodium": "*",
     "ext-xml": "*",
     "ext-zip": "*",
     "ext-xmlwriter": "*",
     "symfony/mailer": "^5.4",
     "guzzlehttp/guzzle": "^7.5"
diff --git a/composer.lock b/composer.lock
index d890b08ea..9eb40c108 100644
--- a/composer.lock
+++ b/composer.lock
@@ -2,11 +2,11 @@
     "_readme": [
         "This file locks the dependencies of your project to a known state",
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "dba4f03bccb10ea790582b909d306ac5",
+    "content-hash": "c4432f3947b3f562463e6633dd744fe6",
     "packages": [
         {
             "name": "abraham/twitteroauth",
             "version": "4.0.1",
             "source": {
@@ -288,20 +288,20 @@
             ],
             "time": "2022-06-18T20:57:19+00:00"
         },
         {
             "name": "elasticsearch/elasticsearch",
-            "version": "v7.17.0",
+            "version": "v7.17.1",
             "source": {
                 "type": "git",
-                "url": "https://github.com/elastic/elasticsearch-php.git",
-                "reference": "1890f9d7fde076b5a3ddcf579a802af05b2e781b"
+                "url": "git@github.com:elastic/elasticsearch-php.git",
+                "reference": "f1b8918f411b837ce5f6325e829a73518fd50367"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/elastic/elasticsearch-php/zipball/1890f9d7fde076b5a3ddcf579a802af05b2e781b",
-                "reference": "1890f9d7fde076b5a3ddcf579a802af05b2e781b",
+                "url": "https://api.github.com/repos/elastic/elasticsearch-php/zipball/f1b8918f411b837ce5f6325e829a73518fd50367",
+                "reference": "f1b8918f411b837ce5f6325e829a73518fd50367",
                 "shasum": ""
             },
             "require": {
                 "ext-json": ">=1.3.7",
                 "ezimuel/ringphp": "^1.1.2",
@@ -347,15 +347,11 @@
             "keywords": [
                 "client",
                 "elasticsearch",
                 "search"
             ],
-            "support": {
-                "issues": "https://github.com/elastic/elasticsearch-php/issues",
-                "source": "https://github.com/elastic/elasticsearch-php/tree/v7.17.0"
-            },
-            "time": "2022-02-03T13:40:04+00:00"
+            "time": "2022-09-30T12:28:55+00:00"
         },
         {
             "name": "erusev/parsedown",
             "version": "1.7.4",
             "source": {
@@ -4845,10 +4841,11 @@
         "ext-curl": "*",
         "ext-fileinfo": "*",
         "ext-filter": "*",
         "ext-gd": "*",
         "ext-json": "*",
+        "ext-sodium": "*",
         "ext-xml": "*",
         "ext-zip": "*",
         "ext-xmlwriter": "*"
     },
     "platform-dev": [],
diff --git a/phpmyfaq/admin/backup.export.php b/phpmyfaq/admin/backup.export.php
index a5cd72406..2b253b1e4 100644
--- a/phpmyfaq/admin/backup.export.php
+++ b/phpmyfaq/admin/backup.export.php
@@ -13,10 +13,11 @@
  * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
  * @link      https://www.phpmyfaq.de
  * @since     2009-08-18
  */
 
+use phpMyFAQ\Backup;
 use phpMyFAQ\Database;
 use phpMyFAQ\Database\DatabaseHelper;
 use phpMyFAQ\Filter;
 use phpMyFAQ\Helper\HttpHelper;
 use phpMyFAQ\Translation;
@@ -47,15 +48,16 @@ if ($user) {
     $user = null;
     unset($user);
 }
 
 if ($user->perm->hasPermission($user->getUserId(), 'backup')) {
-    $tables = $tableNames = $faqConfig->getDb()->getTableNames(Database::getTablePrefix());
-    $tablePrefix = (Database::getTablePrefix() !== '') ? Database::getTablePrefix() . '.phpmyfaq' : 'phpmyfaq';
+    $tables = $faqConfig->getDb()->getTableNames(Database::getTablePrefix());
     $tableNames = '';
-    $majorVersion = substr($faqConfig->getVersion(), 0, 3);
+
     $dbHelper = new DatabaseHelper($faqConfig);
+    $backup = new Backup($faqConfig, $dbHelper);
+
     $httpHelper = new HttpHelper();
     $httpHelper->addHeader();
     $httpHelper->addExtraHeader('Content-Type: application/octet-stream');
 
     switch ($action) {
@@ -80,53 +82,33 @@ if ($user->perm->hasPermission($user->getUserId(), 'backup')) {
                 }
             }
             break;
     }
 
-    $text[] = '-- pmf' . $majorVersion . ': ' . $tableNames;
-    $text[] = '-- DO NOT REMOVE THE FIRST LINE!';
-    $text[] = '-- pmftableprefix: ' . Database::getTablePrefix();
-    $text[] = '-- DO NOT REMOVE THE LINES ABOVE!';
-    $text[] = '-- Otherwise this backup will be broken.';
-
     switch ($action) {
         case 'backup_content':
-            $header = sprintf(
-                'Content-Disposition: attachment; filename=%s',
-                urlencode(
-                    sprintf(
-                        '%s-data.%s.sql',
-                        $tablePrefix,
-                        date('Y-m-d-H-i-s')
-                    )
-                )
-            );
-            $httpHelper->addExtraHeader($header);
-            foreach (explode(' ', $tableNames) as $table) {
-                echo implode("\r\n", $text);
-                if ('' !== $table) {
-                    $text = $dbHelper->buildInsertQueries('SELECT * FROM ' . $table, $table);
-                }
+            $backupQueries = $backup->generateBackupQueries($tableNames);
+            try {
+                $backupFileName = $backup->createBackup(Backup::BACKUP_TYPE_DATA, $backupQueries);
+                $header = sprintf('Content-Disposition: attachment; filename=%s', urlencode($backupFileName));
+                $httpHelper->addExtraHeader($header);
+
+                echo $backupQueries;
+            } catch (SodiumException $e) {
+                // Handle exception
             }
             break;
         case 'backup_logs':
-            $header = sprintf(
-                'Content-Disposition: attachment; filename=%s',
-                urlencode(
-                    sprintf(
-                        '%s-logs.%s.sql',
-                        $tablePrefix,
-                        date('Y-m-d-H-i-s')
-                    )
-                )
-            );
-            $httpHelper->addExtraHeader($header);
-            foreach (explode(' ', $tableNames) as $table) {
-                echo implode("\r\n", $text);
-                if ('' !== $table) {
-                    $text = $dbHelper->buildInsertQueries('SELECT * FROM ' . $table, $table);
-                }
+            $backupQueries = $backup->generateBackupQueries($tableNames);
+            try {
+                $backupFileName = $backup->createBackup(Backup::BACKUP_TYPE_LOGS, $backupQueries);
+                $header = sprintf('Content-Disposition: attachment; filename=%s', urlencode($backupFileName));
+                $httpHelper->addExtraHeader($header);
+
+                echo $backupQueries;
+            } catch (SodiumException $e) {
+                // Handle exception
             }
             break;
     }
 } else {
     echo Translation::get('err_NotAuth');
diff --git a/phpmyfaq/admin/backup.import.php b/phpmyfaq/admin/backup.import.php
index 837d6fefd..8abb85f42 100644
--- a/phpmyfaq/admin/backup.import.php
+++ b/phpmyfaq/admin/backup.import.php
@@ -13,15 +13,17 @@
  * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
  * @link      https://www.phpmyfaq.de
  * @since     2003-02-24
  */
 
+use phpMyFAQ\Backup;
 use phpMyFAQ\Component\Alert;
 use phpMyFAQ\Database;
 use phpMyFAQ\Database\DatabaseHelper;
 use phpMyFAQ\Filter;
 use phpMyFAQ\Strings;
+use phpMyFAQ\Translation;
 
 if (!defined('IS_VALID_PHPMYFAQ')) {
     http_response_code(400);
     exit();
 }
@@ -35,34 +37,52 @@ if (!isset($_SESSION['phpmyfaq_csrf_token']) || $_SESSION['phpmyfaq_csrf_token']
 }
 ?>
     <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
         <h1 class="h2">
             <i aria-hidden="true" class="fa fa-download"></i>
-            <?= $PMF_LANG['ad_csv_rest'] ?>
+            <?= Translation::get('ad_csv_rest') ?>
         </h1>
     </div>
 <?php
 
 if ($user->perm->hasPermission($user->getUserId(), 'restore') && $csrfCheck) {
     if (isset($_FILES['userfile']) && 0 === $_FILES['userfile']['error']) {
         $ok = 1;
         $fileInfo = new finfo(FILEINFO_MIME_ENCODING);
 
+        $dbHelper = new DatabaseHelper($faqConfig);
+        $backup = new Backup($faqConfig, $dbHelper);
+
         if ('utf-8' !== $fileInfo->file($_FILES['userfile']['tmp_name'])) {
             echo 'This file is not UTF-8 encoded.<br>';
             $ok = 0;
         }
+
         $handle = fopen($_FILES['userfile']['tmp_name'], 'r');
         $backupData = fgets($handle, 65536);
         $versionFound = Strings::substr($backupData, 0, 9);
         $versionExpected = '-- pmf' . substr($faqConfig->getVersion(), 0, 3);
         $queries = [];
 
+        $fileName = $_FILES['userfile']['name'];
+
+        try {
+            $verification = $backup->verifyBackup(file_get_contents($_FILES['userfile']['tmp_name']), $fileName);
+            if ($verification) {
+                $ok = 1;
+            } else {
+                $ok = 0;
+            }
+        } catch (SodiumException $e) {
+            echo 'This file cannot be verified.<br>';
+            $ok = 0;
+        }
+
         if ($versionFound !== $versionExpected) {
             printf(
                 '%s (Version check failure: "%s" found, "%s" expected)',
-                $PMF_LANG['ad_csv_no'],
+                Translation::get('ad_csv_no'),
                 $versionFound,
                 $versionExpected
             );
             $ok = 0;
         } else {
@@ -76,11 +96,11 @@ if ($user->perm->hasPermission($user->getUserId(), 'restore') && $csrfCheck) {
             $ok = 1;
         }
 
         if ($ok == 1) {
             $tablePrefix = '';
-            printf("<p>%s</p>\n", $PMF_LANG['ad_csv_prepare']);
+            printf("<p>%s</p>\n", Translation::get('ad_csv_prepare'));
             while ($backupData = fgets($handle, 65536)) {
                 $backupData = trim($backupData);
                 $backupPrefixPattern = '-- pmftableprefix:';
                 $backupPrefixPatternLength = Strings::strlen($backupPrefixPattern);
                 if (Strings::substr($backupData, 0, $backupPrefixPatternLength) === $backupPrefixPattern) {
@@ -91,15 +111,18 @@ if ($user->perm->hasPermission($user->getUserId(), 'restore') && $csrfCheck) {
                 }
             }
 
             $k = 0;
             $g = 0;
-            printf("<p>%s</p>\n", $PMF_LANG['ad_csv_process']);
+
+            printf("<p>%s</p>\n", Translation::get('ad_csv_process'));
+
             $numTables = count($queries);
             $kg = '';
             for ($i = 0; $i < $numTables; ++$i) {
                 $queries[$i] = DatabaseHelper::alignTablePrefix($queries[$i], $tablePrefix, Database::getTablePrefix());
+
                 $kg = $faqConfig->getDb()->query($queries[$i]);
                 if (!$kg) {
                     printf(
                         '<div style="alert alert-danger"><strong>Query</strong>: "%s" failed (Reason: %s)</div>%s',
                         Strings::htmlspecialchars($queries[$i], ENT_QUOTES, 'utf-8'),
@@ -117,13 +140,13 @@ if ($user->perm->hasPermission($user->getUserId(), 'restore') && $csrfCheck) {
                 }
             }
             printf(
                 '<p class="alert alert-success">%d %s %d %s</p>',
                 $g,
-                $PMF_LANG['ad_csv_of'],
+                Translation::get('ad_csv_of'),
                 $numTables,
-                $PMF_LANG['ad_csv_suc']
+                Translation::get('ad_csv_suc')
             );
         }
     } else {
         $errorMessage = match ($_FILES['userfile']['error']) {
             1 => 'The uploaded file exceeds the upload_max_filesize directive in php.ini.',
@@ -136,7 +159,7 @@ if ($user->perm->hasPermission($user->getUserId(), 'restore') && $csrfCheck) {
             default => 'Undefined error.',
         };
         echo Alert::danger('ad_csv_no', $errorMessage);
     }
 } else {
-    echo $PMF_LANG['err_NotAuth'];
+    echo Translation::get('err_NotAuth');
 }
diff --git a/phpmyfaq/assets/themes/default/scss/_mixins.scss b/phpmyfaq/assets/themes/default/scss/_mixins.scss
new file mode 100644
index 000000000..f09a9fe2d
--- /dev/null
+++ b/phpmyfaq/assets/themes/default/scss/_mixins.scss
@@ -0,0 +1,172 @@
+@mixin text-shadow($string: 0 1px 3px rgba(0, 0, 0, 0.25)) {
+  text-shadow: $string;
+}
+@mixin box-shadow($string) {
+  -webkit-box-shadow: $string;
+  -moz-box-shadow: $string;
+  box-shadow: $string;
+}
+
+@mixin box-sizing($type: border-box) {
+  -webkit-box-sizing: $type;
+  -moz-box-sizing: $type;
+  box-sizing: $type;
+}
+
+@mixin border-radius($radius: 5px) {
+  -webkit-border-radius: $radius;
+  -moz-border-radius: $radius;
+  -ms-border-radius: $radius;
+  -o-border-radius: $radius;
+  border-radius: $radius;
+
+  -moz-background-clip: padding;
+  -webkit-background-clip: padding-box;
+  background-clip: padding-box;
+}
+@mixin border-radiuses($topright: 0, $bottomright: 0, $bottomleft: 0, $topleft: 0) {
+  -webkit-border-top-right-radius: $topright;
+  -webkit-border-bottom-right-radius: $bottomright;
+  -webkit-border-bottom-left-radius: $bottomleft;
+  -webkit-border-top-left-radius: $topleft;
+
+  -moz-border-radius-topright: $topright;
+  -moz-border-radius-bottomright: $bottomright;
+  -moz-border-radius-bottomleft: $bottomleft;
+  -moz-border-radius-topleft: $topleft;
+
+  border-top-right-radius: $topright;
+  border-bottom-right-radius: $bottomright;
+  border-bottom-left-radius: $bottomleft;
+  border-top-left-radius: $topleft;
+
+  -moz-background-clip: padding;
+  -webkit-background-clip: padding-box;
+  background-clip: padding-box;
+}
+
+@mixin opacity($opacity: 0.5) {
+  -webkit-opacity: $opacity;
+  -moz-opacity: $opacity;
+  opacity: $opacity;
+}
+
+@mixin gradient($startColor: #eee, $endColor: white) {
+  background-color: $startColor;
+  background: -webkit-gradient(linear, left top, left bottom, from($startColor), to($endColor));
+  background: -webkit-linear-gradient(top, $startColor, $endColor);
+  background: -moz-linear-gradient(top, $startColor, $endColor);
+  background: -ms-linear-gradient(top, $startColor, $endColor);
+  background: -o-linear-gradient(top, $startColor, $endColor);
+}
+@mixin horizontal-gradient($startColor: #eee, $endColor: white) {
+  background-color: $startColor;
+  background-image: -webkit-gradient(linear, left top, right top, from($startColor), to($endColor));
+  background-image: -webkit-linear-gradient(left, $startColor, $endColor);
+  background-image: -moz-linear-gradient(left, $startColor, $endColor);
+  background-image: -ms-linear-gradient(left, $startColor, $endColor);
+  background-image: -o-linear-gradient(left, $startColor, $endColor);
+}
+
+@mixin animation($name, $duration: 300ms, $delay: 0, $ease: ease) {
+  -webkit-animation: $name $duration $delay $ease;
+  -moz-animation: $name $duration $delay $ease;
+  -ms-animation: $name $duration $delay $ease;
+}
+
+@mixin transition($transition) {
+  -webkit-transition: $transition;
+  -moz-transition: $transition;
+  -ms-transition: $transition;
+  -o-transition: $transition;
+}
+@mixin transform($string) {
+  -webkit-transform: $string;
+  -moz-transform: $string;
+  -ms-transform: $string;
+  -o-transform: $string;
+}
+@mixin scale($factor) {
+  -webkit-transform: scale($factor);
+  -moz-transform: scale($factor);
+  -ms-transform: scale($factor);
+  -o-transform: scale($factor);
+}
+@mixin rotate($deg) {
+  -webkit-transform: rotate($deg);
+  -moz-transform: rotate($deg);
+  -ms-transform: rotate($deg);
+  -o-transform: rotate($deg);
+}
+@mixin skew($deg, $deg2) {
+  -webkit-transform: skew($deg, $deg2);
+  -moz-transform: skew($deg, $deg2);
+  -ms-transform: skew($deg, $deg2);
+  -o-transform: skew($deg, $deg2);
+}
+@mixin translate($x, $y: 0) {
+  -webkit-transform: translate($x, $y);
+  -moz-transform: translate($x, $y);
+  -ms-transform: translate($x, $y);
+  -o-transform: translate($x, $y);
+}
+@mixin translate3d($x, $y: 0, $z: 0) {
+  -webkit-transform: translate3d($x, $y, $z);
+  -moz-transform: translate3d($x, $y, $z);
+  -ms-transform: translate3d($x, $y, $z);
+  -o-transform: translate3d($x, $y, $z);
+}
+@mixin perspective($value: 1000) {
+  -webkit-perspective: $value;
+  -moz-perspective: $value;
+  -ms-perspective: $value;
+  perspective: $value;
+}
+@mixin transform-origin($x: center, $y: center) {
+  -webkit-transform-origin: $x $y;
+  -moz-transform-origin: $x $y;
+  -ms-transform-origin: $x $y;
+  -o-transform-origin: $x $y;
+}
+
+@mixin reset-box-sizing($size: content-box) {
+  &,
+  *,
+  *:before,
+  *:after {
+    @include box-sizing($size);
+  }
+}
+
+@mixin truncate($max-width: 250px) {
+  max-width: $max-width;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+@mixin background-size($string: contain) {
+  -webkit-background-size: $string;
+  -moz-background-size: $string;
+  -o-background-size: $string;
+  background-size: $string;
+}
+
+@mixin placeholder($color: #999) {
+  &::-webkit-input-placeholder {
+    /* WebKit browsers */
+    color: $color;
+  }
+  &:-moz-placeholder {
+    /* Mozilla Firefox 4 to 18 */
+    color: $color;
+  }
+  &::-moz-placeholder {
+    /* Mozilla Firefox 19+ */
+    color: $color;
+  }
+  &:-ms-input-placeholder {
+    /* Internet Explorer 10+ */
+    color: $color;
+  }
+}
diff --git a/phpmyfaq/assets/themes/default/scss/_theme.scss b/phpmyfaq/assets/themes/default/scss/_theme.scss
new file mode 100644
index 000000000..360586672
--- /dev/null
+++ b/phpmyfaq/assets/themes/default/scss/_theme.scss
@@ -0,0 +1,26 @@
+$color-primary: #fd7e14;
+$color-green: #75c181;
+$color-red: #f77b6b;
+$color-blue: #58bbee;
+$color-orange: #f88c30;
+$color-pink: #ea5395;
+$color-purple: #8a40a7;
+
+$text-color: #494d55;
+$text-color-secondary: lighten($text-color, 10%);
+$text-grey: lighten($text-color-secondary, 25%);
+
+$grey: lighten($text-color-secondary, 25%);
+$light-grey: #c3c3c3;
+$dark-grey: #666;
+$black: #000;
+$smoky-white: #f5f5f5;
+$smoky-grey: #f9f9fb;
+$divider: #f0f0f0;
+
+$new: #60a823;
+$error: #e65348;
+$facebook: #3b5998;
+$twitter: #55acee;
+$google: #dd4b39;
+$github: #444;
diff --git a/phpmyfaq/assets/themes/default/scss/layout/_startpage.scss b/phpmyfaq/assets/themes/default/scss/layout/_startpage.scss
new file mode 100644
index 000000000..8b1378917
--- /dev/null
+++ b/phpmyfaq/assets/themes/default/scss/layout/_startpage.scss
@@ -0,0 +1 @@
+
diff --git a/phpmyfaq/setup/update.php b/phpmyfaq/setup/update.php
index fff9504d4..891c03b64 100644
--- a/phpmyfaq/setup/update.php
+++ b/phpmyfaq/setup/update.php
@@ -393,10 +393,11 @@ if ($step == 3) {
     //
     // UPDATES FROM 3.2.0-alpha
     //
     if (version_compare($version, '3.2.0-alpha', '<=')) {
         // Azure AD support
+        $faqConfig->add('security.enableSignInWithMicrosoft', false);
         if ('sqlite3' === $DB['type']) {
             $query[] = 'ALTER TABLE ' . $prefix . 'faquser 
                 ADD COLUMN refresh_token TEXT NULL DEFAULT NULL,
                 ADD COLUMN access_token TEXT NULL DEFAULT NULL,
                 ADD COLUMN code_verifier VARCHAR(255) NULL DEFAULT NULL,
@@ -406,11 +407,20 @@ if ($step == 3) {
                 ADD refresh_token TEXT NULL DEFAULT NULL,
                 ADD access_token TEXT NULL DEFAULT NULL,
                 ADD code_verifier VARCHAR(255) NULL DEFAULT NULL,
                 ADD jwt TEXT NULL DEFAULT NULL';
         }
-        $faqConfig->add('security.enableSignInWithMicrosoft', false);
+
+        // New backup
+        $query[] = 'CREATE TABLE ' . $prefix . 'faqbackup (
+            id INT(11) NOT NULL,
+            filename VARCHAR(255) NOT NULL,
+            authkey VARCHAR(255) NOT NULL,
+            authcode VARCHAR(255) NOT NULL,
+            created timestamp NOT NULL,
+            PRIMARY KEY (id))';
+
 
         if ('sqlserv' === $DB['type']) {
             // queries to update VARCHAR -> NVARCHAR on MS SQL Server
             // @todo ALTER TABLE [TableName] ALTER COLUMN [ColumnName] nvarchar(N) null
             $query[] = 'DBCC CLEANTABLE';
diff --git a/phpmyfaq/src/phpMyFAQ/Backup.php b/phpmyfaq/src/phpMyFAQ/Backup.php
new file mode 100644
index 000000000..eb9777906
--- /dev/null
+++ b/phpmyfaq/src/phpMyFAQ/Backup.php
@@ -0,0 +1,147 @@
+<?php
+
+/**
+ * Provides methods for phpMyFAQ backups
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public License,
+ * v. 2.0. If a copy of the MPL was not distributed with this file, You can
+ * obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * @package   phpMyFAQ
+ * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
+ * @copyright 2022 phpMyFAQ Team
+ * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
+ * @link      https://www.phpmyfaq.de
+ * @since     2022-10-08
+ */
+
+namespace phpMyFAQ;
+
+use phpMyFAQ\Core\Exception;
+use phpMyFAQ\Database\DatabaseHelper;
+use SodiumException;
+
+/**
+ * Class Backup
+ *
+ * @package phpMyFAQ
+ */
+class Backup
+{
+    public const BACKUP_TYPE_DATA = 'data';
+    public const BACKUP_TYPE_LOGS = 'logs';
+
+    /** @var Configuration */
+    private Configuration $config;
+
+    /** @var DatabaseHelper */
+    private DatabaseHelper $databaseHelper;
+
+    /**
+     * Constructor.
+     *
+     * @param Configuration  $config
+     * @param DatabaseHelper $databaseHelper
+     */
+    public function __construct(Configuration $config, DatabaseHelper $databaseHelper)
+    {
+        $this->config = $config;
+        $this->databaseHelper = $databaseHelper;
+    }
+
+    /**
+     * @param string $backupType
+     * @param string $backupFile
+     * @return string
+     * @throws SodiumException
+     */
+    public function createBackup(string $backupType, string $backupFile): string
+    {
+        $backupDate = date('Y-m-d-H-i-s');
+
+        $fileNamePrefix = (Database::getTablePrefix() !== '') ? Database::getTablePrefix() . '.phpmyfaq' : 'phpmyfaq';
+        $fileName = sprintf('%s-%s.%s.sql', $fileNamePrefix, $backupType, $backupDate);
+
+        $authKey = sodium_crypto_auth_keygen();
+        $authCode = sodium_crypto_auth($backupFile, $authKey);
+
+        $query = sprintf(
+            "INSERT INTO %sfaqbackup (id, filename, authkey, authcode, created) VALUES (%d, '%s', '%s', '%s', '%s')",
+            Database::getTablePrefix(),
+            $this->config->getDb()->nextId(Database::getTablePrefix() . 'faqbackup', 'id'),
+            $this->config->getDb()->escape($fileName),
+            $this->config->getDb()->escape(sodium_bin2hex($authKey)),
+            $this->config->getDb()->escape(sodium_bin2hex($authCode)),
+            $backupDate
+        );
+
+        $this->config->getDb()->query($query);
+
+        return $fileName;
+    }
+
+    /**
+     * @param string $backup
+     * @param string $backupFileName
+     * @return bool
+     * @throws SodiumException
+     */
+    public function verifyBackup(string $backup, string $backupFileName): bool
+    {
+        $query = sprintf(
+            "SELECT id, filename, authkey, authcode, created FROM %sfaqbackup WHERE filename = '%s'",
+            Database::getTablePrefix(),
+            $this->config->getDb()->escape($backupFileName),
+        );
+
+        $result = $this->config->getDb()->query($query);
+
+        if ($this->config->getDb()->numRows($result) > 0) {
+            $row = $this->config->getDb()->fetchObject($result);
+
+            return sodium_crypto_auth_verify(
+                sodium_hex2bin($row->authcode),
+                $backup,
+                sodium_hex2bin($row->authkey)
+            );
+        }
+
+        return false;
+    }
+
+    /**
+     * @param string $tableNames
+     * @return string
+     */
+    public function generateBackupQueries(string $tableNames): string
+    {
+        $backup = implode("\r\n", $this->getBackupHeader($tableNames));
+
+        foreach (explode(' ', $tableNames) as $table) {
+            if ('' !== $table) {
+                $backup .= implode(
+                    "\r\n",
+                    $this->databaseHelper->buildInsertQueries('SELECT * FROM ' . $table, $table)
+                );
+            }
+        }
+
+        return $backup;
+    }
+
+    /**
+     * Returns the backup file header
+     * @param string $tableNames
+     * @return string[]
+     */
+    private function getBackupHeader(string $tableNames): array
+    {
+        return [
+            sprintf('-- pmf%s: %s', substr($this->config->getVersion(), 0, 3), $tableNames),
+            '-- DO NOT REMOVE THE FIRST LINE!',
+            '-- pmftableprefix: ' . Database::getTablePrefix(),
+            '-- DO NOT REMOVE THE LINES ABOVE!',
+            '-- Otherwise this backup will be broken.'
+        ];
+    }
+}
diff --git a/phpmyfaq/src/phpMyFAQ/Database.php b/phpmyfaq/src/phpMyFAQ/Database.php
index ff2c4c476..4f8fcf772 100755
--- a/phpmyfaq/src/phpMyFAQ/Database.php
+++ b/phpmyfaq/src/phpMyFAQ/Database.php
@@ -139,17 +139,19 @@ class Database
     /**
      * Error page, if the database connection is not possible.
      *
      * @param string $method
      */
-    public static function errorPage(string $method)
+    public static function errorPage(string $method): void
     {
         echo '<!DOCTYPE html>
             <html lang="en" class="no-js">
             <head>
                 <meta charset="utf-8">
                 <title>Fatal phpMyFAQ Error</title>
+                <link href="assets/dist/styles.css" rel="stylesheet">
+                <script src="assets/dist/frontend.js"></script>
             </head>
             <body>
                 <div class="container">
                 <p class="alert alert-danger">The connection to the database server could not be established.</p>
                 <p class="alert alert-danger">The error message of the database server:<br>' . $method . '</p>
@@ -161,11 +163,11 @@ class Database
     /**
      * Sets the table prefix.
      *
      * @param string $tablePrefix
      */
-    public static function setTablePrefix(string $tablePrefix)
+    public static function setTablePrefix(string $tablePrefix): void
     {
         self::$tablePrefix = $tablePrefix;
     }
 
     /**
diff --git a/phpmyfaq/src/phpMyFAQ/Database/DatabaseHelper.php b/phpmyfaq/src/phpMyFAQ/Database/DatabaseHelper.php
index cfd7cf223..394081175 100644
--- a/phpmyfaq/src/phpMyFAQ/Database/DatabaseHelper.php
+++ b/phpmyfaq/src/phpMyFAQ/Database/DatabaseHelper.php
@@ -5,17 +5,17 @@
  *
  * This Source Code Form is subject to the terms of the Mozilla Public License,
  * v. 2.0. If a copy of the MPL was not distributed with this file, You can
  * obtain one at https://mozilla.org/MPL/2.0/.
  *
- * @package phpMyFAQ
- * @author Thorsten Rinne <thorsten@phpmyfaq.de>
- * @author Matteo Scaramuccia <matteo@phpmyfaq.de>
+ * @package   phpMyFAQ
+ * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
+ * @author    Matteo Scaramuccia <matteo@phpmyfaq.de>
  * @copyright 2012-2022 phpMyFAQ Team
- * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
- * @link https://www.phpmyfaq.de
- * @since 2012-04-12
+ * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
+ * @link      https://www.phpmyfaq.de
+ * @since     2012-04-12
  */
 
 namespace phpMyFAQ\Database;
 
 use phpMyFAQ\Configuration;
@@ -26,14 +26,12 @@ use phpMyFAQ\Strings;
  *
  * @package phpMyFAQ\Database
  */
 class DatabaseHelper
 {
-    /**
-     * @var Configuration
-     */
-    private $config;
+    /** @var Configuration */
+    private Configuration $config;
 
     /**
      * Constructor.
      *
      * @param Configuration $config
@@ -101,11 +99,11 @@ class DatabaseHelper
     /**
      * This function builds the queries for the backup.
      *
      * @param string $query
      * @param string $table
-     * @return array
+     * @return string[]
      */
     public function buildInsertQueries(string $query, string $table): array
     {
         if (!$result = $this->config->getDb()->query($query)) {
             return [];
@@ -116,10 +114,13 @@ class DatabaseHelper
 
         while ($row = $this->config->getDb()->fetchArray($result)) {
             $p1 = [];
             $p2 = [];
             foreach ($row as $key => $val) {
+                if (is_int($key)) {
+                    continue; // Fix for SQLite3
+                }
                 $p1[] = $key;
                 if ('rights' != $key && is_numeric($val)) {
                     $p2[] = $val;
                 } else {
                     if (is_null($val)) {
diff --git a/phpmyfaq/src/phpMyFAQ/Export.php b/phpmyfaq/src/phpMyFAQ/Export.php
index c5398d0fb..baff6a764 100644
--- a/phpmyfaq/src/phpMyFAQ/Export.php
+++ b/phpmyfaq/src/phpMyFAQ/Export.php
@@ -28,18 +28,18 @@ use phpMyFAQ\Export\Pdf;
  *
  * @package phpMyFAQ
  */
 class Export
 {
-    /** @var Faq */
-    protected $faq = null;
+    /** @var Faq|null */
+    protected ?Faq $faq = null;
 
-    /** @var Category */
-    protected $category = null;
+    /** @var Category|null */
+    protected ?Category $category = null;
 
-    /** @var Configuration */
-    protected $config = null;
+    /** @var Configuration|null */
+    protected ?Configuration $config = null;
 
     /**
      * Factory.
      *
      * @param Faq           $faq FaqHelper object
@@ -47,22 +47,18 @@ class Export
      * @param Configuration $config Configuration object
      * @param string        $mode Export
      * @return mixed
      * @throws \Exception
      */
-    public static function create(Faq $faq, Category $category, Configuration $config, string $mode = 'pdf')
+    public static function create(Faq $faq, Category $category, Configuration $config, string $mode = 'pdf'): mixed
     {
-        switch ($mode) {
-            case 'json':
-                return new Json($faq, $category, $config);
-            case 'pdf':
-                return new Pdf($faq, $category, $config);
-            case 'html5':
-                return new Html5($faq, $category, $config);
-            default:
-                throw new Exception('Export not implemented!');
-        }
+        return match ($mode) {
+            'json' => new Json($faq, $category, $config),
+            'pdf' => new Pdf($faq, $category, $config),
+            'html5' => new Html5($faq, $category, $config),
+            default => throw new Exception('Export not implemented!'),
+        };
     }
 
     /**
      * Returns the timestamp of the export.
      *
diff --git a/phpmyfaq/src/phpMyFAQ/Filesystem.php b/phpmyfaq/src/phpMyFAQ/Filesystem.php
index 79780631e..9ca175898 100644
--- a/phpmyfaq/src/phpMyFAQ/Filesystem.php
+++ b/phpmyfaq/src/phpMyFAQ/Filesystem.php
@@ -27,31 +27,31 @@ use phpMyFAQ\Core\Exception;
 class Filesystem
 {
     /**
      * @var string
      */
-    private $rootPath;
+    private string $rootPath;
 
     /**
      * @var string
      */
-    private $path;
+    private string $path;
 
     /**
      * @var string[]
      */
-    private $folders = [];
+    private array $folders = [];
 
     /**
      * Constructor, sets the root path of the master phpMyFAQ installation.
      *
      * @param string $rootPath
      */
-    public function __construct($rootPath = '')
+    public function __construct(string $rootPath = '')
     {
         if (empty($rootPath)) {
-            $this->rootPath = dirname(dirname(__DIR__));
+            $this->rootPath = dirname(__DIR__, 2);
         } else {
             $this->rootPath = $rootPath;
         }
     }
 
@@ -139,11 +139,11 @@ class Filesystem
      * @param int    $mode      The mode is 0777 by default
      * @param bool   $recursive Allows the creation of nested directories
      *                          specified in the pathname.
      * @return bool
      */
-    public function createDirectory(string $pathname, $mode = 0777, $recursive = false): bool
+    public function createDirectory(string $pathname, int $mode = 0777, bool $recursive = false): bool
     {
         if (is_dir($pathname)) {
             return true; // Directory already exists
         }
 
diff --git a/phpmyfaq/src/phpMyFAQ/Helper/CategoryHelper.php b/phpmyfaq/src/phpMyFAQ/Helper/CategoryHelper.php
index 7e1e5179e..06d22fb23 100644
--- a/phpmyfaq/src/phpMyFAQ/Helper/CategoryHelper.php
+++ b/phpmyfaq/src/phpMyFAQ/Helper/CategoryHelper.php
@@ -340,35 +340,24 @@ class CategoryHelper extends Helper
         if (count($categories) === 0) {
             return '';
         }
 
         $decks = '';
-        $key = 1;
         foreach ($categories as $category) {
-            $decks .= '<div class="card mb-4"><a href="' . $category['url'] . '">';
+            $decks .= '<div class="phpmyfaq-startpage-item phpmyfaq-startpage-item-primary col-lg-4 col-6">';
+            $decks .= ' <div class="phpmyfaq-startpage-item-inner">';
+            $decks .= '   <div class="icon-holder">';
             if ('' !== $category['image']) {
                 $decks .= '<img class="card-img-top embed-responsive-item" width="200" alt="' .
-                $category['name'] . '" src="' . $category['image'] . '" />';
+                    $category['name'] . '" src="' . $category['image'] . '" />';
             }
-            $decks .= '</a>' .
-                '<div class="card-body">' .
-                '<h4 class="card-title text-center">' .
-                '<a href="' . $category['url'] . '">' . $category['name'] . '</a>' .
-                '</h4>' .
-                '<p class="card-text">' . $category['description'] . '</p>' .
-                '</div>' .
-                '</div>';
-            if ($key % 2 === 0) {
-                $decks .= '<div class="w-100 d-none d-sm-block d-md-none"></div>';
-            }
-            if ($key % 3 === 0) {
-                $decks .= '<div class="w-100 d-none d-md-block d-lg-none"></div>';
-            }
-            if ($key % 4 === 0) {
-                $decks .= '<div class="w-100 d-none d-lg-block d-xl-block"></div>';
-            }
-            $key++;
+            $decks .= '     </div>';
+            $decks .= '     <h3 class="title">' . $category['name'] . '</h3>';
+            $decks .= '     <p class="intro">' . $category['description'] . '</p>';
+            $decks .= '     <a class="link" href="' . $category['url'] . '"><span></span></a>';
+            $decks .= ' </div>';
+            $decks .= '</div>';
         }
 
         return $decks;
     }
 
diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/Mysqli.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/Mysqli.php
index d8a3a1988..3592ff090 100644
--- a/phpmyfaq/src/phpMyFAQ/Instance/Database/Mysqli.php
+++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/Mysqli.php
@@ -55,10 +55,18 @@ class Mysqli extends Database implements Driver
         'faqattachment_file' => 'CREATE TABLE %sfaqattachment_file (
             virtual_hash CHAR(32) NOT NULL,
             contents BLOB NOT NULL,
             PRIMARY KEY (virtual_hash)) DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci',
 
+        'faqbackup' => 'CREATE TABLE %sfaqbackup (
+            id INT(11) NOT NULL,
+            filename VARCHAR(255) NOT NULL,
+            authkey VARCHAR(255) NOT NULL,
+            authcode VARCHAR(255) NOT NULL,
+            created timestamp NOT NULL,
+            PRIMARY KEY (id))',
+
         'faqcaptcha' => 'CREATE TABLE %sfaqcaptcha (
             id VARCHAR(6) NOT NULL,
             useragent VARCHAR(255) NOT NULL,
             language VARCHAR(5) NOT NULL,
             ip VARCHAR(64) NOT NULL,
diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/Pgsql.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/Pgsql.php
index 3e89931d6..36991945a 100644
--- a/phpmyfaq/src/phpMyFAQ/Instance/Database/Pgsql.php
+++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/Pgsql.php
@@ -57,10 +57,18 @@ class Pgsql extends Database implements Driver
         'faqattachment_file' => 'CREATE TABLE %sfaqattachment_file (
             virtual_hash CHAR(32) NOT NULL,
             contents BYTEA,
             PRIMARY KEY (virtual_hash))',
 
+        'faqbackup' => 'CREATE TABLE %sfaqbackup (
+            id INT(11) NOT NULL,
+            filename VARCHAR(255) NOT NULL,
+            authkey VARCHAR(255) NOT NULL,
+            authcode VARCHAR(255) NOT NULL,
+            created timestamp NOT NULL,
+            PRIMARY KEY (id))',
+
         'faqcaptcha' => 'CREATE TABLE %sfaqcaptcha (
             id VARCHAR(6) NOT NULL,
             useragent VARCHAR(255) NOT NULL,
             language VARCHAR(5) NOT NULL,
             ip VARCHAR(64) NOT NULL,
diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlite3.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlite3.php
index 68b834082..a74ea9d4d 100644
--- a/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlite3.php
+++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlite3.php
@@ -55,10 +55,18 @@ class Sqlite3 extends Database implements Driver
         'faqattachment_file' => 'CREATE TABLE %sfaqattachment_file (
             virtual_hash CHAR(32) NOT NULL,
             contents TEXT NOT NULL,
             PRIMARY KEY (virtual_hash))',
 
+        'faqbackup' => 'CREATE TABLE %sfaqbackup (
+            id INT(11) NOT NULL,
+            filename VARCHAR(255) NOT NULL,
+            authkey VARCHAR(255) NOT NULL,
+            authcode VARCHAR(255) NOT NULL,
+            created timestamp NOT NULL,
+            PRIMARY KEY (id))',
+
         'faqcaptcha' => 'CREATE TABLE %sfaqcaptcha (
             id VARCHAR(6) NOT NULL,
             useragent VARCHAR(255) NOT NULL,
             language VARCHAR(5) NOT NULL,
             ip VARCHAR(64) NOT NULL,
diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlsrv.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlsrv.php
index a264f501f..164e25e37 100644
--- a/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlsrv.php
+++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlsrv.php
@@ -55,10 +55,18 @@ class Sqlsrv extends Database implements Driver
         'faqattachment_file' => 'CREATE TABLE %sfaqattachment_file (
             virtual_hash CHAR(32) NOT NULL,
             contents NVARCHAR(MAX) NOT NULL,
             PRIMARY KEY (virtual_hash))',
 
+        'faqbackup' => 'CREATE TABLE %sfaqbackup (
+            id INT(11) NOT NULL,
+            filename VARCHAR(255) NOT NULL,
+            authkey VARCHAR(255) NOT NULL,
+            authcode VARCHAR(255) NOT NULL,
+            created timestamp NOT NULL,
+            PRIMARY KEY (id))',
+
         'faqcaptcha' => 'CREATE TABLE %sfaqcaptcha (
             id NVARCHAR(6) NOT NULL,
             useragent NVARCHAR(255) NOT NULL,
             language NVARCHAR(5) NOT NULL,
             ip NVARCHAR(64) NOT NULL,
diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Setup.php b/phpmyfaq/src/phpMyFAQ/Instance/Setup.php
index 84a38be90..0478d637a 100644
--- a/phpmyfaq/src/phpMyFAQ/Instance/Setup.php
+++ b/phpmyfaq/src/phpMyFAQ/Instance/Setup.php
@@ -29,11 +29,11 @@ use phpMyFAQ\User;
 class Setup
 {
     /**
      * @var string
      */
-    private $rootDir;
+    private string $rootDir;
 
     /**
      * Setup constructor.
      */
     public function __construct()
@@ -44,11 +44,11 @@ class Setup
     /**
      * Sets the root directory of the phpMyFAQ instance.
      *
      * @param string $rootDir
      */
-    public function setRootDir($rootDir)
+    public function setRootDir(string $rootDir): void
     {
         $this->rootDir = $rootDir;
     }
 
     /**
@@ -71,13 +71,13 @@ class Setup
 
     /**
      * Checks basic folders and creates them if necessary.
      *
      * @param  string[] $dirs
-     * @return array
+     * @return string[]
      */
-    public function checkDirs(array $dirs)
+    public function checkDirs(array $dirs): array
     {
         $failedDirs = [];
 
         foreach ($dirs as $dir) {
             if (false === is_dir($this->rootDir . $dir)) {
@@ -111,15 +111,15 @@ class Setup
     }
 
     /**
      * Creates the file /config/database.php.
      *
-     * @param  array  $data   Array with database credentials
-     * @param  string $folder Folder
+     * @param  int[]|string[] $data   Array with database credentials
+     * @param  string         $folder Folder
      * @return int|bool
      */
-    public function createDatabaseFile(array $data, $folder = '/config')
+    public function createDatabaseFile(array $data, string $folder = '/config'): int|bool
     {
         return file_put_contents(
             $this->rootDir . $folder . '/database.php',
             "<?php\n" .
             "\$DB['server'] = '" . $data['dbServer'] . "';\n" .
@@ -134,15 +134,15 @@ class Setup
     }
 
     /**
      * Creates the file /config/ldap.php.
      *
-     * @param  array  $data   Array with LDAP credentials
-     * @param  string $folder Folder
+     * @param  int[]|string[] $data   Array with LDAP credentials
+     * @param  string         $folder Folder
      * @return int|bool
      */
-    public function createLdapFile(array $data, string $folder = '/config')
+    public function createLdapFile(array $data, string $folder = '/config'): int|bool
     {
         return file_put_contents(
             $this->rootDir . $folder . '/config/ldap.php',
             "<?php\n" .
             "\$PMF_LDAP['ldap_server'] = '" . $data['ldapServer'] . "';\n" .
@@ -155,15 +155,15 @@ class Setup
     }
 
     /**
      * Creates the file /config/elasticsearch.php
      *
-     * @param  array  $data   Array with LDAP credentials
-     * @param  string $folder Folder
+     * @param  int[]|string[] $data   Array with Elasticsearch credentials
+     * @param  string         $folder Folder
      * @return int|bool
      */
-    public function createElasticsearchFile(array $data, string $folder = '/config')
+    public function createElasticsearchFile(array $data, string $folder = '/config'): int|bool
     {
         return file_put_contents(
             $this->rootDir . $folder . '/config/elasticsearch.php',
             "<?php\n" .
             "\$PMF_ES['hosts'] = ['" . implode("'], ['", $data['hosts']) . "'];\n" .
diff --git a/tests/phpMyFAQ/ApiTest.php b/tests/phpMyFAQ/ApiTest.php
index dd98963ad..e3d0e9c48 100644
--- a/tests/phpMyFAQ/ApiTest.php
+++ b/tests/phpMyFAQ/ApiTest.php
@@ -11,11 +11,10 @@ use PHPUnit\Framework\TestCase;
  * @testdox The phpMyFAQ API should
  * @package phpMyFAQ
  */
 class ApiTest extends TestCase
 {
-
     /** @var Configuration */
     private Configuration $configuration;
 
     protected function setUp(): void
     {
@@ -27,10 +26,11 @@ class ApiTest extends TestCase
         $this->configuration->set('main.currentVersion', System::getVersion());
     }
 
     /**
      * @testdox return the available versions
+     * @throws Core\Exception
      */
     public function testGetVersions(): void
     {
         $mockedApi = $this->getMockBuilder('phpMyFAQ\Api')->disableOriginalConstructor()->getMock();
 
@@ -50,10 +50,11 @@ class ApiTest extends TestCase
         $this->assertEquals($versions, $response);
     }
 
     /**
      * @testdox return the current verification hashes
+     * @throws Core\Exception
      */
     public function testGetVerificationIssues(): void
     {
         $mockedApi = $this->getMockBuilder('phpMyFAQ\Api')->disableOriginalConstructor()->getMock();
 
diff --git a/tests/phpMyFAQ/BackupTest.php b/tests/phpMyFAQ/BackupTest.php
new file mode 100644
index 000000000..dbc1c475c
--- /dev/null
+++ b/tests/phpMyFAQ/BackupTest.php
@@ -0,0 +1,87 @@
+<?php
+
+namespace phpMyFAQ;
+
+use phpMyFAQ\Database\DatabaseHelper;
+use phpMyFAQ\Database\Sqlite3;
+use PHPUnit\Framework\TestCase;
+use SodiumException;
+
+/**
+ * Class BackupTest
+ *
+ * @testdox Backup should
+ * @package phpMyFAQ
+ */
+class BackupTest extends TestCase
+{
+    /** @var Configuration */
+    protected Configuration $configuration;
+
+    /** @var DatabaseHelper */
+    protected DatabaseHelper $databaseHelper;
+
+    /** @var Backup */
+    protected Backup $backup;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $dbHandle = new Sqlite3();
+        $dbHandle->connect(PMF_TEST_DIR . '/test.db', '', '');
+
+        $this->configuration = new Configuration($dbHandle);
+        $this->configuration->config['main.currentVersion'] = System::getVersion();
+
+        $this->databaseHelper = new DatabaseHelper($this->configuration);
+
+        $this->backup = new Backup($this->configuration, $this->databaseHelper);
+    }
+
+    /**
+     * @testdox create a complete backup file
+     * @throws SodiumException
+     */
+    public function testCreateBackup(): void
+    {
+        $tableNames = 'faqconfig faqinstances';
+        $backupQueries = $this->backup->generateBackupQueries($tableNames);
+        $dataBackup = $this->backup->createBackup(Backup::BACKUP_TYPE_DATA, $backupQueries);
+        $expected = 'phpmyfaq-data.' . date('Y-m-d-H-i-s') . '.sql';
+
+        $this->assertEquals($expected, $dataBackup);
+
+        $tableNames = 'faqadminlog faqsessions';
+        $backupQueries = $this->backup->generateBackupQueries($tableNames);
+        $logsBackup = $this->backup->createBackup(Backup::BACKUP_TYPE_LOGS, $backupQueries);
+        $expected = 'phpmyfaq-logs.' . date('Y-m-d-H-i-s') . '.sql';
+
+        $this->assertEquals($expected, $logsBackup);
+    }
+
+    /**
+     * @throws SodiumException
+     */
+    public function testVerifyBackup(): void
+    {
+        $tableNames = 'faqconfig faqinstances';
+        $backupQueries = $this->backup->generateBackupQueries($tableNames);
+        $dataBackup = $this->backup->createBackup(Backup::BACKUP_TYPE_DATA, $backupQueries);
+
+        $result = $this->backup->verifyBackup($backupQueries, $dataBackup);
+
+        $this->assertTrue($result);
+    }
+
+    /**
+     * @testdox generates correct INSERT queries for the backup
+     */
+    public function testGenerateBackupQueries(): void
+    {
+        $tableNames = 'faqconfig faqinstances';
+        $queries = $this->backup->generateBackupQueries($tableNames);
+
+        $this->assertStringContainsString('DO NOT REMOVE THE FIRST LINE!', $queries);
+    }
+}
diff --git a/tests/phpMyFAQ/Database/DatabaseHelperTest.php b/tests/phpMyFAQ/Database/DatabaseHelperTest.php
new file mode 100644
index 000000000..7db4e93c4
--- /dev/null
+++ b/tests/phpMyFAQ/Database/DatabaseHelperTest.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace phpMyFAQ\Database;
+
+use phpMyFAQ\Configuration;
+use phpMyFAQ\System;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Class DatabaseHelperTest
+ *
+ * @testdox DatabaseHelper should
+ * @package phpMyFAQ
+ */
+class DatabaseHelperTest extends TestCase
+{
+    /** @var DatabaseHelper */
+    private DatabaseHelper $databaseHelper;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $dbHandle = new Sqlite3();
+        $dbHandle->connect(PMF_TEST_DIR . '/test.db', '', '');
+        $dbHandle->query(
+            'CREATE TABLE faqtest (name VARCHAR(255) NOT NULL, testvalue VARCHAR(255) DEFAULT NULL, PRIMARY KEY (name))'
+        );
+        $dbHandle->query("INSERT INTO faqtest (name,testvalue) VALUES ('foo','bar')");
+        $dbHandle->query("INSERT INTO faqtest (name,testvalue) VALUES ('bar','baz')");
+
+        $configuration = new Configuration($dbHandle);
+        $configuration->config['main.currentVersion'] = System::getVersion();
+
+        $this->databaseHelper = new DatabaseHelper($configuration);
+    }
+
+    protected function tearDown(): void
+    {
+        parent::tearDown();
+
+        $dbHandle = new Sqlite3();
+        $dbHandle->connect(PMF_TEST_DIR . '/test.db', '', '');
+        $dbHandle->query('DROP TABLE faqtest');
+    }
+
+    /**
+     * @testdox create the correct INSERT queries
+     */
+    public function testBuildInsertQueries(): void
+    {
+        $table = 'faqtest';
+        $queries = $this->databaseHelper->buildInsertQueries('SELECT * FROM ' . $table, $table);
+
+        $expected = [
+            "\r\n-- Table: faqtest",
+            "INSERT INTO faqtest (name,testvalue) VALUES ('foo','bar');",
+            "INSERT INTO faqtest (name,testvalue) VALUES ('bar','baz');"
+        ];
+
+        $this->assertEquals($expected, $queries);
+    }
+}

Vulnerable code:     "content-hash": "dba4f03bccb10ea790582b909d306ac5",
            "version": "v7.17.0",
                "url": "https://github.com/elastic/elasticsearch-php.git",
                "reference": "1890f9d7fde076b5a3ddcf579a802af05b2e781b"
                "url": "https://api.github.com/repos/elastic/elasticsearch-php/zipball/1890f9d7fde076b5a3ddcf579a802af05b2e781b",
                "reference": "1890f9d7fde076b5a3ddcf579a802af05b2e781b",
            "support": {
                "issues": "https://github.com/elastic/elasticsearch-php/issues",
                "source": "https://github.com/elastic/elasticsearch-php/tree/v7.17.0"
            },
            "time": "2022-02-03T13:40:04+00:00"
    $tables = $tableNames = $faqConfig->getDb()->getTableNames(Database::getTablePrefix());
    $tablePrefix = (Database::getTablePrefix() !== '') ? Database::getTablePrefix() . '.phpmyfaq' : 'phpmyfaq';
    $majorVersion = substr($faqConfig->getVersion(), 0, 3);
    $text[] = '-- pmf' . $majorVersion . ': ' . $tableNames;
    $text[] = '-- DO NOT REMOVE THE FIRST LINE!';
    $text[] = '-- pmftableprefix: ' . Database::getTablePrefix();
    $text[] = '-- DO NOT REMOVE THE LINES ABOVE!';
    $text[] = '-- Otherwise this backup will be broken.';

            $header = sprintf(
                'Content-Disposition: attachment; filename=%s',
                urlencode(
                    sprintf(
                        '%s-data.%s.sql',
                        $tablePrefix,
                        date('Y-m-d-H-i-s')
                    )
                )
            );
            $httpHelper->addExtraHeader($header);
            foreach (explode(' ', $tableNames) as $table) {
                echo implode("\r\n", $text);
                if ('' !== $table) {
                    $text = $dbHelper->buildInsertQueries('SELECT * FROM ' . $table, $table);
                }
            $header = sprintf(
                'Content-Disposition: attachment; filename=%s',
                urlencode(
                    sprintf(
                        '%s-logs.%s.sql',
                        $tablePrefix,
                        date('Y-m-d-H-i-s')
                    )
                )
            );
            $httpHelper->addExtraHeader($header);
            foreach (explode(' ', $tableNames) as $table) {
                echo implode("\r\n", $text);
                if ('' !== $table) {
                    $text = $dbHelper->buildInsertQueries('SELECT * FROM ' . $table, $table);
                }
            <?= $PMF_LANG['ad_csv_rest'] ?>
                $PMF_LANG['ad_csv_no'],
            printf("<p>%s</p>\n", $PMF_LANG['ad_csv_prepare']);
            printf("<p>%s</p>\n", $PMF_LANG['ad_csv_process']);
                $PMF_LANG['ad_csv_of'],
                $PMF_LANG['ad_csv_suc']
    echo $PMF_LANG['err_NotAuth'];
        $faqConfig->add('security.enableSignInWithMicrosoft', false);
    public static function errorPage(string $method)
    public static function setTablePrefix(string $tablePrefix)
 * @package phpMyFAQ
 * @author Thorsten Rinne <thorsten@phpmyfaq.de>
 * @author Matteo Scaramuccia <matteo@phpmyfaq.de>
 * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
 * @link https://www.phpmyfaq.de
 * @since 2012-04-12
    /**
     * @var Configuration
     */
    private $config;
     * @return array
    /** @var Faq */
    protected $faq = null;
    /** @var Category */
    protected $category = null;
    /** @var Configuration */
    protected $config = null;
    public static function create(Faq $faq, Category $category, Configuration $config, string $mode = 'pdf')
        switch ($mode) {
            case 'json':
                return new Json($faq, $category, $config);
            case 'pdf':
                return new Pdf($faq, $category, $config);
            case 'html5':
                return new Html5($faq, $category, $config);
            default:
                throw new Exception('Export not implemented!');
        }
    private $rootPath;
    private $path;
    private $folders = [];
    public function __construct($rootPath = '')
            $this->rootPath = dirname(dirname(__DIR__));
    public function createDirectory(string $pathname, $mode = 0777, $recursive = false): bool
        $key = 1;
            $decks .= '<div class="card mb-4"><a href="' . $category['url'] . '">';
                $category['name'] . '" src="' . $category['image'] . '" />';
            $decks .= '</a>' .
                '<div class="card-body">' .
                '<h4 class="card-title text-center">' .
                '<a href="' . $category['url'] . '">' . $category['name'] . '</a>' .
                '</h4>' .
                '<p class="card-text">' . $category['description'] . '</p>' .
                '</div>' .
                '</div>';
            if ($key % 2 === 0) {
                $decks .= '<div class="w-100 d-none d-sm-block d-md-none"></div>';
            }
            if ($key % 3 === 0) {
                $decks .= '<div class="w-100 d-none d-md-block d-lg-none"></div>';
            }
            if ($key % 4 === 0) {
                $decks .= '<div class="w-100 d-none d-lg-block d-xl-block"></div>';
            }
            $key++;
    private $rootDir;
    public function setRootDir($rootDir)
     * @return array
    public function checkDirs(array $dirs)
     * @param  array  $data   Array with database credentials
     * @param  string $folder Folder
    public function createDatabaseFile(array $data, $folder = '/config')
     * @param  array  $data   Array with LDAP credentials
     * @param  string $folder Folder
    public function createLdapFile(array $data, string $folder = '/config')
     * @param  array  $data   Array with LDAP credentials
     * @param  string $folder Folder
    public function createElasticsearchFile(array $data, string $folder = '/config')

Fixed code: - added verification of backup files (Thorsten)
    "ext-sodium": "*",
    "content-hash": "c4432f3947b3f562463e6633dd744fe6",
            "version": "v7.17.1",
                "url": "git@github.com:elastic/elasticsearch-php.git",
                "reference": "f1b8918f411b837ce5f6325e829a73518fd50367"
                "url": "https://api.github.com/repos/elastic/elasticsearch-php/zipball/f1b8918f411b837ce5f6325e829a73518fd50367",
                "reference": "f1b8918f411b837ce5f6325e829a73518fd50367",
            "time": "2022-09-30T12:28:55+00:00"
        "ext-sodium": "*",
use phpMyFAQ\Backup;
    $tables = $faqConfig->getDb()->getTableNames(Database::getTablePrefix());

    $backup = new Backup($faqConfig, $dbHelper);

            $backupQueries = $backup->generateBackupQueries($tableNames);
            try {
                $backupFileName = $backup->createBackup(Backup::BACKUP_TYPE_DATA, $backupQueries);
                $header = sprintf('Content-Disposition: attachment; filename=%s', urlencode($backupFileName));
                $httpHelper->addExtraHeader($header);

                echo $backupQueries;
            } catch (SodiumException $e) {
                // Handle exception
            $backupQueries = $backup->generateBackupQueries($tableNames);
            try {
                $backupFileName = $backup->createBackup(Backup::BACKUP_TYPE_LOGS, $backupQueries);
                $header = sprintf('Content-Disposition: attachment; filename=%s', urlencode($backupFileName));
                $httpHelper->addExtraHeader($header);

                echo $backupQueries;
            } catch (SodiumException $e) {
                // Handle exception
use phpMyFAQ\Backup;
use phpMyFAQ\Translation;
            <?= Translation::get('ad_csv_rest') ?>
        $dbHelper = new DatabaseHelper($faqConfig);
        $backup = new Backup($faqConfig, $dbHelper);


        $fileName = $_FILES['userfile']['name'];

        try {
            $verification = $backup->verifyBackup(file_get_contents($_FILES['userfile']['tmp_name']), $fileName);
            if ($verification) {
                $ok = 1;
            } else {
                $ok = 0;
            }
        } catch (SodiumException $e) {
            echo 'This file cannot be verified.<br>';
            $ok = 0;
        }

                Translation::get('ad_csv_no'),
            printf("<p>%s</p>\n", Translation::get('ad_csv_prepare'));

            printf("<p>%s</p>\n", Translation::get('ad_csv_process'));


                Translation::get('ad_csv_of'),
                Translation::get('ad_csv_suc')
    echo Translation::get('err_NotAuth');
@mixin text-shadow($string: 0 1px 3px rgba(0, 0, 0, 0.25)) {
  text-shadow: $string;
}
@mixin box-shadow($string) {
  -webkit-box-shadow: $string;
  -moz-box-shadow: $string;
  box-shadow: $string;
}

@mixin box-sizing($type: border-box) {
  -webkit-box-sizing: $type;
  -moz-box-sizing: $type;
  box-sizing: $type;
}

@mixin border-radius($radius: 5px) {
  -webkit-border-radius: $radius;
  -moz-border-radius: $radius;
  -ms-border-radius: $radius;
  -o-border-radius: $radius;
  border-radius: $radius;

  -moz-background-clip: padding;
  -webkit-background-clip: padding-box;
  background-clip: padding-box;
}
@mixin border-radiuses($topright: 0, $bottomright: 0, $bottomleft: 0, $topleft: 0) {
  -webkit-border-top-right-radius: $topright;
  -webkit-border-bottom-right-radius: $bottomright;
  -webkit-border-bottom-left-radius: $bottomleft;
  -webkit-border-top-left-radius: $topleft;

  -moz-border-radius-topright: $topright;
  -moz-border-radius-bottomright: $bottomright;
  -moz-border-radius-bottomleft: $bottomleft;
  -moz-border-radius-topleft: $topleft;

  border-top-right-radius: $topright;
  border-bottom-right-radius: $bottomright;
  border-bottom-left-radius: $bottomleft;
  border-top-left-radius: $topleft;

  -moz-background-clip: padding;
  -webkit-background-clip: padding-box;
  background-clip: padding-box;
}

@mixin opacity($opacity: 0.5) {
  -webkit-opacity: $opacity;
  -moz-opacity: $opacity;
  opacity: $opacity;
}

@mixin gradient($startColor: #eee, $endColor: white) {
  background-color: $startColor;
  background: -webkit-gradient(linear, left top, left bottom, from($startColor), to($endColor));
  background: -webkit-linear-gradient(top, $startColor, $endColor);
  background: -moz-linear-gradient(top, $startColor, $endColor);
  background: -ms-linear-gradient(top, $startColor, $endColor);
  background: -o-linear-gradient(top, $startColor, $endColor);
}
@mixin horizontal-gradient($startColor: #eee, $endColor: white) {
  background-color: $startColor;
  background-image: -webkit-gradient(linear, left top, right top, from($startColor), to($endColor));
  background-image: -webkit-linear-gradient(left, $startColor, $endColor);
  background-image: -moz-linear-gradient(left, $startColor, $endColor);
  background-image: -ms-linear-gradient(left, $startColor, $endColor);
  background-image: -o-linear-gradient(left, $startColor, $endColor);
}

@mixin animation($name, $duration: 300ms, $delay: 0, $ease: ease) {
  -webkit-animation: $name $duration $delay $ease;
  -moz-animation: $name $duration $delay $ease;
  -ms-animation: $name $duration $delay $ease;
}

@mixin transition($transition) {
  -webkit-transition: $transition;
  -moz-transition: $transition;
  -ms-transition: $transition;
  -o-transition: $transition;
}
@mixin transform($string) {
  -webkit-transform: $string;
  -moz-transform: $string;
  -ms-transform: $string;
  -o-transform: $string;
}
@mixin scale($factor) {
  -webkit-transform: scale($factor);
  -moz-transform: scale($factor);
  -ms-transform: scale($factor);
  -o-transform: scale($factor);
}
@mixin rotate($deg) {
  -webkit-transform: rotate($deg);
  -moz-transform: rotate($deg);
  -ms-transform: rotate($deg);
  -o-transform: rotate($deg);
}
@mixin skew($deg, $deg2) {
  -webkit-transform: skew($deg, $deg2);
  -moz-transform: skew($deg, $deg2);
  -ms-transform: skew($deg, $deg2);
  -o-transform: skew($deg, $deg2);
}
@mixin translate($x, $y: 0) {
  -webkit-transform: translate($x, $y);
  -moz-transform: translate($x, $y);
  -ms-transform: translate($x, $y);
  -o-transform: translate($x, $y);
}
@mixin translate3d($x, $y: 0, $z: 0) {
  -webkit-transform: translate3d($x, $y, $z);
  -moz-transform: translate3d($x, $y, $z);
  -ms-transform: translate3d($x, $y, $z);
  -o-transform: translate3d($x, $y, $z);
}
@mixin perspective($value: 1000) {
  -webkit-perspective: $value;
  -moz-perspective: $value;
  -ms-perspective: $value;
  perspective: $value;
}
@mixin transform-origin($x: center, $y: center) {
  -webkit-transform-origin: $x $y;
  -moz-transform-origin: $x $y;
  -ms-transform-origin: $x $y;
  -o-transform-origin: $x $y;
}

@mixin reset-box-sizing($size: content-box) {
  &,
  *,
  *:before,
  *:after {
    @include box-sizing($size);
  }
}

@mixin truncate($max-width: 250px) {
  max-width: $max-width;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

@mixin background-size($string: contain) {
  -webkit-background-size: $string;
  -moz-background-size: $string;
  -o-background-size: $string;
  background-size: $string;
}

@mixin placeholder($color: #999) {
  &::-webkit-input-placeholder {
    /* WebKit browsers */
    color: $color;
  }
  &:-moz-placeholder {
    /* Mozilla Firefox 4 to 18 */
    color: $color;
  }
  &::-moz-placeholder {
    /* Mozilla Firefox 19+ */
    color: $color;
  }
  &:-ms-input-placeholder {
    /* Internet Explorer 10+ */
    color: $color;
  }
}
$color-primary: #fd7e14;
$color-green: #75c181;
$color-red: #f77b6b;
$color-blue: #58bbee;
$color-orange: #f88c30;
$color-pink: #ea5395;
$color-purple: #8a40a7;

$text-color: #494d55;
$text-color-secondary: lighten($text-color, 10%);
$text-grey: lighten($text-color-secondary, 25%);

$grey: lighten($text-color-secondary, 25%);
$light-grey: #c3c3c3;
$dark-grey: #666;
$black: #000;
$smoky-white: #f5f5f5;
$smoky-grey: #f9f9fb;
$divider: #f0f0f0;

$new: #60a823;
$error: #e65348;
$facebook: #3b5998;
$twitter: #55acee;
$google: #dd4b39;
$github: #444;

        $faqConfig->add('security.enableSignInWithMicrosoft', false);

        // New backup
        $query[] = 'CREATE TABLE ' . $prefix . 'faqbackup (
            id INT(11) NOT NULL,
            filename VARCHAR(255) NOT NULL,
            authkey VARCHAR(255) NOT NULL,
            authcode VARCHAR(255) NOT NULL,
            created timestamp NOT NULL,
            PRIMARY KEY (id))';

<?php

/**
 * Provides methods for phpMyFAQ backups
 *
 * This Source Code Form is subject to the terms of the Mozilla Public License,
 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
 * obtain one at https://mozilla.org/MPL/2.0/.
 *
 * @package   phpMyFAQ
 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
 * @copyright 2022 phpMyFAQ Team
 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
 * @link      https://www.phpmyfaq.de
 * @since     2022-10-08
 */

namespace phpMyFAQ;

use phpMyFAQ\Core\Exception;
use phpMyFAQ\Database\DatabaseHelper;
use SodiumException;

/**
 * Class Backup
 *
 * @package phpMyFAQ
 */
class Backup
{
    public const BACKUP_TYPE_DATA = 'data';
    public const BACKUP_TYPE_LOGS = 'logs';

    /** @var Configuration */
    private Configuration $config;

    /** @var DatabaseHelper */
    private DatabaseHelper $databaseHelper;

    /**
     * Constructor.
     *
     * @param Configuration  $config
     * @param DatabaseHelper $databaseHelper
     */
    public function __construct(Configuration $config, DatabaseHelper $databaseHelper)
    {
        $this->config = $config;
        $this->databaseHelper = $databaseHelper;
    }

    /**
     * @param string $backupType
     * @param string $backupFile
     * @return string
     * @throws SodiumException
     */
    public function createBackup(string $backupType, string $backupFile): string
    {
        $backupDate = date('Y-m-d-H-i-s');

        $fileNamePrefix = (Database::getTablePrefix() !== '') ? Database::getTablePrefix() . '.phpmyfaq' : 'phpmyfaq';
        $fileName = sprintf('%s-%s.%s.sql', $fileNamePrefix, $backupType, $backupDate);

        $authKey = sodium_crypto_auth_keygen();
        $authCode = sodium_crypto_auth($backupFile, $authKey);

        $query = sprintf(
            "INSERT INTO %sfaqbackup (id, filename, authkey, authcode, created) VALUES (%d, '%s', '%s', '%s', '%s')",
            Database::getTablePrefix(),
            $this->config->getDb()->nextId(Database::getTablePrefix() . 'faqbackup', 'id'),
            $this->config->getDb()->escape($fileName),
            $this->config->getDb()->escape(sodium_bin2hex($authKey)),
            $this->config->getDb()->escape(sodium_bin2hex($authCode)),
            $backupDate
        );

        $this->config->getDb()->query($query);

        return $fileName;
    }

    /**
     * @param string $backup
     * @param string $backupFileName
     * @return bool
     * @throws SodiumException
     */
    public function verifyBackup(string $backup, string $backupFileName): bool
    {
        $query = sprintf(
            "SELECT id, filename, authkey, authcode, created FROM %sfaqbackup WHERE filename = '%s'",
            Database::getTablePrefix(),
            $this->config->getDb()->escape($backupFileName),
        );

        $result = $this->config->getDb()->query($query);

        if ($this->config->getDb()->numRows($result) > 0) {
            $row = $this->config->getDb()->fetchObject($result);

            return sodium_crypto_auth_verify(
                sodium_hex2bin($row->authcode),
                $backup,
                sodium_hex2bin($row->authkey)
            );
        }

        return false;
    }

    /**
     * @param string $tableNames
     * @return string
     */
    public function generateBackupQueries(string $tableNames): string
    {
        $backup = implode("\r\n", $this->getBackupHeader($tableNames));

        foreach (explode(' ', $tableNames) as $table) {
            if ('' !== $table) {
                $backup .= implode(
                    "\r\n",
                    $this->databaseHelper->buildInsertQueries('SELECT * FROM ' . $table, $table)
                );
            }
        }

        return $backup;
    }

    /**
     * Returns the backup file header
     * @param string $tableNames
     * @return string[]
     */
    private function getBackupHeader(string $tableNames): array
    {
        return [
            sprintf('-- pmf%s: %s', substr($this->config->getVersion(), 0, 3), $tableNames),
            '-- DO NOT REMOVE THE FIRST LINE!',
            '-- pmftableprefix: ' . Database::getTablePrefix(),
            '-- DO NOT REMOVE THE LINES ABOVE!',
            '-- Otherwise this backup will be broken.'
        ];
    }
}
    public static function errorPage(string $method): void
                <link href="assets/dist/styles.css" rel="stylesheet">
                <script src="assets/dist/frontend.js"></script>
    public static function setTablePrefix(string $tablePrefix): void
 * @package   phpMyFAQ
 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
 * @author    Matteo Scaramuccia <matteo@phpmyfaq.de>
 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
 * @link      https://www.phpmyfaq.de
 * @since     2012-04-12
    /** @var Configuration */
    private Configuration $config;
     * @return string[]
                if (is_int($key)) {
                    continue; // Fix for SQLite3
                }
    /** @var Faq|null */
    protected ?Faq $faq = null;
    /** @var Category|null */
    protected ?Category $category = null;
    /** @var Configuration|null */
    protected ?Configuration $config = null;
    public static function create(Faq $faq, Category $category, Configuration $config, string $mode = 'pdf'): mixed
        return match ($mode) {
            'json' => new Json($faq, $category, $config),
            'pdf' => new Pdf($faq, $category, $config),
            'html5' => new Html5($faq, $category, $config),
            default => throw new Exception('Export not implemented!'),
        };
    private string $rootPath;
    private string $path;
    private array $folders = [];
    public function __construct(string $rootPath = '')
            $this->rootPath = dirname(__DIR__, 2);
    public function createDirectory(string $pathname, int $mode = 0777, bool $recursive = false): bool
            $decks .= '<div class="phpmyfaq-startpage-item phpmyfaq-startpage-item-primary col-lg-4 col-6">';
            $decks .= ' <div class="phpmyfaq-startpage-item-inner">';
            $decks .= '   <div class="icon-holder">';
                    $category['name'] . '" src="' . $category['image'] . '" />';
            $decks .= '     </div>';
            $decks .= '     <h3 class="title">' . $category['name'] . '</h3>';
            $decks .= '     <p class="intro">' . $category['description'] . '</p>';
            $decks .= '     <a class="link" href="' . $category['url'] . '"><span></span></a>';
            $decks .= ' </div>';
            $decks .= '</div>';
        'faqbackup' => 'CREATE TABLE %sfaqbackup (
            id INT(11) NOT NULL,
            filename VARCHAR(255) NOT NULL,
            authkey VARCHAR(255) NOT NULL,
            authcode VARCHAR(255) NOT NULL,
            created timestamp NOT NULL,
            PRIMARY KEY (id))',

        'faqbackup' => 'CREATE TABLE %sfaqbackup (
            id INT(11) NOT NULL,
            filename VARCHAR(255) NOT NULL,
            authkey VARCHAR(255) NOT NULL,
            authcode VARCHAR(255) NOT NULL,
            created timestamp NOT NULL,
            PRIMARY KEY (id))',

        'faqbackup' => 'CREATE TABLE %sfaqbackup (
            id INT(11) NOT NULL,
            filename VARCHAR(255) NOT NULL,
            authkey VARCHAR(255) NOT NULL,
            authcode VARCHAR(255) NOT NULL,
            created timestamp NOT NULL,
            PRIMARY KEY (id))',

        'faqbackup' => 'CREATE TABLE %sfaqbackup (
            id INT(11) NOT NULL,
            filename VARCHAR(255) NOT NULL,
            authkey VARCHAR(255) NOT NULL,
            authcode VARCHAR(255) NOT NULL,
            created timestamp NOT NULL,
            PRIMARY KEY (id))',

    private string $rootDir;
    public function setRootDir(string $rootDir): void
     * @return string[]
    public function checkDirs(array $dirs): array
     * @param  int[]|string[] $data   Array with database credentials
     * @param  string         $folder Folder
    public function createDatabaseFile(array $data, string $folder = '/config'): int|bool
     * @param  int[]|string[] $data   Array with LDAP credentials
     * @param  string         $folder Folder
    public function createLdapFile(array $data, string $folder = '/config'): int|bool
     * @param  int[]|string[] $data   Array with Elasticsearch credentials
     * @param  string         $folder Folder
    public function createElasticsearchFile(array $data, string $folder = '/config'): int|bool
     * @throws Core\Exception
     * @throws Core\Exception
<?php

namespace phpMyFAQ;

use phpMyFAQ\Database\DatabaseHelper;
use phpMyFAQ\Database\Sqlite3;
use PHPUnit\Framework\TestCase;
use SodiumException;

/**
 * Class BackupTest
 *
 * @testdox Backup should
 * @package phpMyFAQ
 */
class BackupTest extends TestCase
{
    /** @var Configuration */
    protected Configuration $configuration;

    /** @var DatabaseHelper */
    protected DatabaseHelper $databaseHelper;

    /** @var Backup */
    protected Backup $backup;

    protected function setUp(): void
    {
        parent::setUp();

        $dbHandle = new Sqlite3();
        $dbHandle->connect(PMF_TEST_DIR . '/test.db', '', '');

        $this->configuration = new Configuration($dbHandle);
        $this->configuration->config['main.currentVersion'] = System::getVersion();

        $this->databaseHelper = new DatabaseHelper($this->configuration);

        $this->backup = new Backup($this->configuration, $this->databaseHelper);
    }

    /**
     * @testdox create a complete backup file
     * @throws SodiumException
     */
    public function testCreateBackup(): void
    {
        $tableNames = 'faqconfig faqinstances';
        $backupQueries = $this->backup->generateBackupQueries($tableNames);
        $dataBackup = $this->backup->createBackup(Backup::BACKUP_TYPE_DATA, $backupQueries);
        $expected = 'phpmyfaq-data.' . date('Y-m-d-H-i-s') . '.sql';

        $this->assertEquals($expected, $dataBackup);

        $tableNames = 'faqadminlog faqsessions';
        $backupQueries = $this->backup->generateBackupQueries($tableNames);
        $logsBackup = $this->backup->createBackup(Backup::BACKUP_TYPE_LOGS, $backupQueries);
        $expected = 'phpmyfaq-logs.' . date('Y-m-d-H-i-s') . '.sql';

        $this->assertEquals($expected, $logsBackup);
    }

    /**
     * @throws SodiumException
     */
    public function testVerifyBackup(): void
    {
        $tableNames = 'faqconfig faqinstances';
        $backupQueries = $this->backup->generateBackupQueries($tableNames);
        $dataBackup = $this->backup->createBackup(Backup::BACKUP_TYPE_DATA, $backupQueries);

        $result = $this->backup->verifyBackup($backupQueries, $dataBackup);

        $this->assertTrue($result);
    }

    /**
     * @testdox generates correct INSERT queries for the backup
     */
    public function testGenerateBackupQueries(): void
    {
        $tableNames = 'faqconfig faqinstances';
        $queries = $this->backup->generateBackupQueries($tableNames);

        $this->assertStringContainsString('DO NOT REMOVE THE FIRST LINE!', $queries);
    }
}
<?php

namespace phpMyFAQ\Database;

use phpMyFAQ\Configuration;
use phpMyFAQ\System;
use PHPUnit\Framework\TestCase;

/**
 * Class DatabaseHelperTest
 *
 * @testdox DatabaseHelper should
 * @package phpMyFAQ
 */
class DatabaseHelperTest extends TestCase
{
    /** @var DatabaseHelper */
    private DatabaseHelper $databaseHelper;

    protected function setUp(): void
    {
        parent::setUp();

        $dbHandle = new Sqlite3();
        $dbHandle->connect(PMF_TEST_DIR . '/test.db', '', '');
        $dbHandle->query(
            'CREATE TABLE faqtest (name VARCHAR(255) NOT NULL, testvalue VARCHAR(255) DEFAULT NULL, PRIMARY KEY (name))'
        );
        $dbHandle->query("INSERT INTO faqtest (name,testvalue) VALUES ('foo','bar')");
        $dbHandle->query("INSERT INTO faqtest (name,testvalue) VALUES ('bar','baz')");

        $configuration = new Configuration($dbHandle);
        $configuration->config['main.currentVersion'] = System::getVersion();

        $this->databaseHelper = new DatabaseHelper($configuration);
    }

    protected function tearDown(): void
    {
        parent::tearDown();

        $dbHandle = new Sqlite3();
        $dbHandle->connect(PMF_TEST_DIR . '/test.db', '', '');
        $dbHandle->query('DROP TABLE faqtest');
    }

    /**
     * @testdox create the correct INSERT queries
     */
    public function testBuildInsertQueries(): void
    {
        $table = 'faqtest';
        $queries = $this->databaseHelper->buildInsertQueries('SELECT * FROM ' . $table, $table);

        $expected = [
            "\r\n-- Table: faqtest",
            "INSERT INTO faqtest (name,testvalue) VALUES ('foo','bar');",
            "INSERT INTO faqtest (name,testvalue) VALUES ('bar','baz');"
        ];

        $this->assertEquals($expected, $queries);
    }
}