From 37123edd50f854bd141e6fbe65221af2d5cf2677 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sun, 9 Oct 2022 17:51:32 +0200 Subject: [PATCH] feat(backup): added verification of backup files --- CHANGELOG.md | 1 + composer.json | 1 + composer.lock | 19 +- phpmyfaq/admin/backup.export.php | 64 +++---- phpmyfaq/admin/backup.import.php | 37 +++- .../assets/themes/default/scss/_mixins.scss | 172 ++++++++++++++++++ .../assets/themes/default/scss/_theme.scss | 26 +++ .../default/scss/layout/_startpage.scss | 1 + phpmyfaq/setup/update.php | 12 +- phpmyfaq/src/phpMyFAQ/Backup.php | 147 +++++++++++++++ phpmyfaq/src/phpMyFAQ/Database.php | 6 +- .../src/phpMyFAQ/Database/DatabaseHelper.php | 23 +-- phpmyfaq/src/phpMyFAQ/Export.php | 30 ++- phpmyfaq/src/phpMyFAQ/Filesystem.php | 12 +- .../src/phpMyFAQ/Helper/CategoryHelper.php | 31 +--- .../src/phpMyFAQ/Instance/Database/Mysqli.php | 8 + .../src/phpMyFAQ/Instance/Database/Pgsql.php | 8 + .../phpMyFAQ/Instance/Database/Sqlite3.php | 8 + .../src/phpMyFAQ/Instance/Database/Sqlsrv.php | 8 + phpmyfaq/src/phpMyFAQ/Instance/Setup.php | 26 +-- tests/phpMyFAQ/ApiTest.php | 3 +- tests/phpMyFAQ/BackupTest.php | 87 +++++++++ .../phpMyFAQ/Database/DatabaseHelperTest.php | 63 +++++++ 23 files changed, 662 insertions(+), 131 deletions(-) create mode 100644 phpmyfaq/assets/themes/default/scss/_mixins.scss create mode 100644 phpmyfaq/assets/themes/default/scss/_theme.scss create mode 100644 phpmyfaq/assets/themes/default/scss/layout/_startpage.scss create mode 100644 phpmyfaq/src/phpMyFAQ/Backup.php create mode 100644 tests/phpMyFAQ/BackupTest.php create mode 100644 tests/phpMyFAQ/Database/DatabaseHelperTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bd30f95da..e138680f07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ This is a log of major user-visible changes in each phpMyFAQ release. - 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) diff --git a/composer.json b/composer.json index 0d9ef67704..e1104ad9b6 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,7 @@ "ext-filter": "*", "ext-gd": "*", "ext-json": "*", + "ext-sodium": "*", "ext-xml": "*", "ext-zip": "*", "ext-xmlwriter": "*", diff --git a/composer.lock b/composer.lock index d890b08ead..9eb40c108a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "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", @@ -290,16 +290,16 @@ }, { "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": { @@ -349,11 +349,7 @@ "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", @@ -4847,6 +4843,7 @@ "ext-filter": "*", "ext-gd": "*", "ext-json": "*", + "ext-sodium": "*", "ext-xml": "*", "ext-zip": "*", "ext-xmlwriter": "*" diff --git a/phpmyfaq/admin/backup.export.php b/phpmyfaq/admin/backup.export.php index a5cd724060..2b253b1e42 100644 --- a/phpmyfaq/admin/backup.export.php +++ b/phpmyfaq/admin/backup.export.php @@ -15,6 +15,7 @@ * @since 2009-08-18 */ +use phpMyFAQ\Backup; use phpMyFAQ\Database; use phpMyFAQ\Database\DatabaseHelper; use phpMyFAQ\Filter; @@ -49,11 +50,12 @@ } 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'); @@ -82,49 +84,29 @@ 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; } diff --git a/phpmyfaq/admin/backup.import.php b/phpmyfaq/admin/backup.import.php index 837d6fefd0..8abb85f42f 100644 --- a/phpmyfaq/admin/backup.import.php +++ b/phpmyfaq/admin/backup.import.php @@ -15,11 +15,13 @@ * @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); @@ -37,7 +39,7 @@

- +

file($_FILES['userfile']['tmp_name'])) { echo 'This file is not UTF-8 encoded.
'; $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.
'; + $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 ); @@ -78,7 +98,7 @@ if ($ok == 1) { $tablePrefix = ''; - printf("

%s

\n", $PMF_LANG['ad_csv_prepare']); + printf("

%s

\n", Translation::get('ad_csv_prepare')); while ($backupData = fgets($handle, 65536)) { $backupData = trim($backupData); $backupPrefixPattern = '-- pmftableprefix:'; @@ -93,11 +113,14 @@ $k = 0; $g = 0; - printf("

%s

\n", $PMF_LANG['ad_csv_process']); + + printf("

%s

\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( @@ -119,9 +142,9 @@ printf( '

%d %s %d %s

', $g, - $PMF_LANG['ad_csv_of'], + Translation::get('ad_csv_of'), $numTables, - $PMF_LANG['ad_csv_suc'] + Translation::get('ad_csv_suc') ); } } else { @@ -138,5 +161,5 @@ 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 0000000000..f09a9fe2d1 --- /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 0000000000..3605866724 --- /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 0000000000..8b13789179 --- /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 fff9504d4f..891c03b64e 100644 --- a/phpmyfaq/setup/update.php +++ b/phpmyfaq/setup/update.php @@ -395,6 +395,7 @@ // 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, @@ -408,7 +409,16 @@ 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 diff --git a/phpmyfaq/src/phpMyFAQ/Backup.php b/phpmyfaq/src/phpMyFAQ/Backup.php new file mode 100644 index 0000000000..eb97779065 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Backup.php @@ -0,0 +1,147 @@ + + * @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 ff2c4c4767..4f8fcf7724 100755 --- a/phpmyfaq/src/phpMyFAQ/Database.php +++ b/phpmyfaq/src/phpMyFAQ/Database.php @@ -141,13 +141,15 @@ public static function checkOnEmptyTable(string $tableName): bool * * @param string $method */ - public static function errorPage(string $method) + public static function errorPage(string $method): void { echo ' Fatal phpMyFAQ Error + +
@@ -163,7 +165,7 @@ public static function errorPage(string $method) * * @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 cfd7cf2235..3940811751 100644 --- a/phpmyfaq/src/phpMyFAQ/Database/DatabaseHelper.php +++ b/phpmyfaq/src/phpMyFAQ/Database/DatabaseHelper.php @@ -7,13 +7,13 @@ * 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 - * @author Matteo Scaramuccia + * @package phpMyFAQ + * @author Thorsten Rinne + * @author Matteo Scaramuccia * @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; @@ -28,10 +28,8 @@ */ class DatabaseHelper { - /** - * @var Configuration - */ - private $config; + /** @var Configuration */ + private Configuration $config; /** * Constructor. @@ -103,7 +101,7 @@ private static function alignTablePrefixByPattern( * * @param string $query * @param string $table - * @return array + * @return string[] */ public function buildInsertQueries(string $query, string $table): array { @@ -118,6 +116,9 @@ public function buildInsertQueries(string $query, string $table): array $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; diff --git a/phpmyfaq/src/phpMyFAQ/Export.php b/phpmyfaq/src/phpMyFAQ/Export.php index c5398d0fb9..baff6a764c 100644 --- a/phpmyfaq/src/phpMyFAQ/Export.php +++ b/phpmyfaq/src/phpMyFAQ/Export.php @@ -30,14 +30,14 @@ */ 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. @@ -49,18 +49,14 @@ class 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!'), + }; } /** diff --git a/phpmyfaq/src/phpMyFAQ/Filesystem.php b/phpmyfaq/src/phpMyFAQ/Filesystem.php index 79780631e8..9ca1758985 100644 --- a/phpmyfaq/src/phpMyFAQ/Filesystem.php +++ b/phpmyfaq/src/phpMyFAQ/Filesystem.php @@ -29,27 +29,27 @@ 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; } @@ -141,7 +141,7 @@ public function recursiveCopy(string $source, string $dest): bool * 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 7e1e5179e7..06d22fb23b 100644 --- a/phpmyfaq/src/phpMyFAQ/Helper/CategoryHelper.php +++ b/phpmyfaq/src/phpMyFAQ/Helper/CategoryHelper.php @@ -342,31 +342,20 @@ public function renderStartPageCategories(array $categories): string } $decks = ''; - $key = 1; foreach ($categories as $category) { - $decks .= '
'; + $decks .= '
'; + $decks .= '
'; + $decks .= ' '; - if ($key % 2 === 0) { - $decks .= '
'; - } - if ($key % 3 === 0) { - $decks .= '
'; - } - if ($key % 4 === 0) { - $decks .= '
'; - } - $key++; + $decks .= '
'; + $decks .= '

' . $category['name'] . '

'; + $decks .= '

' . $category['description'] . '

'; + $decks .= ' '; + $decks .= '
'; + $decks .= '
'; } return $decks; diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/Mysqli.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/Mysqli.php index d8a3a1988f..3592ff0909 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/Mysqli.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/Mysqli.php @@ -57,6 +57,14 @@ class Mysqli extends Database implements Driver 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, diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/Pgsql.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/Pgsql.php index 3e89931d63..36991945a3 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/Pgsql.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/Pgsql.php @@ -59,6 +59,14 @@ class Pgsql extends Database implements Driver 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, diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlite3.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlite3.php index 68b8340824..a74ea9d4d3 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlite3.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlite3.php @@ -57,6 +57,14 @@ class Sqlite3 extends Database implements Driver 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, diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlsrv.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlsrv.php index a264f501f6..164e25e377 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlsrv.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlsrv.php @@ -57,6 +57,14 @@ class Sqlsrv extends Database implements Driver 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, diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Setup.php b/phpmyfaq/src/phpMyFAQ/Instance/Setup.php index 84a38be906..0478d637a7 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Setup.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Setup.php @@ -31,7 +31,7 @@ class Setup /** * @var string */ - private $rootDir; + private string $rootDir; /** * Setup constructor. @@ -46,7 +46,7 @@ public function __construct() * * @param string $rootDir */ - public function setRootDir($rootDir) + public function setRootDir(string $rootDir): void { $this->rootDir = $rootDir; } @@ -73,9 +73,9 @@ public function createAnonymousUser(Configuration $faqConfig): void * 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 = []; @@ -113,11 +113,11 @@ public function checkDirs(array $dirs) /** * 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', @@ -136,11 +136,11 @@ public function createDatabaseFile(array $data, $folder = '/config') /** * 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', @@ -157,11 +157,11 @@ public function createLdapFile(array $data, string $folder = '/config') /** * 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', diff --git a/tests/phpMyFAQ/ApiTest.php b/tests/phpMyFAQ/ApiTest.php index dd98963ad3..e3d0e9c48c 100644 --- a/tests/phpMyFAQ/ApiTest.php +++ b/tests/phpMyFAQ/ApiTest.php @@ -13,7 +13,6 @@ */ class ApiTest extends TestCase { - /** @var Configuration */ private Configuration $configuration; @@ -29,6 +28,7 @@ protected function setUp(): void /** * @testdox return the available versions + * @throws Core\Exception */ public function testGetVersions(): void { @@ -52,6 +52,7 @@ public function testGetVersions(): void /** * @testdox return the current verification hashes + * @throws Core\Exception */ public function testGetVerificationIssues(): void { diff --git a/tests/phpMyFAQ/BackupTest.php b/tests/phpMyFAQ/BackupTest.php new file mode 100644 index 0000000000..dbc1c475c0 --- /dev/null +++ b/tests/phpMyFAQ/BackupTest.php @@ -0,0 +1,87 @@ +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 0000000000..7db4e93c4c --- /dev/null +++ b/tests/phpMyFAQ/Database/DatabaseHelperTest.php @@ -0,0 +1,63 @@ +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); + } +}