From 3e97224391af39018b65eaf4fe3d028965df154c Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Mon, 2 Feb 2026 08:09:37 +0100 Subject: [PATCH 1/6] refactor(setup): improved installation and setup classes --- phpmyfaq/src/phpMyFAQ/Instance/Database.php | 20 +- .../src/phpMyFAQ/Session/SessionWrapper.php | 8 - .../Setup/Installation/DatabaseSchema.php | 655 ++++++++++ .../Setup/Installation/DefaultDataSeeder.php | 506 ++++++++ .../Setup/Installation/SchemaInstaller.php | 149 +++ .../src/phpMyFAQ/Setup/InstallationInput.php | 47 + .../Setup/InstallationInputValidator.php | 327 +++++ .../src/phpMyFAQ/Setup/InstallationRunner.php | 391 ++++++ phpmyfaq/src/phpMyFAQ/Setup/Installer.php | 1055 +---------------- .../Operations/FormInputInsertOperation.php | 71 ++ .../Operations/OperationRecorder.php | 36 + .../Operations/UserCreateOperation.php | 87 ++ .../QueryBuilder/Dialect/MysqlDialect.php | 10 + .../QueryBuilder/Dialect/PostgresDialect.php | 10 + .../QueryBuilder/Dialect/SqlServerDialect.php | 10 + .../QueryBuilder/Dialect/SqliteDialect.php | 13 +- .../QueryBuilder/DialectInterface.php | 10 + .../Migration/QueryBuilder/TableBuilder.php | 56 +- phpmyfaq/src/phpMyFAQ/Setup/Update.php | 15 +- tests/phpMyFAQ/Instance/DatabaseTest.php | 12 +- tests/phpMyFAQ/Session/SessionWrapperTest.php | 10 +- .../Setup/Installation/DatabaseSchemaTest.php | 186 +++ .../Installation/DefaultDataSeederTest.php | 101 ++ .../Installation/SchemaInstallerTest.php | 124 ++ .../Setup/InstallationInputValidatorTest.php | 56 + .../phpMyFAQ/Setup/InstallationRunnerTest.php | 102 ++ .../QueryBuilder/Dialect/MysqlDialectTest.php | 15 +- .../QueryBuilder/TableBuilderTest.php | 62 +- 28 files changed, 3046 insertions(+), 1098 deletions(-) create mode 100644 phpmyfaq/src/phpMyFAQ/Setup/Installation/DatabaseSchema.php create mode 100644 phpmyfaq/src/phpMyFAQ/Setup/Installation/DefaultDataSeeder.php create mode 100644 phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php create mode 100644 phpmyfaq/src/phpMyFAQ/Setup/InstallationInput.php create mode 100644 phpmyfaq/src/phpMyFAQ/Setup/InstallationInputValidator.php create mode 100644 phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php create mode 100644 phpmyfaq/src/phpMyFAQ/Setup/Migration/Operations/FormInputInsertOperation.php create mode 100644 phpmyfaq/src/phpMyFAQ/Setup/Migration/Operations/UserCreateOperation.php create mode 100644 tests/phpMyFAQ/Setup/Installation/DatabaseSchemaTest.php create mode 100644 tests/phpMyFAQ/Setup/Installation/DefaultDataSeederTest.php create mode 100644 tests/phpMyFAQ/Setup/Installation/SchemaInstallerTest.php create mode 100644 tests/phpMyFAQ/Setup/InstallationInputValidatorTest.php create mode 100644 tests/phpMyFAQ/Setup/InstallationRunnerTest.php diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database.php b/phpmyfaq/src/phpMyFAQ/Instance/Database.php index 64d9e8bcf0..416f7c76f0 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database.php @@ -22,6 +22,8 @@ use phpMyFAQ\Configuration; use phpMyFAQ\Core\Exception; use phpMyFAQ\Instance\Database\DriverInterface; +use phpMyFAQ\Setup\Installation\SchemaInstaller; +use phpMyFAQ\Setup\Migration\QueryBuilder\DialectFactory; /** * Class Database @@ -87,25 +89,23 @@ private function __construct( /** * Database factory. * + * Returns a SchemaInstaller that uses the dialect-agnostic DatabaseSchema. + * * @param Configuration $configuration phpMyFAQ configuration container * @param string $type Database management system type * @throws Exception */ public static function factory(Configuration $configuration, string $type): ?DriverInterface { - if (str_starts_with($type, 'pdo_')) { - $class = 'phpMyFAQ\Instance\Database\Pdo' . ucfirst(substr($type, 4)); - } else { - $class = 'phpMyFAQ\Instance\Database\\' . ucfirst($type); + try { + $dialect = DialectFactory::createForType(strtolower($type)); + } catch (\InvalidArgumentException) { + throw new Exception('Invalid Database Type: ' . $type); } - if (class_exists($class)) { - self::$driver = new $class($configuration); - - return self::$driver; - } + self::$driver = new SchemaInstaller($configuration, $dialect); - throw new Exception('Invalid Database Type: ' . $type); + return self::$driver; } /** diff --git a/phpmyfaq/src/phpMyFAQ/Session/SessionWrapper.php b/phpmyfaq/src/phpMyFAQ/Session/SessionWrapper.php index a0854be943..473cb700d0 100644 --- a/phpmyfaq/src/phpMyFAQ/Session/SessionWrapper.php +++ b/phpmyfaq/src/phpMyFAQ/Session/SessionWrapper.php @@ -71,12 +71,4 @@ public function remove(string $key): mixed { return $this->session->remove($key); } - - /** - * Get the underlying Symfony Session instance - */ - public function getSession(): Session - { - return $this->session; - } } diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Installation/DatabaseSchema.php b/phpmyfaq/src/phpMyFAQ/Setup/Installation/DatabaseSchema.php new file mode 100644 index 0000000000..4b72f247b1 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Setup/Installation/DatabaseSchema.php @@ -0,0 +1,655 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-31 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Setup\Installation; + +use phpMyFAQ\Setup\Migration\QueryBuilder\DialectInterface; +use phpMyFAQ\Setup\Migration\QueryBuilder\TableBuilder; + +class DatabaseSchema +{ + public function __construct( + private readonly DialectInterface $dialect, + ) { + } + + /** + * Returns all table definitions in creation order. + * + * @return array + */ + public function getAllTables(): array + { + return [ + 'faqadminlog' => $this->adminLog(), + 'faqattachment' => $this->attachment(), + 'faqattachment_file' => $this->attachmentFile(), + 'faqbackup' => $this->backup(), + 'faqbookmarks' => $this->faqbookmarks(), + 'faqcaptcha' => $this->faqcaptcha(), + 'faqcategories' => $this->faqcategories(), + 'faqcategory_news' => $this->faqcategoryNews(), + 'faqcategoryrelations' => $this->faqcategoryrelations(), + 'faqcategory_group' => $this->faqcategoryGroup(), + 'faqcategory_user' => $this->faqcategoryUser(), + 'faqcategory_order' => $this->faqcategoryOrder(), + 'faqchanges' => $this->faqchanges(), + 'faqcomments' => $this->faqcomments(), + 'faqconfig' => $this->faqconfig(), + 'faqdata' => $this->faqdata(), + 'faqdata_revisions' => $this->faqdataRevisions(), + 'faqdata_group' => $this->faqdataGroup(), + 'faqdata_tags' => $this->faqdataTags(), + 'faqdata_user' => $this->faqdataUser(), + 'faqforms' => $this->faqforms(), + 'faqglossary' => $this->faqglossary(), + 'faqgroup' => $this->faqgroup(), + 'faqgroup_right' => $this->faqgroupRight(), + 'faqinstances' => $this->faqinstances(), + 'faqinstances_config' => $this->faqinstancesConfig(), + 'faqnews' => $this->faqnews(), + 'faqcustompages' => $this->faqcustompages(), + 'faqquestions' => $this->faqquestions(), + 'faqright' => $this->faqright(), + 'faqsearches' => $this->faqsearches(), + 'faqseo' => $this->faqseo(), + 'faqsessions' => $this->faqsessions(), + 'faqstopwords' => $this->faqstopwords(), + 'faqtags' => $this->faqtags(), + 'faquser' => $this->faquser(), + 'faquserdata' => $this->faquserdata(), + 'faquserlogin' => $this->faquserlogin(), + 'faquser_group' => $this->faquserGroup(), + 'faquser_right' => $this->faquserRight(), + 'faqvisits' => $this->faqvisits(), + 'faqvoting' => $this->faqvoting(), + 'faqchat_messages' => $this->faqchatMessages(), + ]; + } + + /** + * Returns the table names in order. + * + * @return string[] + */ + public function getTableNames(): array + { + return array_keys($this->getAllTables()); + } + + public function adminLog(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqadminlog') + ->integer('id', false) + ->integer('time', false) + ->integer('usr', false) + ->text('text', false) + ->varchar('hash', 64) + ->varchar('previous_hash', 64) + ->varchar('ip', 64, false) + ->primaryKey('id'); + } + + public function attachment(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqattachment') + ->integer('id', false) + ->integer('record_id', false) + ->varchar('record_lang', 5, false) + ->char('real_hash', 32, false) + ->char('virtual_hash', 32, false) + ->char('password_hash', 40) + ->varchar('filename', 255, false) + ->integer('filesize', false) + ->integer('encrypted', false, 0) + ->varchar('mime_type', 255) + ->primaryKey('id'); + } + + public function attachmentFile(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqattachment_file') + ->char('virtual_hash', 32, false) + ->blob('contents', false) + ->primaryKey('virtual_hash'); + } + + public function backup(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqbackup') + ->integer('id', false) + ->varchar('filename', 255, false) + ->varchar('authkey', 255, false) + ->varchar('authcode', 255, false) + ->timestamp('created', false) + ->primaryKey('id'); + } + + public function faqbookmarks(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqbookmarks') + ->integer('userid') + ->integer('faqid'); + } + + public function faqcaptcha(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqcaptcha') + ->varchar('id', 6, false) + ->varchar('useragent', 255, false) + ->varchar('language', 5, false) + ->varchar('ip', 64, false) + ->integer('captcha_time', false) + ->primaryKey('id'); + } + + public function faqcategories(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqcategories') + ->integer('id', false) + ->varchar('lang', 5, false) + ->integer('parent_id', false) + ->varchar('name', 255, false) + ->varchar('description', 255) + ->integer('user_id', false) + ->integer('group_id', false, -1) + ->integer('active', true, 1) + ->varchar('image', 255) + ->smallInteger('show_home') + ->primaryKey(['id', 'lang']); + } + + public function faqcategoryNews(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqcategory_news') + ->integer('category_id', false) + ->integer('news_id', false) + ->primaryKey(['category_id', 'news_id']); + } + + public function faqcategoryrelations(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqcategoryrelations') + ->integer('category_id', false) + ->varchar('category_lang', 5, false) + ->integer('record_id', false) + ->varchar('record_lang', 5, false) + ->primaryKey(['category_id', 'category_lang', 'record_id', 'record_lang']) + ->index('idx_records', ['record_id', 'record_lang']); + } + + public function faqcategoryGroup(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqcategory_group') + ->integer('category_id', false) + ->integer('group_id', false) + ->primaryKey(['category_id', 'group_id']); + } + + public function faqcategoryUser(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqcategory_user') + ->integer('category_id', false) + ->integer('user_id', false) + ->primaryKey(['category_id', 'user_id']); + } + + public function faqcategoryOrder(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqcategory_order') + ->integer('category_id', false) + ->integer('parent_id') + ->integer('position', false) + ->primaryKey('category_id'); + } + + public function faqchanges(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqchanges') + ->integer('id', false) + ->smallInteger('beitrag', false) + ->varchar('lang', 5, false) + ->integer('revision_id', false, 0) + ->integer('usr', false) + ->integer('datum', false) + ->text('what') + ->primaryKey(['id', 'lang']); + } + + public function faqcomments(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqcomments') + ->integer('id_comment', false) + ->integer('id', false) + ->varchar('type', 10, false) + ->varchar('usr', 255, false) + ->varchar('email', 255, false) + ->text('comment', false) + ->varchar('datum', 64, false) + ->text('helped') + ->primaryKey('id_comment'); + } + + public function faqconfig(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqconfig') + ->varchar('config_name', 255, false, '') + ->text('config_value') + ->primaryKey('config_name'); + } + + public function faqdata(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqdata') + ->integer('id', false) + ->varchar('lang', 5, false) + ->integer('solution_id', false) + ->integer('revision_id', false, 0) + ->char('active', 3, false) + ->integer('sticky', false) + ->text('keywords') + ->text('thema', false) + ->longText('content') + ->varchar('author', 255, false) + ->varchar('email', 255, false) + ->char('comment', 1, true, 'y') + ->varchar('updated', 15, false) + ->varchar('date_start', 14, false, '00000000000000') + ->varchar('date_end', 14, false, '99991231235959') + ->timestamp('created', true, true) + ->text('notes') + ->integer('sticky_order') + ->fullTextIndex(['keywords', 'thema', 'content']) + ->primaryKey(['id', 'lang']); + } + + public function faqdataRevisions(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqdata_revisions') + ->integer('id', false) + ->varchar('lang', 5, false) + ->integer('solution_id', false) + ->integer('revision_id', false, 0) + ->char('active', 3, false) + ->integer('sticky', false) + ->text('keywords') + ->text('thema', false) + ->longText('content') + ->varchar('author', 255, false) + ->varchar('email', 255, false) + ->char('comment', 1, true, 'y') + ->varchar('updated', 15, false) + ->varchar('date_start', 14, false, '00000000000000') + ->varchar('date_end', 14, false, '99991231235959') + ->timestamp('created', true, true) + ->text('notes') + ->integer('sticky_order') + ->primaryKey(['id', 'lang', 'solution_id', 'revision_id']); + } + + public function faqdataGroup(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqdata_group') + ->integer('record_id', false) + ->integer('group_id', false) + ->primaryKey(['record_id', 'group_id']); + } + + public function faqdataTags(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqdata_tags') + ->integer('record_id', false) + ->integer('tagging_id', false) + ->primaryKey(['record_id', 'tagging_id']); + } + + public function faqdataUser(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqdata_user') + ->integer('record_id', false) + ->integer('user_id', false) + ->primaryKey(['record_id', 'user_id']); + } + + public function faqforms(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqforms') + ->integer('form_id', false) + ->integer('input_id', false) + ->varchar('input_type', 1000, false) + ->varchar('input_label', 500, false) + ->integer('input_active', false) + ->integer('input_required', false) + ->varchar('input_lang', 11, false); + } + + public function faqglossary(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqglossary') + ->integer('id', false) + ->varchar('lang', 5, false) + ->varchar('item', 255, false) + ->text('definition', false) + ->primaryKey(['id', 'lang']); + } + + public function faqgroup(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqgroup') + ->integer('group_id', false) + ->varchar('name', 25) + ->text('description') + ->integer('auto_join') + ->primaryKey('group_id') + ->index('idx_name', 'name'); + } + + public function faqgroupRight(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqgroup_right') + ->integer('group_id', false) + ->integer('right_id', false) + ->primaryKey(['group_id', 'right_id']); + } + + public function faqinstances(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqinstances') + ->integer('id', false) + ->varchar('url', 255, false) + ->varchar('instance', 255, false) + ->text('comment') + ->timestamp('created', false) + ->timestamp('modified', false) + ->primaryKey('id'); + } + + public function faqinstancesConfig(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqinstances_config') + ->integer('instance_id', false) + ->varchar('config_name', 255, false, '') + ->varchar('config_value', 255) + ->primaryKey(['instance_id', 'config_name']); + } + + public function faqnews(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqnews') + ->integer('id', false) + ->varchar('lang', 5, false) + ->varchar('header', 255, false) + ->text('artikel', false) + ->varchar('datum', 14, false) + ->varchar('author_name', 255) + ->varchar('author_email', 255) + ->char('active', 1, true, 'y') + ->char('comment', 1, true, 'n') + ->varchar('link', 255) + ->varchar('linktitel', 255) + ->varchar('target', 255, false) + ->primaryKey('id'); + } + + public function faqcustompages(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqcustompages') + ->integer('id', false) + ->varchar('lang', 5, false) + ->varchar('page_title', 255, false) + ->varchar('slug', 255, false) + ->text('content', false) + ->varchar('author_name', 255, false) + ->varchar('author_email', 255, false) + ->char('active', 1, false, 'n') + ->timestamp('created', false, true) + ->timestamp('updated') + ->varchar('seo_title', 60) + ->varchar('seo_description', 160) + ->varchar('seo_robots', 50, false, 'index,follow') + ->primaryKey(['id', 'lang']) + ->index('idx_custompages_slug', ['slug', 'lang']); + } + + public function faqquestions(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqquestions') + ->integer('id', false) + ->varchar('lang', 5, false) + ->varchar('username', 100, false) + ->varchar('email', 100, false) + ->integer('category_id', false) + ->text('question', false) + ->varchar('created', 20, false) + ->char('is_visible', 1, true, 'Y') + ->integer('answer_id', false, 0) + ->primaryKey('id'); + } + + public function faqright(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqright') + ->integer('right_id', false) + ->varchar('name', 50) + ->text('description') + ->integer('for_users', true, 1) + ->integer('for_groups', true, 1) + ->integer('for_sections', true, 1) + ->primaryKey('right_id'); + } + + public function faqsearches(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqsearches') + ->integer('id', false) + ->varchar('lang', 5, false) + ->varchar('searchterm', 255, false) + ->timestamp('searchdate', false, true) + ->primaryKey(['id', 'lang']) + ->index('idx_faqsearches_searchterm', 'searchterm') + ->index('idx_faqsearches_date_term', ['searchdate', 'searchterm']) + ->index('idx_faqsearches_date_term_lang', ['searchdate', 'searchterm', 'lang']); + } + + public function faqseo(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqseo') + ->integer('id', false) + ->varchar('type', 32, false) + ->integer('reference_id', false) + ->varchar('reference_language', 5, false) + ->text('title') + ->text('description') + ->text('slug') + ->timestamp('created', false, true) + ->primaryKey('id'); + } + + public function faqsessions(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqsessions') + ->integer('sid', false) + ->integer('user_id', false) + ->varchar('ip', 64, false) + ->integer('time', false) + ->primaryKey('sid') + ->index('idx_time', 'time'); + } + + public function faqstopwords(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqstopwords') + ->integer('id', false) + ->varchar('lang', 5, false) + ->varchar('stopword', 64, false) + ->primaryKey(['id', 'lang']); + } + + public function faqtags(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqtags') + ->integer('tagging_id', false) + ->varchar('tagging_name', 255, false) + ->primaryKey(['tagging_id', 'tagging_name']); + } + + public function faquser(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faquser') + ->integer('user_id', false) + ->varchar('login', 128, false) + ->varchar('session_id', 150) + ->integer('session_timestamp') + ->varchar('ip', 64) + ->varchar('account_status', 50) + ->varchar('last_login', 14) + ->varchar('auth_source', 100) + ->varchar('member_since', 14) + ->varchar('remember_me', 150) + ->smallInteger('success', true, 1) + ->smallInteger('is_superadmin', true, 0) + ->smallInteger('login_attempts', true, 0) + ->text('refresh_token') + ->text('access_token') + ->varchar('code_verifier', 255) + ->text('jwt') + ->text('webauthnkeys') + ->primaryKey('user_id'); + } + + public function faquserdata(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faquserdata') + ->integer('user_id', false) + ->varchar('last_modified', 14) + ->varchar('display_name', 128) + ->varchar('email', 128) + ->smallInteger('is_visible', true, 0) + ->smallInteger('twofactor_enabled', true, 0) + ->varchar('secret', 128); + } + + public function faquserlogin(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faquserlogin') + ->varchar('login', 128, false) + ->varchar('pass', 80) + ->varchar('domain', 255) + ->primaryKey('login'); + } + + public function faquserGroup(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faquser_group') + ->integer('user_id', false) + ->integer('group_id', false) + ->primaryKey(['user_id', 'group_id']); + } + + public function faquserRight(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faquser_right') + ->integer('user_id', false) + ->integer('right_id', false) + ->primaryKey(['user_id', 'right_id']); + } + + public function faqvisits(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqvisits') + ->integer('id', false) + ->varchar('lang', 5, false) + ->integer('visits', false) + ->integer('last_visit', false) + ->primaryKey(['id', 'lang']); + } + + public function faqvoting(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqvoting') + ->integer('id', false) + ->integer('artikel', false) + ->integer('vote', false) + ->integer('usr', false) + ->varchar('datum', 20, true, '') + ->varchar('ip', 15, true, '') + ->primaryKey('id'); + } + + public function faqchatMessages(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqchat_messages') + ->autoIncrement('id') + ->integer('sender_id', false) + ->integer('recipient_id', false) + ->text('message', false) + ->boolean('is_read', false, false) + ->timestamp('created_at', false, true) + ->index('idx_chat_sender', 'sender_id') + ->index('idx_chat_recipient', 'recipient_id') + ->index('idx_chat_conversation', ['sender_id', 'recipient_id']) + ->index('idx_chat_created', 'created_at'); + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Installation/DefaultDataSeeder.php b/phpmyfaq/src/phpMyFAQ/Setup/Installation/DefaultDataSeeder.php new file mode 100644 index 0000000000..e5cc52fbb9 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Setup/Installation/DefaultDataSeeder.php @@ -0,0 +1,506 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-31 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Setup\Installation; + +use phpMyFAQ\Configuration; +use phpMyFAQ\Enums\PermissionType; +use phpMyFAQ\Enums\ReleaseType; +use phpMyFAQ\System; + +class DefaultDataSeeder +{ + /** + * Configuration array. + * + * @var array + */ + private array $mainConfig; + + /** + * Array with user rights. + * @var array> + */ + private readonly array $mainRights; + + /** + * Array with form inputs. + * @var array> + */ + private readonly array $formInputs; + + /** + * @throws \Exception + */ + public function __construct() + { + $this->mainConfig = self::buildDefaultConfig(); + $this->mainRights = self::buildDefaultRights(); + $this->formInputs = self::buildDefaultFormInputs(); + } + + /** + * Returns the configuration array with dynamic values applied. + * + * @return array + */ + public function getMainConfig(): array + { + return $this->mainConfig; + } + + /** + * Applies personal settings to the config. + */ + public function applyPersonalSettings(string $realname, string $email, string $language, string $permLevel): void + { + $this->mainConfig['main.metaPublisher'] = $realname; + $this->mainConfig['main.administrationMail'] = $email; + $this->mainConfig['main.language'] = $language; + $this->mainConfig['security.permLevel'] = $permLevel; + } + + /** + * Seeds all configuration entries into the database. + */ + public function seedConfig(Configuration $configuration): void + { + foreach ($this->mainConfig as $name => $value) { + $configuration->add($name, $value); + } + } + + /** + * Returns the permissions array. + * + * @return array> + */ + public function getMainRights(): array + { + return $this->mainRights; + } + + /** + * Returns the form inputs array. + * + * @return array> + */ + public function getFormInputs(): array + { + return $this->formInputs; + } + + /** + * @return array + * @throws \Exception + */ + private static function buildDefaultConfig(): array + { + $config = [ + 'main.currentVersion' => null, + 'main.currentApiVersion' => null, + 'main.language' => '__PHPMYFAQ_LANGUAGE__', + 'main.languageDetection' => 'true', + 'main.phpMyFAQToken' => null, + 'main.referenceURL' => '__PHPMYFAQ_REFERENCE_URL__', + 'main.administrationMail' => 'webmaster@example.org', + 'main.contactInformation' => '', + 'main.enableAdminLog' => 'true', + 'main.enableUserTracking' => 'true', + 'main.metaDescription' => 'phpMyFAQ should be the answer for all questions in life', + 'main.metaPublisher' => '__PHPMYFAQ_PUBLISHER__', + 'main.titleFAQ' => 'phpMyFAQ Codename Palaimon', + 'main.enableWysiwygEditor' => 'true', + 'main.enableWysiwygEditorFrontend' => 'false', + 'main.enableMarkdownEditor' => 'false', + 'main.enableCommentEditor' => 'false', + 'main.dateFormat' => 'Y-m-d H:i', + 'main.maintenanceMode' => 'false', + 'main.enableGravatarSupport' => 'false', + 'main.customPdfHeader' => '', + 'main.customPdfFooter' => '', + 'main.enableSmartAnswering' => 'true', + 'main.enableCategoryRestrictions' => 'true', + 'main.enableSendToFriend' => 'true', + 'main.privacyURL' => '', + 'main.termsURL' => '', + 'main.imprintURL' => '', + 'main.cookiePolicyURL' => '', + 'main.accessibilityStatementURL' => '', + 'main.enableAutoUpdateHint' => 'true', + 'main.enableAskQuestions' => 'false', + 'main.enableNotifications' => 'false', + 'main.botIgnoreList' => + 'nustcrape,webpost,GoogleBot,msnbot,crawler,scooter,bravobrian,archiver,' + . 'w3c,controler,wget,bot,spider,Yahoo! Slurp,htdig,gsa-crawler,AirControler,Uptime-Kuma,facebookcatalog/1.0,' + . 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php),facebookexternalhit/1.1', + 'records.numberOfRecordsPerPage' => '10', + 'records.numberOfShownNewsEntries' => '3', + 'records.defaultActivation' => 'false', + 'records.defaultAllowComments' => 'false', + 'records.enableVisibilityQuestions' => 'false', + 'records.numberOfRelatedArticles' => '5', + 'records.orderby' => 'id', + 'records.sortby' => 'DESC', + 'records.orderingPopularFaqs' => 'visits', + 'records.disableAttachments' => 'true', + 'records.maxAttachmentSize' => '100000', + 'records.attachmentsPath' => 'content/user/attachments', + 'records.attachmentsStorageType' => '0', + 'records.enableAttachmentEncryption' => 'false', + 'records.defaultAttachmentEncKey' => '', + 'records.enableCloseQuestion' => 'false', + 'records.enableDeleteQuestion' => 'false', + 'records.randomSort' => 'false', + 'records.allowCommentsForGuests' => 'true', + 'records.allowQuestionsForGuests' => 'true', + 'records.allowNewFaqsForGuests' => 'true', + 'records.hideEmptyCategories' => 'false', + 'records.allowDownloadsForGuests' => 'false', + 'records.numberMaxStoredRevisions' => '10', + 'records.enableAutoRevisions' => 'false', + 'records.orderStickyFaqsCustom' => 'false', + 'records.allowedMediaHosts' => 'www.youtube.com', + 'search.numberSearchTerms' => '10', + 'search.relevance' => 'thema,content,keywords', + 'search.enableRelevance' => 'false', + 'search.enableHighlighting' => 'true', + 'search.searchForSolutionId' => 'true', + 'search.popularSearchTimeWindow' => '180', + 'search.enableElasticsearch' => 'false', + 'search.enableOpenSearch' => 'false', + 'security.permLevel' => 'basic', + 'security.ipCheck' => 'false', + 'security.enableLoginOnly' => 'false', + 'security.bannedIPs' => '', + 'security.ssoSupport' => 'false', + 'security.ssoLogoutRedirect' => '', + 'security.useSslForLogins' => 'false', + 'security.useSslOnly' => 'false', + 'security.forcePasswordUpdate' => 'false', + 'security.enableRegistration' => 'true', + 'security.domainWhiteListForRegistrations' => '', + 'security.enableSignInWithMicrosoft' => 'false', + 'security.enableGoogleReCaptchaV2' => 'false', + 'security.googleReCaptchaV2SiteKey' => '', + 'security.googleReCaptchaV2SecretKey' => '', + 'security.loginWithEmailAddress' => 'false', + 'security.enableWebAuthnSupport' => 'false', + 'security.enableAdminSessionTimeoutCounter' => 'true', + 'spam.checkBannedWords' => 'true', + 'spam.enableCaptchaCode' => null, + 'spam.enableSafeEmail' => 'true', + 'spam.manualActivation' => 'true', + 'spam.mailAddressInExport' => 'true', + 'seo.title' => 'phpMyFAQ Codename Porus', + 'seo.description' => 'phpMyFAQ should be the answer for all questions in life', + 'seo.enableXMLSitemap' => 'true', + 'seo.enableRichSnippets' => 'false', + 'seo.metaTagsHome' => 'index, follow', + 'seo.metaTagsFaqs' => 'index, follow', + 'seo.metaTagsCategories' => 'index, follow', + 'seo.metaTagsPages' => 'index, follow', + 'seo.metaTagsAdmin' => 'noindex, nofollow', + 'seo.contentRobotsText' => 'User-agent: *\nDisallow: /admin/\nSitemap: /sitemap.xml', + 'seo.contentLlmsText' => + "# phpMyFAQ LLMs.txt\n\n" + . "This file provides information about the AI/LLM training data availability for this FAQ system.\n\n" + . "Contact: Please see the contact information on the main website.\n\n" + . "The FAQ content in this system is available for LLM training purposes.\n" + . "Please respect the licensing terms and usage guidelines of the content.\n\n" + . 'For more information about this FAQ system, visit: https://www.phpmyfaq.de', + 'mail.noReplySenderAddress' => '', + 'mail.remoteSMTP' => 'false', + 'mail.remoteSMTPServer' => '', + 'mail.remoteSMTPUsername' => '', + 'mail.remoteSMTPPassword' => '', + 'mail.remoteSMTPPort' => '25', + 'mail.remoteSMTPDisableTLSPeerVerification' => 'false', + 'ldap.ldapSupport' => 'false', + 'ldap.ldap_mapping.name' => 'cn', + 'ldap.ldap_mapping.username' => 'samAccountName', + 'ldap.ldap_mapping.mail' => 'mail', + 'ldap.ldap_mapping.memberOf' => '', + 'ldap.ldap_use_domain_prefix' => 'true', + 'ldap.ldap_options.LDAP_OPT_PROTOCOL_VERSION' => '3', + 'ldap.ldap_options.LDAP_OPT_REFERRALS' => '0', + 'ldap.ldap_use_memberOf' => 'false', + 'ldap.ldap_use_sasl' => 'false', + 'ldap.ldap_use_multiple_servers' => 'false', + 'ldap.ldap_use_anonymous_login' => 'false', + 'ldap.ldap_use_dynamic_login' => 'false', + 'ldap.ldap_dynamic_login_attribute' => 'uid', + 'ldap.ldap_use_group_restriction' => 'false', + 'ldap.ldap_group_allowed_groups' => '', + 'ldap.ldap_group_auto_assign' => 'false', + 'ldap.ldap_group_mapping' => '', + 'api.enableAccess' => 'true', + 'api.apiClientToken' => '', + 'api.onlyActiveFaqs' => 'true', + 'api.onlyActiveCategories' => 'true', + 'translation.provider' => 'none', + 'translation.googleApiKey' => '', + 'translation.deeplApiKey' => '', + 'translation.deeplUseFreeApi' => 'true', + 'translation.azureKey' => '', + 'translation.azureRegion' => '', + 'translation.amazonAccessKeyId' => '', + 'translation.amazonSecretAccessKey' => '', + 'translation.amazonRegion' => 'us-east-1', + 'translation.libreTranslateUrl' => 'https://libretranslate.com', + 'translation.libreTranslateApiKey' => '', + 'routing.useAttributesOnly' => 'false', + 'routing.cache.enabled' => 'false', + 'routing.cache.dir' => './cache', + 'api.onlyPublicQuestions' => 'true', + 'api.ignoreOrphanedFaqs' => 'true', + 'upgrade.dateLastChecked' => '', + 'upgrade.lastDownloadedPackage' => '', + 'upgrade.onlineUpdateEnabled' => 'false', + 'upgrade.releaseEnvironment' => '__PHPMYFAQ_RELEASE__', + 'layout.templateSet' => 'default', + 'layout.enablePrivacyLink' => 'true', + 'layout.enableCookieConsent' => 'true', + 'layout.contactInformationHTML' => 'false', + 'layout.customCss' => '', + ]; + + // Apply dynamic values + $config['main.currentVersion'] = System::getVersion(); + $config['main.currentApiVersion'] = System::getApiVersion(); + $config['main.phpMyFAQToken'] = bin2hex(random_bytes(16)); + $config['spam.enableCaptchaCode'] = extension_loaded('gd') ? 'true' : 'false'; + $config['upgrade.releaseEnvironment'] = System::isDevelopmentVersion() + ? ReleaseType::DEVELOPMENT->value + : ReleaseType::STABLE->value; + + return $config; + } + + /** + * @return array> + */ + private static function buildDefaultRights(): array + { + return [ + ['name' => PermissionType::USER_ADD->value, 'description' => 'Right to add user accounts'], + ['name' => PermissionType::USER_EDIT->value, 'description' => 'Right to edit user accounts'], + ['name' => PermissionType::USER_DELETE->value, 'description' => 'Right to delete user accounts'], + ['name' => PermissionType::FAQ_ADD->value, 'description' => 'Right to add faq entries'], + ['name' => PermissionType::FAQ_EDIT->value, 'description' => 'Right to edit faq entries'], + ['name' => PermissionType::FAQ_DELETE->value, 'description' => 'Right to delete faq entries'], + ['name' => PermissionType::STATISTICS_VIEWLOGS->value, 'description' => 'Right to view logfiles'], + ['name' => PermissionType::STATISTICS_ADMINLOG->value, 'description' => 'Right to view admin log'], + ['name' => PermissionType::COMMENT_DELETE->value, 'description' => 'Right to delete comments'], + ['name' => PermissionType::NEWS_ADD->value, 'description' => 'Right to add news'], + ['name' => PermissionType::NEWS_EDIT->value, 'description' => 'Right to edit news'], + ['name' => PermissionType::NEWS_DELETE->value, 'description' => 'Right to delete news'], + ['name' => PermissionType::PAGE_ADD->value, 'description' => 'Right to add custom pages'], + ['name' => PermissionType::PAGE_EDIT->value, 'description' => 'Right to edit custom pages'], + ['name' => PermissionType::PAGE_DELETE->value, 'description' => 'Right to delete custom pages'], + ['name' => PermissionType::CATEGORY_ADD->value, 'description' => 'Right to add categories'], + ['name' => PermissionType::CATEGORY_EDIT->value, 'description' => 'Right to edit categories'], + ['name' => PermissionType::CATEGORY_DELETE->value, 'description' => 'Right to delete categories'], + ['name' => PermissionType::PASSWORD_CHANGE->value, 'description' => 'Right to change passwords'], + ['name' => PermissionType::CONFIGURATION_EDIT->value, 'description' => 'Right to edit configuration'], + [ + 'name' => PermissionType::VIEW_ADMIN_LINK->value, + 'description' => 'Right to see the link to the admin section', + ], + ['name' => PermissionType::BACKUP->value, 'description' => 'Right to save backups'], + ['name' => PermissionType::RESTORE->value, 'description' => 'Right to load backups'], + ['name' => PermissionType::QUESTION_DELETE->value, 'description' => 'Right to delete questions'], + ['name' => PermissionType::GLOSSARY_ADD->value, 'description' => 'Right to add glossary entries'], + ['name' => PermissionType::GLOSSARY_EDIT->value, 'description' => 'Right to edit glossary entries'], + ['name' => PermissionType::GLOSSARY_DELETE->value, 'description' => 'Right to delete glossary entries'], + ['name' => PermissionType::REVISION_UPDATE->value, 'description' => 'Right to edit revisions'], + ['name' => PermissionType::GROUP_ADD->value, 'description' => 'Right to add group accounts'], + ['name' => PermissionType::GROUP_EDIT->value, 'description' => 'Right to edit group accounts'], + ['name' => PermissionType::GROUP_DELETE->value, 'description' => 'Right to delete group accounts'], + ['name' => PermissionType::FAQ_APPROVE->value, 'description' => 'Right to approve FAQs'], + ['name' => PermissionType::ATTACHMENT_ADD->value, 'description' => 'Right to add attachments'], + ['name' => PermissionType::ATTACHMENT_EDIT->value, 'description' => 'Right to edit attachments'], + ['name' => PermissionType::ATTACHMENT_DELETE->value, 'description' => 'Right to delete attachments'], + ['name' => PermissionType::ATTACHMENT_DOWNLOAD->value, 'description' => 'Right to download attachments'], + ['name' => PermissionType::REPORTS->value, 'description' => 'Right to generate reports'], + ['name' => PermissionType::FAQ_ADD->value, 'description' => 'Right to add FAQs in frontend'], + ['name' => PermissionType::QUESTION_ADD->value, 'description' => 'Right to add questions in frontend'], + ['name' => PermissionType::COMMENT_ADD->value, 'description' => 'Right to add comments in frontend'], + ['name' => PermissionType::INSTANCE_EDIT->value, 'description' => 'Right to edit multi-site instances'], + ['name' => PermissionType::INSTANCE_ADD->value, 'description' => 'Right to add multi-site instances'], + ['name' => PermissionType::INSTANCE_DELETE->value, 'description' => 'Right to delete multi-site instances'], + ['name' => PermissionType::EXPORT->value, 'description' => 'Right to export the complete FAQ'], + ['name' => PermissionType::FAQS_VIEW->value, 'description' => 'Right to view FAQs'], + ['name' => PermissionType::CATEGORIES_VIEW->value, 'description' => 'Right to view categories'], + ['name' => PermissionType::NEWS_VIEW->value, 'description' => 'Right to view news'], + ['name' => PermissionType::GROUPS_ADMINISTRATE->value, 'description' => 'Right to administrate groups'], + ['name' => PermissionType::FORMS_EDIT->value, 'description' => 'Right to edit forms'], + ['name' => PermissionType::FAQ_TRANSLATE->value, 'description' => 'Right to translate FAQs'], + ]; + } + + /** + * @return array> + */ + private static function buildDefaultFormInputs(): array + { + return [ + // Ask Question inputs + [ + 'form_id' => 1, + 'input_id' => 1, + 'input_type' => 'title', + 'input_label' => 'msgQuestion', + 'input_active' => 1, + 'input_required' => -1, + 'input_lang' => 'default', + ], + [ + 'form_id' => 1, + 'input_id' => 2, + 'input_type' => 'message', + 'input_label' => 'msgNewQuestion', + 'input_active' => 1, + 'input_required' => -1, + 'input_lang' => 'default', + ], + [ + 'form_id' => 1, + 'input_id' => 3, + 'input_type' => 'text', + 'input_label' => 'msgNewContentName', + 'input_active' => 1, + 'input_required' => 1, + 'input_lang' => 'default', + ], + [ + 'form_id' => 1, + 'input_id' => 4, + 'input_type' => 'email', + 'input_label' => 'msgNewContentMail', + 'input_active' => 1, + 'input_required' => 1, + 'input_lang' => 'default', + ], + [ + 'form_id' => 1, + 'input_id' => 5, + 'input_type' => 'select', + 'input_label' => 'msgNewContentCategory', + 'input_active' => 1, + 'input_required' => 1, + 'input_lang' => 'default', + ], + [ + 'form_id' => 1, + 'input_id' => 6, + 'input_type' => 'textarea', + 'input_label' => 'msgAskYourQuestion', + 'input_active' => -1, + 'input_required' => -1, + 'input_lang' => 'default', + ], + // Add New FAQ inputs + [ + 'form_id' => 2, + 'input_id' => 1, + 'input_type' => 'title', + 'input_label' => 'msgNewContentHeader', + 'input_active' => 1, + 'input_required' => -1, + 'input_lang' => 'default', + ], + [ + 'form_id' => 2, + 'input_id' => 2, + 'input_type' => 'message', + 'input_label' => 'msgNewContentAddon', + 'input_active' => 1, + 'input_required' => -1, + 'input_lang' => 'default', + ], + [ + 'form_id' => 2, + 'input_id' => 3, + 'input_type' => 'text', + 'input_label' => 'msgNewContentName', + 'input_active' => 1, + 'input_required' => 1, + 'input_lang' => 'default', + ], + [ + 'form_id' => 2, + 'input_id' => 4, + 'input_type' => 'email', + 'input_label' => 'msgNewContentMail', + 'input_active' => 1, + 'input_required' => 1, + 'input_lang' => 'default', + ], + [ + 'form_id' => 2, + 'input_id' => 5, + 'input_type' => 'select', + 'input_label' => 'msgNewContentCategory', + 'input_active' => 1, + 'input_required' => 1, + 'input_lang' => 'default', + ], + [ + 'form_id' => 2, + 'input_id' => 6, + 'input_type' => 'textarea', + 'input_label' => 'msgNewContentTheme', + 'input_active' => -1, + 'input_required' => -1, + 'input_lang' => 'default', + ], + [ + 'form_id' => 2, + 'input_id' => 7, + 'input_type' => 'textarea', + 'input_label' => 'msgNewContentArticle', + 'input_active' => 1, + 'input_required' => 1, + 'input_lang' => 'default', + ], + [ + 'form_id' => 2, + 'input_id' => 8, + 'input_type' => 'text', + 'input_label' => 'msgNewContentKeywords', + 'input_active' => 1, + 'input_required' => 1, + 'input_lang' => 'default', + ], + [ + 'form_id' => 2, + 'input_id' => 9, + 'input_type' => 'title', + 'input_label' => 'msgNewContentLink', + 'input_active' => 1, + 'input_required' => 1, + 'input_lang' => 'default', + ], + ]; + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php b/phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php new file mode 100644 index 0000000000..e96a532583 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php @@ -0,0 +1,149 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-31 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Setup\Installation; + +use phpMyFAQ\Configuration; +use phpMyFAQ\Database; +use phpMyFAQ\Instance\Database\DriverInterface; +use phpMyFAQ\Setup\Migration\QueryBuilder\DialectFactory; +use phpMyFAQ\Setup\Migration\QueryBuilder\DialectInterface; + +class SchemaInstaller implements DriverInterface +{ + private readonly DialectInterface $dialect; + + private readonly DatabaseSchema $schema; + + /** @var string[] Collected SQL for dry-run */ + private array $collectedSql = []; + + private bool $dryRun = false; + + public function __construct( + private readonly Configuration $configuration, + ?DialectInterface $dialect = null, + ) { + $this->dialect = $dialect ?? DialectFactory::create(); + $this->schema = new DatabaseSchema($this->dialect); + } + + /** + * Enables or disables dry-run mode. In dry-run mode, SQL is collected but not executed. + */ + public function setDryRun(bool $dryRun): void + { + $this->dryRun = $dryRun; + } + + /** + * Returns collected SQL statements from dry-run mode. + * + * @return string[] + */ + public function getCollectedSql(): array + { + return $this->collectedSql; + } + + /** + * Returns the DatabaseSchema instance. + */ + public function getSchema(): DatabaseSchema + { + return $this->schema; + } + + /** + * Executes all CREATE TABLE and CREATE INDEX statements. + * + * @param string $prefix Table prefix to apply. The previous prefix is restored after execution. + */ + public function createTables(string $prefix = ''): bool + { + $previousPrefix = Database::getTablePrefix(); + + if ($prefix !== '') { + Database::setTablePrefix($prefix); + } + + $this->collectedSql = []; + + try { + foreach ($this->schema->getAllTables() as $tableBuilder) { + $createTableSql = $tableBuilder->build(); + + if (!$this->executeSql($createTableSql)) { + return false; + } + + foreach ($tableBuilder->buildIndexStatements() as $indexSql) { + if (!$this->executeSql($indexSql)) { + return false; + } + } + } + + return true; + } finally { + if ($prefix !== '') { + Database::setTablePrefix($previousPrefix ?? ''); + } + } + } + + /** + * Executes all DROP TABLE statements for the schema tables. + */ + public function dropTables(string $prefix = ''): bool + { + foreach ($this->schema->getTableNames() as $tableName) { + $sql = sprintf('DROP TABLE %s%s', $prefix, $tableName); + $result = $this->configuration->getDb()->query($sql); + + if (!$result) { + return false; + } + } + + return true; + } + + private function executeSql(string $sql): bool + { + $this->collectedSql[] = $sql; + + if ($this->dryRun) { + return true; + } + + $result = $this->configuration->getDb()->query($sql); + + if (!$result) { + return false; + } + + return true; + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Setup/InstallationInput.php b/phpmyfaq/src/phpMyFAQ/Setup/InstallationInput.php new file mode 100644 index 0000000000..91cd5ffa20 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Setup/InstallationInput.php @@ -0,0 +1,47 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-31 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Setup; + +readonly class InstallationInput +{ + /** + * @param array $dbSetup + * @param array $ldapSetup + * @param array> $esSetup + * @param array> $osSetup + */ + public function __construct( + public array $dbSetup, + public array $ldapSetup, + public array $esSetup, + public array $osSetup, + public string $loginName, + public string $password, + public string $language, + public string $realname, + public string $email, + public string $permLevel, + public string $rootDir, + public bool $ldapEnabled = false, + public bool $esEnabled = false, + public bool $osEnabled = false, + ) { + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Setup/InstallationInputValidator.php b/phpmyfaq/src/phpMyFAQ/Setup/InstallationInputValidator.php new file mode 100644 index 0000000000..3f92b456a4 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Setup/InstallationInputValidator.php @@ -0,0 +1,327 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-31 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Setup; + +use phpMyFAQ\Core\Exception; +use phpMyFAQ\Database; +use phpMyFAQ\Filter; +use phpMyFAQ\System; + +class InstallationInputValidator +{ + /** + * Validates and parses installation input, returning a value object. + * + * @param array|null $setup Optional setup array (for programmatic installs) + * @throws Exception + */ + public function validate(?array $setup = null): InstallationInput + { + $dbSetup = $this->validateDatabaseInput($setup); + $ldapSetup = []; + $esSetup = []; + $osSetup = []; + + $ldapEnabled = $this->isLdapEnabled(); + if ($ldapEnabled) { + $ldapSetup = $this->validateLdapInput(); + } + + $esEnabled = $this->isElasticsearchEnabled(); + if ($esEnabled) { + $esSetup = $this->validateElasticsearchInput(); + } + + $osEnabled = $this->isOpenSearchEnabled(); + if ($osEnabled) { + $osSetup = $this->validateOpenSearchInput(); + } + + [$loginName, $password] = $this->validateUserCredentials($setup); + + $language = Filter::filterInput(INPUT_POST, 'language', FILTER_SANITIZE_SPECIAL_CHARS, 'en'); + $realname = Filter::filterInput(INPUT_POST, 'realname', FILTER_SANITIZE_SPECIAL_CHARS, ''); + $email = Filter::filterInput(INPUT_POST, 'email', FILTER_SANITIZE_EMAIL, ''); + $permLevel = Filter::filterInput(INPUT_POST, 'permLevel', FILTER_SANITIZE_SPECIAL_CHARS, 'basic'); + $rootDir = $setup['rootDir'] ?? PMF_ROOT_DIR; + + return new InstallationInput( + dbSetup: $dbSetup, + ldapSetup: $ldapSetup, + esSetup: $esSetup, + osSetup: $osSetup, + loginName: (string) $loginName, + password: (string) $password, + language: (string) $language, + realname: (string) $realname, + email: (string) $email, + permLevel: (string) $permLevel, + rootDir: (string) $rootDir, + ldapEnabled: $ldapEnabled, + esEnabled: $esEnabled, + osEnabled: $osEnabled, + ); + } + + /** + * @param array|null $setup + * @return array + * @throws Exception + */ + private function validateDatabaseInput(?array $setup): array + { + $dbSetup = []; + + $dbSetup['dbPrefix'] = Filter::filterInput(INPUT_POST, 'sqltblpre', FILTER_SANITIZE_SPECIAL_CHARS, ''); + if ('' !== $dbSetup['dbPrefix']) { + Database::setTablePrefix($dbSetup['dbPrefix']); + } + + if (!isset($setup['dbType'])) { + $dbSetup['dbType'] = Filter::filterInput(INPUT_POST, 'sql_type', FILTER_SANITIZE_SPECIAL_CHARS); + } else { + $dbSetup['dbType'] = $setup['dbType']; + } + + if (!is_null($dbSetup['dbType'])) { + $dbSetup['dbType'] = trim((string) $dbSetup['dbType']); + if (str_starts_with($dbSetup['dbType'], 'pdo_')) { + $dataBaseFile = 'Pdo' . ucfirst(substr($dbSetup['dbType'], offset: 4)); + } else { + $dataBaseFile = ucfirst($dbSetup['dbType']); + } + + if (!file_exists(PMF_SRC_DIR . '/phpMyFAQ/Instance/Database/' . $dataBaseFile . '.php')) { + throw new Exception(sprintf('Installation Error: Invalid server type "%s"', $dbSetup['dbType'])); + } + } else { + throw new Exception('Installation Error: Please select a database type.'); + } + + $dbSetup['dbServer'] = Filter::filterInput(INPUT_POST, 'sql_server', FILTER_SANITIZE_SPECIAL_CHARS, ''); + if (is_null($dbSetup['dbServer']) && !System::isSqlite($dbSetup['dbType'])) { + throw new Exception('Installation Error: Please add a database server.'); + } + + if (!isset($setup['dbType'])) { + $dbSetup['dbPort'] = Filter::filterInput(INPUT_POST, 'sql_port', FILTER_VALIDATE_INT); + } else { + $dbSetup['dbPort'] = $setup['dbPort']; + } + + if (is_null($dbSetup['dbPort']) && !System::isSqlite($dbSetup['dbType'])) { + throw new Exception('Installation Error: Please add a valid database port.'); + } + + $dbSetup['dbUser'] = Filter::filterInput(INPUT_POST, 'sql_user', FILTER_SANITIZE_SPECIAL_CHARS, ''); + if (is_null($dbSetup['dbUser']) && !System::isSqlite($dbSetup['dbType'])) { + throw new Exception('Installation Error: Please add a database username.'); + } + + $dbSetup['dbPassword'] = Filter::filterInput(INPUT_POST, 'sql_password', FILTER_SANITIZE_SPECIAL_CHARS, ''); + if (is_null($dbSetup['dbPassword']) && !System::isSqlite($dbSetup['dbType'])) { + $dbSetup['dbPassword'] = ''; + } + + if (!isset($setup['dbType'])) { + $dbSetup['dbDatabaseName'] = Filter::filterInput(INPUT_POST, 'sql_db', FILTER_SANITIZE_SPECIAL_CHARS); + } else { + $dbSetup['dbDatabaseName'] = $setup['dbDatabaseName']; + } + + if (is_null($dbSetup['dbDatabaseName']) && !System::isSqlite($dbSetup['dbType'])) { + throw new Exception('Installation Error: Please add a database name.'); + } + + if (System::isSqlite($dbSetup['dbType'])) { + $dbSetup['dbServer'] = Filter::filterInput( + INPUT_POST, + 'sql_sqlitefile', + FILTER_SANITIZE_SPECIAL_CHARS, + $setup['dbServer'] ?? null, + ); + if (is_null($dbSetup['dbServer'])) { + throw new Exception('Installation Error: Please add a SQLite database filename.'); + } + } + + return $dbSetup; + } + + private function isLdapEnabled(): bool + { + $ldapEnabled = Filter::filterInput(INPUT_POST, 'ldap_enabled', FILTER_SANITIZE_SPECIAL_CHARS); + return extension_loaded('ldap') && !is_null($ldapEnabled); + } + + /** + * @return array + * @throws Exception + */ + private function validateLdapInput(): array + { + $ldapSetup = []; + + $ldapSetup['ldapServer'] = Filter::filterInput(INPUT_POST, 'ldap_server', FILTER_SANITIZE_SPECIAL_CHARS); + if (is_null($ldapSetup['ldapServer'])) { + throw new Exception('LDAP Installation Error: Please add a LDAP server.'); + } + + $ldapSetup['ldapPort'] = Filter::filterInput(INPUT_POST, 'ldap_port', FILTER_VALIDATE_INT); + if (is_null($ldapSetup['ldapPort'])) { + throw new Exception('LDAP Installation Error: Please add a LDAP port.'); + } + + $ldapSetup['ldapBase'] = Filter::filterInput(INPUT_POST, 'ldap_base', FILTER_SANITIZE_SPECIAL_CHARS); + if (is_null($ldapSetup['ldapBase'])) { + throw new Exception('LDAP Installation Error: Please add a LDAP base search DN.'); + } + + $ldapSetup['ldapUser'] = Filter::filterInput(INPUT_POST, 'ldap_user', FILTER_SANITIZE_SPECIAL_CHARS); + $ldapSetup['ldapPassword'] = Filter::filterInput(INPUT_POST, 'ldap_password', FILTER_SANITIZE_SPECIAL_CHARS); + + return $ldapSetup; + } + + private function isElasticsearchEnabled(): bool + { + return !is_null(Filter::filterInput(INPUT_POST, 'elasticsearch_enabled', FILTER_SANITIZE_SPECIAL_CHARS)); + } + + /** + * @return array> + * @throws Exception + */ + private function validateElasticsearchInput(): array + { + $esSetup = []; + $esHostFilter = [ + 'elasticsearch_server' => [ + 'filter' => FILTER_SANITIZE_SPECIAL_CHARS, + 'flags' => FILTER_REQUIRE_ARRAY, + ], + ]; + + $esHosts = Filter::filterInputArray(INPUT_POST, $esHostFilter); + if (is_null($esHosts)) { + throw new Exception('Elasticsearch Installation Error: Please add at least one Elasticsearch host.'); + } + + $esSetup['hosts'] = $esHosts['elasticsearch_server']; + + $esSetup['index'] = Filter::filterInput(INPUT_POST, 'elasticsearch_index', FILTER_SANITIZE_SPECIAL_CHARS); + if (is_null($esSetup['index'])) { + throw new Exception('Elasticsearch Installation Error: Please add an Elasticsearch index name.'); + } + + return $esSetup; + } + + private function isOpenSearchEnabled(): bool + { + return !is_null(Filter::filterInput(INPUT_POST, 'opensearch_enabled', FILTER_SANITIZE_SPECIAL_CHARS)); + } + + /** + * @return array> + * @throws Exception + */ + private function validateOpenSearchInput(): array + { + $osSetup = []; + $osHostFilter = [ + 'opensearch_server' => [ + 'filter' => FILTER_SANITIZE_SPECIAL_CHARS, + 'flags' => FILTER_REQUIRE_ARRAY, + ], + ]; + + $osHosts = Filter::filterInputArray(INPUT_POST, $osHostFilter); + if (is_null($osHosts)) { + throw new Exception('OpenSearch Installation Error: Please add at least one OpenSearch host.'); + } + + $osSetup['hosts'] = $osHosts['opensearch_server']; + + $osSetup['index'] = Filter::filterInput(INPUT_POST, 'opensearch_index', FILTER_SANITIZE_SPECIAL_CHARS); + if (is_null($osSetup['index'])) { + throw new Exception('OpenSearch Installation Error: Please add an OpenSearch index name.'); + } + + return $osSetup; + } + + /** + * @param array|null $setup + * @return array{string, string} + * @throws Exception + */ + private function validateUserCredentials(?array $setup): array + { + if (!isset($setup['loginname'])) { + $loginName = Filter::filterInput(INPUT_POST, 'loginname', FILTER_SANITIZE_SPECIAL_CHARS); + } else { + $loginName = $setup['loginname']; + } + + if (is_null($loginName)) { + throw new Exception('Installation Error: Please add a login name for your account.'); + } + + if (!isset($setup['password'])) { + $password = Filter::filterInput(INPUT_POST, 'password', FILTER_SANITIZE_SPECIAL_CHARS); + } else { + $password = $setup['password']; + } + + if (is_null($password)) { + throw new Exception('Installation Error: Please add a password for your account.'); + } + + if (!isset($setup['password_retyped'])) { + $passwordRetyped = Filter::filterInput(INPUT_POST, 'password_retyped', FILTER_SANITIZE_SPECIAL_CHARS); + } else { + $passwordRetyped = $setup['password_retyped']; + } + + if (is_null($passwordRetyped)) { + throw new Exception('Installation Error: Please add a retyped password.'); + } + + if (strlen((string) $password) <= 7 || strlen((string) $passwordRetyped) <= 7) { + throw new Exception( + 'Installation Error: Your password and retyped password are too short. Please set your password ' + . 'and your retyped password with a minimum of 8 characters.', + ); + } + + if (!hash_equals((string) $password, (string) $passwordRetyped)) { + throw new Exception( + 'Installation Error: Your password and retyped password are not equal. Please check your password ' + . 'and your retyped password.', + ); + } + + return [(string) $loginName, (string) $password]; + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php b/phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php new file mode 100644 index 0000000000..a949d25e76 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php @@ -0,0 +1,391 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-31 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Setup; + +use Composer\Autoload\ClassLoader; +use Elastic\Elasticsearch\ClientBuilder; +use OpenSearch\SymfonyClientFactory; +use phpMyFAQ\Configuration; +use phpMyFAQ\Configuration\DatabaseConfiguration; +use phpMyFAQ\Configuration\ElasticsearchConfiguration; +use phpMyFAQ\Configuration\OpenSearchConfiguration; +use phpMyFAQ\Core\Exception; +use phpMyFAQ\Database; +use phpMyFAQ\Database\DatabaseDriver; +use phpMyFAQ\Entity\InstanceEntity; +use phpMyFAQ\Forms; +use phpMyFAQ\Instance; +use phpMyFAQ\Instance\Database as InstanceDatabase; +use phpMyFAQ\Instance\Database\Stopwords; +use phpMyFAQ\Instance\Main; +use phpMyFAQ\Instance\Search\Elasticsearch; +use phpMyFAQ\Instance\Search\OpenSearch; +use phpMyFAQ\Instance\Setup; +use phpMyFAQ\Ldap; +use phpMyFAQ\Link; +use phpMyFAQ\Setup\Installation\DefaultDataSeeder; +use phpMyFAQ\System; +use phpMyFAQ\User; +use Symfony\Component\HttpFoundation\Request; + +class InstallationRunner +{ + private ?Configuration $configuration = null; + + private ?DatabaseDriver $db = null; + + public function __construct( + private readonly System $system, + ) { + } + + /** + * Runs the full installation using validated input. + * + * @throws Exception|\Exception + */ + public function run(InstallationInput $input): void + { + $this->stepValidateConnectivity($input); + $this->stepCreateConfigFiles($input); + $this->stepEstablishDbConnection($input); + $this->stepCreateDatabaseTables($input); + $this->stepInsertStopwords($input); + $this->stepSeedConfiguration($input); + $this->stepCreateAdminUser($input); + $this->stepGrantPermissions($input); + $this->stepInsertFormInputs(); + $this->stepCreateAnonymousUser($input); + $this->stepCreateInstance(); + $this->stepInitializeSearchEngine($input); + $this->stepAdjustHtaccess(); + } + + /** + * Step 1: Validate database, LDAP, ES, and OpenSearch connectivity. + * + * @throws Exception + */ + private function stepValidateConnectivity(InstallationInput $input): void + { + Database::setTablePrefix($input->dbSetup['dbPrefix'] ?? ''); + $db = Database::factory($input->dbSetup['dbType']); + $db->connect( + $input->dbSetup['dbServer'], + $input->dbSetup['dbUser'], + $input->dbSetup['dbPassword'], + $input->dbSetup['dbDatabaseName'], + $input->dbSetup['dbPort'], + ); + + $configuration = new Configuration($db); + + // Validate LDAP connection if enabled + if ($input->ldapEnabled && $input->ldapSetup !== []) { + $seeder = new DefaultDataSeeder(); + foreach ($seeder->getMainConfig() as $configKey => $configValue) { + if (!str_contains($configKey, 'ldap.')) { + continue; + } + + $configuration->set($configKey, $configValue); + } + + $ldap = new Ldap($configuration); + $ldapConnection = $ldap->connect( + $input->ldapSetup['ldapServer'], + $input->ldapSetup['ldapPort'], + $input->ldapSetup['ldapBase'], + $input->ldapSetup['ldapUser'], + $input->ldapSetup['ldapPassword'], + ); + + if (!$ldapConnection) { + throw new Exception(sprintf('LDAP Installation Error: %s.', $ldap->error())); + } + } + + // Validate Elasticsearch connection if enabled + if ($input->esEnabled && $input->esSetup !== []) { + $classLoader = new ClassLoader(); + $classLoader->addPsr4('Elasticsearch\\', PMF_SRC_DIR . '/libs/elasticsearch/src/Elasticsearch'); + $classLoader->addPsr4('Monolog\\', PMF_SRC_DIR . '/libs/monolog/src/Monolog'); + $classLoader->addPsr4('Psr\\', PMF_SRC_DIR . '/libs/psr/log/Psr'); + $classLoader->addPsr4('React\\Promise\\', PMF_SRC_DIR . '/libs/react/promise/src'); + $classLoader->register(); + + $esHosts = array_values($input->esSetup['hosts']); + $esClient = ClientBuilder::create()->setHosts($esHosts)->build(); + + if (!$esClient) { + throw new Exception('Elasticsearch Installation Error: No connection to Elasticsearch.'); + } + } + + // Validate OpenSearch connection if enabled + if ($input->osEnabled && $input->osSetup !== []) { + $osHosts = array_values($input->osSetup['hosts']); + $osClient = new SymfonyClientFactory()->create([ + 'base_uri' => $osHosts[0], + 'verify_peer' => false, + ]); + + if (!$osClient) { + throw new Exception('OpenSearch Installation Error: No connection to OpenSearch.'); + } + } + } + + /** + * Step 2: Write config files (database.php, ldap.php, elasticsearch.php, opensearch.php). + * + * @throws Exception + */ + private function stepCreateConfigFiles(InstallationInput $input): void + { + $instanceSetup = new Setup(); + $instanceSetup->setRootDir($input->rootDir); + + if (!$instanceSetup->createDatabaseFile($input->dbSetup)) { + Installer::cleanFailedInstallationFiles(); + throw new Exception('Installation Error: Setup cannot write to ./content/core/config/database.php.'); + } + + if ($input->ldapEnabled && $input->ldapSetup !== [] && !$instanceSetup->createLdapFile($input->ldapSetup, '')) { + Installer::cleanFailedInstallationFiles(); + throw new Exception('LDAP Installation Error: Setup cannot write to ./content/core/config/ldap.php.'); + } + + if ( + $input->esEnabled + && $input->esSetup !== [] + && !$instanceSetup->createElasticsearchFile($input->esSetup, '') + ) { + Installer::cleanFailedInstallationFiles(); + throw new Exception( + 'Elasticsearch Installation Error: Setup cannot write to ./content/core/config/elasticsearch.php.', + ); + } + + if ($input->osEnabled && $input->osSetup !== [] && !$instanceSetup->createOpenSearchFile($input->osSetup, '')) { + Installer::cleanFailedInstallationFiles(); + throw new Exception( + 'OpenSearch Installation Error: Setup cannot write to ./content/core/config/opensearch.php.', + ); + } + } + + /** + * Step 3: Connect to the database using the freshly-written config file. + * + * @throws Exception + */ + private function stepEstablishDbConnection(InstallationInput $input): void + { + $databaseConfiguration = new DatabaseConfiguration($input->rootDir . '/content/core/config/database.php'); + try { + $this->db = Database::factory($input->dbSetup['dbType']); + } catch (Exception $exception) { + Installer::cleanFailedInstallationFiles(); + throw new Exception(sprintf('Database Installation Error: %s', $exception->getMessage())); + } + + $this->db->connect( + $databaseConfiguration->getServer(), + $databaseConfiguration->getUser(), + $databaseConfiguration->getPassword(), + $databaseConfiguration->getDatabase(), + $databaseConfiguration->getPort(), + ); + + if (!$this->db instanceof DatabaseDriver) { + Installer::cleanFailedInstallationFiles(); + throw new Exception(sprintf('Database Installation Error: %s', $this->db->error())); + } + + $this->configuration = new Configuration($this->db); + } + + /** + * Step 4: Create all database tables via SchemaInstaller. + * + * @throws Exception + */ + private function stepCreateDatabaseTables(InstallationInput $input): void + { + try { + $databaseInstaller = InstanceDatabase::factory($this->configuration, $input->dbSetup['dbType']); + $databaseInstaller->createTables($input->dbSetup['dbPrefix'] ?? ''); + } catch (Exception $exception) { + Installer::cleanFailedInstallationFiles(); + throw new Exception(sprintf('Database Installation Error: %s', $exception->getMessage())); + } + } + + /** + * Step 5: Insert stopwords into the database. + */ + private function stepInsertStopwords(InstallationInput $input): void + { + $stopWords = new Stopwords($this->configuration); + $stopWords->executeInsertQueries($input->dbSetup['dbPrefix'] ?? ''); + + $this->system->setDatabase($this->db); + } + + /** + * Step 6: Seed default configuration. + */ + private function stepSeedConfiguration(InstallationInput $input): void + { + $seeder = new DefaultDataSeeder(); + $seeder->applyPersonalSettings($input->realname, $input->email, $input->language, $input->permLevel); + $seeder->seedConfig($this->configuration); + + $link = new Link('', $this->configuration); + $this->configuration->update(['main.referenceURL' => $link->getSystemUri('/setup/index.php')]); + $this->configuration->add('security.salt', md5($this->configuration->getDefaultUrl())); + } + + /** + * Step 7: Create admin user (user_id = 1). + * + * @throws Exception + */ + private function stepCreateAdminUser(InstallationInput $input): void + { + $user = new User($this->configuration); + if (!$user->createUser($input->loginName, $input->password, '', 1)) { + Installer::cleanFailedInstallationFiles(); + throw new Exception(sprintf( + 'Fatal Installation Error: Could not create the admin user: %s', + $user->error(), + )); + } + + $user->setStatus('protected'); + $adminData = [ + 'display_name' => $input->realname, + 'email' => $input->email, + ]; + $user->setUserData($adminData); + $user->setSuperAdmin(true); + } + + /** + * Step 8: Grant all permissions to admin user. + */ + private function stepGrantPermissions(InstallationInput $input): void + { + $user = new User($this->configuration); + $user->getUserById(1, true); + + $seeder = new DefaultDataSeeder(); + foreach ($seeder->getMainRights() as $mainRight) { + $user->perm->grantUserRight(1, $user->perm->addRight($mainRight)); + } + } + + /** + * Step 9: Insert form inputs. + */ + private function stepInsertFormInputs(): void + { + $forms = new Forms($this->configuration); + $seeder = new DefaultDataSeeder(); + foreach ($seeder->getFormInputs() as $formInput) { + $forms->insertInputIntoDatabase($formInput); + } + } + + /** + * Step 10: Create anonymous user (user_id = -1). + * + * @throws Exception + */ + private function stepCreateAnonymousUser(InstallationInput $input): void + { + $instanceSetup = new Setup(); + $instanceSetup->setRootDir($input->rootDir); + $instanceSetup->createAnonymousUser($this->configuration); + } + + /** + * Step 11: Create primary instance. + */ + private function stepCreateInstance(): void + { + $link = new Link('', $this->configuration); + $instanceEntity = new InstanceEntity(); + $instanceEntity + ->setUrl($link->getSystemUri(Request::createFromGlobals()->getScriptName())) + ->setInstance($link->getSystemRelativeUri('setup/index.php')) + ->setComment('phpMyFAQ ' . System::getVersion()); + + $faqInstance = new Instance($this->configuration); + $faqInstance->create($instanceEntity); + + $main = new Main($this->configuration); + $main->createMain($faqInstance); + } + + /** + * Step 12: Initialize Elasticsearch/OpenSearch indices. + */ + private function stepInitializeSearchEngine(InstallationInput $input): void + { + if ($input->esEnabled && is_file($input->rootDir . '/config/elasticsearch.php')) { + $elasticsearchConfiguration = new ElasticsearchConfiguration($input->rootDir . '/config/elasticsearch.php'); + $this->configuration->setElasticsearchConfig($elasticsearchConfiguration); + + $esClient = ClientBuilder::create()->setHosts($elasticsearchConfiguration->getHosts())->build(); + $this->configuration->setElasticsearch($esClient); + + $elasticsearch = new Elasticsearch($this->configuration); + $elasticsearch->createIndex(); + } + + if ($input->osEnabled && is_file($input->rootDir . '/config/opensearch.php')) { + $openSearchConfiguration = new OpenSearchConfiguration($input->rootDir . '/config/opensearch.php'); + $this->configuration->setOpenSearchConfig($openSearchConfiguration); + + $osClient = new SymfonyClientFactory()->create([ + 'base_uri' => $openSearchConfiguration->getHosts()[0], + 'verify_peer' => false, + ]); + $this->configuration->setOpenSearch($osClient); + + $openSearch = new OpenSearch($this->configuration); + $openSearch->createIndex(); + } + } + + /** + * Step 13: Adjust .htaccess RewriteBase. + */ + private function stepAdjustHtaccess(): void + { + $environmentConfigurator = new EnvironmentConfigurator($this->configuration); + $environmentConfigurator->adjustRewriteBaseHtaccess(); + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Installer.php b/phpmyfaq/src/phpMyFAQ/Setup/Installer.php index 0e5a26eae8..194ef4eb0c 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Installer.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Installer.php @@ -19,33 +19,11 @@ namespace phpMyFAQ\Setup; -use Composer\Autoload\ClassLoader; -use Elastic\Elasticsearch\ClientBuilder; use Elastic\Elasticsearch\Exception\AuthenticationException; -use OpenSearch\SymfonyClientFactory; -use phpMyFAQ\Configuration; -use phpMyFAQ\Configuration\DatabaseConfiguration; -use phpMyFAQ\Configuration\ElasticsearchConfiguration; -use phpMyFAQ\Configuration\OpenSearchConfiguration; use phpMyFAQ\Core\Exception; -use phpMyFAQ\Database; -use phpMyFAQ\Database\DatabaseDriver; -use phpMyFAQ\Entity\InstanceEntity; -use phpMyFAQ\Enums\PermissionType; -use phpMyFAQ\Enums\ReleaseType; -use phpMyFAQ\Filter; -use phpMyFAQ\Forms; -use phpMyFAQ\Instance; -use phpMyFAQ\Instance\Database as InstanceDatabase; -use phpMyFAQ\Instance\Database\Stopwords; -use phpMyFAQ\Instance\Main; -use phpMyFAQ\Instance\Search\Elasticsearch; -use phpMyFAQ\Instance\Search\OpenSearch; use phpMyFAQ\Instance\Setup; -use phpMyFAQ\Ldap; -use phpMyFAQ\Link; +use phpMyFAQ\Setup\Installation\DefaultDataSeeder; use phpMyFAQ\System; -use phpMyFAQ\User; use Symfony\Component\HttpFoundation\Request; /** @@ -55,531 +33,6 @@ */ class Installer extends Setup { - /** - * Array with user rights. - * @var array> - */ - protected array $mainRights = [ - [ - 'name' => PermissionType::USER_ADD->value, - 'description' => 'Right to add user accounts', - ], - [ - 'name' => PermissionType::USER_EDIT->value, - 'description' => 'Right to edit user accounts', - ], - [ - 'name' => PermissionType::USER_DELETE->value, - 'description' => 'Right to delete user accounts', - ], - [ - 'name' => PermissionType::FAQ_ADD->value, - 'description' => 'Right to add faq entries', - ], - [ - 'name' => PermissionType::FAQ_EDIT->value, - 'description' => 'Right to edit faq entries', - ], - [ - 'name' => PermissionType::FAQ_DELETE->value, - 'description' => 'Right to delete faq entries', - ], - [ - 'name' => PermissionType::STATISTICS_VIEWLOGS->value, - 'description' => 'Right to view logfiles', - ], - [ - 'name' => PermissionType::STATISTICS_ADMINLOG->value, - 'description' => 'Right to view admin log', - ], - [ - 'name' => PermissionType::COMMENT_DELETE->value, - 'description' => 'Right to delete comments', - ], - [ - 'name' => PermissionType::NEWS_ADD->value, - 'description' => 'Right to add news', - ], - [ - 'name' => PermissionType::NEWS_EDIT->value, - 'description' => 'Right to edit news', - ], - [ - 'name' => PermissionType::NEWS_DELETE->value, - 'description' => 'Right to delete news', - ], - [ - 'name' => PermissionType::PAGE_ADD->value, - 'description' => 'Right to add custom pages', - ], - [ - 'name' => PermissionType::PAGE_EDIT->value, - 'description' => 'Right to edit custom pages', - ], - [ - 'name' => PermissionType::PAGE_DELETE->value, - 'description' => 'Right to delete custom pages', - ], - [ - 'name' => PermissionType::CATEGORY_ADD->value, - 'description' => 'Right to add categories', - ], - [ - 'name' => PermissionType::CATEGORY_EDIT->value, - 'description' => 'Right to edit categories', - ], - [ - 'name' => PermissionType::CATEGORY_DELETE->value, - 'description' => 'Right to delete categories', - ], - [ - 'name' => PermissionType::PASSWORD_CHANGE->value, - 'description' => 'Right to change passwords', - ], - [ - 'name' => PermissionType::CONFIGURATION_EDIT->value, - 'description' => 'Right to edit configuration', - ], - [ - 'name' => PermissionType::VIEW_ADMIN_LINK->value, - 'description' => 'Right to see the link to the admin section', - ], - [ - 'name' => PermissionType::BACKUP->value, - 'description' => 'Right to save backups', - ], - [ - 'name' => PermissionType::RESTORE->value, - 'description' => 'Right to load backups', - ], - [ - 'name' => PermissionType::QUESTION_DELETE->value, - 'description' => 'Right to delete questions', - ], - [ - 'name' => PermissionType::GLOSSARY_ADD->value, - 'description' => 'Right to add glossary entries', - ], - [ - 'name' => PermissionType::GLOSSARY_EDIT->value, - 'description' => 'Right to edit glossary entries', - ], - [ - 'name' => PermissionType::GLOSSARY_DELETE->value, - 'description' => 'Right to delete glossary entries', - ], - [ - 'name' => PermissionType::REVISION_UPDATE->value, - 'description' => 'Right to edit revisions', - ], - [ - 'name' => PermissionType::GROUP_ADD->value, - 'description' => 'Right to add group accounts', - ], - [ - 'name' => PermissionType::GROUP_EDIT->value, - 'description' => 'Right to edit group accounts', - ], - [ - 'name' => PermissionType::GROUP_DELETE->value, - 'description' => 'Right to delete group accounts', - ], - [ - 'name' => PermissionType::FAQ_APPROVE->value, - 'description' => 'Right to approve FAQs', - ], - [ - 'name' => PermissionType::ATTACHMENT_ADD->value, - 'description' => 'Right to add attachments', - ], - [ - 'name' => PermissionType::ATTACHMENT_EDIT->value, - 'description' => 'Right to edit attachments', - ], - [ - 'name' => PermissionType::ATTACHMENT_DELETE->value, - 'description' => 'Right to delete attachments', - ], - [ - 'name' => PermissionType::ATTACHMENT_DOWNLOAD->value, - 'description' => 'Right to download attachments', - ], - [ - 'name' => PermissionType::REPORTS->value, - 'description' => 'Right to generate reports', - ], - [ - 'name' => PermissionType::FAQ_ADD->value, - 'description' => 'Right to add FAQs in frontend', - ], - [ - 'name' => PermissionType::QUESTION_ADD->value, - 'description' => 'Right to add questions in frontend', - ], - [ - 'name' => PermissionType::COMMENT_ADD->value, - 'description' => 'Right to add comments in frontend', - ], - [ - 'name' => PermissionType::INSTANCE_EDIT->value, - 'description' => 'Right to edit multi-site instances', - ], - [ - 'name' => PermissionType::INSTANCE_ADD->value, - 'description' => 'Right to add multi-site instances', - ], - [ - 'name' => PermissionType::INSTANCE_DELETE->value, - 'description' => 'Right to delete multi-site instances', - ], - [ - 'name' => PermissionType::EXPORT->value, - 'description' => 'Right to export the complete FAQ', - ], - [ - 'name' => PermissionType::FAQS_VIEW->value, - 'description' => 'Right to view FAQs', - ], - [ - 'name' => PermissionType::CATEGORIES_VIEW->value, - 'description' => 'Right to view categories', - ], - [ - 'name' => PermissionType::NEWS_VIEW->value, - 'description' => 'Right to view news', - ], - [ - 'name' => PermissionType::GROUPS_ADMINISTRATE->value, - 'description' => 'Right to administrate groups', - ], - [ - 'name' => PermissionType::FORMS_EDIT->value, - 'description' => 'Right to edit forms', - ], - [ - 'name' => PermissionType::FAQ_TRANSLATE->value, - 'description' => 'Right to translate FAQs', - ], - ]; - - /** - * Configuration array. - * - * @var array - */ - protected array $mainConfig = [ - 'main.currentVersion' => null, - 'main.currentApiVersion' => null, - 'main.language' => '__PHPMYFAQ_LANGUAGE__', - 'main.languageDetection' => 'true', - 'main.phpMyFAQToken' => null, - 'main.referenceURL' => '__PHPMYFAQ_REFERENCE_URL__', - 'main.administrationMail' => 'webmaster@example.org', - 'main.contactInformation' => '', - 'main.enableAdminLog' => 'true', - 'main.enableUserTracking' => 'true', - 'main.metaDescription' => 'phpMyFAQ should be the answer for all questions in life', - 'main.metaPublisher' => '__PHPMYFAQ_PUBLISHER__', - 'main.titleFAQ' => 'phpMyFAQ Codename Palaimon', - 'main.enableWysiwygEditor' => 'true', - 'main.enableWysiwygEditorFrontend' => 'false', - 'main.enableMarkdownEditor' => 'false', - 'main.enableCommentEditor' => 'false', - 'main.dateFormat' => 'Y-m-d H:i', - 'main.maintenanceMode' => 'false', - 'main.enableGravatarSupport' => 'false', - 'main.customPdfHeader' => '', - 'main.customPdfFooter' => '', - 'main.enableSmartAnswering' => 'true', - 'main.enableCategoryRestrictions' => 'true', - 'main.enableSendToFriend' => 'true', - 'main.privacyURL' => '', - 'main.termsURL' => '', - 'main.imprintURL' => '', - 'main.cookiePolicyURL' => '', - 'main.accessibilityStatementURL' => '', - 'main.enableAutoUpdateHint' => 'true', - 'main.enableAskQuestions' => 'false', - 'main.enableNotifications' => 'false', - 'main.botIgnoreList' => - 'nustcrape,webpost,GoogleBot,msnbot,crawler,scooter,bravobrian,archiver,' - . 'w3c,controler,wget,bot,spider,Yahoo! Slurp,htdig,gsa-crawler,AirControler,Uptime-Kuma,facebookcatalog/1.0,' - . 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php),facebookexternalhit/1.1', - 'records.numberOfRecordsPerPage' => '10', - 'records.numberOfShownNewsEntries' => '3', - 'records.defaultActivation' => 'false', - 'records.defaultAllowComments' => 'false', - 'records.enableVisibilityQuestions' => 'false', - 'records.numberOfRelatedArticles' => '5', - 'records.orderby' => 'id', - 'records.sortby' => 'DESC', - 'records.orderingPopularFaqs' => 'visits', - 'records.disableAttachments' => 'true', - 'records.maxAttachmentSize' => '100000', - 'records.attachmentsPath' => 'content/user/attachments', - 'records.attachmentsStorageType' => '0', - 'records.enableAttachmentEncryption' => 'false', - 'records.defaultAttachmentEncKey' => '', - 'records.enableCloseQuestion' => 'false', - 'records.enableDeleteQuestion' => 'false', - 'records.randomSort' => 'false', - 'records.allowCommentsForGuests' => 'true', - 'records.allowQuestionsForGuests' => 'true', - 'records.allowNewFaqsForGuests' => 'true', - 'records.hideEmptyCategories' => 'false', - 'records.allowDownloadsForGuests' => 'false', - 'records.numberMaxStoredRevisions' => '10', - 'records.enableAutoRevisions' => 'false', - 'records.orderStickyFaqsCustom' => 'false', - 'records.allowedMediaHosts' => 'www.youtube.com', - 'search.numberSearchTerms' => '10', - 'search.relevance' => 'thema,content,keywords', - 'search.enableRelevance' => 'false', - 'search.enableHighlighting' => 'true', - 'search.searchForSolutionId' => 'true', - 'search.popularSearchTimeWindow' => '180', - 'search.enableElasticsearch' => 'false', - 'search.enableOpenSearch' => 'false', - 'security.permLevel' => 'basic', - 'security.ipCheck' => 'false', - 'security.enableLoginOnly' => 'false', - 'security.bannedIPs' => '', - 'security.ssoSupport' => 'false', - 'security.ssoLogoutRedirect' => '', - 'security.useSslForLogins' => 'false', - 'security.useSslOnly' => 'false', - 'security.forcePasswordUpdate' => 'false', - 'security.enableRegistration' => 'true', - 'security.domainWhiteListForRegistrations' => '', - 'security.enableSignInWithMicrosoft' => 'false', - 'security.enableGoogleReCaptchaV2' => 'false', - 'security.googleReCaptchaV2SiteKey' => '', - 'security.googleReCaptchaV2SecretKey' => '', - 'security.loginWithEmailAddress' => 'false', - 'security.enableWebAuthnSupport' => 'false', - 'security.enableAdminSessionTimeoutCounter' => 'true', - 'spam.checkBannedWords' => 'true', - 'spam.enableCaptchaCode' => null, - 'spam.enableSafeEmail' => 'true', - 'spam.manualActivation' => 'true', - 'spam.mailAddressInExport' => 'true', - 'seo.title' => 'phpMyFAQ Codename Porus', - 'seo.description' => 'phpMyFAQ should be the answer for all questions in life', - 'seo.enableXMLSitemap' => 'true', - 'seo.enableRichSnippets' => 'false', - 'seo.metaTagsHome' => 'index, follow', - 'seo.metaTagsFaqs' => 'index, follow', - 'seo.metaTagsCategories' => 'index, follow', - 'seo.metaTagsPages' => 'index, follow', - 'seo.metaTagsAdmin' => 'noindex, nofollow', - 'seo.contentRobotsText' => 'User-agent: *\nDisallow: /admin/\nSitemap: /sitemap.xml', - 'seo.contentLlmsText' => - "# phpMyFAQ LLMs.txt\n\n" - . "This file provides information about the AI/LLM training data availability for this FAQ system.\n\n" - . "Contact: Please see the contact information on the main website.\n\n" - . "The FAQ content in this system is available for LLM training purposes.\n" - . "Please respect the licensing terms and usage guidelines of the content.\n\n" - . 'For more information about this FAQ system, visit: https://www.phpmyfaq.de', - 'mail.noReplySenderAddress' => '', - 'mail.remoteSMTP' => 'false', - 'mail.remoteSMTPServer' => '', - 'mail.remoteSMTPUsername' => '', - 'mail.remoteSMTPPassword' => '', - 'mail.remoteSMTPPort' => '25', - 'mail.remoteSMTPDisableTLSPeerVerification' => 'false', - 'ldap.ldapSupport' => 'false', - 'ldap.ldap_mapping.name' => 'cn', - 'ldap.ldap_mapping.username' => 'samAccountName', - 'ldap.ldap_mapping.mail' => 'mail', - 'ldap.ldap_mapping.memberOf' => '', - 'ldap.ldap_use_domain_prefix' => 'true', - 'ldap.ldap_options.LDAP_OPT_PROTOCOL_VERSION' => '3', - 'ldap.ldap_options.LDAP_OPT_REFERRALS' => '0', - 'ldap.ldap_use_memberOf' => 'false', - 'ldap.ldap_use_sasl' => 'false', - 'ldap.ldap_use_multiple_servers' => 'false', - 'ldap.ldap_use_anonymous_login' => 'false', - 'ldap.ldap_use_dynamic_login' => 'false', - 'ldap.ldap_dynamic_login_attribute' => 'uid', - 'ldap.ldap_use_group_restriction' => 'false', - 'ldap.ldap_group_allowed_groups' => '', - 'ldap.ldap_group_auto_assign' => 'false', - 'ldap.ldap_group_mapping' => '', - 'api.enableAccess' => 'true', - 'api.apiClientToken' => '', - 'api.onlyActiveFaqs' => 'true', - 'api.onlyActiveCategories' => 'true', - 'translation.provider' => 'none', - 'translation.googleApiKey' => '', - 'translation.deeplApiKey' => '', - 'translation.deeplUseFreeApi' => 'true', - 'translation.azureKey' => '', - 'translation.azureRegion' => '', - 'translation.amazonAccessKeyId' => '', - 'translation.amazonSecretAccessKey' => '', - 'translation.amazonRegion' => 'us-east-1', - 'translation.libreTranslateUrl' => 'https://libretranslate.com', - 'translation.libreTranslateApiKey' => '', - 'routing.useAttributesOnly' => 'false', - 'routing.cache.enabled' => 'false', - 'routing.cache.dir' => './cache', - 'api.onlyPublicQuestions' => 'true', - 'api.ignoreOrphanedFaqs' => 'true', - 'upgrade.dateLastChecked' => '', - 'upgrade.lastDownloadedPackage' => '', - 'upgrade.onlineUpdateEnabled' => 'false', - 'upgrade.releaseEnvironment' => '__PHPMYFAQ_RELEASE__', - 'layout.templateSet' => 'default', - 'layout.enablePrivacyLink' => 'true', - 'layout.enableCookieConsent' => 'true', - 'layout.contactInformationHTML' => 'false', - 'layout.customCss' => '', - ]; - - /** - * Array with form inputs - * @var array> - */ - public array $formInputs = [ - // Ask Question inputs - [ - 'form_id' => 1, - 'input_id' => 1, - 'input_type' => 'title', - 'input_label' => 'msgQuestion', - 'input_active' => 1, - 'input_required' => -1, - 'input_lang' => 'default', - ], - [ - 'form_id' => 1, - 'input_id' => 2, - 'input_type' => 'message', - 'input_label' => 'msgNewQuestion', - 'input_active' => 1, - 'input_required' => -1, - 'input_lang' => 'default', - ], - [ - 'form_id' => 1, - 'input_id' => 3, - 'input_type' => 'text', - 'input_label' => 'msgNewContentName', - 'input_active' => 1, - 'input_required' => 1, - 'input_lang' => 'default', - ], - [ - 'form_id' => 1, - 'input_id' => 4, - 'input_type' => 'email', - 'input_label' => 'msgNewContentMail', - 'input_active' => 1, - 'input_required' => 1, - 'input_lang' => 'default', - ], - [ - 'form_id' => 1, - 'input_id' => 5, - 'input_type' => 'select', - 'input_label' => 'msgNewContentCategory', - 'input_active' => 1, - 'input_required' => 1, - 'input_lang' => 'default', - ], - [ - 'form_id' => 1, - 'input_id' => 6, - 'input_type' => 'textarea', - 'input_label' => 'msgAskYourQuestion', - 'input_active' => -1, - 'input_required' => -1, - 'input_lang' => 'default', - ], - // Add New FAQ inputs - [ - 'form_id' => 2, - 'input_id' => 1, - 'input_type' => 'title', - 'input_label' => 'msgNewContentHeader', - 'input_active' => 1, - 'input_required' => -1, - 'input_lang' => 'default', - ], - [ - 'form_id' => 2, - 'input_id' => 2, - 'input_type' => 'message', - 'input_label' => 'msgNewContentAddon', - 'input_active' => 1, - 'input_required' => -1, - 'input_lang' => 'default', - ], - [ - 'form_id' => 2, - 'input_id' => 3, - 'input_type' => 'text', - 'input_label' => 'msgNewContentName', - 'input_active' => 1, - 'input_required' => 1, - 'input_lang' => 'default', - ], - [ - 'form_id' => 2, - 'input_id' => 4, - 'input_type' => 'email', - 'input_label' => 'msgNewContentMail', - 'input_active' => 1, - 'input_required' => 1, - 'input_lang' => 'default', - ], - [ - 'form_id' => 2, - 'input_id' => 5, - 'input_type' => 'select', - 'input_label' => 'msgNewContentCategory', - 'input_active' => 1, - 'input_required' => 1, - 'input_lang' => 'default', - ], - [ - 'form_id' => 2, - 'input_id' => 6, - 'input_type' => 'textarea', - 'input_label' => 'msgNewContentTheme', - 'input_active' => -1, - 'input_required' => -1, - 'input_lang' => 'default', - ], - [ - 'form_id' => 2, - 'input_id' => 7, - 'input_type' => 'textarea', - 'input_label' => 'msgNewContentArticle', - 'input_active' => 1, - 'input_required' => 1, - 'input_lang' => 'default', - ], - [ - 'form_id' => 2, - 'input_id' => 8, - 'input_type' => 'text', - 'input_label' => 'msgNewContentKeywords', - 'input_active' => 1, - 'input_required' => 1, - 'input_lang' => 'default', - ], - [ - 'form_id' => 2, - 'input_id' => 9, - 'input_type' => 'title', - 'input_label' => 'msgNewContentLink', - 'input_active' => 1, - 'input_required' => 1, - 'input_lang' => 'default', - ], - ]; - /** * Constructor. * @@ -589,18 +42,6 @@ public function __construct( private readonly System $system, ) { parent::__construct(); - - $dynMainConfig = [ - 'main.currentVersion' => System::getVersion(), - 'main.currentApiVersion' => System::getApiVersion(), - 'main.phpMyFAQToken' => bin2hex(random_bytes(16)), - 'spam.enableCaptchaCode' => extension_loaded('gd') ? 'true' : 'false', - 'upgrade.releaseEnvironment' => System::isDevelopmentVersion() - ? ReleaseType::DEVELOPMENT->value - : ReleaseType::STABLE->value, - ]; - - $this->mainConfig = array_merge($this->mainConfig, $dynMainConfig); } /** @@ -760,486 +201,20 @@ public function checkInitialRewriteBasePath(Request $request): bool /** * Starts the installation. * + * Delegates to InstallationInputValidator for input parsing and + * InstallationRunner for the actual installation steps. + * + * @param array|null $setup Optional setup array (for programmatic/test installs) * @throws Exception|AuthenticationException * @throws \Exception */ public function startInstall(?array $setup = null): void { - $ldapSetup = []; - $query = []; - $uninstall = []; - $dbSetup = []; - - // Check table prefix - $dbSetup['dbPrefix'] = Filter::filterInput(INPUT_POST, 'sqltblpre', FILTER_SANITIZE_SPECIAL_CHARS, ''); - if ('' !== $dbSetup['dbPrefix']) { - Database::setTablePrefix($dbSetup['dbPrefix']); - } - - // Check database entries - if (!isset($setup['dbType'])) { - $dbSetup['dbType'] = Filter::filterInput(INPUT_POST, 'sql_type', FILTER_SANITIZE_SPECIAL_CHARS); - } else { - $dbSetup['dbType'] = $setup['dbType']; - } - - if (!is_null($dbSetup['dbType'])) { - $dbSetup['dbType'] = trim((string) $dbSetup['dbType']); - if (str_starts_with($dbSetup['dbType'], 'pdo_')) { - $dataBaseFile = 'Pdo' . ucfirst(substr($dbSetup['dbType'], 4)); - } else { - $dataBaseFile = ucfirst($dbSetup['dbType']); - } - - if (!file_exists(PMF_SRC_DIR . '/phpMyFAQ/Instance/Database/' . $dataBaseFile . '.php')) { - throw new Exception(sprintf('Installation Error: Invalid server type "%s"', $dbSetup['dbType'])); - } - } else { - throw new Exception('Installation Error: Please select a database type.'); - } - - $dbSetup['dbServer'] = Filter::filterInput(INPUT_POST, 'sql_server', FILTER_SANITIZE_SPECIAL_CHARS, ''); - if (is_null($dbSetup['dbServer']) && !System::isSqlite($dbSetup['dbType'])) { - throw new Exception('Installation Error: Please add a database server.'); - } - - // Check database port - if (!isset($setup['dbType'])) { - $dbSetup['dbPort'] = Filter::filterInput(INPUT_POST, 'sql_port', FILTER_VALIDATE_INT); - } else { - $dbSetup['dbPort'] = $setup['dbPort']; - } - - if (is_null($dbSetup['dbPort']) && !System::isSqlite($dbSetup['dbType'])) { - throw new Exception('Installation Error: Please add a valid database port.'); - } - - $dbSetup['dbUser'] = Filter::filterInput(INPUT_POST, 'sql_user', FILTER_SANITIZE_SPECIAL_CHARS, ''); - if (is_null($dbSetup['dbUser']) && !System::isSqlite($dbSetup['dbType'])) { - throw new Exception('Installation Error: Please add a database username.'); - } - - $dbSetup['dbPassword'] = Filter::filterInput(INPUT_POST, 'sql_password', FILTER_SANITIZE_SPECIAL_CHARS, ''); - if (is_null($dbSetup['dbPassword']) && !System::isSqlite($dbSetup['dbType'])) { - // A password can be empty... - $dbSetup['dbPassword'] = ''; - } - - // Check the database name - if (!isset($setup['dbType'])) { - $dbSetup['dbDatabaseName'] = Filter::filterInput(INPUT_POST, 'sql_db', FILTER_SANITIZE_SPECIAL_CHARS); - } else { - $dbSetup['dbDatabaseName'] = $setup['dbDatabaseName']; - } - - if (is_null($dbSetup['dbDatabaseName']) && !System::isSqlite($dbSetup['dbType'])) { - throw new Exception('Installation Error: Please add a database name.'); - } - - if (System::isSqlite($dbSetup['dbType'])) { - $dbSetup['dbServer'] = Filter::filterInput( - INPUT_POST, - 'sql_sqlitefile', - FILTER_SANITIZE_SPECIAL_CHARS, - $setup['dbServer'] ?? null, - ); - if (is_null($dbSetup['dbServer'])) { - throw new Exception('Installation Error: Please add a SQLite database filename.'); - } - } - - // check database connection - Database::setTablePrefix($dbSetup['dbPrefix']); - $db = Database::factory($dbSetup['dbType']); - $db->connect( - $dbSetup['dbServer'], - $dbSetup['dbUser'], - $dbSetup['dbPassword'], - $dbSetup['dbDatabaseName'], - $dbSetup['dbPort'], - ); - - $configuration = new Configuration($db); - - // Check LDAP if enabled - - $ldapEnabled = Filter::filterInput(INPUT_POST, 'ldap_enabled', FILTER_SANITIZE_SPECIAL_CHARS); - if (extension_loaded('ldap') && !is_null($ldapEnabled)) { - // check LDAP entries - $ldapSetup['ldapServer'] = Filter::filterInput(INPUT_POST, 'ldap_server', FILTER_SANITIZE_SPECIAL_CHARS); - if (is_null($ldapSetup['ldapServer'])) { - throw new Exception('LDAP Installation Error: Please add a LDAP server.'); - } - - $ldapSetup['ldapPort'] = Filter::filterInput(INPUT_POST, 'ldap_port', FILTER_VALIDATE_INT); - if (is_null($ldapSetup['ldapPort'])) { - throw new Exception('LDAP Installation Error: Please add a LDAP port.'); - } - - $ldapSetup['ldapBase'] = Filter::filterInput(INPUT_POST, 'ldap_base', FILTER_SANITIZE_SPECIAL_CHARS); - if (is_null($ldapSetup['ldapBase'])) { - throw new Exception('LDAP Installation Error: Please add a LDAP base search DN.'); - } - - // LDAP User and LDAP password are optional - $ldapSetup['ldapUser'] = Filter::filterInput(INPUT_POST, 'ldap_user', FILTER_SANITIZE_SPECIAL_CHARS); - $ldapSetup['ldapPassword'] = Filter::filterInput( - INPUT_POST, - 'ldap_password', - FILTER_SANITIZE_SPECIAL_CHARS, - ); - - // set LDAP Config to prevent DB query - foreach ($this->mainConfig as $configKey => $configValue) { - if (!str_contains($configKey, 'ldap.')) { - continue; - } - - $configuration->set($configKey, $configValue); - } - - // check LDAP connection - $ldap = new Ldap($configuration); - $ldapConnection = $ldap->connect( - $ldapSetup['ldapServer'], - $ldapSetup['ldapPort'], - $ldapSetup['ldapBase'], - $ldapSetup['ldapUser'], - $ldapSetup['ldapPassword'], - ); - - if (!$ldapConnection) { - throw new Exception(sprintf('LDAP Installation Error: %s.', $ldap->error())); - } - } - - // Check Elasticsearch if enabled - - $esEnabled = Filter::filterInput(INPUT_POST, 'elasticsearch_enabled', FILTER_SANITIZE_SPECIAL_CHARS); - if (!is_null($esEnabled)) { - $esSetup = []; - $esHostFilter = [ - 'elasticsearch_server' => [ - 'filter' => FILTER_SANITIZE_SPECIAL_CHARS, - 'flags' => FILTER_REQUIRE_ARRAY, - ], - ]; - - // ES hosts - $esHosts = Filter::filterInputArray(INPUT_POST, $esHostFilter); - if (is_null($esHosts)) { - throw new Exception('Elasticsearch Installation Error: Please add at least one Elasticsearch host.'); - } - - $esSetup['hosts'] = $esHosts['elasticsearch_server']; - - // ES Index name - $esSetup['index'] = Filter::filterInput(INPUT_POST, 'elasticsearch_index', FILTER_SANITIZE_SPECIAL_CHARS); - if (is_null($esSetup['index'])) { - throw new Exception('Elasticsearch Installation Error: Please add an Elasticsearch index name.'); - } - - $classLoader = new ClassLoader(); - $classLoader->addPsr4('Elasticsearch\\', PMF_SRC_DIR . '/libs/elasticsearch/src/Elasticsearch'); - $classLoader->addPsr4('Monolog\\', PMF_SRC_DIR . '/libs/monolog/src/Monolog'); - $classLoader->addPsr4('Psr\\', PMF_SRC_DIR . '/libs/psr/log/Psr'); - $classLoader->addPsr4('React\\Promise\\', PMF_SRC_DIR . '/libs/react/promise/src'); - $classLoader->register(); - - // check Elasticsearch connection - $esHosts = array_values($esHosts['elasticsearch_server']); - $esClient = ClientBuilder::create()->setHosts($esHosts)->build(); - - if (!$esClient) { - throw new Exception('Elasticsearch Installation Error: No connection to Elasticsearch.'); - } - } else { - $esSetup = []; - } - - // Check OpenSearch if enabled - - $openSearchEnabled = Filter::filterInput(INPUT_POST, 'opensearch_enabled', FILTER_SANITIZE_SPECIAL_CHARS); - if (!is_null($openSearchEnabled)) { - $osSetup = []; - $osHostFilter = [ - 'opensearch_server' => [ - 'filter' => FILTER_SANITIZE_SPECIAL_CHARS, - 'flags' => FILTER_REQUIRE_ARRAY, - ], - ]; - - // OS hosts - $osHosts = Filter::filterInputArray(INPUT_POST, $osHostFilter); - if (is_null($osHosts)) { - throw new Exception('OpenSearch Installation Error: Please add at least one OpenSearch host.'); - } - - $osSetup['hosts'] = $osHosts['opensearch_server']; - - // OS Index name - $osSetup['index'] = Filter::filterInput(INPUT_POST, 'opensearch_index', FILTER_SANITIZE_SPECIAL_CHARS); - if (is_null($osSetup['index'])) { - throw new Exception('OpenSearch Installation Error: Please add an OpenSearch index name.'); - } - - // check OpenSearch connection - $osHosts = array_values($osHosts['opensearch_server']); - $osClient = new SymfonyClientFactory()->create([ - 'base_uri' => $osHosts[0], - 'verify_peer' => false, - ]); - - if (!$osClient) { - throw new Exception('OpenSearch Installation Error: No connection to OpenSearch.'); - } - } else { - $osSetup = []; - } - - // check the login name - if (!isset($setup['loginname'])) { - $loginName = Filter::filterInput(INPUT_POST, 'loginname', FILTER_SANITIZE_SPECIAL_CHARS); - } else { - $loginName = $setup['loginname']; - } + $validator = new InstallationInputValidator(); + $input = $validator->validate($setup); - if (is_null($loginName)) { - throw new Exception('Installation Error: Please add a login name for your account.'); - } - - // check user entries - if (!isset($setup['password'])) { - $password = Filter::filterInput(INPUT_POST, 'password', FILTER_SANITIZE_SPECIAL_CHARS); - } else { - $password = $setup['password']; - } - - if (is_null($password)) { - throw new Exception('Installation Error: Please add a password for your account.'); - } - - if (!isset($setup['password_retyped'])) { - $passwordRetyped = Filter::filterInput(INPUT_POST, 'password_retyped', FILTER_SANITIZE_SPECIAL_CHARS); - } else { - $passwordRetyped = $setup['password_retyped']; - } - - if (is_null($passwordRetyped)) { - throw new Exception('Installation Error: Please add a retyped password.'); - } - - if (strlen((string) $password) <= 7 || strlen((string) $passwordRetyped) <= 7) { - throw new Exception( - 'Installation Error: Your password and retyped password are too short. Please set your password ' - . 'and your retyped password with a minimum of 8 characters.', - ); - } - - if ($password !== $passwordRetyped) { - throw new Exception( - 'Installation Error: Your password and retyped password are not equal. Please check your password ' - . 'and your retyped password.', - ); - } - - $language = Filter::filterInput(INPUT_POST, 'language', FILTER_SANITIZE_SPECIAL_CHARS, 'en'); - $realname = Filter::filterInput(INPUT_POST, 'realname', FILTER_SANITIZE_SPECIAL_CHARS, ''); - $email = Filter::filterInput(INPUT_POST, 'email', FILTER_SANITIZE_EMAIL, ''); - $permLevel = Filter::filterInput(INPUT_POST, 'permLevel', FILTER_SANITIZE_SPECIAL_CHARS, 'basic'); - - $rootDir = $setup['rootDir'] ?? PMF_ROOT_DIR; - - $instanceSetup = new Setup(); - $instanceSetup->setRootDir($rootDir); - - // Write the DB variables in database.php - if (!$instanceSetup->createDatabaseFile($dbSetup)) { - self::cleanFailedInstallationFiles(); - throw new Exception('Installation Error: Setup cannot write to ./content/core/config/database.php.'); - } - - // check LDAP is enabled - if ( - extension_loaded('ldap') - && !is_null($ldapEnabled) - && count($ldapSetup) - && !$instanceSetup->createLdapFile($ldapSetup, '') - ) { - self::cleanFailedInstallationFiles(); - throw new Exception('LDAP Installation Error: Setup cannot write to ./content/core/config/ldap.php.'); - } - - // check if Elasticsearch is enabled - if (!is_null($esEnabled) && count($esSetup) && !$instanceSetup->createElasticsearchFile($esSetup, '')) { - self::cleanFailedInstallationFiles(); - throw new Exception( - 'Elasticsearch Installation Error: Setup cannot write to ./content/core/config/elasticsearch.php.', - ); - } - - // check if OpenSearch is enabled - if (!is_null($openSearchEnabled) && count($osSetup) && !$instanceSetup->createOpenSearchFile($osSetup, '')) { - self::cleanFailedInstallationFiles(); - throw new Exception( - 'OpenSearch Installation Error: Setup cannot write to ./content/core/config/opensearch.php.', - ); - } - - // connect to the database using config/database.php - $databaseConfiguration = new DatabaseConfiguration($rootDir . '/content/core/config/database.php'); - try { - $db = Database::factory($dbSetup['dbType']); - } catch (Exception $exception) { - self::cleanFailedInstallationFiles(); - throw new Exception(sprintf('Database Installation Error: %s', $exception->getMessage())); - } - - $db->connect( - $databaseConfiguration->getServer(), - $databaseConfiguration->getUser(), - $databaseConfiguration->getPassword(), - $databaseConfiguration->getDatabase(), - $databaseConfiguration->getPort(), - ); - - if (!$db instanceof DatabaseDriver) { - self::cleanFailedInstallationFiles(); - throw new Exception(sprintf('Database Installation Error: %s', $db->error())); - } - - try { - $databaseInstaller = InstanceDatabase::factory($configuration, $dbSetup['dbType']); - $databaseInstaller->createTables($dbSetup['dbPrefix']); - } catch (Exception $exception) { - self::cleanFailedInstallationFiles(); - throw new Exception(sprintf('Database Installation Error: %s', $exception->getMessage())); - } - - $stopWords = new Stopwords($configuration); - $stopWords->executeInsertQueries($dbSetup['dbPrefix']); - - $this->system->setDatabase($db); - - // Erase any table before starting creating the required ones - if (!System::isSqlite($dbSetup['dbType'])) { - $this->system->dropTables($uninstall); - } - - // Start creating the required tables - $count = 0; - foreach ($query as $executeQuery) { - $result = @$db->query($executeQuery); - if (!$result) { - $this->system->dropTables($uninstall); - self::cleanFailedInstallationFiles(); - throw new Exception(sprintf( - 'Installation Error: Please install your version of phpMyFAQ once again: %s (%s)', - $db->error(), - htmlentities($executeQuery), - )); - } - - usleep(1000); - ++$count; - if (($count % 10) === 0) { - echo '| '; - } - } - - $link = new Link('', $configuration); - - // add the main configuration, add personal settings - $this->mainConfig['main.metaPublisher'] = $realname; - $this->mainConfig['main.administrationMail'] = $email; - $this->mainConfig['main.language'] = $language; - $this->mainConfig['security.permLevel'] = $permLevel; - - foreach ($this->mainConfig as $name => $value) { - $configuration->add($name, $value); - } - - $configuration->update(['main.referenceURL' => $setup['mainUrl'] ?? $link->getSystemUri('/setup/index.php')]); - $configuration->add('security.salt', md5($configuration->getDefaultUrl())); - - // add an admin account and rights - $user = new User($configuration); - if (!$user->createUser($loginName, $password, '', 1)) { - self::cleanFailedInstallationFiles(); - throw new Exception(sprintf( - 'Fatal Installation Error: Could not create the admin user: %s', - $user->error(), - )); - } - - $user->setStatus('protected'); - $adminData = [ - 'display_name' => $realname, - 'email' => $email, - ]; - $user->setUserData($adminData); - $user->setSuperAdmin(true); - - // add default rights - foreach ($this->mainRights as $mainRight) { - $user->perm->grantUserRight(1, $user->perm->addRight($mainRight)); - } - - // add inputs in table "faqforms" - $forms = new Forms($configuration); - foreach ($this->formInputs as $formInput) { - $forms->insertInputIntoDatabase($formInput); - } - - // Add an anonymous user account - $instanceSetup->createAnonymousUser($configuration); - - // Add primary instance - $instanceEntity = new InstanceEntity(); - $instanceEntity - ->setUrl($link->getSystemUri(Request::createFromGlobals()->getScriptName())) - ->setInstance($link->getSystemRelativeUri('setup/index.php')) - ->setComment('phpMyFAQ ' . System::getVersion()); - $faqInstance = new Instance($configuration); - $faqInstance->create($instanceEntity); - - $main = new Main($configuration); - $main->createMain($faqInstance); - - // connect to Elasticsearch if enabled - if (!is_null($esEnabled) && is_file($rootDir . '/config/elasticsearch.php')) { - $elasticsearchConfiguration = new ElasticsearchConfiguration($rootDir . '/config/elasticsearch.php'); - - $configuration->setElasticsearchConfig($elasticsearchConfiguration); - - $esClient = ClientBuilder::create()->setHosts($elasticsearchConfiguration->getHosts())->build(); - - $configuration->setElasticsearch($esClient); - - $elasticsearch = new Elasticsearch($configuration); - $elasticsearch->createIndex(); - } - - // connect to OpenSearch if enabled - if (!is_null($openSearchEnabled) && is_file($rootDir . '/config/opensearch.php')) { - $openSearchConfiguration = new OpenSearchConfiguration($rootDir . '/config/opensearch.php'); - - $configuration->setOpenSearchConfig($openSearchConfiguration); - - $osClient = new SymfonyClientFactory()->create([ - 'base_uri' => $openSearchConfiguration->getHosts()[0], - 'verify_peer' => false, - ]); - - $configuration->setOpenSearch($osClient); - - $openSearch = new OpenSearch($configuration); - $openSearch->createIndex(); - } - - // adjust RewriteBase in .htaccess - $environmentConfigurator = new EnvironmentConfigurator($configuration); - $environmentConfigurator->adjustRewriteBaseHtaccess(); + $runner = new InstallationRunner($this->system); + $runner->run($input); } /** @@ -1260,4 +235,16 @@ public function hasElasticsearchSupport(): bool { return extension_loaded('curl') && extension_loaded('openssl'); } + + /** + * Returns the form inputs array, delegating to DefaultDataSeeder. + * + * @return array> + * @throws \Exception + */ + public function getFormInputs(): array + { + $seeder = new DefaultDataSeeder(); + return $seeder->getFormInputs(); + } } diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Migration/Operations/FormInputInsertOperation.php b/phpmyfaq/src/phpMyFAQ/Setup/Migration/Operations/FormInputInsertOperation.php new file mode 100644 index 0000000000..99219af0b7 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Setup/Migration/Operations/FormInputInsertOperation.php @@ -0,0 +1,71 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-31 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Setup\Migration\Operations; + +use phpMyFAQ\Configuration; +use phpMyFAQ\Forms; +use Throwable; + +readonly class FormInputInsertOperation implements OperationInterface +{ + /** + * @param array $formInput + */ + public function __construct( + private Configuration $configuration, + private array $formInput, + ) { + } + + public function getType(): string + { + return 'form_input_insert'; + } + + public function getDescription(): string + { + return sprintf( + 'Insert form input: form=%d, input=%d, label=%s', + $this->formInput['form_id'], + $this->formInput['input_id'], + $this->formInput['input_label'], + ); + } + + public function execute(): bool + { + try { + $forms = new Forms($this->configuration); + $forms->insertInputIntoDatabase($this->formInput); + return true; + } catch (Throwable) { + return false; + } + } + + public function toArray(): array + { + return [ + 'type' => $this->getType(), + 'description' => $this->getDescription(), + 'form_input' => $this->formInput, + ]; + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Migration/Operations/OperationRecorder.php b/phpmyfaq/src/phpMyFAQ/Setup/Migration/Operations/OperationRecorder.php index 9b9d668580..c8cb122488 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Migration/Operations/OperationRecorder.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Migration/Operations/OperationRecorder.php @@ -122,6 +122,42 @@ public function grantPermission(string $name, string $description, int $userId = return $this; } + /** + * Records a user creation operation. + */ + public function createUser( + string $loginName, + string $password, + string $displayName, + string $email, + int $userId, + bool $isSuperAdmin = false, + string $status = 'protected', + ): self { + $this->operations[] = new UserCreateOperation( + $this->configuration, + $loginName, + $password, + $displayName, + $email, + $userId, + $isSuperAdmin, + $status, + ); + return $this; + } + + /** + * Records a form input insertion operation. + * + * @param array $formInput + */ + public function insertFormInput(array $formInput): self + { + $this->operations[] = new FormInputInsertOperation($this->configuration, $formInput); + return $this; + } + /** * Records a custom operation. */ diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Migration/Operations/UserCreateOperation.php b/phpmyfaq/src/phpMyFAQ/Setup/Migration/Operations/UserCreateOperation.php new file mode 100644 index 0000000000..c4e9dcb29a --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Setup/Migration/Operations/UserCreateOperation.php @@ -0,0 +1,87 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-31 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Setup\Migration\Operations; + +use phpMyFAQ\Configuration; +use phpMyFAQ\User; +use SensitiveParameter; + +readonly class UserCreateOperation implements OperationInterface +{ + public function __construct( + private Configuration $configuration, + private string $loginName, + #[SensitiveParameter] + private string $password, + private string $displayName, + private string $email, + private int $userId, + private bool $isSuperAdmin = false, + private string $status = 'protected', + ) { + } + + public function getType(): string + { + return 'user_create'; + } + + public function getDescription(): string + { + return sprintf('Create user "%s" (ID: %d)', $this->loginName, $this->userId); + } + + public function execute(): bool + { + try { + $user = new User($this->configuration); + if (!$user->createUser($this->loginName, $this->password, '', $this->userId)) { + return false; + } + + $user->setStatus($this->status); + + $userData = [ + 'display_name' => $this->displayName, + 'email' => $this->email, + ]; + $user->setUserData($userData); + + if ($this->isSuperAdmin) { + $user->setSuperAdmin(true); + } + + return true; + } catch (\Throwable) { + return false; + } + } + + public function toArray(): array + { + return [ + 'type' => $this->getType(), + 'description' => $this->getDescription(), + 'login_name' => $this->loginName, + 'user_id' => $this->userId, + 'is_super_admin' => $this->isSuperAdmin, + ]; + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/Dialect/MysqlDialect.php b/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/Dialect/MysqlDialect.php index 8b907c2c00..f75c42344c 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/Dialect/MysqlDialect.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/Dialect/MysqlDialect.php @@ -54,6 +54,16 @@ public function text(): string return 'TEXT'; } + public function longText(): string + { + return 'LONGTEXT'; + } + + public function blob(): string + { + return 'BLOB'; + } + public function boolean(): string { return 'TINYINT(1)'; diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/Dialect/PostgresDialect.php b/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/Dialect/PostgresDialect.php index f4c18403cd..e2d8e89320 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/Dialect/PostgresDialect.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/Dialect/PostgresDialect.php @@ -53,6 +53,16 @@ public function text(): string return 'TEXT'; } + public function longText(): string + { + return 'TEXT'; + } + + public function blob(): string + { + return 'BYTEA'; + } + public function boolean(): string { return 'SMALLINT'; diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/Dialect/SqlServerDialect.php b/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/Dialect/SqlServerDialect.php index 0b8f40c414..487b8968af 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/Dialect/SqlServerDialect.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/Dialect/SqlServerDialect.php @@ -53,6 +53,16 @@ public function text(): string return 'NVARCHAR(MAX)'; } + public function longText(): string + { + return 'NVARCHAR(MAX)'; + } + + public function blob(): string + { + return 'VARBINARY(MAX)'; + } + public function boolean(): string { return 'TINYINT'; diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/Dialect/SqliteDialect.php b/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/Dialect/SqliteDialect.php index d6519e11a0..fef354d3a0 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/Dialect/SqliteDialect.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/Dialect/SqliteDialect.php @@ -20,6 +20,7 @@ namespace phpMyFAQ\Setup\Migration\QueryBuilder\Dialect; use phpMyFAQ\Setup\Migration\QueryBuilder\DialectInterface; +use RuntimeException; class SqliteDialect implements DialectInterface { @@ -53,6 +54,16 @@ public function text(): string return 'TEXT'; } + public function longText(): string + { + return 'TEXT'; + } + + public function blob(): string + { + return 'BLOB'; + } + public function boolean(): string { return 'INTEGER'; @@ -109,7 +120,7 @@ public function modifyColumn(string $tableName, string $columnName, string $newT { // SQLite doesn't support ALTER COLUMN directly // This would require a table rebuild, which is handled separately - throw new \RuntimeException('SQLite does not support modifying columns. Use table rebuild pattern.'); + throw new RuntimeException('SQLite does not support modifying columns. Use table rebuild pattern.'); } public function createIndex(string $indexName, string $tableName, array $columns, bool $ifNotExists = false): string diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/DialectInterface.php b/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/DialectInterface.php index 7b1e43ea23..e014d53635 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/DialectInterface.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/DialectInterface.php @@ -51,6 +51,16 @@ public function varchar(int $length): string; */ public function text(): string; + /** + * Returns the LONGTEXT column type (or equivalent). + */ + public function longText(): string; + + /** + * Returns the BLOB column type (or equivalent). + */ + public function blob(): string; + /** * Returns the BOOLEAN/TINYINT column type. */ diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/TableBuilder.php b/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/TableBuilder.php index ea5b7c925b..52991b2836 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/TableBuilder.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/TableBuilder.php @@ -19,6 +19,7 @@ namespace phpMyFAQ\Setup\Migration\QueryBuilder; +use LogicException; use phpMyFAQ\Database; class TableBuilder @@ -36,6 +37,9 @@ class TableBuilder /** @var array */ private array $indexes = []; + /** @var array */ + private array $fullTextIndexes = []; + public function __construct(?DialectInterface $dialect = null) { $this->dialect = $dialect ?? DialectFactory::create(); @@ -104,7 +108,7 @@ public function smallInteger(string $name, bool $nullable = true, ?int $default public function varchar(string $name, int $length, bool $nullable = true, ?string $default = null): self { // Escape single quotes in default value per SQL string literal rules (replace ' with '') - $defaultVal = $default !== null ? "'" . str_replace("'", "''", $default) . "'" : null; + $defaultVal = $default !== null ? "'" . str_replace(search: "'", replace: "''", subject: $default) . "'" : null; return $this->addColumn($name, $this->dialect->varchar($length), $nullable, $defaultVal); } @@ -116,12 +120,32 @@ public function text(string $name, bool $nullable = true): self return $this->addColumn($name, $this->dialect->text(), $nullable); } + /** + * Adds a LONGTEXT column (or equivalent). + */ + public function longText(string $name, bool $nullable = true): self + { + return $this->addColumn($name, $this->dialect->longText(), $nullable); + } + + /** + * Adds a BLOB column (or equivalent). + */ + public function blob(string $name, bool $nullable = true): self + { + return $this->addColumn($name, $this->dialect->blob(), $nullable); + } + /** * Adds a BOOLEAN/TINYINT column. */ public function boolean(string $name, bool $nullable = true, ?bool $default = null): self { - $defaultVal = $default !== null ? ($default ? '1' : '0') : null; + $defaultVal = match (true) { + $default === null => null, + $default => '1', + default => '0', + }; return $this->addColumn($name, $this->dialect->boolean(), $nullable, $defaultVal); } @@ -149,7 +173,7 @@ public function date(string $name, bool $nullable = true, bool $defaultCurrent = public function char(string $name, int $length, bool $nullable = true, ?string $default = null): self { // Escape single quotes in default value per SQL string literal rules (replace ' with '') - $defaultVal = $default !== null ? "'" . str_replace("'", "''", $default) . "'" : null; + $defaultVal = $default !== null ? "'" . str_replace(search: "'", replace: "''", subject: $default) . "'" : null; return $this->addColumn($name, $this->dialect->char($length), $nullable, $defaultVal); } @@ -165,7 +189,7 @@ public function autoIncrement(string $name = 'id'): self 'extra' => null, ]; - if (empty($this->primaryKey)) { + if ($this->primaryKey === []) { $this->primaryKey = [$name]; } @@ -197,6 +221,17 @@ public function index(string $name, string|array $columns): self return $this; } + /** + * Adds a FULLTEXT index (MySQL only, ignored for other dialects). + * + * @param string|string[] $columns + */ + public function fullTextIndex(string|array $columns): self + { + $this->fullTextIndexes[] = (array) $columns; + return $this; + } + /** * Adds a unique index. * @@ -216,8 +251,8 @@ public function uniqueIndex(string $name, string|array $columns): self */ public function build(): string { - if (empty($this->tableName)) { - throw new \LogicException('Table name not set: call table() before build()'); + if ($this->tableName === '') { + throw new LogicException('Table name not set: call table() before build()'); } $parts = []; @@ -259,6 +294,11 @@ public function build(): string // Add inline indexes only for MySQL (MySQL supports this, other databases don't) $isMysql = in_array($this->dialect->getType(), ['mysqli', 'pdo_mysql'], true); if ($isMysql) { + foreach ($this->fullTextIndexes as $ftColumns) { + $columnList = implode(',', $ftColumns); + $parts[] = "FULLTEXT ($columnList)"; + } + foreach ($this->indexes as $indexName => $indexDef) { $columnList = implode(', ', $indexDef['columns']); $indexType = $indexDef['unique'] ? 'UNIQUE INDEX' : 'INDEX'; @@ -286,8 +326,8 @@ public function build(): string */ public function buildIndexStatements(): array { - if (empty($this->tableName)) { - throw new \LogicException('Table name not set: call table() before buildIndexStatements()'); + if ($this->tableName === '') { + throw new LogicException('Table name not set: call table() before buildIndexStatements()'); } // MySQL already has indexes inlined in CREATE TABLE, so no separate statements needed diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Update.php b/phpmyfaq/src/phpMyFAQ/Setup/Update.php index 0dbf7d8e3f..45e98aed8c 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Update.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Update.php @@ -26,6 +26,7 @@ use phpMyFAQ\Database\DatabaseDriver; use phpMyFAQ\Filesystem\Filesystem; use phpMyFAQ\Forms; +use phpMyFAQ\Setup\Installation\DefaultDataSeeder; use phpMyFAQ\Setup\Migration\MigrationExecutor; use phpMyFAQ\Setup\Migration\MigrationInterface; use phpMyFAQ\Setup\Migration\MigrationRegistry; @@ -253,15 +254,15 @@ private function collectDryRunQueries(array $migrations): void */ private function runPostMigrationTasks(): void { - // Handle admin log hash migration for 4.2.0-alpha - if (version_compare($this->version, '4.2.0-alpha', '<')) { - $this->migrateAdminLogHashes(); - } - // Insert form inputs for 4.0.0-alpha.2 if (version_compare($this->version, '4.0.0-alpha.2', '<')) { $this->insertFormInputs(); } + + // Handle admin log hash migration for 4.2.0-alpha + if (version_compare($this->version, '4.2.0-alpha', '<')) { + $this->migrateAdminLogHashes(); + } } /** @@ -271,8 +272,8 @@ private function insertFormInputs(): void { try { $forms = new Forms($this->configuration); - $installer = new Installer(new System()); - foreach ($installer->formInputs as $input) { + $seeder = new DefaultDataSeeder(); + foreach ($seeder->getFormInputs() as $input) { $this->queries[] = $forms->getInsertQueries($input); } } catch (\Exception) { diff --git a/tests/phpMyFAQ/Instance/DatabaseTest.php b/tests/phpMyFAQ/Instance/DatabaseTest.php index c7a802b0ae..da612fe512 100644 --- a/tests/phpMyFAQ/Instance/DatabaseTest.php +++ b/tests/phpMyFAQ/Instance/DatabaseTest.php @@ -24,12 +24,8 @@ protected function setUp(): void public function testFactoryWithValidType(): void { - $type = 'Mysqli'; - $driverClass = '\phpMyFAQ\Instance\Database\\' . ucfirst($type); - $this->assertTrue(class_exists($driverClass)); - - $driver = Database::factory($this->configuration, $type); - $this->assertInstanceOf($driverClass, $driver); + $driver = Database::factory($this->configuration, 'mysqli'); + $this->assertInstanceOf(\phpMyFAQ\Instance\Database\DriverInterface::class, $driver); } public function testFactoryWithInvalidType(): void @@ -40,8 +36,10 @@ public function testFactoryWithInvalidType(): void public function testGetInstance(): void { + // After factory() has been called, getInstance() returns the last created driver + Database::factory($this->configuration, 'mysqli'); $instance = Database::getInstance(); - $this->assertInstanceOf(Database::class, $instance); + $this->assertInstanceOf(Database\DriverInterface::class, $instance); } /** diff --git a/tests/phpMyFAQ/Session/SessionWrapperTest.php b/tests/phpMyFAQ/Session/SessionWrapperTest.php index 56582f99d7..6ecfa12db7 100644 --- a/tests/phpMyFAQ/Session/SessionWrapperTest.php +++ b/tests/phpMyFAQ/Session/SessionWrapperTest.php @@ -27,7 +27,9 @@ public function testConstructorWithSessionParameter(): void { $sessionWrapper = new SessionWrapper($this->sessionMock); $this->assertInstanceOf(SessionWrapper::class, $sessionWrapper); - $this->assertSame($this->sessionMock, $sessionWrapper->getSession()); + + // The session is private, so we can't access it directly + // Instead, verify the wrapper works by testing delegation } public function testConstructorWithoutSessionParameter(): void @@ -128,12 +130,6 @@ public function testRemoveMethodDelegatesToSession(): void $this->assertEquals($expectedValue, $result); } - public function testGetSessionReturnsUnderlyingSession(): void - { - $result = $this->sessionWrapper->getSession(); - $this->assertSame($this->sessionMock, $result); - } - public function testSetAndGetWorkTogether(): void { $key = 'test_key'; diff --git a/tests/phpMyFAQ/Setup/Installation/DatabaseSchemaTest.php b/tests/phpMyFAQ/Setup/Installation/DatabaseSchemaTest.php new file mode 100644 index 0000000000..2497b7af78 --- /dev/null +++ b/tests/phpMyFAQ/Setup/Installation/DatabaseSchemaTest.php @@ -0,0 +1,186 @@ + + */ + public static function dialectProvider(): array + { + return [ + 'mysql' => [new MysqlDialect()], + 'postgres' => [new PostgresDialect()], + 'sqlite' => [new SqliteDialect()], + 'sqlserver' => [new SqlServerDialect()], + ]; + } + + #[DataProvider('dialectProvider')] + public function testGetAllTablesReturnsExpectedCount(DialectInterface $dialect): void + { + $schema = new DatabaseSchema($dialect); + $tables = $schema->getAllTables(); + $this->assertCount(self::EXPECTED_TABLE_COUNT, $tables); + } + + #[DataProvider('dialectProvider')] + public function testGetTableNamesReturnsCorrectNames(DialectInterface $dialect): void + { + $schema = new DatabaseSchema($dialect); + $names = $schema->getTableNames(); + + $this->assertContains('faqadminlog', $names); + $this->assertContains('faqdata', $names); + $this->assertContains('faquser', $names); + $this->assertContains('faqconfig', $names); + $this->assertContains('faqchat_messages', $names); + $this->assertContains('faqcustompages', $names); + $this->assertContains('faqseo', $names); + } + + #[DataProvider('dialectProvider')] + public function testAllTablesBuildValidSql(DialectInterface $dialect): void + { + $schema = new DatabaseSchema($dialect); + foreach ($schema->getAllTables() as $name => $builder) { + $sql = $builder->build(); + $this->assertStringContainsString('CREATE TABLE', $sql, "Table $name should produce CREATE TABLE SQL"); + $this->assertNotEmpty($sql, "Table $name should produce non-empty SQL"); + } + } + + public function testFaqdataHasFulltextIndexForMysql(): void + { + $schema = new DatabaseSchema(new MysqlDialect()); + $sql = $schema->faqdata()->build(); + $this->assertStringContainsString('FULLTEXT (keywords,thema,content)', $sql); + } + + public function testFaqdataHasNoFulltextIndexForPostgres(): void + { + $schema = new DatabaseSchema(new PostgresDialect()); + $sql = $schema->faqdata()->build(); + $this->assertStringNotContainsString('FULLTEXT', $sql); + } + + public function testFaqattachmentFileUsesBlobForMysql(): void + { + $schema = new DatabaseSchema(new MysqlDialect()); + $sql = $schema->attachmentFile()->build(); + $this->assertStringContainsString('BLOB', $sql); + } + + public function testFaqattachmentFileUsesByteaForPostgres(): void + { + $schema = new DatabaseSchema(new PostgresDialect()); + $sql = $schema->attachmentFile()->build(); + $this->assertStringContainsString('BYTEA', $sql); + } + + public function testFaqdataUsesLongtextForMysql(): void + { + $schema = new DatabaseSchema(new MysqlDialect()); + $sql = $schema->faqdata()->build(); + $this->assertStringContainsString('LONGTEXT', $sql); + } + + public function testFaqdataUsesTextForPostgres(): void + { + $schema = new DatabaseSchema(new PostgresDialect()); + $sql = $schema->faqdata()->build(); + // content should be TEXT (longText maps to TEXT in PostgreSQL) + $this->assertStringContainsString('content TEXT', $sql); + } + + public function testFaqchatMessagesHasAutoIncrement(): void + { + $schema = new DatabaseSchema(new MysqlDialect()); + $sql = $schema->faqchatMessages()->build(); + $this->assertStringContainsString('AUTO_INCREMENT', $sql); + } + + public function testFaqchatMessagesHasIndexes(): void + { + $schema = new DatabaseSchema(new MysqlDialect()); + $sql = $schema->faqchatMessages()->build(); + $this->assertStringContainsString('idx_chat_sender', $sql); + $this->assertStringContainsString('idx_chat_recipient', $sql); + $this->assertStringContainsString('idx_chat_conversation', $sql); + $this->assertStringContainsString('idx_chat_created', $sql); + } + + public function testFaqsessionsHasTimeIndex(): void + { + $schema = new DatabaseSchema(new PostgresDialect()); + $builder = $schema->faqsessions(); + $indexStatements = $builder->buildIndexStatements(); + $this->assertCount(1, $indexStatements); + $this->assertStringContainsString('idx_time', $indexStatements[0]); + } + + public function testFaqsearchesHasMultipleIndexes(): void + { + $schema = new DatabaseSchema(new PostgresDialect()); + $builder = $schema->faqsearches(); + $indexStatements = $builder->buildIndexStatements(); + $this->assertCount(3, $indexStatements); + } + + public function testFaqcategoryrelationsHasIndex(): void + { + $schema = new DatabaseSchema(new MysqlDialect()); + $sql = $schema->faqcategoryrelations()->build(); + $this->assertStringContainsString('idx_records', $sql); + } + + #[DataProvider('dialectProvider')] + public function testFaqconfigHasPrimaryKeyOnConfigName(DialectInterface $dialect): void + { + $schema = new DatabaseSchema($dialect); + $sql = $schema->faqconfig()->build(); + $this->assertStringContainsString('PRIMARY KEY (config_name)', $sql); + } + + #[DataProvider('dialectProvider')] + public function testFaqcategoriesHasCompositePrimaryKey(DialectInterface $dialect): void + { + $schema = new DatabaseSchema($dialect); + $sql = $schema->faqcategories()->build(); + $this->assertStringContainsString('PRIMARY KEY (id, lang)', $sql); + } + + public function testFaqgroupHasNameIndex(): void + { + $schema = new DatabaseSchema(new MysqlDialect()); + $sql = $schema->faqgroup()->build(); + $this->assertStringContainsString('idx_name', $sql); + } + + #[DataProvider('dialectProvider')] + public function testFaqbookmarksHasNoPrimaryKey(DialectInterface $dialect): void + { + $schema = new DatabaseSchema($dialect); + $sql = $schema->faqbookmarks()->build(); + $this->assertStringNotContainsString('PRIMARY KEY', $sql); + } + + #[DataProvider('dialectProvider')] + public function testFaqformsHasNoPrimaryKey(DialectInterface $dialect): void + { + $schema = new DatabaseSchema($dialect); + $sql = $schema->faqforms()->build(); + $this->assertStringNotContainsString('PRIMARY KEY', $sql); + } +} diff --git a/tests/phpMyFAQ/Setup/Installation/DefaultDataSeederTest.php b/tests/phpMyFAQ/Setup/Installation/DefaultDataSeederTest.php new file mode 100644 index 0000000000..a58885b833 --- /dev/null +++ b/tests/phpMyFAQ/Setup/Installation/DefaultDataSeederTest.php @@ -0,0 +1,101 @@ +seeder = new DefaultDataSeeder(); + } + + public function testGetMainConfigReturnsNonEmptyArray(): void + { + $config = $this->seeder->getMainConfig(); + $this->assertNotEmpty($config); + $this->assertGreaterThan(60, count($config)); + } + + public function testGetMainConfigContainsRequiredKeys(): void + { + $config = $this->seeder->getMainConfig(); + $this->assertArrayHasKey('main.currentVersion', $config); + $this->assertArrayHasKey('main.currentApiVersion', $config); + $this->assertArrayHasKey('main.language', $config); + $this->assertArrayHasKey('main.phpMyFAQToken', $config); + $this->assertArrayHasKey('security.permLevel', $config); + $this->assertArrayHasKey('spam.enableCaptchaCode', $config); + } + + public function testGetMainConfigHasDynamicValues(): void + { + $config = $this->seeder->getMainConfig(); + // currentVersion should be set to an actual version, not null + $this->assertNotNull($config['main.currentVersion']); + // phpMyFAQToken should be a hex string + $this->assertNotNull($config['main.phpMyFAQToken']); + $this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $config['main.phpMyFAQToken']); + } + + public function testApplyPersonalSettings(): void + { + $this->seeder->applyPersonalSettings('John Doe', 'john@example.com', 'de', 'medium'); + $config = $this->seeder->getMainConfig(); + + $this->assertEquals('John Doe', $config['main.metaPublisher']); + $this->assertEquals('john@example.com', $config['main.administrationMail']); + $this->assertEquals('de', $config['main.language']); + $this->assertEquals('medium', $config['security.permLevel']); + } + + public function testGetMainRightsReturnsNonEmptyArray(): void + { + $rights = $this->seeder->getMainRights(); + $this->assertNotEmpty($rights); + $this->assertEquals(50, count($rights)); + } + + public function testGetMainRightsHasCorrectStructure(): void + { + $rights = $this->seeder->getMainRights(); + foreach ($rights as $right) { + $this->assertArrayHasKey('name', $right); + $this->assertArrayHasKey('description', $right); + $this->assertNotEmpty($right['name']); + $this->assertNotEmpty($right['description']); + } + } + + public function testGetFormInputsReturnsCorrectCount(): void + { + $inputs = $this->seeder->getFormInputs(); + $this->assertCount(15, $inputs); + } + + public function testGetFormInputsHasCorrectStructure(): void + { + $inputs = $this->seeder->getFormInputs(); + foreach ($inputs as $input) { + $this->assertArrayHasKey('form_id', $input); + $this->assertArrayHasKey('input_id', $input); + $this->assertArrayHasKey('input_type', $input); + $this->assertArrayHasKey('input_label', $input); + $this->assertArrayHasKey('input_active', $input); + $this->assertArrayHasKey('input_required', $input); + $this->assertArrayHasKey('input_lang', $input); + } + } + + public function testGetFormInputsHasTwoForms(): void + { + $inputs = $this->seeder->getFormInputs(); + $formIds = array_unique(array_column($inputs, 'form_id')); + $this->assertCount(2, $formIds); + $this->assertContains(1, $formIds); + $this->assertContains(2, $formIds); + } +} diff --git a/tests/phpMyFAQ/Setup/Installation/SchemaInstallerTest.php b/tests/phpMyFAQ/Setup/Installation/SchemaInstallerTest.php new file mode 100644 index 0000000000..d21089b483 --- /dev/null +++ b/tests/phpMyFAQ/Setup/Installation/SchemaInstallerTest.php @@ -0,0 +1,124 @@ + + */ + public static function dialectProvider(): array + { + return [ + 'mysql' => [new MysqlDialect()], + 'postgres' => [new PostgresDialect()], + 'sqlite' => [new SqliteDialect()], + 'sqlserver' => [new SqlServerDialect()], + ]; + } + + #[DataProvider('dialectProvider')] + public function testDryRunCollectsAllSql(DialectInterface $dialect): void + { + $db = $this->createStub(DatabaseDriver::class); + $configuration = $this->createStub(Configuration::class); + $configuration->method('getDb')->willReturn($db); + + $installer = new SchemaInstaller($configuration, $dialect); + $installer->setDryRun(true); + + $result = $installer->createTables(''); + $this->assertTrue($result); + + $sql = $installer->getCollectedSql(); + $this->assertNotEmpty($sql); + + // Should have at least one CREATE TABLE per table definition + $createTableCount = 0; + foreach ($sql as $statement) { + if (str_contains($statement, 'CREATE TABLE')) { + $createTableCount++; + } + } + $this->assertEquals(43, $createTableCount, 'Should generate CREATE TABLE for all 43 tables'); + } + + #[DataProvider('dialectProvider')] + public function testDryRunDoesNotExecuteQueries(DialectInterface $dialect): void + { + $db = $this->createMock(DatabaseDriver::class); + $db->expects($this->never())->method('query'); + $configuration = $this->createStub(Configuration::class); + $configuration->method('getDb')->willReturn($db); + + $installer = new SchemaInstaller($configuration, $dialect); + $installer->setDryRun(true); + $installer->createTables(''); + } + + public function testGetSchemaReturnsDatabaseSchema(): void + { + $configuration = $this->createStub(Configuration::class); + $installer = new SchemaInstaller($configuration, new MysqlDialect()); + $this->assertInstanceOf(DatabaseSchema::class, $installer->getSchema()); + } + + public function testCreateTablesReturnsFalseOnDbError(): void + { + $db = $this->createStub(DatabaseDriver::class); + $db->method('query')->willReturn(false); + $configuration = $this->createStub(Configuration::class); + $configuration->method('getDb')->willReturn($db); + + $installer = new SchemaInstaller($configuration, new MysqlDialect()); + $result = $installer->createTables(''); + $this->assertFalse($result); + } + + public function testMysqlDryRunContainsInnodb(): void + { + $db = $this->createStub(DatabaseDriver::class); + $configuration = $this->createStub(Configuration::class); + $configuration->method('getDb')->willReturn($db); + + $installer = new SchemaInstaller($configuration, new MysqlDialect()); + $installer->setDryRun(true); + $installer->createTables(''); + + $allSql = implode("\n", $installer->getCollectedSql()); + $this->assertStringContainsString('InnoDB', $allSql); + $this->assertStringContainsString('utf8mb4', $allSql); + } + + public function testPostgresNoIndexStatementsDuplicate(): void + { + $db = $this->createStub(DatabaseDriver::class); + $configuration = $this->createStub(Configuration::class); + $configuration->method('getDb')->willReturn($db); + + $installer = new SchemaInstaller($configuration, new PostgresDialect()); + $installer->setDryRun(true); + $installer->createTables(''); + + $sql = $installer->getCollectedSql(); + $createIndexCount = 0; + foreach ($sql as $statement) { + if (str_contains($statement, 'CREATE INDEX')) { + $createIndexCount++; + } + } + + // PostgreSQL should have separate CREATE INDEX statements for indexes + $this->assertGreaterThan(0, $createIndexCount, 'PostgreSQL should have separate CREATE INDEX statements'); + } +} diff --git a/tests/phpMyFAQ/Setup/InstallationInputValidatorTest.php b/tests/phpMyFAQ/Setup/InstallationInputValidatorTest.php new file mode 100644 index 0000000000..14fc40fa59 --- /dev/null +++ b/tests/phpMyFAQ/Setup/InstallationInputValidatorTest.php @@ -0,0 +1,56 @@ +validator = new InstallationInputValidator(); + } + + public function testValidateThrowsExceptionForMissingDatabaseType(): void + { + // Without POST data and without setup array, dbType will be null + $this->expectException(Exception::class); + $this->expectExceptionMessage('Installation Error: Please select a database type.'); + $this->validator->validate(); + } + + public function testInstallationInputIsReadonly(): void + { + $input = new InstallationInput( + dbSetup: [ + 'dbType' => 'pdo_mysql', + 'dbServer' => 'localhost', + 'dbPort' => 3306, + 'dbUser' => 'root', + 'dbPassword' => '', + 'dbDatabaseName' => 'faq', + 'dbPrefix' => '', + ], + ldapSetup: [], + esSetup: [], + osSetup: [], + loginName: 'admin', + password: 'password123', + language: 'en', + realname: 'Admin', + email: 'admin@example.com', + permLevel: 'basic', + rootDir: '/tmp', + ); + + $this->assertEquals('admin', $input->loginName); + $this->assertEquals('password123', $input->password); + $this->assertEquals('en', $input->language); + $this->assertFalse($input->ldapEnabled); + $this->assertFalse($input->esEnabled); + $this->assertFalse($input->osEnabled); + } +} diff --git a/tests/phpMyFAQ/Setup/InstallationRunnerTest.php b/tests/phpMyFAQ/Setup/InstallationRunnerTest.php new file mode 100644 index 0000000000..f8366ab02b --- /dev/null +++ b/tests/phpMyFAQ/Setup/InstallationRunnerTest.php @@ -0,0 +1,102 @@ +createStub(System::class); + $runner = new InstallationRunner($system); + + $this->assertInstanceOf(InstallationRunner::class, $runner); + } + + public function testRunMethodExists(): void + { + $system = $this->createStub(System::class); + $runner = new InstallationRunner($system); + + $this->assertTrue(method_exists($runner, 'run')); + } + + public function testRunAcceptsInstallationInput(): void + { + $reflectionMethod = new \ReflectionMethod(InstallationRunner::class, 'run'); + $parameters = $reflectionMethod->getParameters(); + + $this->assertCount(1, $parameters); + $this->assertEquals('input', $parameters[0]->getName()); + $this->assertEquals(InstallationInput::class, $parameters[0]->getType()->getName()); + } + + public function testRunnerHasAllExpectedSteps(): void + { + $reflectionClass = new \ReflectionClass(InstallationRunner::class); + $methods = $reflectionClass->getMethods(\ReflectionMethod::IS_PRIVATE); + $stepMethods = array_filter($methods, fn($m) => str_starts_with($m->getName(), 'step')); + + $expectedSteps = [ + 'stepValidateConnectivity', + 'stepCreateConfigFiles', + 'stepEstablishDbConnection', + 'stepCreateDatabaseTables', + 'stepInsertStopwords', + 'stepSeedConfiguration', + 'stepCreateAdminUser', + 'stepGrantPermissions', + 'stepInsertFormInputs', + 'stepCreateAnonymousUser', + 'stepCreateInstance', + 'stepInitializeSearchEngine', + 'stepAdjustHtaccess', + ]; + + $actualStepNames = array_map(fn($m) => $m->getName(), $stepMethods); + sort($actualStepNames); + sort($expectedSteps); + + $this->assertEquals($expectedSteps, $actualStepNames); + } + + public function testRunCallsStepsInCorrectOrder(): void + { + $reflectionMethod = new \ReflectionMethod(InstallationRunner::class, 'run'); + $fileName = $reflectionMethod->getFileName(); + $startLine = $reflectionMethod->getStartLine(); + $endLine = $reflectionMethod->getEndLine(); + + $lines = array_slice(file($fileName), $startLine - 1, $endLine - $startLine + 1); + $body = implode('', $lines); + + // Verify steps are called in the correct order + $stepOrder = [ + 'stepValidateConnectivity', + 'stepCreateConfigFiles', + 'stepEstablishDbConnection', + 'stepCreateDatabaseTables', + 'stepInsertStopwords', + 'stepSeedConfiguration', + 'stepCreateAdminUser', + 'stepGrantPermissions', + 'stepInsertFormInputs', + 'stepCreateAnonymousUser', + 'stepCreateInstance', + 'stepInitializeSearchEngine', + 'stepAdjustHtaccess', + ]; + + $lastPos = 0; + foreach ($stepOrder as $step) { + $pos = strpos($body, $step); + $this->assertNotFalse($pos, "Step $step should be called in run()"); + $this->assertGreaterThan($lastPos, $pos, "Step $step should come after previous step"); + $lastPos = $pos; + } + } +} diff --git a/tests/phpMyFAQ/Setup/Migration/QueryBuilder/Dialect/MysqlDialectTest.php b/tests/phpMyFAQ/Setup/Migration/QueryBuilder/Dialect/MysqlDialectTest.php index 9a067ad19d..c7084a3cd0 100644 --- a/tests/phpMyFAQ/Setup/Migration/QueryBuilder/Dialect/MysqlDialectTest.php +++ b/tests/phpMyFAQ/Setup/Migration/QueryBuilder/Dialect/MysqlDialectTest.php @@ -48,6 +48,16 @@ public function testText(): void $this->assertEquals('TEXT', $this->dialect->text()); } + public function testLongText(): void + { + $this->assertEquals('LONGTEXT', $this->dialect->longText()); + } + + public function testBlob(): void + { + $this->assertEquals('BLOB', $this->dialect->blob()); + } + public function testBoolean(): void { $this->assertEquals('TINYINT(1)', $this->dialect->boolean()); @@ -82,7 +92,10 @@ public function testCurrentDate(): void public function testAutoIncrement(): void { $this->assertEquals('id INT NOT NULL PRIMARY KEY AUTO_INCREMENT', $this->dialect->autoIncrement('id')); - $this->assertEquals('user_id INT NOT NULL PRIMARY KEY AUTO_INCREMENT', $this->dialect->autoIncrement('user_id')); + $this->assertEquals( + 'user_id INT NOT NULL PRIMARY KEY AUTO_INCREMENT', + $this->dialect->autoIncrement('user_id'), + ); } public function testCreateTablePrefix(): void diff --git a/tests/phpMyFAQ/Setup/Migration/QueryBuilder/TableBuilderTest.php b/tests/phpMyFAQ/Setup/Migration/QueryBuilder/TableBuilderTest.php index bc0b70dc45..04bf5332f6 100644 --- a/tests/phpMyFAQ/Setup/Migration/QueryBuilder/TableBuilderTest.php +++ b/tests/phpMyFAQ/Setup/Migration/QueryBuilder/TableBuilderTest.php @@ -215,10 +215,7 @@ public function testBuildIndexStatements(): void { // Use PostgreSQL builder since MySQL inlines indexes in CREATE TABLE $postgresBuilder = new TableBuilder(new PostgresDialect()); - $postgresBuilder - ->table('test', false) - ->varchar('email', 255) - ->index('idx_email', 'email'); + $postgresBuilder->table('test', false)->varchar('email', 255)->index('idx_email', 'email'); $statements = $postgresBuilder->buildIndexStatements(); @@ -341,11 +338,7 @@ public function testCharWithQuotesInDefault(): void public function testSqliteAutoIncrementDoesNotDuplicatePrimaryKey(): void { $sqliteBuilder = new TableBuilder(new SqliteDialect()); - $sql = $sqliteBuilder - ->table('test', false) - ->autoIncrement('id') - ->varchar('name', 100) - ->build(); + $sql = $sqliteBuilder->table('test', false)->autoIncrement('id')->varchar('name', 100)->build(); // SQLite autoIncrement already includes PRIMARY KEY $this->assertStringContainsString('INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT', $sql); @@ -359,15 +352,54 @@ public function testSqliteAutoIncrementDoesNotDuplicatePrimaryKey(): void public function testAutoIncrementSkipsExplicitPrimaryKey(): void { $sqliteBuilder = new TableBuilder(new SqliteDialect()); - $sql = $sqliteBuilder - ->table('test', false) - ->autoIncrement('id') - ->varchar('name', 100) - ->primaryKey('id') // This should be ignored when autoIncrement is used - ->build(); + $sql = $sqliteBuilder->table('test', false)->autoIncrement('id')->varchar('name', 100)->primaryKey('id')->build(); // This should be ignored when autoIncrement is used // Should still only have one PRIMARY KEY (from autoIncrement) $count = substr_count($sql, 'PRIMARY KEY'); $this->assertEquals(1, $count, 'Should skip explicit PRIMARY KEY when AUTO_INCREMENT exists'); } + + public function testLongTextColumnMysql(): void + { + $sql = $this->builder + ->table('test', false) + ->longText('content', true) + ->build(); + + $this->assertStringContainsString('content LONGTEXT NULL', $sql); + } + + public function testLongTextColumnPostgres(): void + { + $builder = new TableBuilder(new PostgresDialect()); + $sql = $builder->table('test', false)->longText('content', true)->build(); + + $this->assertStringContainsString('content TEXT NULL', $sql); + } + + public function testBlobColumnMysql(): void + { + $sql = $this->builder + ->table('test', false) + ->blob('data', false) + ->build(); + + $this->assertStringContainsString('data BLOB NOT NULL', $sql); + } + + public function testBlobColumnPostgres(): void + { + $builder = new TableBuilder(new PostgresDialect()); + $sql = $builder->table('test', false)->blob('data', false)->build(); + + $this->assertStringContainsString('data BYTEA NOT NULL', $sql); + } + + public function testBlobColumnSqlite(): void + { + $builder = new TableBuilder(new SqliteDialect()); + $sql = $builder->table('test', false)->blob('data', false)->build(); + + $this->assertStringContainsString('data BLOB NOT NULL', $sql); + } } From 963077ec8614213aab1d7905d8d6fdb241acf094 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Mon, 2 Feb 2026 10:17:42 +0100 Subject: [PATCH 2/6] fix: corrected review notes --- phpmyfaq/.htaccess | 4 +- .../Setup/Installation/SchemaInstaller.php | 4 + .../src/phpMyFAQ/Setup/InstallationInput.php | 24 +++++- .../Setup/InstallationInputValidator.php | 4 +- .../src/phpMyFAQ/Setup/InstallationRunner.php | 78 +++++++++++++------ .../Operations/FormInputInsertOperation.php | 3 +- .../Operations/UserCreateOperation.php | 12 ++- .../Migration/QueryBuilder/TableBuilder.php | 2 +- .../Installation/SchemaInstallerTest.php | 13 +++- .../Setup/InstallationInputValidatorTest.php | 4 +- 10 files changed, 106 insertions(+), 42 deletions(-) diff --git a/phpmyfaq/.htaccess b/phpmyfaq/.htaccess index 574e29686a..24fed1739c 100644 --- a/phpmyfaq/.htaccess +++ b/phpmyfaq/.htaccess @@ -102,13 +102,13 @@ Header set Access-Control-Allow-Headers "Content-Type, Authorization" RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] # the path to your phpMyFAQ installation - RewriteBase / + RewriteBase /Users/thorsten/htdocs/phpMyFAQ/phpmyfaq/src/libs/bin/phpunit/ # Block zip files in content directory RewriteRule ^content/.*\.zip$ - [F,L] # Exclude assets from being handled by Symfony Router RewriteRule ^admin/assets($|/) - [L] # Error pages - ErrorDocument 404 /404.html + ErrorDocument 404 /Users/thorsten/htdocs/phpMyFAQ/phpmyfaq/src/libs/bin/phpunit/404.html # Administration API RewriteRule ^admin/api/ admin/api/index.php [L,QSA] # Administration pages (redirect /admin to /admin/) diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php b/phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php index e96a532583..cee847f0cb 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php @@ -118,6 +118,10 @@ public function createTables(string $prefix = ''): bool */ public function dropTables(string $prefix = ''): bool { + if ($prefix === '') { + $prefix = Database::getTablePrefix() ?? ''; + } + foreach ($this->schema->getTableNames() as $tableName) { $sql = sprintf('DROP TABLE %s%s', $prefix, $tableName); $result = $this->configuration->getDb()->query($sql); diff --git a/phpmyfaq/src/phpMyFAQ/Setup/InstallationInput.php b/phpmyfaq/src/phpMyFAQ/Setup/InstallationInput.php index 91cd5ffa20..381ccbc921 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/InstallationInput.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/InstallationInput.php @@ -19,6 +19,8 @@ namespace phpMyFAQ\Setup; +use SensitiveParameter; + readonly class InstallationInput { /** @@ -32,11 +34,12 @@ public function __construct( public array $ldapSetup, public array $esSetup, public array $osSetup, - public string $loginName, - public string $password, + private string $loginName, + #[SensitiveParameter] + private string $password, public string $language, public string $realname, - public string $email, + private string $email, public string $permLevel, public string $rootDir, public bool $ldapEnabled = false, @@ -44,4 +47,19 @@ public function __construct( public bool $osEnabled = false, ) { } + + public function getLoginName(): string + { + return $this->loginName; + } + + public function getPassword(): string + { + return $this->password; + } + + public function getEmail(): string + { + return $this->email; + } } diff --git a/phpmyfaq/src/phpMyFAQ/Setup/InstallationInputValidator.php b/phpmyfaq/src/phpMyFAQ/Setup/InstallationInputValidator.php index 3f92b456a4..96d0eca162 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/InstallationInputValidator.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/InstallationInputValidator.php @@ -119,7 +119,7 @@ private function validateDatabaseInput(?array $setup): array } $dbSetup['dbServer'] = Filter::filterInput(INPUT_POST, 'sql_server', FILTER_SANITIZE_SPECIAL_CHARS, ''); - if (is_null($dbSetup['dbServer']) && !System::isSqlite($dbSetup['dbType'])) { + if (trim((string) $dbSetup['dbServer']) === '' && !System::isSqlite($dbSetup['dbType'])) { throw new Exception('Installation Error: Please add a database server.'); } @@ -134,7 +134,7 @@ private function validateDatabaseInput(?array $setup): array } $dbSetup['dbUser'] = Filter::filterInput(INPUT_POST, 'sql_user', FILTER_SANITIZE_SPECIAL_CHARS, ''); - if (is_null($dbSetup['dbUser']) && !System::isSqlite($dbSetup['dbType'])) { + if (trim((string) $dbSetup['dbUser']) === '' && !System::isSqlite($dbSetup['dbType'])) { throw new Exception('Installation Error: Please add a database username.'); } diff --git a/phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php b/phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php index a949d25e76..20e60a1517 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php @@ -134,24 +134,37 @@ private function stepValidateConnectivity(InstallationInput $input): void $classLoader->addPsr4('React\\Promise\\', PMF_SRC_DIR . '/libs/react/promise/src'); $classLoader->register(); - $esHosts = array_values($input->esSetup['hosts']); - $esClient = ClientBuilder::create()->setHosts($esHosts)->build(); - - if (!$esClient) { - throw new Exception('Elasticsearch Installation Error: No connection to Elasticsearch.'); + try { + $esHosts = array_values($input->esSetup['hosts']); + $esClient = ClientBuilder::create()->setHosts($esHosts)->build(); + $esClient->ping(); + } catch (\Throwable $e) { + throw new Exception(sprintf( + 'Elasticsearch Installation Error: Could not connect to Elasticsearch: %s', + $e->getMessage(), + )); } } // Validate OpenSearch connection if enabled if ($input->osEnabled && $input->osSetup !== []) { - $osHosts = array_values($input->osSetup['hosts']); - $osClient = new SymfonyClientFactory()->create([ - 'base_uri' => $osHosts[0], - 'verify_peer' => false, - ]); - - if (!$osClient) { - throw new Exception('OpenSearch Installation Error: No connection to OpenSearch.'); + try { + $osHosts = array_values($input->osSetup['hosts']); + $osClient = new SymfonyClientFactory()->create([ + 'base_uri' => $osHosts[0], + 'verify_peer' => false, + ]); + + if (!$osClient->ping()) { + throw new Exception('OpenSearch Installation Error: Server did not respond to ping.'); + } + } catch (Exception $e) { + throw $e; + } catch (\Throwable $e) { + throw new Exception(sprintf( + 'OpenSearch Installation Error: Could not connect to OpenSearch: %s', + $e->getMessage(), + )); } } } @@ -259,7 +272,7 @@ private function stepInsertStopwords(InstallationInput $input): void private function stepSeedConfiguration(InstallationInput $input): void { $seeder = new DefaultDataSeeder(); - $seeder->applyPersonalSettings($input->realname, $input->email, $input->language, $input->permLevel); + $seeder->applyPersonalSettings($input->realname, $input->getEmail(), $input->language, $input->permLevel); $seeder->seedConfig($this->configuration); $link = new Link('', $this->configuration); @@ -275,7 +288,7 @@ private function stepSeedConfiguration(InstallationInput $input): void private function stepCreateAdminUser(InstallationInput $input): void { $user = new User($this->configuration); - if (!$user->createUser($input->loginName, $input->password, '', 1)) { + if (!$user->createUser($input->getLoginName(), $input->getPassword(), '', 1)) { Installer::cleanFailedInstallationFiles(); throw new Exception(sprintf( 'Fatal Installation Error: Could not create the admin user: %s', @@ -283,13 +296,30 @@ private function stepCreateAdminUser(InstallationInput $input): void )); } - $user->setStatus('protected'); + if (!$user->setStatus('protected')) { + Installer::cleanFailedInstallationFiles(); + throw new Exception(sprintf( + 'Fatal Installation Error: Could not set admin user status: %s', + $user->error(), + )); + } + $adminData = [ 'display_name' => $input->realname, - 'email' => $input->email, + 'email' => $input->getEmail(), ]; - $user->setUserData($adminData); - $user->setSuperAdmin(true); + if (!$user->setUserData($adminData)) { + Installer::cleanFailedInstallationFiles(); + throw new Exception(sprintf('Fatal Installation Error: Could not set admin user data: %s', $user->error())); + } + + if (!$user->setSuperAdmin(true)) { + Installer::cleanFailedInstallationFiles(); + throw new Exception(sprintf( + 'Fatal Installation Error: Could not set admin as super admin: %s', + $user->error(), + )); + } } /** @@ -354,8 +384,9 @@ private function stepCreateInstance(): void */ private function stepInitializeSearchEngine(InstallationInput $input): void { - if ($input->esEnabled && is_file($input->rootDir . '/config/elasticsearch.php')) { - $elasticsearchConfiguration = new ElasticsearchConfiguration($input->rootDir . '/config/elasticsearch.php'); + if ($input->esEnabled && is_file($input->rootDir . '/content/core/config/elasticsearch.php')) { + $elasticsearchConfiguration = new ElasticsearchConfiguration($input->rootDir + . '/content/core/config/elasticsearch.php'); $this->configuration->setElasticsearchConfig($elasticsearchConfiguration); $esClient = ClientBuilder::create()->setHosts($elasticsearchConfiguration->getHosts())->build(); @@ -365,8 +396,9 @@ private function stepInitializeSearchEngine(InstallationInput $input): void $elasticsearch->createIndex(); } - if ($input->osEnabled && is_file($input->rootDir . '/config/opensearch.php')) { - $openSearchConfiguration = new OpenSearchConfiguration($input->rootDir . '/config/opensearch.php'); + if ($input->osEnabled && is_file($input->rootDir . '/content/core/config/opensearch.php')) { + $openSearchConfiguration = new OpenSearchConfiguration($input->rootDir + . '/content/core/config/opensearch.php'); $this->configuration->setOpenSearchConfig($openSearchConfiguration); $osClient = new SymfonyClientFactory()->create([ diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Migration/Operations/FormInputInsertOperation.php b/phpmyfaq/src/phpMyFAQ/Setup/Migration/Operations/FormInputInsertOperation.php index 99219af0b7..b033b0a18c 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Migration/Operations/FormInputInsertOperation.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Migration/Operations/FormInputInsertOperation.php @@ -53,8 +53,7 @@ public function execute(): bool { try { $forms = new Forms($this->configuration); - $forms->insertInputIntoDatabase($this->formInput); - return true; + return $forms->insertInputIntoDatabase($this->formInput); } catch (Throwable) { return false; } diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Migration/Operations/UserCreateOperation.php b/phpmyfaq/src/phpMyFAQ/Setup/Migration/Operations/UserCreateOperation.php index c4e9dcb29a..551e66cfe6 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Migration/Operations/UserCreateOperation.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Migration/Operations/UserCreateOperation.php @@ -56,16 +56,20 @@ public function execute(): bool return false; } - $user->setStatus($this->status); + if (!$user->setStatus($this->status)) { + return false; + } $userData = [ 'display_name' => $this->displayName, 'email' => $this->email, ]; - $user->setUserData($userData); + if (!$user->setUserData($userData)) { + return false; + } - if ($this->isSuperAdmin) { - $user->setSuperAdmin(true); + if ($this->isSuperAdmin && !$user->setSuperAdmin(true)) { + return false; } return true; diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/TableBuilder.php b/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/TableBuilder.php index 52991b2836..44267e00ce 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/TableBuilder.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/TableBuilder.php @@ -24,7 +24,7 @@ class TableBuilder { - private string $tableName; + private string $tableName = ''; private bool $ifNotExists = false; private DialectInterface $dialect; diff --git a/tests/phpMyFAQ/Setup/Installation/SchemaInstallerTest.php b/tests/phpMyFAQ/Setup/Installation/SchemaInstallerTest.php index d21089b483..0437f2975a 100644 --- a/tests/phpMyFAQ/Setup/Installation/SchemaInstallerTest.php +++ b/tests/phpMyFAQ/Setup/Installation/SchemaInstallerTest.php @@ -111,14 +111,21 @@ public function testPostgresNoIndexStatementsDuplicate(): void $installer->createTables(''); $sql = $installer->getCollectedSql(); - $createIndexCount = 0; + $indexes = []; foreach ($sql as $statement) { if (str_contains($statement, 'CREATE INDEX')) { - $createIndexCount++; + $indexes[] = $statement; } } // PostgreSQL should have separate CREATE INDEX statements for indexes - $this->assertGreaterThan(0, $createIndexCount, 'PostgreSQL should have separate CREATE INDEX statements'); + $this->assertGreaterThan(0, count($indexes), 'PostgreSQL should have separate CREATE INDEX statements'); + + // No duplicate CREATE INDEX statements should be emitted + $this->assertEquals( + count($indexes), + count(array_unique($indexes)), + 'PostgreSQL should not emit duplicate CREATE INDEX statements', + ); } } diff --git a/tests/phpMyFAQ/Setup/InstallationInputValidatorTest.php b/tests/phpMyFAQ/Setup/InstallationInputValidatorTest.php index 14fc40fa59..d559794fff 100644 --- a/tests/phpMyFAQ/Setup/InstallationInputValidatorTest.php +++ b/tests/phpMyFAQ/Setup/InstallationInputValidatorTest.php @@ -46,8 +46,8 @@ public function testInstallationInputIsReadonly(): void rootDir: '/tmp', ); - $this->assertEquals('admin', $input->loginName); - $this->assertEquals('password123', $input->password); + $this->assertEquals('admin', $input->getLoginName()); + $this->assertEquals('password123', $input->getPassword()); $this->assertEquals('en', $input->language); $this->assertFalse($input->ldapEnabled); $this->assertFalse($input->esEnabled); From aa7fed1d762d210e7667a761c4f2021adaa3b205 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Mon, 2 Feb 2026 10:24:51 +0100 Subject: [PATCH 3/6] fix: corrected corrupted .htaccess file --- phpmyfaq/.htaccess | 4 ++-- phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php | 11 +++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/phpmyfaq/.htaccess b/phpmyfaq/.htaccess index 24fed1739c..574e29686a 100644 --- a/phpmyfaq/.htaccess +++ b/phpmyfaq/.htaccess @@ -102,13 +102,13 @@ Header set Access-Control-Allow-Headers "Content-Type, Authorization" RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] # the path to your phpMyFAQ installation - RewriteBase /Users/thorsten/htdocs/phpMyFAQ/phpmyfaq/src/libs/bin/phpunit/ + RewriteBase / # Block zip files in content directory RewriteRule ^content/.*\.zip$ - [F,L] # Exclude assets from being handled by Symfony Router RewriteRule ^admin/assets($|/) - [L] # Error pages - ErrorDocument 404 /Users/thorsten/htdocs/phpMyFAQ/phpmyfaq/src/libs/bin/phpunit/404.html + ErrorDocument 404 /404.html # Administration API RewriteRule ^admin/api/ admin/api/index.php [L,QSA] # Administration pages (redirect /admin to /admin/) diff --git a/phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php b/phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php index 20e60a1517..23e91adf06 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php @@ -78,7 +78,7 @@ public function run(InstallationInput $input): void $this->stepCreateAnonymousUser($input); $this->stepCreateInstance(); $this->stepInitializeSearchEngine($input); - $this->stepAdjustHtaccess(); + $this->stepAdjustHtaccess($input); } /** @@ -414,9 +414,16 @@ private function stepInitializeSearchEngine(InstallationInput $input): void /** * Step 13: Adjust .htaccess RewriteBase. + * + * Skips when the installation rootDir differs from the application's root path + * (e.g. in test environments) to avoid modifying the real .htaccess file. */ - private function stepAdjustHtaccess(): void + private function stepAdjustHtaccess(InstallationInput $input): void { + if (realpath($input->rootDir) !== realpath($this->configuration->getRootPath())) { + return; + } + $environmentConfigurator = new EnvironmentConfigurator($this->configuration); $environmentConfigurator->adjustRewriteBaseHtaccess(); } From e699a043902deac23a9f7ebed47da26abfc3b512 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Mon, 2 Feb 2026 12:06:54 +0100 Subject: [PATCH 4/6] fix: corrected review notes --- .../Setup/Installation/SchemaInstaller.php | 30 +---- .../src/phpMyFAQ/Setup/InstallationRunner.php | 108 +++++++++++++----- .../Installation/SchemaInstallerTest.php | 14 +-- 3 files changed, 92 insertions(+), 60 deletions(-) diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php b/phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php index cee847f0cb..8a038d73d2 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php @@ -37,9 +37,9 @@ class SchemaInstaller implements DriverInterface private readonly DatabaseSchema $schema; /** @var string[] Collected SQL for dry-run */ - private array $collectedSql = []; + public array $collectedSql; - private bool $dryRun = false; + public bool $dryRun = false; public function __construct( private readonly Configuration $configuration, @@ -49,24 +49,6 @@ public function __construct( $this->schema = new DatabaseSchema($this->dialect); } - /** - * Enables or disables dry-run mode. In dry-run mode, SQL is collected but not executed. - */ - public function setDryRun(bool $dryRun): void - { - $this->dryRun = $dryRun; - } - - /** - * Returns collected SQL statements from dry-run mode. - * - * @return string[] - */ - public function getCollectedSql(): array - { - return $this->collectedSql; - } - /** * Returns the DatabaseSchema instance. */ @@ -142,12 +124,6 @@ private function executeSql(string $sql): bool return true; } - $result = $this->configuration->getDb()->query($sql); - - if (!$result) { - return false; - } - - return true; + return (bool) $this->configuration->getDb()->query($sql); } } diff --git a/phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php b/phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php index 23e91adf06..d2859d51e8 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php @@ -90,20 +90,29 @@ private function stepValidateConnectivity(InstallationInput $input): void { Database::setTablePrefix($input->dbSetup['dbPrefix'] ?? ''); $db = Database::factory($input->dbSetup['dbType']); - $db->connect( - $input->dbSetup['dbServer'], - $input->dbSetup['dbUser'], - $input->dbSetup['dbPassword'], - $input->dbSetup['dbDatabaseName'], - $input->dbSetup['dbPort'], - ); + + try { + $connected = $db->connect( + $input->dbSetup['dbServer'], + $input->dbSetup['dbUser'], + $input->dbSetup['dbPassword'], + $input->dbSetup['dbDatabaseName'], + $input->dbSetup['dbPort'], + ); + } catch (\Throwable $e) { + throw new Exception(sprintf('Database Connection Error: %s', $e->getMessage()), 0, $e); + } + + if ($connected === false || $connected === null) { + throw new Exception(sprintf('Database Connection Error: %s', $db->error())); + } $configuration = new Configuration($db); // Validate LDAP connection if enabled if ($input->ldapEnabled && $input->ldapSetup !== []) { $seeder = new DefaultDataSeeder(); - foreach ($seeder->getMainConfig() as $configKey => $configValue) { + foreach ($seeder->mainConfig as $configKey => $configValue) { if (!str_contains($configKey, 'ldap.')) { continue; } @@ -137,7 +146,11 @@ private function stepValidateConnectivity(InstallationInput $input): void try { $esHosts = array_values($input->esSetup['hosts']); $esClient = ClientBuilder::create()->setHosts($esHosts)->build(); - $esClient->ping(); + if (!$esClient->ping()->asBool()) { + throw new Exception('Elasticsearch Installation Error: Server did not respond to ping.'); + } + } catch (Exception $e) { + throw $e; } catch (\Throwable $e) { throw new Exception(sprintf( 'Elasticsearch Installation Error: Could not connect to Elasticsearch: %s', @@ -150,10 +163,10 @@ private function stepValidateConnectivity(InstallationInput $input): void if ($input->osEnabled && $input->osSetup !== []) { try { $osHosts = array_values($input->osSetup['hosts']); - $osClient = new SymfonyClientFactory()->create([ - 'base_uri' => $osHosts[0], - 'verify_peer' => false, - ]); + $osClient = new SymfonyClientFactory()->create($this->buildOpenSearchClientOptions( + $osHosts[0], + $input->osSetup, + )); if (!$osClient->ping()) { throw new Exception('OpenSearch Installation Error: Server did not respond to ping.'); @@ -223,15 +236,20 @@ private function stepEstablishDbConnection(InstallationInput $input): void throw new Exception(sprintf('Database Installation Error: %s', $exception->getMessage())); } - $this->db->connect( - $databaseConfiguration->getServer(), - $databaseConfiguration->getUser(), - $databaseConfiguration->getPassword(), - $databaseConfiguration->getDatabase(), - $databaseConfiguration->getPort(), - ); + try { + $connected = $this->db->connect( + $databaseConfiguration->getServer(), + $databaseConfiguration->getUser(), + $databaseConfiguration->getPassword(), + $databaseConfiguration->getDatabase(), + $databaseConfiguration->getPort(), + ); + } catch (\Throwable $e) { + Installer::cleanFailedInstallationFiles(); + throw new Exception(sprintf('Database Installation Error: %s', $e->getMessage()), 0, $e); + } - if (!$this->db instanceof DatabaseDriver) { + if ($connected === false || $connected === null) { Installer::cleanFailedInstallationFiles(); throw new Exception(sprintf('Database Installation Error: %s', $this->db->error())); } @@ -277,7 +295,13 @@ private function stepSeedConfiguration(InstallationInput $input): void $link = new Link('', $this->configuration); $this->configuration->update(['main.referenceURL' => $link->getSystemUri('/setup/index.php')]); - $this->configuration->add('security.salt', md5($this->configuration->getDefaultUrl())); + try { + $salt = bin2hex(random_bytes(32)); + } catch (\Random\RandomException $e) { + throw new Exception(sprintf('Installation Error: Could not generate security salt: %s', $e->getMessage())); + } + + $this->configuration->add('security.salt', $salt); } /** @@ -401,10 +425,10 @@ private function stepInitializeSearchEngine(InstallationInput $input): void . '/content/core/config/opensearch.php'); $this->configuration->setOpenSearchConfig($openSearchConfiguration); - $osClient = new SymfonyClientFactory()->create([ - 'base_uri' => $openSearchConfiguration->getHosts()[0], - 'verify_peer' => false, - ]); + $osClient = new SymfonyClientFactory()->create($this->buildOpenSearchClientOptions( + $openSearchConfiguration->getHosts()[0], + $input->osSetup, + )); $this->configuration->setOpenSearch($osClient); $openSearch = new OpenSearch($this->configuration); @@ -412,6 +436,38 @@ private function stepInitializeSearchEngine(InstallationInput $input): void } } + /** + * Builds the options array for OpenSearch SymfonyClientFactory. + * + * Defaults to verify_peer=true; callers may pass optional TLS overrides + * (verify_peer, cafile, capath) via the $tlsSettings array. + * + * @param string $baseUri The OpenSearch server base URI + * @param array $tlsSettings Optional TLS settings from osSetup + * @return array + */ + private function buildOpenSearchClientOptions(string $baseUri, array $tlsSettings = []): array + { + $options = [ + 'base_uri' => $baseUri, + 'verify_peer' => true, + ]; + + if (isset($tlsSettings['verify_peer'])) { + $options['verify_peer'] = filter_var($tlsSettings['verify_peer'], FILTER_VALIDATE_BOOLEAN); + } + + if (isset($tlsSettings['cafile']) && $tlsSettings['cafile'] !== '') { + $options['cafile'] = (string) $tlsSettings['cafile']; + } + + if (isset($tlsSettings['capath']) && $tlsSettings['capath'] !== '') { + $options['capath'] = (string) $tlsSettings['capath']; + } + + return $options; + } + /** * Step 13: Adjust .htaccess RewriteBase. * diff --git a/tests/phpMyFAQ/Setup/Installation/SchemaInstallerTest.php b/tests/phpMyFAQ/Setup/Installation/SchemaInstallerTest.php index 0437f2975a..775773144c 100644 --- a/tests/phpMyFAQ/Setup/Installation/SchemaInstallerTest.php +++ b/tests/phpMyFAQ/Setup/Installation/SchemaInstallerTest.php @@ -35,12 +35,12 @@ public function testDryRunCollectsAllSql(DialectInterface $dialect): void $configuration->method('getDb')->willReturn($db); $installer = new SchemaInstaller($configuration, $dialect); - $installer->setDryRun(true); + $installer->dryRun = true; $result = $installer->createTables(''); $this->assertTrue($result); - $sql = $installer->getCollectedSql(); + $sql = $installer->collectedSql; $this->assertNotEmpty($sql); // Should have at least one CREATE TABLE per table definition @@ -62,7 +62,7 @@ public function testDryRunDoesNotExecuteQueries(DialectInterface $dialect): void $configuration->method('getDb')->willReturn($db); $installer = new SchemaInstaller($configuration, $dialect); - $installer->setDryRun(true); + $installer->dryRun = true; $installer->createTables(''); } @@ -92,10 +92,10 @@ public function testMysqlDryRunContainsInnodb(): void $configuration->method('getDb')->willReturn($db); $installer = new SchemaInstaller($configuration, new MysqlDialect()); - $installer->setDryRun(true); + $installer->dryRun = true; $installer->createTables(''); - $allSql = implode("\n", $installer->getCollectedSql()); + $allSql = implode("\n", $installer->collectedSql); $this->assertStringContainsString('InnoDB', $allSql); $this->assertStringContainsString('utf8mb4', $allSql); } @@ -107,10 +107,10 @@ public function testPostgresNoIndexStatementsDuplicate(): void $configuration->method('getDb')->willReturn($db); $installer = new SchemaInstaller($configuration, new PostgresDialect()); - $installer->setDryRun(true); + $installer->dryRun = true; $installer->createTables(''); - $sql = $installer->getCollectedSql(); + $sql = $installer->collectedSql; $indexes = []; foreach ($sql as $statement) { if (str_contains($statement, 'CREATE INDEX')) { From 482b783eb39fc3424ed2c09df4569c1e227ad7b2 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Mon, 2 Feb 2026 12:46:46 +0100 Subject: [PATCH 5/6] fix: corrected review notes --- .../phpMyFAQ/Setup/Installation/SchemaInstaller.php | 2 +- phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php b/phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php index 8a038d73d2..9fd57f3936 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php @@ -37,7 +37,7 @@ class SchemaInstaller implements DriverInterface private readonly DatabaseSchema $schema; /** @var string[] Collected SQL for dry-run */ - public array $collectedSql; + public array $collectedSql = []; public bool $dryRun = false; diff --git a/phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php b/phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php index d2859d51e8..71b642b863 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php @@ -112,7 +112,7 @@ private function stepValidateConnectivity(InstallationInput $input): void // Validate LDAP connection if enabled if ($input->ldapEnabled && $input->ldapSetup !== []) { $seeder = new DefaultDataSeeder(); - foreach ($seeder->mainConfig as $configKey => $configValue) { + foreach ($seeder->getMainConfig() as $configKey => $configValue) { if (!str_contains($configKey, 'ldap.')) { continue; } @@ -266,11 +266,20 @@ private function stepCreateDatabaseTables(InstallationInput $input): void { try { $databaseInstaller = InstanceDatabase::factory($this->configuration, $input->dbSetup['dbType']); - $databaseInstaller->createTables($input->dbSetup['dbPrefix'] ?? ''); + $result = $databaseInstaller->createTables($input->dbSetup['dbPrefix'] ?? ''); } catch (Exception $exception) { Installer::cleanFailedInstallationFiles(); throw new Exception(sprintf('Database Installation Error: %s', $exception->getMessage())); } + + if (!$result) { + Installer::cleanFailedInstallationFiles(); + throw new Exception(sprintf( + 'Database Installation Error: Failed to create tables for database type "%s" with prefix "%s".', + $input->dbSetup['dbType'], + $input->dbSetup['dbPrefix'] ?? '', + )); + } } /** From d7aa857e005d9107b05b7ca601e808580a2972ec Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Mon, 2 Feb 2026 13:26:11 +0100 Subject: [PATCH 6/6] fix: corrected review notes --- phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php b/phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php index 71b642b863..bbbab4c862 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php @@ -46,7 +46,6 @@ use phpMyFAQ\Setup\Installation\DefaultDataSeeder; use phpMyFAQ\System; use phpMyFAQ\User; -use Symfony\Component\HttpFoundation\Request; class InstallationRunner { @@ -401,7 +400,7 @@ private function stepCreateInstance(): void $link = new Link('', $this->configuration); $instanceEntity = new InstanceEntity(); $instanceEntity - ->setUrl($link->getSystemUri(Request::createFromGlobals()->getScriptName())) + ->setUrl($link->getSystemUri()) ->setInstance($link->getSystemRelativeUri('setup/index.php')) ->setComment('phpMyFAQ ' . System::getVersion());