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 @@
%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 '' . $category['description'] . '
' . - '' . $category['description'] . '
'; + $decks .= ' '; + $decks .= '