From d13171698e7e74d22dcecdec95b5a28b48847714 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Thu, 30 Jul 2026 17:40:46 -0700 Subject: [PATCH 1/9] =?UTF-8?q?Implement=20=E2=80=9Cdevelopment=E2=80=9D?= =?UTF-8?q?=20seed=20preset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docs/sandbox-cli.md | 52 +- src/QuickInstall/Sandbox/Application.php | 33 +- src/QuickInstall/Sandbox/BoardRunner.php | 10 + .../Sandbox/DevelopmentSeederWriter.php | 67 +++ .../Sandbox/SeedPresetCatalog.php | 52 ++ .../Sandbox/SeedRuntime/ContentBuilder.php | 484 +++++++++++++++++ .../Sandbox/SeedRuntime/DevelopmentSeeder.php | 467 +++++++++++++++++ .../Sandbox/SeedRuntime/ForumBuilder.php | 265 ++++++++++ .../Sandbox/SeedRuntime/SeedContext.php | 458 ++++++++++++++++ .../Sandbox/SeedRuntime/StateBuilder.php | 488 ++++++++++++++++++ .../Sandbox/SeedRuntime/UserBuilder.php | 213 ++++++++ src/QuickInstall/Sandbox/SeedRuntime/run.php | 87 ++++ src/QuickInstall/Sandbox/Web/Application.php | 20 +- src/QuickInstall/Sandbox/bootstrap.php | 2 + tests/Integration/ApplicationTest.php | 10 + tests/Integration/WebApplicationTest.php | 2 + tests/Unit/BoardRunnerTest.php | 18 + tests/Unit/DevelopmentSeederWriterTest.php | 155 ++++++ tests/Unit/SeedPresetCatalogTest.php | 34 ++ 20 files changed, 2882 insertions(+), 37 deletions(-) create mode 100644 src/QuickInstall/Sandbox/DevelopmentSeederWriter.php create mode 100644 src/QuickInstall/Sandbox/SeedPresetCatalog.php create mode 100644 src/QuickInstall/Sandbox/SeedRuntime/ContentBuilder.php create mode 100644 src/QuickInstall/Sandbox/SeedRuntime/DevelopmentSeeder.php create mode 100644 src/QuickInstall/Sandbox/SeedRuntime/ForumBuilder.php create mode 100644 src/QuickInstall/Sandbox/SeedRuntime/SeedContext.php create mode 100644 src/QuickInstall/Sandbox/SeedRuntime/StateBuilder.php create mode 100644 src/QuickInstall/Sandbox/SeedRuntime/UserBuilder.php create mode 100644 src/QuickInstall/Sandbox/SeedRuntime/run.php create mode 100644 tests/Unit/DevelopmentSeederWriterTest.php create mode 100644 tests/Unit/SeedPresetCatalogTest.php diff --git a/README.md b/README.md index ee7c260c..d6c25906 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Requirements: Create your first new board: ```bash -php bin/qi board:create test --phpbb 3.3 --db mariadb --port 8081 --populate extension-dev +php bin/qi board:create test --phpbb 3.3 --db mariadb --port 8081 --populate development ``` The QuickInstall CLI targets phpBB 3.2+ installer-based boards. phpBB 3.0/3.1 remain legacy-web-app territory and are not planned for the QuickInstall CLI. diff --git a/docs/sandbox-cli.md b/docs/sandbox-cli.md index 98e819ab..ee7dd865 100644 --- a/docs/sandbox-cli.md +++ b/docs/sandbox-cli.md @@ -112,16 +112,16 @@ Create a small empty board: php bin/qi board:create clean --phpbb 3.3 --db mariadb --port 8081 --populate none ``` -Create a board with extension-development fixtures: +Create a board with comprehensive development fixtures: ```bash -php bin/qi board:create extdev --phpbb 3.3.17 --db mariadb --port 8082 --populate extension-dev +php bin/qi board:create dev --phpbb 3.3.17 --db mariadb --port 8082 --populate development ``` Create a board with phpBB debug output enabled: ```bash -php bin/qi board:create debug --phpbb 3.3 --db mariadb --port 8085 --populate extension-dev --debug +php bin/qi board:create debug --phpbb 3.3 --db mariadb --port 8085 --populate development --debug ``` Create an older supported phpBB 3.2 board: @@ -172,42 +172,64 @@ php bin/qi board:create demo --phpbb 3.3 --db mariadb --port 8081 --populate tin ## Fixture Presets -Fixture seeding populates a board with categories, forums, users, topics, and replies. For non-tiny presets, it also adds a few seeded users to Global Moderators and Newly Registered Users. Newly registered users are kept at zero posts. It does not create custom groups, permission matrices, or attachments. +Fixture seeding populates a board with repeatable test data. The `development` preset favors meaningful phpBB states over raw volume; use `load-test` when topic and post volume is the primary goal. Use `--populate ` during `board:create`: ```bash -php bin/qi board:create demo --populate extension-dev +php bin/qi board:create demo --populate development ``` Available presets: -| Preset | Description | -|-----------------|----------------------------------------------------------------------| -| `none` | No seed data | -| `tiny` | 3 users, 1 category, 2 forums, 2 topics, 2 replies per topic | -| `extension-dev` | 10 users, 2 categories, 6 forums, 25 topics, 10 replies per topic | -| `load-test` | 100 users, 4 categories, 20 forums, 100 topics, 20 replies per topic | -| `random` | Random counts up to load-test size | +| Preset | Description | +|---------------|-------------------------------------------------------------------------------------------------------| +| `none` | No seed data | +| `tiny` | 3 users, 1 category, 2 forums, 2 topics, 2 replies per topic | +| `development` | 25 users, 4 categories, 12 new forums, 47 topic rows, 90 posts, and comprehensive development states | +| `load-test` | 100 users, 4 categories, 20 forums, 100 topics, 20 replies per topic | +| `random` | Random counts up to load-test size | + +`extension-dev` is deprecated and hidden from CLI help and Dashboard selectors. Existing scripts and saved boards may continue using it; it retains its original 10-user, 6-forum, 25-topic behavior. New workflows should use `development`. + +### Development fixture coverage + +The `development` preset creates: + +- Compact `[QI N]` markers followed by plain-language labels, making each generated content type easy to find. +- Exactly 25 generated users assigned across Registered Users, Global Moderators, and Administrators. Every generated user has a deterministic 80×80 avatar and parsed signature. Dedicated users exercise inactive, banned, and warned-account layouts. +- Four categories and twelve new forums covering root-level, read, unread, locked, password-protected, private, empty, nested subforum, link redirect, rules text, and forum-icon states. Generated users and the protected forum both use `password`. +- Normal, sticky, announcement, global announcement, locked, icon, and moved-redirect topics. +- Plain-language, descriptive posts covering rich BBCode, smileys, lists, code blocks, deep quotes, long-content overflow, moderation, and multi-page topics. +- Two polls: one active and unvoted, plus one with a deterministic six-user vote distribution. +- A generated PNG attachment and generated ZIP attachment. +- An open post report, an open private-message report, and two user warnings. +- Nine clearly labeled private messages covering unread, read, sent, reported, and peer-to-peer states. +- A bookmark, topic watch, forum watch, saved draft, and three unread notifications for the installed founder account. +- Search indexing through the board's configured phpBB search backend. + +Avatars, the forum icon, the image attachment, and the ZIP archive are generated locally from the numeric seed. Seeding performs no asset downloads and bundles no third-party media. + +Development fixtures are tracked by a manifest under the board's `store/` directory. This lets `--reset` and `--replace` remove owned database rows and physical files. If the manifest is missing, QuickInstall falls back to its deterministic fixture prefixes. Fixture seeding is supported for MariaDB, MySQL, and PostgreSQL boards. SQLite boards currently support `--populate none` only; phpBB's posting and permission APIs can hold SQLite write locks too long for reliable fixture generation. You can seed again manually: ```bash -php bin/qi board:seed demo --preset extension-dev --seed 1 +php bin/qi board:seed demo --preset development --seed 1 ``` Replace seed data: ```bash -php bin/qi board:seed demo --preset extension-dev --seed 1 --replace +php bin/qi board:seed demo --preset development --seed 1 --replace ``` Remove seed data: ```bash -php bin/qi board:seed demo --preset extension-dev --seed 1 --reset +php bin/qi board:seed demo --preset development --seed 1 --reset ``` `--seed` is a repeatable random seed number. Use the same seed to get the same fixture shape. diff --git a/src/QuickInstall/Sandbox/Application.php b/src/QuickInstall/Sandbox/Application.php index 299b9207..8d69457b 100644 --- a/src/QuickInstall/Sandbox/Application.php +++ b/src/QuickInstall/Sandbox/Application.php @@ -287,6 +287,10 @@ private function boardCreate(array $args): int $populate = $cli->option('populate', 'none'); $debug = $cli->has('debug'); $this->validateBoardCreateOptions($db, $port, $populate); + if (SeedPresetCatalog::isDeprecated($populate)) + { + echo 'Warning: ' . SeedPresetCatalog::deprecationMessage() . "\n"; + } $created = (new BoardService($this->project, $this->sandboxOutput()))->create($name, $version, $db, $port, $populate, $debug, $cli->has('replace')); $paths = $created['paths']; @@ -519,12 +523,16 @@ private function boardSeed(array $args): int $name = $cli->argument(0); if ($name === null) { - throw new InvalidArgumentException('Usage: qi board:seed [--preset tiny|extension-dev|load-test|random] [--seed N] [--reset|--replace]'); + throw new InvalidArgumentException('Usage: qi board:seed [--preset tiny|development|load-test|random] [--seed N] [--reset|--replace]'); } - $preset = $cli->option('preset', 'extension-dev'); + $preset = $cli->option('preset', SeedPresetCatalog::defaultSeed()); $seed = (int) $cli->option('seed', '1'); $this->validatePreset($preset); + if (SeedPresetCatalog::isDeprecated($preset)) + { + echo 'Warning: ' . SeedPresetCatalog::deprecationMessage() . "\n"; + } if ($seed < 1) { throw new InvalidArgumentException('--seed must be a positive integer.'); @@ -577,10 +585,7 @@ private function validateBoardCreateOptions(string $db, int $port, string $popul private function validatePreset(string $preset): void { - if (!in_array($preset, ['tiny', 'extension-dev', 'load-test', 'random'], true)) - { - throw new InvalidArgumentException('Preset must be one of: tiny, extension-dev, load-test, random.'); - } + SeedPresetCatalog::validate($preset); } private function extMount(array $args): int @@ -907,7 +912,7 @@ private function help(array $args = []): void echo ' ' . $this->style('qi [arguments] [options]', '1;36') . "\n"; echo ' ' . $this->style('qi help [command]', '1;36') . "\n\n"; echo $this->style('Common workflow:', '1;33') . "\n"; - echo ' ' . $this->style('qi board:create demo --phpbb 3.3 --db mariadb --port 8081 --populate extension-dev', '1;36') . "\n"; + echo ' ' . $this->style('qi board:create demo --phpbb 3.3 --db mariadb --port 8081 --populate development', '1;36') . "\n"; echo ' ' . $this->style('qi board:start demo', '1;36') . "\n\n"; foreach ($commands as $group => $items) @@ -991,13 +996,13 @@ private function helpCommands(): array '--phpbb VERSION' => 'phpBB selector. Examples: latest, 3.3, 3.3.17, 3.2, master. Default: latest.', '--db DB' => 'Database engine. One of: mariadb, mysql, postgres, sqlite. Default: mariadb.', '--port PORT' => 'Local browser port. Default: 8080.', - '--populate PRESET' => 'Seed preset. One of: none, tiny, extension-dev, load-test, random. Default: none.', + '--populate PRESET' => 'Seed preset. One of: none, tiny, development, load-test, random. Default: none.', '--debug' => 'Enable phpBB debug settings after install.', '--replace' => 'Destroy an existing board with the same name before creating the new one.', ], 'examples' => [ 'board:create demo --phpbb 3.3 --db mariadb --port 8081', - 'board:create extdev --phpbb 3.3.17 --populate extension-dev --debug', + 'board:create dev --phpbb 3.3.17 --populate development --debug', ], ], 'board:start' => [ @@ -1050,21 +1055,21 @@ private function helpCommands(): array ], 'board:seed' => [ 'title' => 'board:seed', - 'usage' => 'board:seed [--preset tiny|extension-dev|load-test|random] [--seed N] [--reset|--replace]', + 'usage' => 'board:seed [--preset tiny|development|load-test|random] [--seed N] [--reset|--replace]', 'summary' => 'Add, replace, or remove fixture content.', - 'description' => 'Seeds categories, forums, users, topics, and replies on an installed board. SQLite boards only support reset.', + 'description' => 'Seeds repeatable phpBB development or load fixtures on an installed board. SQLite boards only support reset.', 'arguments' => [ '' => 'Required board name.', ], 'options' => [ - '--preset PRESET' => 'Fixture preset. One of: tiny, extension-dev, load-test, random. Default: extension-dev.', + '--preset PRESET' => 'Fixture preset. One of: tiny, development, load-test, random. Default: development.', '--seed N' => 'Positive random seed number for repeatable fixture shape. Default: 1.', '--replace' => 'Remove existing QuickInstall seed data, then seed again.', '--reset' => 'Remove existing QuickInstall seed data without adding new data.', ], 'examples' => [ - 'board:seed demo --preset extension-dev --seed 1', - 'board:seed demo --preset extension-dev --replace', + 'board:seed demo --preset development --seed 1', + 'board:seed demo --preset development --replace', ], ], ], diff --git a/src/QuickInstall/Sandbox/BoardRunner.php b/src/QuickInstall/Sandbox/BoardRunner.php index 1066892b..abd41e72 100644 --- a/src/QuickInstall/Sandbox/BoardRunner.php +++ b/src/QuickInstall/Sandbox/BoardRunner.php @@ -394,6 +394,16 @@ protected function serviceState(string $name, string $service): string protected function runSeeder(string $name, string $preset, int $seed, string $action = 'seed'): void { + if ($preset === 'development') + { + $package = (new DevelopmentSeederWriter($this->project))->write($name); + $this->output->write("Running seed preset: $preset\n"); + $this->run(['docker', 'compose', '-f', $this->project->composePath($name), 'exec', '-T', 'web', 'mkdir', '-p', '/tmp/qi-development-seed']); + $this->run(['docker', 'compose', '-f', $this->project->composePath($name), 'cp', $package . '/.', 'web:/tmp/qi-development-seed']); + $this->run(['docker', 'compose', '-f', $this->project->composePath($name), 'exec', '-T', 'web', 'timeout', '300', 'php', '-d', 'memory_limit=512M', '/tmp/qi-development-seed/run.php', (string) $seed, $action]); + return; + } + $writer = new SeederWriter($this->project); $script = $writer->write($name); diff --git a/src/QuickInstall/Sandbox/DevelopmentSeederWriter.php b/src/QuickInstall/Sandbox/DevelopmentSeederWriter.php new file mode 100644 index 00000000..52bcd6ad --- /dev/null +++ b/src/QuickInstall/Sandbox/DevelopmentSeederWriter.php @@ -0,0 +1,67 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstall\Sandbox; + +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use RuntimeException; + +/** Copies the standalone development fixture package into board runtime state. */ +class DevelopmentSeederWriter +{ + private Project $project; + + public function __construct(Project $project) + { + $this->project = $project; + } + + public function write(string $name): string + { + $source = __DIR__ . '/SeedRuntime'; + $target = $this->project->runtimePath($name) . '/development-seed'; + if (!is_dir($source)) + { + throw new RuntimeException("Development seed runtime missing: $source"); + } + + $this->project->deleteTree($target); + if (!mkdir($target, 0775, true) && !is_dir($target)) + { + throw new RuntimeException("Unable to create development seed runtime: $target"); + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($source, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($iterator as $item) + { + $relative = substr($item->getPathname(), strlen($source) + 1); + $destination = $target . '/' . $relative; + if ($item->isDir()) + { + if (!is_dir($destination) && !mkdir($destination, 0775, true) && !is_dir($destination)) + { + throw new RuntimeException("Unable to create development seed directory: $destination"); + } + continue; + } + + if (!copy($item->getPathname(), $destination)) + { + throw new RuntimeException("Unable to copy development seed file: $destination"); + } + } + + return $target; + } +} diff --git a/src/QuickInstall/Sandbox/SeedPresetCatalog.php b/src/QuickInstall/Sandbox/SeedPresetCatalog.php new file mode 100644 index 00000000..7a234296 --- /dev/null +++ b/src/QuickInstall/Sandbox/SeedPresetCatalog.php @@ -0,0 +1,52 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstall\Sandbox; + +use InvalidArgumentException; + +/** Defines accepted, visible, default, and deprecated fixture presets. */ +class SeedPresetCatalog +{ + /** @return string[] */ + public static function accepted(): array + { + return ['tiny', 'development', 'extension-dev', 'load-test', 'random']; + } + + /** @return string[] */ + public static function visible(): array + { + return ['tiny', 'development', 'load-test', 'random']; + } + + public static function defaultSeed(): string + { + return 'development'; + } + + public static function validate(string $preset): void + { + if (!in_array($preset, self::accepted(), true)) + { + throw new InvalidArgumentException('Preset must be one of: ' . implode(', ', self::visible()) . '.'); + } + } + + public static function isDeprecated(string $preset): bool + { + return $preset === 'extension-dev'; + } + + public static function deprecationMessage(): string + { + return 'Preset extension-dev is deprecated and hidden from preset lists; use development for comprehensive development fixtures.'; + } +} diff --git a/src/QuickInstall/Sandbox/SeedRuntime/ContentBuilder.php b/src/QuickInstall/Sandbox/SeedRuntime/ContentBuilder.php new file mode 100644 index 00000000..00e5c4b4 --- /dev/null +++ b/src/QuickInstall/Sandbox/SeedRuntime/ContentBuilder.php @@ -0,0 +1,484 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstallSeed; + +use RuntimeException; + +class ContentBuilder extends BaseBuilder +{ + private $users; + private $forums; + private $posters; + private $posterIndex = 0; + + public function seed(array $users, array $forums): array + { + $this->users = $users; + $this->forums = $forums; + $this->posters = $users['posters']; + $items = []; + $iconId = $this->firstIcon(); + + $items['announcement'] = $this->createTopic( + $forums['news'], + $users['admin'], + 'Announcement topic', + 'This announcement is shown more prominently than a regular topic in this forum.', + POST_ANNOUNCE + ); + $items['sticky'] = $this->createTopic( + $forums['news'], + $users['admin_2'], + 'Sticky topic', + 'This sticky stays near the top of the topic list so important information is easy to find.', + POST_STICKY + ); + $items['normal'] = $this->createTopic( + $forums['lobby'], + $users['regular'], + 'Normal topic', + 'This is a regular topic with no special status. Members can read it and add replies normally.' + ); + $items['locked'] = $this->createTopic( + $forums['lobby'], + $users['admin'], + 'Locked topic', + 'This topic has been locked. Members can still read it, but they cannot add new replies.' + ); + $this->context->db->sql_query('UPDATE ' . TOPICS_TABLE . ' + SET topic_status = ' . ITEM_LOCKED . ' + WHERE topic_id = ' . $items['locked']['topic_id']); + $items['icon'] = $this->createTopic( + $forums['lobby'], + $users['regular_2'], + 'Topic with icon', + 'This regular topic has an icon beside its title so you can check how topic icons are displayed.', + POST_NORMAL, + ['icon_id' => $iconId] + ); + $items['global'] = $this->createTopic( + $forums['lobby'], + $users['moderator'], + 'Global announcement', + 'This global announcement can appear across the board instead of belonging to only one forum.', + POST_GLOBAL + ); + $items['moved_target'] = $this->createTopic( + $forums['lobby'], + $users['regular_3'], + 'Moved topic destination', + 'This is the destination of a moved topic. The original forum contains a redirect that points here.' + ); + $items['moved_redirect'] = [ + 'topic_id' => $this->createMovedRedirect($forums['read'], $items['moved_target']['topic_id']), + 'post_id' => 0, + ]; + + $sectionNames = [ + 'news' => 'News forum', + 'lobby' => 'Lobby', + 'read' => 'Read forum', + 'unread' => 'Unread forum', + 'parent' => 'Parent forum', + 'locked_forum' => 'Locked forum', + 'password' => 'Password-protected forum', + 'private' => 'Private forum', + 'icon' => 'Forum with icon', + 'child_a' => 'Child subforum A', + 'child_b' => 'Child subforum B', + ]; + foreach ($forums as $key => $forumId) + { + if (in_array($key, ['link', 'empty'], true)) + { + continue; + } + for ($number = 1; $number <= 3; $number++) + { + $this->createTopic( + $forumId, + $this->nextPoster(), + sprintf('%s topic %d', $sectionNames[$key] ?? ucfirst(str_replace('_', ' ', $key)), $number), + sprintf( + 'This is topic %d in the %s. It has a short opening message and works like a regular discussion.', + $number, + $sectionNames[$key] ?? ucfirst(str_replace('_', ' ', $key)) + ) + ); + } + } + + $items += $this->seedRichPosts(); + $items += $this->seedPolls(); + $this->context->setMeta('content', $items); + $this->context->restoreUser(); + return $items; + } + + private function seedRichPosts(): array + { + $items = []; + $items['bbcode'] = $this->createTopic( + $this->forums['lobby'], + $this->users['regular'], + 'BBCode and smiley showcase', + 'This topic includes common BBCode, lists, a code block, a quote, links, and smileys.' + ); + $bbcode = 'This reply shows several formatting options in one place. + +[b]Bold text[/b], [i]italic text[/i], [u]underlined text[/u], and [s]strikethrough text[/s]. +[color=#AA0000]Colored text[/color] and [size=150]larger text[/size]. + +[quote="Sample user"]This quote also includes [url=https://www.phpbb.com/]a link[/url].[/quote] + +[code]function welcome($name) { + return "Welcome, " . $name; +}[/code] + +[list] +[*]First item +[*]Second item +[list=1] +[*]First numbered item +[*]Second numbered item +[/list] +[/list] + +Common smileys: :) :D ;) :( :o :P :lol: :idea: :arrow:'; + $items['bbcode_reply'] = [ + 'topic_id' => $items['bbcode']['topic_id'], + 'post_id' => $this->createReply( + $items['bbcode']['topic_id'], + $this->forums['lobby'], + $this->users['moderator'], + 'BBCode and smiley showcase', + $bbcode, + 'bbcode' + ), + ]; + + $items['quotes'] = $this->createTopic( + $this->forums['lobby'], + $this->users['regular_2'], + 'Deep quote nesting', + 'This topic contains a reply with four quote levels so deeply nested quotes are easy to find.' + ); + $this->createReply( + $items['quotes']['topic_id'], + $this->forums['lobby'], + $this->users['admin'], + 'Deep quote nesting', + '[quote="User one"][quote="User two"][quote="User three"][quote="User four"]Innermost quote.[/quote]Third-level reply.[/quote]Second-level reply.[/quote]First-level reply.[/quote]This text appears after all four quote levels.', + 'quotes' + ); + + $items['overflow'] = $this->createTopic( + $this->forums['lobby'], + $this->users['regular_3'], + 'Long content and overflow', + 'This topic contains a long link and a wide code block so wrapping and horizontal overflow are easy to find.' + ); + $this->createReply( + $items['overflow']['topic_id'], + $this->forums['lobby'], + $this->users['moderator_2'], + 'Long content and overflow', + 'https://example.test/events/summer-community-picnic/registration/this-is-an-intentionally-long-address-without-any-natural-break-points + +[code]------------------------------------------------------------------------------------------------------------------------ +This line is intentionally wide and should remain readable without breaking the surrounding page. +------------------------------------------------------------------------------------------------------------------------[/code]', + 'overflow' + ); + + $items['pagination'] = $this->createTopic( + $this->forums['lobby'], + $this->users['admin'], + 'Pagination topic', + 'This topic has 40 approved replies plus one reply waiting for approval, creating multiple topic pages.' + ); + for ($number = 1; $number <= 40; $number++) + { + $this->createReply( + $items['pagination']['topic_id'], + $this->forums['lobby'], + $this->nextPoster(), + 'Pagination topic', + sprintf('This is pagination reply %d of 40.', $number), + sprintf('page-%02d', $number) + ); + } + $unapproved = $this->createReply( + $items['pagination']['topic_id'], + $this->forums['lobby'], + $this->users['regular_3'], + 'Pagination topic', + 'This reply is waiting for moderator approval.', + 'unapproved' + ); + $this->context->db->sql_query('UPDATE ' . POSTS_TABLE . ' + SET post_visibility = ' . ITEM_UNAPPROVED . ' + WHERE post_id = ' . $unapproved); + $this->context->db->sql_query('UPDATE ' . TOPICS_TABLE . ' + SET topic_posts_approved = CASE WHEN topic_posts_approved > 0 THEN topic_posts_approved - 1 ELSE 0 END, + topic_posts_unapproved = topic_posts_unapproved + 1 + WHERE topic_id = ' . $items['pagination']['topic_id']); + $this->context->addId('unapproved_posts', $unapproved); + $items['unapproved'] = ['topic_id' => $items['pagination']['topic_id'], 'post_id' => $unapproved]; + + return $items; + } + + private function seedPolls(): array + { + $activePoll = [ + 'poll_title' => 'Which board area are you reviewing?', + 'poll_options' => ['Extensions', 'Styles', 'Permissions', 'Notifications'], + 'poll_max_options' => 1, + 'poll_length' => 0, + 'poll_vote_change' => 1, + 'poll_start' => time(), + ]; + $votedPoll = [ + 'poll_title' => 'How much sample content do you prefer?', + 'poll_options' => ['Focused', 'Balanced', 'Dense', 'Maximum'], + 'poll_max_options' => 1, + 'poll_length' => 0, + 'poll_vote_change' => 0, + 'poll_start' => time(), + ]; + + $items = []; + $items['active_poll'] = $this->createTopic( + $this->forums['lobby'], + $this->users['admin'], + 'Active poll', + 'This poll has no votes yet and allows members to change their vote.', + POST_NORMAL, + [], + $activePoll + ); + $items['voted_poll'] = $this->createTopic( + $this->forums['lobby'], + $this->users['admin_2'], + 'Poll with results', + 'This poll already has six votes spread across four choices.', + POST_NORMAL, + [], + $votedPoll + ); + $this->injectVotes($items['voted_poll']['topic_id']); + return $items; + } + + private function injectVotes(int $topicId): void + { + $db = $this->context->db; + $result = $db->sql_query('SELECT poll_option_id FROM ' . POLL_OPTIONS_TABLE . ' + WHERE topic_id = ' . $topicId . ' + ORDER BY poll_option_id ASC'); + $options = []; + while ($row = $db->sql_fetchrow($result)) + { + $options[] = (int) $row['poll_option_id']; + } + $db->sql_freeresult($result); + $voters = array_slice($this->posters, 0, 6); + $distribution = [0, 0, 1, 1, 2, 3]; + $totals = array_fill_keys($options, 0); + foreach ($voters as $index => $userId) + { + $optionId = $options[$distribution[$index]] ?? 0; + if (!$optionId) + { + continue; + } + $db->sql_query('INSERT INTO ' . POLL_VOTES_TABLE . ' ' . $db->sql_build_array('INSERT', [ + 'topic_id' => $topicId, + 'poll_option_id' => $optionId, + 'vote_user_id' => $userId, + 'vote_user_ip' => '127.0.0.1', + ])); + $totals[$optionId]++; + } + foreach ($totals as $optionId => $total) + { + $db->sql_query('UPDATE ' . POLL_OPTIONS_TABLE . ' + SET poll_option_total = ' . $total . ' + WHERE topic_id = ' . $topicId . ' AND poll_option_id = ' . $optionId); + } + $db->sql_query('UPDATE ' . TOPICS_TABLE . ' SET poll_last_vote = ' . time() . ' WHERE topic_id = ' . $topicId); + } + + private function createTopic( + int $forumId, + int $userId, + string $label, + string $message, + int $topicType = POST_NORMAL, + array $additional = [], + array $poll = [] + ): array + { + $db = $this->context->db; + $subject = $this->context->prefix . $label; + $result = $db->sql_query_limit( + 'SELECT topic_id, topic_first_post_id FROM ' . TOPICS_TABLE . " + WHERE topic_title = '" . $db->sql_escape($subject) . "'", + 1 + ); + $row = $db->sql_fetchrow($result); + $db->sql_freeresult($result); + if ($row) + { + $topicId = (int) $row['topic_id']; + $postId = (int) $row['topic_first_post_id']; + $this->context->addId('topics', $topicId); + $this->context->addId('posts', $postId); + return ['topic_id' => $topicId, 'post_id' => $postId]; + } + + $this->context->switchUser($userId); + $data = $this->postData($forumId, 0, $subject, $message); + $data = array_merge($data, $additional); + submit_post('post', $subject, $this->context->user->data['username'], $topicType, $poll, $data); + $topicId = (int) ($data['topic_id'] ?? 0); + $postId = (int) ($data['post_id'] ?? 0); + if (!$topicId || !$postId) + { + throw new RuntimeException("Unable to create development topic: $subject"); + } + $this->context->addId('topics', $topicId); + $this->context->addId('posts', $postId); + return ['topic_id' => $topicId, 'post_id' => $postId]; + } + + private function createReply( + int $topicId, + int $forumId, + int $userId, + string $label, + string $message, + string $key + ): int + { + $db = $this->context->db; + $suffix = strpos($key, 'page-') === 0 ? ' — reply ' . substr($key, 5) : ''; + $subject = $this->context->prefix . 'Re: ' . $label . $suffix; + $result = $db->sql_query_limit( + 'SELECT post_id FROM ' . POSTS_TABLE . " + WHERE topic_id = $topicId + AND post_subject = '" . $db->sql_escape($subject) . "'", + 1 + ); + $postId = (int) $db->sql_fetchfield('post_id'); + $db->sql_freeresult($result); + if ($postId) + { + $this->context->addId('posts', $postId); + return $postId; + } + + $this->context->switchUser($userId); + $data = $this->postData($forumId, $topicId, $subject, $message); + $poll = []; + submit_post('reply', $subject, $this->context->user->data['username'], POST_NORMAL, $poll, $data); + $postId = (int) ($data['post_id'] ?? 0); + if (!$postId) + { + throw new RuntimeException("Unable to create development reply: $subject"); + } + $this->context->addId('posts', $postId); + return $postId; + } + + private function postData(int $forumId, int $topicId, string $subject, string $message): array + { + $db = $this->context->db; + $uid = $bitfield = ''; + $options = 7; + generate_text_for_storage($message, $uid, $bitfield, $options, true, true, true); + $result = $db->sql_query_limit('SELECT forum_name FROM ' . FORUMS_TABLE . ' WHERE forum_id = ' . $forumId, 1); + $forumName = (string) $db->sql_fetchfield('forum_name'); + $db->sql_freeresult($result); + + return [ + 'forum_id' => $forumId, + 'topic_id' => $topicId, + 'icon_id' => 0, + 'topic_title' => $subject, + 'topic_time_limit' => 0, + 'poster_id' => (int) $this->context->user->data['user_id'], + 'enable_bbcode' => true, + 'enable_smilies' => true, + 'enable_urls' => true, + 'enable_sig' => true, + 'message' => $message, + 'message_md5' => md5($message), + 'bbcode_bitfield' => $bitfield, + 'bbcode_uid' => $uid, + 'post_edit_locked' => 0, + 'notify_set' => false, + 'notify' => false, + 'post_time' => time(), + 'forum_name' => $forumName, + 'enable_indexing' => true, + 'force_approved_state' => true, + ]; + } + + private function createMovedRedirect(int $forumId, int $targetTopicId): int + { + $db = $this->context->db; + $subject = $this->context->prefix . 'Moved topic redirect'; + $result = $db->sql_query_limit( + 'SELECT topic_id FROM ' . TOPICS_TABLE . ' + WHERE forum_id = ' . $forumId . ' + AND topic_status = ' . ITEM_MOVED . ' + AND topic_moved_id = ' . $targetTopicId, + 1 + ); + $id = (int) $db->sql_fetchfield('topic_id'); + $db->sql_freeresult($result); + if (!$id) + { + $db->sql_query('INSERT INTO ' . TOPICS_TABLE . ' ' . $db->sql_build_array('INSERT', [ + 'forum_id' => $forumId, + 'icon_id' => 0, + 'topic_title' => $subject, + 'topic_poster' => $this->users['regular_3'], + 'topic_time' => time(), + 'topic_status' => ITEM_MOVED, + 'topic_type' => POST_NORMAL, + 'topic_moved_id' => $targetTopicId, + 'topic_visibility' => ITEM_APPROVED, + ])); + $id = (int) $db->sql_nextid(); + } + $this->context->addId('topics', $id); + return $id; + } + + private function firstIcon(): int + { + $result = $this->context->db->sql_query_limit('SELECT icons_id FROM ' . ICONS_TABLE . ' ORDER BY icons_id ASC', 1); + $id = (int) $this->context->db->sql_fetchfield('icons_id'); + $this->context->db->sql_freeresult($result); + return $id; + } + + private function nextPoster(): int + { + $id = $this->posters[$this->posterIndex % count($this->posters)]; + $this->posterIndex++; + return $id; + } +} diff --git a/src/QuickInstall/Sandbox/SeedRuntime/DevelopmentSeeder.php b/src/QuickInstall/Sandbox/SeedRuntime/DevelopmentSeeder.php new file mode 100644 index 00000000..846485d9 --- /dev/null +++ b/src/QuickInstall/Sandbox/SeedRuntime/DevelopmentSeeder.php @@ -0,0 +1,467 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstallSeed; + +use RuntimeException; + +class DevelopmentSeeder +{ + private $context; + + public function __construct(SeedContext $context) + { + $this->context = $context; + } + + public function run(string $action): void + { + if (!in_array($action, ['seed', 'reset', 'replace'], true)) + { + throw new RuntimeException("Unknown development seed action: $action"); + } + + $hasManifest = $this->context->loadManifest(); + if ($hasManifest) + { + $this->context->reconcileStorageFiles(); + $this->context->saveManifest(); + } + if ($action === 'reset' || $action === 'replace') + { + if (!$hasManifest) + { + $this->discoverExistingFixtures(); + $this->context->reconcileStorageFiles(); + } + $this->context->status('Resetting development fixtures...'); + $this->reset(); + if ($action === 'reset') + { + echo "Reset development seed {$this->context->seed}.\n"; + return; + } + $hasManifest = false; + } + + if ($hasManifest) + { + $this->context->status('Development seed already exists; verifying fixtures...'); + $this->verify(); + return; + } + + $this->context->setMeta('founder_state', $this->founderState()); + $this->context->status('Creating 25 development users and generated avatars...'); + $users = (new UserBuilder($this->context))->seed(); + $this->context->status('Creating development forum scenarios...'); + $forums = (new ForumBuilder($this->context))->seed(); + $this->context->status('Creating topic, post, pagination, and poll scenarios...'); + $content = (new ContentBuilder($this->context))->seed($users, $forums); + $this->context->status('Creating attachments, moderation, UCP, notification, and search states...'); + (new StateBuilder($this->context))->seed($users, $forums, $content); + $this->finalize(); + $this->context->saveManifest(); + $this->verify(); + } + + private function reset(): void + { + $db = $this->context->db; + $this->deleteByIds(NOTIFICATIONS_TABLE, 'notification_id', $this->context->ids('notifications')); + $this->deleteByIds(REPORTS_TABLE, 'report_id', $this->context->ids('reports')); + $this->deleteByIds(DRAFTS_TABLE, 'draft_id', $this->context->ids('drafts')); + $this->deleteByIds(ATTACHMENTS_TABLE, 'attach_id', $this->context->ids('attachments')); + + $messageIds = $this->context->ids('messages'); + $this->deleteByIds(PRIVMSGS_TO_TABLE, 'msg_id', $messageIds); + $this->deleteByIds(PRIVMSGS_TABLE, 'msg_id', $messageIds); + + $topicIds = $this->context->ids('topics'); + $forumIds = array_values(array_unique(array_merge( + $this->context->ids('forums'), + $this->context->ids('categories') + ))); + $founderId = $this->context->founderId(); + if ($topicIds) + { + $db->sql_query('DELETE FROM ' . BOOKMARKS_TABLE . ' + WHERE user_id = ' . $founderId . ' + AND ' . $db->sql_in_set('topic_id', $topicIds)); + $db->sql_query('DELETE FROM ' . TOPICS_WATCH_TABLE . ' + WHERE user_id = ' . $founderId . ' + AND ' . $db->sql_in_set('topic_id', $topicIds)); + delete_topics('topic_id', $topicIds, true, true, true); + } + if ($forumIds) + { + $db->sql_query('DELETE FROM ' . FORUMS_WATCH_TABLE . ' + WHERE user_id = ' . $founderId . ' + AND ' . $db->sql_in_set('forum_id', $forumIds)); + foreach ([ACL_GROUPS_TABLE, ACL_USERS_TABLE, FORUMS_TRACK_TABLE, FORUMS_ACCESS_TABLE] as $table) + { + $db->sql_query("DELETE FROM $table WHERE " . $db->sql_in_set('forum_id', $forumIds)); + } + $db->sql_query('DELETE FROM ' . FORUMS_TABLE . ' WHERE ' . $db->sql_in_set('forum_id', $forumIds)); + $newId = 1; + recalc_nested_sets($newId, 'forum_id', FORUMS_TABLE); + } + + $this->deleteByIds(WARNINGS_TABLE, 'warning_id', $this->context->ids('warnings')); + $this->deleteByIds($this->context->banTable(), 'ban_id', $this->context->ids('bans')); + $userIds = $this->context->ids('users'); + if ($userIds) + { + user_delete('remove', $userIds); + } + + $this->context->deleteRegisteredFiles(); + $this->restoreFounderState(); + $this->syncUserPostCounts(); + $this->syncPrivateMessageCounts(); + $this->finalize(false); + $path = $this->context->manifestPath(); + if (is_file($path) && !unlink($path)) + { + throw new RuntimeException("Unable to delete development seed manifest: $path"); + } + } + + private function deleteByIds(string $table, string $column, array $ids): void + { + if ($ids) + { + $this->context->db->sql_query("DELETE FROM $table WHERE " . $this->context->db->sql_in_set($column, $ids)); + } + } + + private function finalize(bool $markUnread = true): void + { + $db = $this->context->db; + sync('forum', '', '', false, true); + sync('topic'); + $this->syncUserPostCounts(); + + if ($markUnread) + { + $founderId = $this->context->founderId(); + $db->sql_query('UPDATE ' . USERS_TABLE . ' + SET user_lastmark = ' . (time() - 86400) . ', + user_lastvisit = ' . (time() - 86400) . ' + WHERE user_id = ' . $founderId); + } + $this->syncConfigCounts(); + $this->context->auth->acl_clear_prefetch(); + $GLOBALS['cache']->purge(); + } + + private function syncUserPostCounts(): void + { + $db = $this->context->db; + $db->sql_query('UPDATE ' . USERS_TABLE . ' SET user_posts = 0'); + $result = $db->sql_query('SELECT poster_id, COUNT(post_id) AS post_count + FROM ' . POSTS_TABLE . ' + WHERE poster_id > 1 + AND post_postcount = 1 + AND post_visibility = ' . ITEM_APPROVED . ' + GROUP BY poster_id'); + while ($row = $db->sql_fetchrow($result)) + { + $db->sql_query('UPDATE ' . USERS_TABLE . ' + SET user_posts = ' . (int) $row['post_count'] . ' + WHERE user_id = ' . (int) $row['poster_id']); + } + $db->sql_freeresult($result); + } + + private function syncPrivateMessageCounts(): void + { + $db = $this->context->db; + $userIds = array_values(array_unique(array_merge( + [$this->context->founderId()], + $this->context->ids('users') + ))); + foreach ($userIds as $userId) + { + $result = $db->sql_query('SELECT + SUM(CASE WHEN pm_new = 1 THEN 1 ELSE 0 END) AS new_count, + SUM(CASE WHEN pm_unread = 1 THEN 1 ELSE 0 END) AS unread_count + FROM ' . PRIVMSGS_TO_TABLE . ' + WHERE user_id = ' . $userId); + $row = $db->sql_fetchrow($result); + $db->sql_freeresult($result); + $db->sql_query('UPDATE ' . USERS_TABLE . ' + SET user_new_privmsg = ' . (int) ($row['new_count'] ?? 0) . ', + user_unread_privmsg = ' . (int) ($row['unread_count'] ?? 0) . ' + WHERE user_id = ' . $userId); + } + } + + private function syncConfigCounts(): void + { + $db = $this->context->db; + $config = $this->context->config; + $result = $db->sql_query('SELECT COUNT(user_id) AS total FROM ' . USERS_TABLE . ' WHERE user_type <> ' . USER_IGNORE); + $config->set('num_users', (int) $db->sql_fetchfield('total')); + $db->sql_freeresult($result); + $result = $db->sql_query('SELECT COUNT(post_id) AS total FROM ' . POSTS_TABLE . ' WHERE post_visibility = ' . ITEM_APPROVED); + $config->set('num_posts', (int) $db->sql_fetchfield('total')); + $db->sql_freeresult($result); + $result = $db->sql_query('SELECT COUNT(topic_id) AS total FROM ' . TOPICS_TABLE . ' WHERE topic_visibility = ' . ITEM_APPROVED); + $config->set('num_topics', (int) $db->sql_fetchfield('total')); + $db->sql_freeresult($result); + $result = $db->sql_query_limit( + 'SELECT user_id, username, user_colour FROM ' . USERS_TABLE . ' + WHERE user_type <> ' . USER_IGNORE . ' + ORDER BY user_id DESC', + 1 + ); + $newest = $db->sql_fetchrow($result); + $db->sql_freeresult($result); + if ($newest) + { + $config->set('newest_user_id', (int) $newest['user_id']); + $config->set('newest_username', $newest['username']); + $config->set('newest_user_colour', $newest['user_colour']); + } + } + + private function verify(): void + { + $users = $this->context->meta('users', []); + $forums = $this->context->meta('forums', []); + $content = $this->context->meta('content', []); + $messages = $this->context->meta('messages', []); + $ucp = $this->context->meta('ucp', []); + $restrictedGroups = array_filter([ + $this->context->groupId('GUESTS'), + $this->context->groupId('REGISTERED'), + $this->context->groupId('NEWLY_REGISTERED'), + ]); + $checks = [ + '25 users' => $this->existingIdCount(USERS_TABLE, 'user_id', $this->context->ids('users')) === 25, + '25 avatars' => $this->fieldCount(USERS_TABLE, 'user_id', $this->context->ids('users'), "user_avatar <> ''") === 25, + '25 signatures' => $this->fieldCount(USERS_TABLE, 'user_id', $this->context->ids('users'), "user_sig <> ''") === 25, + 'inactive user state' => isset($users['inactive']) && $this->queryCount(USERS_TABLE, 'user_id = ' . (int) $users['inactive'] . ' AND user_type = ' . USER_INACTIVE) === 1, + '4 categories' => $this->existingIdCount(FORUMS_TABLE, 'forum_id', $this->context->ids('categories')) === 4, + '12 forums' => $this->existingIdCount(FORUMS_TABLE, 'forum_id', $this->context->ids('forums')) === 12, + 'password forum uses standard credential' => isset($forums['password']) && $this->forumPasswordMatches((int) $forums['password']), + 'private forum ACL' => isset($forums['private']) && (!$restrictedGroups || $this->queryCount(ACL_GROUPS_TABLE, 'forum_id = ' . (int) $forums['private'] . ' AND ' . $this->context->db->sql_in_set('group_id', $restrictedGroups)) === 0), + 'empty forum state' => isset($forums['empty']) && $this->queryCount(TOPICS_TABLE, 'forum_id = ' . (int) $forums['empty']) === 0, + 'nested subforums' => isset($forums['parent'], $forums['child_a'], $forums['child_b']) && $this->queryCount(FORUMS_TABLE, 'parent_id = ' . (int) $forums['parent'] . ' AND ' . $this->context->db->sql_in_set('forum_id', [(int) $forums['child_a'], (int) $forums['child_b']])) === 2, + '47 topic rows' => $this->existingIdCount(TOPICS_TABLE, 'topic_id', $this->context->ids('topics')) === 47, + '90 posts' => $this->existingIdCount(POSTS_TABLE, 'post_id', $this->context->ids('posts')) === 90, + 'unapproved post' => $this->fieldCount(POSTS_TABLE, 'post_id', $this->context->ids('unapproved_posts'), 'post_visibility = ' . ITEM_UNAPPROVED) === 1, + '2 polls' => $this->fieldCount(TOPICS_TABLE, 'topic_id', $this->context->ids('topics'), "poll_title <> ''") === 2, + '6 poll votes' => isset($content['voted_poll']['topic_id']) && $this->queryCount(POLL_VOTES_TABLE, 'topic_id = ' . (int) $content['voted_poll']['topic_id']) === 6, + '2 attachments' => $this->existingIdCount(ATTACHMENTS_TABLE, 'attach_id', $this->context->ids('attachments')) === 2, + '2 open reports' => $this->existingIdCount(REPORTS_TABLE, 'report_id', $this->context->ids('reports')) === 2, + '9 private messages' => $this->existingIdCount(PRIVMSGS_TABLE, 'msg_id', $this->context->ids('messages')) === 9, + 'read private message' => isset($messages['read']) && $this->queryCount(PRIVMSGS_TO_TABLE, 'msg_id = ' . (int) $messages['read'] . ' AND user_id = ' . $this->context->founderId() . ' AND pm_unread = 0 AND folder_id = ' . PRIVMSGS_INBOX) === 1, + 'sent private message' => isset($messages['sent']) && $this->queryCount(PRIVMSGS_TO_TABLE, 'msg_id = ' . (int) $messages['sent'] . ' AND user_id = ' . $this->context->founderId() . ' AND folder_id = ' . PRIVMSGS_SENTBOX) === 1, + '3 unread notifications' => $this->fieldCount(NOTIFICATIONS_TABLE, 'notification_id', $this->context->ids('notifications'), 'notification_read = 0') === 3, + '2 warnings' => $this->existingIdCount(WARNINGS_TABLE, 'warning_id', $this->context->ids('warnings')) === 2, + '1 active ban' => $this->existingIdCount($this->context->banTable(), 'ban_id', $this->context->ids('bans')) === 1, + '1 saved draft' => $this->existingIdCount(DRAFTS_TABLE, 'draft_id', $this->context->ids('drafts')) === 1, + 'topic bookmark' => isset($ucp['topic_id'], $ucp['user_id']) && $this->queryCount(BOOKMARKS_TABLE, 'topic_id = ' . (int) $ucp['topic_id'] . ' AND user_id = ' . (int) $ucp['user_id']) === 1, + 'topic watch' => isset($ucp['topic_id'], $ucp['user_id']) && $this->queryCount(TOPICS_WATCH_TABLE, 'topic_id = ' . (int) $ucp['topic_id'] . ' AND user_id = ' . (int) $ucp['user_id']) === 1, + 'forum watch' => isset($ucp['forum_id'], $ucp['user_id']) && $this->queryCount(FORUMS_WATCH_TABLE, 'forum_id = ' . (int) $ucp['forum_id'] . ' AND user_id = ' . (int) $ucp['user_id']) === 1, + 'generated files' => $this->existingFileCount() === 28, + 'generated storage files' => !$this->context->usesStorage() + || ($this->context->storageFileCount() === 27 && $this->context->storageFilesExist()), + 'search backend indexed' => (string) $this->context->meta('search_backend', '') !== '', + 'locked topic' => $this->topicStateCount('topic_status', ITEM_LOCKED) >= 1, + 'moved redirect' => $this->topicStateCount('topic_status', ITEM_MOVED) === 1, + 'sticky topic' => $this->topicStateCount('topic_type', POST_STICKY) >= 1, + 'announcement topic' => $this->topicStateCount('topic_type', POST_ANNOUNCE) >= 1, + 'global announcement' => $this->topicStateCount('topic_type', POST_GLOBAL) >= 1, + ]; + $failed = []; + foreach ($checks as $label => $passed) + { + echo ($passed ? '[OK] ' : '[FAIL] ') . $label . "\n"; + if (!$passed) + { + $failed[] = $label; + } + } + if ($failed) + { + throw new RuntimeException('Development fixture verification failed: ' . implode(', ', $failed)); + } + echo "Seeded development preset {$this->context->seed}: 25 users, 12 forums, 47 topic rows, 90 posts.\n"; + } + + private function existingIdCount(string $table, string $column, array $ids): int + { + if (!$ids) + { + return 0; + } + $result = $this->context->db->sql_query("SELECT COUNT($column) AS total FROM $table WHERE " + . $this->context->db->sql_in_set($column, $ids)); + $count = (int) $this->context->db->sql_fetchfield('total'); + $this->context->db->sql_freeresult($result); + return $count; + } + + private function fieldCount(string $table, string $column, array $ids, string $condition): int + { + if (!$ids) + { + return 0; + } + $result = $this->context->db->sql_query("SELECT COUNT($column) AS total FROM $table WHERE " + . $this->context->db->sql_in_set($column, $ids) . " AND $condition"); + $count = (int) $this->context->db->sql_fetchfield('total'); + $this->context->db->sql_freeresult($result); + return $count; + } + + private function topicStateCount(string $column, int $value): int + { + return $this->fieldCount(TOPICS_TABLE, 'topic_id', $this->context->ids('topics'), "$column = $value"); + } + + private function forumPasswordMatches(int $forumId): bool + { + $result = $this->context->db->sql_query_limit( + 'SELECT forum_password FROM ' . FORUMS_TABLE . ' WHERE forum_id = ' . $forumId, + 1 + ); + $password = (string) $this->context->db->sql_fetchfield('forum_password'); + $this->context->db->sql_freeresult($result); + return $password !== '' + && $this->context->container->get('passwords.manager')->check(SeedContext::PASSWORD, $password); + } + + private function queryCount(string $table, string $where): int + { + $result = $this->context->db->sql_query("SELECT COUNT(*) AS total FROM $table WHERE $where"); + $count = (int) $this->context->db->sql_fetchfield('total'); + $this->context->db->sql_freeresult($result); + return $count; + } + + private function existingFileCount(): int + { + return count(array_filter($this->context->manifest['files'] ?? [], 'is_file')); + } + + private function founderState(): array + { + $founderId = $this->context->founderId(); + $result = $this->context->db->sql_query_limit( + 'SELECT user_lastmark, user_lastvisit FROM ' . USERS_TABLE . ' WHERE user_id = ' . $founderId, + 1 + ); + $row = $this->context->db->sql_fetchrow($result) ?: []; + $this->context->db->sql_freeresult($result); + return [ + 'user_id' => $founderId, + 'user_lastmark' => (int) ($row['user_lastmark'] ?? 0), + 'user_lastvisit' => (int) ($row['user_lastvisit'] ?? 0), + ]; + } + + private function restoreFounderState(): void + { + $state = $this->context->meta('founder_state', []); + if (!$state) + { + return; + } + $this->context->db->sql_query('UPDATE ' . USERS_TABLE . ' + SET user_lastmark = ' . (int) $state['user_lastmark'] . ', + user_lastvisit = ' . (int) $state['user_lastvisit'] . ' + WHERE user_id = ' . (int) $state['user_id']); + } + + private function discoverExistingFixtures(): void + { + $db = $this->context->db; + $userPattern = sprintf('qi_dev_%d_user_%%', $this->context->seed); + $this->discoverIds('users', USERS_TABLE, 'user_id', "username LIKE '" . $db->sql_escape($userPattern) . "'"); + $this->discoverIds('categories', FORUMS_TABLE, 'forum_id', "forum_name LIKE '" . $db->sql_escape($this->context->prefix . '%') . "' AND forum_type = " . FORUM_CAT); + $this->discoverIds('forums', FORUMS_TABLE, 'forum_id', "forum_name LIKE '" . $db->sql_escape($this->context->prefix . '%') . "' AND forum_type <> " . FORUM_CAT); + $this->discoverIds('topics', TOPICS_TABLE, 'topic_id', "topic_title LIKE '" . $db->sql_escape($this->context->prefix . '%') . "'"); + $topicIds = $this->context->ids('topics'); + if ($topicIds) + { + $this->discoverIds('posts', POSTS_TABLE, 'post_id', $db->sql_in_set('topic_id', $topicIds)); + } + $messagePattern = $this->context->prefix . '%'; + $this->discoverIds('messages', PRIVMSGS_TABLE, 'msg_id', "message_subject LIKE '" . $db->sql_escape($messagePattern) . "'"); + $this->discoverIds('drafts', DRAFTS_TABLE, 'draft_id', "draft_subject LIKE '" . $db->sql_escape($messagePattern) . "'"); + $this->discoverIds('attachments', ATTACHMENTS_TABLE, 'attach_id', "physical_filename LIKE '" + . $db->sql_escape('qi_dev_' . $this->context->seed . '_%') . "'"); + + $userIds = $this->context->ids('users'); + if ($userIds) + { + $this->discoverIds('warnings', WARNINGS_TABLE, 'warning_id', $db->sql_in_set('user_id', $userIds)); + $this->discoverIds('bans', $this->context->banTable(), 'ban_id', $db->sql_in_set('ban_userid', $userIds)); + } + $postIds = $this->context->ids('posts'); + $messageIds = $this->context->ids('messages'); + $reportClauses = []; + if ($postIds) + { + $reportClauses[] = $db->sql_in_set('post_id', $postIds); + } + if ($messageIds) + { + $reportClauses[] = $db->sql_in_set('pm_id', $messageIds); + } + if ($reportClauses) + { + $this->discoverIds('reports', REPORTS_TABLE, 'report_id', '(' . implode(' OR ', $reportClauses) . ')'); + } + $itemIds = array_values(array_unique(array_merge($postIds, $messageIds))); + if ($itemIds) + { + $this->discoverIds('notifications', NOTIFICATIONS_TABLE, 'notification_id', $db->sql_in_set('item_id', $itemIds)); + } + $this->discoverFiles(); + } + + private function discoverIds(string $type, string $table, string $column, string $where): void + { + $result = $this->context->db->sql_query("SELECT $column FROM $table WHERE $where"); + while ($row = $this->context->db->sql_fetchrow($result)) + { + $this->context->addId($type, (int) $row[$column]); + } + $this->context->db->sql_freeresult($result); + } + + private function discoverFiles(): void + { + $salt = (string) ($this->context->config['avatar_salt'] ?? ''); + $avatarPath = trim((string) ($this->context->config['avatar_path'] ?? 'images/avatars/upload'), '/'); + foreach ($this->context->ids('users') as $userId) + { + $path = $this->context->root . $avatarPath . '/' . $salt . '_' . $userId . '.png'; + if (is_file($path)) + { + $this->context->addFile($path); + } + } + $icon = $this->context->root . 'images/qi-development-' . $this->context->seed . '-forum.png'; + if (is_file($icon)) + { + $this->context->addFile($icon); + } + foreach (glob($this->context->root . 'files/qi_dev_' . $this->context->seed . '_*') ?: [] as $path) + { + $this->context->addFile($path); + } + } +} diff --git a/src/QuickInstall/Sandbox/SeedRuntime/ForumBuilder.php b/src/QuickInstall/Sandbox/SeedRuntime/ForumBuilder.php new file mode 100644 index 00000000..46b7c249 --- /dev/null +++ b/src/QuickInstall/Sandbox/SeedRuntime/ForumBuilder.php @@ -0,0 +1,265 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstallSeed; + +use RuntimeException; + +class ForumBuilder extends BaseBuilder +{ + public function seed(): array + { + $db = $this->context->db; + if (!class_exists('acp_forums')) + { + require_once $this->context->root . 'includes/acp/acp_forums.' . $this->context->phpEx; + } + + $defaultForum = $this->firstForum(FORUM_POST); + if (!$defaultForum) + { + throw new RuntimeException('Development preset requires an existing postable forum.'); + } + + $acp = new \acp_forums(); + $forums = ['news' => $defaultForum]; + $forums['lobby'] = $this->create([ + 'parent_id' => 0, + 'forum_type' => FORUM_POST, + 'forum_name' => $this->context->prefix . 'Lobby', + 'forum_desc' => 'A regular forum with topics, replies, rules, and a mix of post types.', + ], $acp, $defaultForum, 'forums'); + + $categories = [ + 'Forum states' => [ + 'read' => ['Read forum', 'This forum is marked as read for the board founder.'], + 'unread' => ['Unread forum', 'This forum contains unread posts for the board founder.'], + ], + 'Nested forums' => [ + 'parent' => ['Parent forum', 'This forum contains two child forums.'], + ], + 'Locked forums' => [ + 'locked_forum' => ['Locked forum', 'You can read this forum, but you cannot start topics or post replies.', 'status' => ITEM_LOCKED], + ], + 'Special forums' => [ + 'link' => ['Link forum', 'Opening this forum takes you to phpBB.com.', 'type' => FORUM_LINK, 'link' => 'https://www.phpbb.com/'], + 'password' => ['Password-protected forum', 'This forum requires a password. Password: password', 'password' => SeedContext::PASSWORD], + 'private' => ['Private forum', 'Only members with the right permissions can see this forum.'], + 'empty' => ['Empty forum', 'This forum intentionally contains no topics.'], + 'icon' => ['Forum with icon', 'This forum displays a generated image beside its name.', 'image' => $this->forumIcon()], + ], + ]; + $categoryDescriptions = [ + 'Forum states' => 'Forums showing read and unread states.', + 'Nested forums' => 'A parent forum with two child forums.', + 'Locked forums' => 'A forum where new topics and replies are disabled.', + 'Special forums' => 'Forums with links, passwords, permissions, icons, or no topics.', + ]; + + foreach ($categories as $categoryName => $definitions) + { + $categoryId = $this->create([ + 'parent_id' => 0, + 'forum_type' => FORUM_CAT, + 'forum_name' => $this->context->prefix . $categoryName, + 'forum_desc' => $categoryDescriptions[$categoryName], + ], $acp, $defaultForum, 'categories'); + + foreach ($definitions as $key => $definition) + { + $data = [ + 'parent_id' => $categoryId, + 'forum_type' => $definition['type'] ?? FORUM_POST, + 'forum_name' => $this->context->prefix . $definition[0], + 'forum_desc' => $definition[1], + ]; + if (isset($definition['status'])) + { + $data['forum_status'] = $definition['status']; + } + if (isset($definition['link'])) + { + $data['forum_link'] = $definition['link']; + } + if (isset($definition['password'])) + { + $data['forum_password'] = $definition['password']; + $data['forum_password_confirm'] = $definition['password']; + } + if (isset($definition['image'])) + { + $data['forum_image'] = $definition['image']; + } + $forums[$key] = $this->create($data, $acp, $defaultForum, 'forums'); + } + } + + $forums['child_a'] = $this->create([ + 'parent_id' => $forums['parent'], + 'forum_type' => FORUM_POST, + 'forum_name' => $this->context->prefix . 'Child subforum A', + 'forum_desc' => 'The first child forum inside the parent forum.', + ], $acp, $defaultForum, 'forums'); + $forums['child_b'] = $this->create([ + 'parent_id' => $forums['parent'], + 'forum_type' => FORUM_POST, + 'forum_name' => $this->context->prefix . 'Child subforum B', + 'forum_desc' => 'The second child forum inside the parent forum.', + ], $acp, $defaultForum, 'forums'); + + $this->makePrivate($forums['private']); + $this->addRules([$forums['lobby'], $forums['read'], $forums['unread']]); + $this->markRead($forums['read']); + $this->context->setMeta('forums', $forums); + $this->context->auth->acl_clear_prefetch(); + return $forums; + } + + private function firstForum(int $type): int + { + $result = $this->context->db->sql_query_limit( + 'SELECT forum_id FROM ' . FORUMS_TABLE . ' WHERE forum_type = ' . $type . ' ORDER BY forum_id ASC', + 1 + ); + $id = (int) $this->context->db->sql_fetchfield('forum_id'); + $this->context->db->sql_freeresult($result); + return $id; + } + + private function create(array $data, \acp_forums $acp, int $permissionSource, string $manifestType): int + { + $db = $this->context->db; + $result = $db->sql_query_limit( + 'SELECT forum_id FROM ' . FORUMS_TABLE . " + WHERE forum_name = '" . $db->sql_escape($data['forum_name']) . "'", + 1 + ); + $id = (int) $db->sql_fetchfield('forum_id'); + $db->sql_freeresult($result); + if (!$id) + { + $forumData = array_merge([ + 'parent_id' => 0, + 'forum_type' => FORUM_POST, + 'forum_status' => ITEM_UNLOCKED, + 'forum_parents' => '', + 'forum_options' => 0, + 'forum_name' => '', + 'forum_link' => '', + 'forum_link_track' => false, + 'forum_desc' => '', + 'forum_desc_uid' => '', + 'forum_desc_options' => 7, + 'forum_desc_bitfield' => '', + 'forum_rules' => '', + 'forum_rules_uid' => '', + 'forum_rules_options' => 7, + 'forum_rules_bitfield' => '', + 'forum_rules_link' => '', + 'forum_image' => '', + 'forum_style' => 0, + 'forum_password' => '', + 'forum_password_confirm' => '', + 'display_subforum_list' => true, + 'display_on_index' => true, + 'forum_topics_per_page' => 0, + 'enable_indexing' => true, + 'enable_icons' => true, + 'enable_prune' => false, + 'enable_post_review' => true, + 'enable_quick_reply' => true, + 'prune_days' => 7, + 'prune_viewed' => 7, + 'prune_freq' => 1, + 'prune_old_polls' => false, + 'prune_announce' => false, + 'prune_sticky' => false, + 'forum_password_unset' => false, + 'show_active' => 1, + ], $data); + generate_text_for_storage( + $forumData['forum_desc'], + $forumData['forum_desc_uid'], + $forumData['forum_desc_bitfield'], + $forumData['forum_desc_options'], + false, + false, + false + ); + $errors = $acp->update_forum_data($forumData); + if ($errors) + { + throw new RuntimeException('Unable to create development forum: ' . implode('; ', $errors)); + } + $id = (int) $forumData['forum_id']; + copy_forum_permissions($permissionSource, $id); + } + $this->context->addId($manifestType, $id); + return $id; + } + + private function forumIcon(): string + { + $relative = 'images/qi-development-' . $this->context->seed . '-forum.png'; + $path = $this->context->root . $relative; + if (file_put_contents($path, AssetFactory::png(96, 96, $this->context->seed + 9000), LOCK_EX) === false) + { + throw new RuntimeException("Unable to write generated forum icon: $path"); + } + $this->context->addFile($path); + return $relative; + } + + private function makePrivate(int $forumId): void + { + $groupIds = array_filter([ + $this->context->groupId('GUESTS'), + $this->context->groupId('REGISTERED'), + $this->context->groupId('NEWLY_REGISTERED'), + ]); + if ($groupIds) + { + $this->context->db->sql_query('DELETE FROM ' . ACL_GROUPS_TABLE . ' + WHERE forum_id = ' . $forumId . ' + AND ' . $this->context->db->sql_in_set('group_id', $groupIds)); + } + } + + private function addRules(array $forumIds): void + { + $db = $this->context->db; + $text = '[b]Sample forum rules[/b] Be kind, stay on topic, and read the [url=https://www.phpbb.com/rules/]full rules[/url] before posting.'; + $uid = $bitfield = ''; + $options = 7; + generate_text_for_storage($text, $uid, $bitfield, $options, true, true, true); + foreach ($forumIds as $forumId) + { + $db->sql_query('UPDATE ' . FORUMS_TABLE . " + SET forum_rules = '" . $db->sql_escape($text) . "', + forum_rules_uid = '" . $db->sql_escape($uid) . "', + forum_rules_bitfield = '" . $db->sql_escape($bitfield) . "', + forum_rules_options = $options + WHERE forum_id = " . (int) $forumId); + } + } + + private function markRead(int $forumId): void + { + $db = $this->context->db; + $founderId = $this->context->founderId(); + $db->sql_query('DELETE FROM ' . FORUMS_TRACK_TABLE . ' + WHERE user_id = ' . $founderId . ' AND forum_id = ' . $forumId); + $db->sql_query('INSERT INTO ' . FORUMS_TRACK_TABLE . ' ' . $db->sql_build_array('INSERT', [ + 'user_id' => $founderId, + 'forum_id' => $forumId, + 'mark_time' => time() + 3600, + ])); + } +} diff --git a/src/QuickInstall/Sandbox/SeedRuntime/SeedContext.php b/src/QuickInstall/Sandbox/SeedRuntime/SeedContext.php new file mode 100644 index 00000000..538c1ee6 --- /dev/null +++ b/src/QuickInstall/Sandbox/SeedRuntime/SeedContext.php @@ -0,0 +1,458 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstallSeed; + +use RuntimeException; + +class SeedContext +{ + public const PASSWORD = 'password'; + + public $db; + public $user; + public $auth; + public $config; + public $container; + public $root; + public $phpEx; + public $seed; + public $prefix; + public $manifest; + private $originalUser; + + public function __construct($db, $user, $auth, $config, $container, string $root, string $phpEx, int $seed) + { + $this->db = $db; + $this->user = $user; + $this->auth = $auth; + $this->config = $config; + $this->container = $container; + $this->root = rtrim($root, '/') . '/'; + $this->phpEx = $phpEx; + $this->seed = $seed; + $this->prefix = sprintf('[QI %d] ', $seed); + $this->manifest = [ + 'version' => 2, + 'preset' => 'development', + 'seed' => $seed, + 'created_at' => gmdate('c'), + 'ids' => [], + 'files' => [], + 'storage_files' => [], + 'meta' => [], + ]; + $this->originalUser = $user->data; + } + + public function status(string $message): void + { + echo $message . "\n"; + flush(); + } + + public function manifestPath(): string + { + return $this->root . 'store/qi-development-seed-' . $this->seed . '.json'; + } + + public function loadManifest(): bool + { + $path = $this->manifestPath(); + if (!is_file($path)) + { + return false; + } + + $data = json_decode((string) file_get_contents($path), true); + if (!is_array($data) || ($data['preset'] ?? '') !== 'development' || (int) ($data['seed'] ?? 0) !== $this->seed) + { + throw new RuntimeException("Invalid development seed manifest: $path"); + } + $data['version'] = 2; + $data['storage_files'] = $data['storage_files'] ?? []; + $this->manifest = $data; + return true; + } + + public function saveManifest(): void + { + $path = $this->manifestPath(); + $directory = dirname($path); + if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) + { + throw new RuntimeException("Unable to create seed manifest directory: $directory"); + } + + $json = json_encode($this->manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($json === false || file_put_contents($path, $json . "\n", LOCK_EX) === false) + { + throw new RuntimeException("Unable to write development seed manifest: $path"); + } + } + + public function addId(string $type, int $id): void + { + if ($id < 1) + { + return; + } + if (!isset($this->manifest['ids'][$type])) + { + $this->manifest['ids'][$type] = []; + } + $this->manifest['ids'][$type][] = $id; + $this->manifest['ids'][$type] = array_values(array_unique(array_map('intval', $this->manifest['ids'][$type]))); + } + + public function ids(string $type): array + { + return array_values(array_unique(array_map('intval', $this->manifest['ids'][$type] ?? []))); + } + + public function addFile(string $path): void + { + $path = str_replace('\\', '/', $path); + if (strpos($path, $this->root) !== 0) + { + throw new RuntimeException("Refusing to register seed file outside phpBB root: $path"); + } + $this->manifest['files'][] = $path; + $this->manifest['files'] = array_values(array_unique($this->manifest['files'])); + } + + public function writeFile(string $storageName, string $relativePath, string $absolutePath, string $content): void + { + $serviceName = 'storage.' . $storageName; + if ($this->container->has($serviceName)) + { + $storage = $this->container->get($serviceName); + if (!$storage->exists($relativePath)) + { + $stream = fopen('php://temp', 'w+b'); + if ($stream === false || fwrite($stream, $content) !== strlen($content) || rewind($stream) === false) + { + if (is_resource($stream)) + { + fclose($stream); + } + throw new RuntimeException("Unable to prepare generated storage file: $relativePath"); + } + try + { + $storage->write($relativePath, $stream); + } + finally + { + fclose($stream); + } + } + $this->addStorageFile($storageName, $relativePath); + } + else if (file_put_contents($absolutePath, $content, LOCK_EX) === false) + { + throw new RuntimeException("Unable to write generated file: $absolutePath"); + } + + $this->addFile($absolutePath); + } + + public function storageDirectory(string $storageName, string $fallback): string + { + return trim((string) ($this->config['storage\\' . $storageName . '\\config\\path'] ?? $fallback), '/'); + } + + public function reconcileStorageFiles(): void + { + $paths = [ + 'attachment' => $this->storageDirectory('attachment', (string) ($this->config['upload_path'] ?? 'files')), + 'avatar' => $this->storageDirectory('avatar', (string) ($this->config['avatar_path'] ?? 'images/avatars/upload')), + ]; + foreach ($this->manifest['files'] ?? [] as $absolutePath) + { + $absolutePath = str_replace('\\', '/', (string) $absolutePath); + foreach ($paths as $storageName => $directory) + { + $prefix = $this->root . $directory . '/'; + if (strpos($absolutePath, $prefix) !== 0) + { + continue; + } + $this->trackStorageFile($storageName, substr($absolutePath, strlen($prefix)), $absolutePath); + } + } + } + + public function storageFilesExist(): bool + { + foreach ($this->manifest['storage_files'] ?? [] as $file) + { + $storageName = (string) ($file['storage'] ?? ''); + $relativePath = (string) ($file['path'] ?? ''); + $serviceName = 'storage.' . $storageName; + if (!$this->container->has($serviceName) + || !$this->container->get($serviceName)->exists($relativePath) + || ($this->isLocalStorage($storageName) && !is_file($this->storagePath($storageName, $relativePath)))) + { + return false; + } + } + return true; + } + + public function storageFileCount(): int + { + return count($this->manifest['storage_files'] ?? []); + } + + public function usesStorage(): bool + { + return $this->container->has('storage.attachment') || $this->container->has('storage.avatar'); + } + + private function addStorageFile(string $storageName, string $relativePath): void + { + $file = [ + 'storage' => $storageName, + 'path' => $relativePath, + ]; + foreach ($this->manifest['storage_files'] ?? [] as $registered) + { + if ($registered === $file) + { + return; + } + } + $this->manifest['storage_files'][] = $file; + } + + private function trackStorageFile(string $storageName, string $relativePath, string $absolutePath): void + { + $serviceName = 'storage.' . $storageName; + if (!$this->container->has($serviceName)) + { + return; + } + $storage = $this->container->get($serviceName); + if (!$storage->exists($relativePath)) + { + if (!is_file($absolutePath) || !$this->container->has('storage.file_tracker')) + { + throw new RuntimeException("Unable to register generated storage file: $relativePath"); + } + $this->container->get('storage.file_tracker')->track_file($storageName, $relativePath, (int) filesize($absolutePath)); + } + $this->addStorageFile($storageName, $relativePath); + } + + private function isLocalStorage(string $storageName): bool + { + return (string) ($this->config['storage\\' . $storageName . '\\provider'] ?? '') === 'phpbb\\storage\\provider\\local'; + } + + private function storagePath(string $storageName, string $relativePath): string + { + $directory = trim((string) ($this->config['storage\\' . $storageName . '\\config\\path'] ?? ''), '/'); + return $this->root . ($directory !== '' ? $directory . '/' : '') . $relativePath; + } + + public function setMeta(string $name, $value): void + { + $this->manifest['meta'][$name] = $value; + } + + public function meta(string $name, $default = null) + { + return $this->manifest['meta'][$name] ?? $default; + } + + public function groupId(string $name): int + { + $sql = 'SELECT group_id FROM ' . GROUPS_TABLE . " + WHERE group_name = '" . $this->db->sql_escape($name) . "'"; + $result = $this->db->sql_query_limit($sql, 1); + $id = (int) $this->db->sql_fetchfield('group_id'); + $this->db->sql_freeresult($result); + return $id; + } + + public function founderId(): int + { + $result = $this->db->sql_query_limit( + 'SELECT user_id FROM ' . USERS_TABLE . ' WHERE user_type = ' . USER_FOUNDER . ' ORDER BY user_id ASC', + 1 + ); + $id = (int) $this->db->sql_fetchfield('user_id'); + $this->db->sql_freeresult($result); + return $id ?: 2; + } + + public function banTable(): string + { + if (defined('BANS_TABLE')) + { + return constant('BANS_TABLE'); + } + if (defined('BANLIST_TABLE')) + { + return constant('BANLIST_TABLE'); + } + throw new RuntimeException('Unable to resolve the phpBB bans table.'); + } + + public function usesModernBans(): bool + { + return defined('BANS_TABLE'); + } + + public function switchUser(int $userId): void + { + $result = $this->db->sql_query_limit('SELECT * FROM ' . USERS_TABLE . ' WHERE user_id = ' . $userId, 1); + $row = $this->db->sql_fetchrow($result); + $this->db->sql_freeresult($result); + if (!$row) + { + throw new RuntimeException("Unable to switch seed user context: $userId"); + } + + $this->user->data = array_merge($this->user->data, $row); + $this->user->data['is_registered'] = true; + $this->auth->acl($this->user->data); + } + + public function restoreUser(): void + { + $this->user->data = $this->originalUser; + $this->auth->acl($this->user->data); + } + + public function deleteRegisteredFiles(): void + { + foreach (array_reverse($this->manifest['storage_files'] ?? []) as $file) + { + $serviceName = 'storage.' . ($file['storage'] ?? ''); + $relativePath = (string) ($file['path'] ?? ''); + if ($relativePath !== '' && $this->container->has($serviceName)) + { + $storage = $this->container->get($serviceName); + if ($storage->exists($relativePath)) + { + $storage->delete($relativePath); + } + } + } + foreach (array_reverse($this->manifest['files'] ?? []) as $path) + { + $path = str_replace('\\', '/', (string) $path); + if (strpos($path, $this->root) !== 0) + { + continue; + } + if (is_file($path) && !unlink($path)) + { + throw new RuntimeException("Unable to delete development seed file: $path"); + } + } + } +} + +abstract class BaseBuilder +{ + protected $context; + + public function __construct(SeedContext $context) + { + $this->context = $context; + } + + protected function rowExists(string $table, string $column, int $id): bool + { + $result = $this->context->db->sql_query_limit( + "SELECT $column FROM $table WHERE $column = $id", + 1 + ); + $exists = (bool) $this->context->db->sql_fetchrow($result); + $this->context->db->sql_freeresult($result); + return $exists; + } +} + +class AssetFactory +{ + public static function png(int $width, int $height, int $seed): string + { + $raw = ''; + $r1 = 35 + (($seed * 37) % 190); + $g1 = 35 + (($seed * 59) % 190); + $b1 = 35 + (($seed * 71) % 190); + $r2 = 255 - $r1; + $g2 = 255 - $g1; + $b2 = 255 - $b1; + + for ($y = 0; $y < $height; $y++) + { + $raw .= "\x00"; + for ($x = 0; $x < $width; $x++) + { + $cellX = (int) floor($x * 5 / max(1, $width)); + $cellY = (int) floor($y * 5 / max(1, $height)); + $mirrorX = min($cellX, 4 - $cellX); + $foreground = (($mirrorX * 11 + $cellY * 7 + $seed) % 3) !== 0; + $raw .= chr($foreground ? $r1 : $r2); + $raw .= chr($foreground ? $g1 : $g2); + $raw .= chr($foreground ? $b1 : $b2); + } + } + + $header = pack('NNCCCCC', $width, $height, 8, 2, 0, 0, 0); + return "\x89PNG\r\n\x1a\n" + . self::chunk('IHDR', $header) + . self::chunk('IDAT', gzcompress($raw, 9)) + . self::chunk('IEND', ''); + } + + public static function zip(int $seed): string + { + if (!class_exists('ZipArchive')) + { + throw new RuntimeException('Development attachment generation requires ZipArchive.'); + } + + $path = tempnam(sys_get_temp_dir(), 'qi-dev-'); + if ($path === false) + { + throw new RuntimeException('Unable to create temporary development ZIP.'); + } + + $zip = new \ZipArchive(); + if ($zip->open($path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) + { + unlink($path); + throw new RuntimeException('Unable to create development ZIP.'); + } + $zip->addFromString('README.txt', "QuickInstall sample archive\n\nThis ZIP file is attached by the development seed.\nSeed: $seed\n"); + $zip->addFromString('sample/example-data.txt', "This is a plain-text file inside the sample archive.\n"); + $zip->addFromString('unicode/café.txt', "This filename includes a Unicode character.\n"); + $zip->close(); + + $content = file_get_contents($path); + unlink($path); + if ($content === false) + { + throw new RuntimeException('Unable to read generated development ZIP.'); + } + return $content; + } + + private static function chunk(string $type, string $data): string + { + return pack('N', strlen($data)) . $type . $data . pack('N', crc32($type . $data)); + } +} diff --git a/src/QuickInstall/Sandbox/SeedRuntime/StateBuilder.php b/src/QuickInstall/Sandbox/SeedRuntime/StateBuilder.php new file mode 100644 index 00000000..bd20c010 --- /dev/null +++ b/src/QuickInstall/Sandbox/SeedRuntime/StateBuilder.php @@ -0,0 +1,488 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstallSeed; + +use RuntimeException; + +class StateBuilder extends BaseBuilder +{ + public function seed(array $users, array $forums, array $content): void + { + $this->seedAttachments($content['bbcode']['post_id']); + $this->reportPost($content['bbcode_reply']['post_id'], $users['regular']); + $messageIds = $this->seedPrivateMessages($users); + $this->seedUcpState($forums['lobby'], $content['normal']['topic_id']); + $this->seedNotifications($users, $forums, $content, $messageIds); + $this->refreshSearchIndex(); + $this->context->restoreUser(); + } + + private function seedAttachments(int $postId): void + { + $db = $this->context->db; + $result = $db->sql_query_limit( + 'SELECT topic_id, poster_id FROM ' . POSTS_TABLE . ' WHERE post_id = ' . $postId, + 1 + ); + $post = $db->sql_fetchrow($result); + $db->sql_freeresult($result); + if (!$post) + { + throw new RuntimeException('Attachment target post is unavailable.'); + } + + $definitions = [ + [ + 'real' => sprintf('sample-image-%d.png', $this->context->seed), + 'extension' => 'png', + 'mime' => 'image/png', + 'comment' => 'Sample PNG image attachment.', + 'content' => AssetFactory::png(640, 360, $this->context->seed + 12000), + ], + [ + 'real' => sprintf('sample-archive-%d.zip', $this->context->seed), + 'extension' => 'zip', + 'mime' => 'application/zip', + 'comment' => 'Sample ZIP archive attachment.', + 'content' => AssetFactory::zip($this->context->seed), + ], + ]; + + $directory = $this->context->root . $this->context->storageDirectory( + 'attachment', + (string) ($this->context->config['upload_path'] ?? 'files') + ) . '/'; + if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) + { + throw new RuntimeException("Unable to create attachment directory: $directory"); + } + + foreach ($definitions as $index => $definition) + { + $physical = sprintf('qi_dev_%d_%s', $this->context->seed, hash('sha256', $definition['real'])); + $result = $db->sql_query_limit( + 'SELECT attach_id FROM ' . ATTACHMENTS_TABLE . " + WHERE physical_filename = '" . $db->sql_escape($physical) . "'", + 1 + ); + $attachmentId = (int) $db->sql_fetchfield('attach_id'); + $db->sql_freeresult($result); + $path = $directory . $physical; + $this->context->writeFile('attachment', $physical, $path, $definition['content']); + + if (!$attachmentId) + { + $db->sql_query('INSERT INTO ' . ATTACHMENTS_TABLE . ' ' . $db->sql_build_array('INSERT', [ + 'post_msg_id' => $postId, + 'topic_id' => (int) $post['topic_id'], + 'in_message' => 0, + 'poster_id' => (int) $post['poster_id'], + 'is_orphan' => 0, + 'physical_filename' => $physical, + 'real_filename' => $definition['real'], + 'download_count' => 7 + $index, + 'attach_comment' => $definition['comment'], + 'extension' => $definition['extension'], + 'mimetype' => $definition['mime'], + 'filesize' => strlen($definition['content']), + 'filetime' => time(), + 'thumbnail' => 0, + ])); + $attachmentId = (int) $db->sql_nextid(); + } + $this->context->addId('attachments', $attachmentId); + } + $db->sql_query('UPDATE ' . POSTS_TABLE . ' SET post_attachment = 1 WHERE post_id = ' . $postId); + } + + private function reportPost(int $postId, int $reporterId): void + { + $db = $this->context->db; + $result = $db->sql_query_limit('SELECT report_id FROM ' . REPORTS_TABLE . ' WHERE post_id = ' . $postId, 1); + $reportId = (int) $db->sql_fetchfield('report_id'); + $db->sql_freeresult($result); + if ($reportId) + { + $this->context->addId('reports', $reportId); + return; + } + + $result = $db->sql_query_limit( + 'SELECT topic_id, post_text, bbcode_uid, bbcode_bitfield, enable_bbcode, enable_smilies, enable_magic_url + FROM ' . POSTS_TABLE . ' WHERE post_id = ' . $postId, + 1 + ); + $post = $db->sql_fetchrow($result); + $db->sql_freeresult($result); + $reasonId = $this->reportReasonId(); + $db->sql_query('INSERT INTO ' . REPORTS_TABLE . ' ' . $db->sql_build_array('INSERT', [ + 'reason_id' => $reasonId, + 'post_id' => $postId, + 'pm_id' => 0, + 'user_id' => $reporterId, + 'user_notify' => 0, + 'report_closed' => 0, + 'report_time' => time(), + 'report_text' => 'This reply has been reported for moderator review.', + 'reported_post_text' => $post['post_text'], + 'reported_post_uid' => $post['bbcode_uid'], + 'reported_post_bitfield' => $post['bbcode_bitfield'], + 'reported_post_enable_bbcode' => $post['enable_bbcode'], + 'reported_post_enable_smilies' => $post['enable_smilies'], + 'reported_post_enable_magic_url' => $post['enable_magic_url'], + ])); + $reportId = (int) $db->sql_nextid(); + $this->context->addId('reports', $reportId); + $db->sql_query('UPDATE ' . POSTS_TABLE . ' SET post_reported = 1 WHERE post_id = ' . $postId); + $db->sql_query('UPDATE ' . TOPICS_TABLE . ' SET topic_reported = 1 WHERE topic_id = ' . (int) $post['topic_id']); + } + + private function seedPrivateMessages(array $users): array + { + $founder = $this->context->founderId(); + $messages = []; + $this->context->switchUser($users['admin']); + $messages['unread'] = $this->sendMessage( + $founder, + 'Unread private message', + 'This message has not been opened yet, so it should appear as unread in the inbox.' + ); + $this->context->switchUser($users['moderator']); + $messages['read'] = $this->sendMessage( + $founder, + 'Read private message', + 'This message has already been opened and should appear as read in the inbox.' + ); + $this->markMessageRead($messages['read'], $founder); + $this->context->switchUser($users['regular_2']); + $messages['reported'] = $this->sendMessage( + $founder, + 'Reported private message', + 'This message has an open report attached and should appear in the moderator queue.' + ); + $this->reportMessage($messages['reported'], $users['regular']); + $this->context->switchUser($founder); + $messages['sent'] = $this->sendMessage( + $users['regular'], + 'Sent private message', + 'This message was sent by the board founder and should appear in the sent messages folder.' + ); + $this->markMessageRead($messages['sent'], $users['regular']); + + for ($index = 0; $index < 5; $index++) + { + $sender = $users['posters'][$index]; + $recipient = $users['posters'][$index + 8]; + $this->context->switchUser($sender); + $messages['peer_' . $index] = $this->sendMessage( + $recipient, + sprintf('Peer private message %d', $index + 1), + 'This is a private conversation between two generated users.' + ); + } + $this->context->setMeta('messages', $messages); + return $messages; + } + + private function sendMessage(int $recipientId, string $label, string $message): int + { + $db = $this->context->db; + $subject = $this->context->prefix . $label; + $authorId = (int) $this->context->user->data['user_id']; + $result = $db->sql_query_limit( + 'SELECT msg_id FROM ' . PRIVMSGS_TABLE . " + WHERE author_id = $authorId + AND message_subject = '" . $db->sql_escape($subject) . "'", + 1 + ); + $messageId = (int) $db->sql_fetchfield('msg_id'); + $db->sql_freeresult($result); + if (!$messageId) + { + $uid = $bitfield = ''; + $options = 7; + generate_text_for_storage($message, $uid, $bitfield, $options, true, true, true); + $data = [ + 'address_list' => ['u' => [$recipientId => 'to']], + 'from_user_id' => $authorId, + 'from_user_ip' => '127.0.0.1', + 'from_username' => $this->context->user->data['username'], + 'enable_sig' => true, + 'enable_bbcode' => true, + 'enable_smilies' => true, + 'enable_urls' => true, + 'icon_id' => 0, + 'bbcode_uid' => $uid, + 'bbcode_bitfield' => $bitfield, + 'message' => $message, + ]; + submit_pm('post', $subject, $data, true); + $messageId = (int) ($data['msg_id'] ?? 0); + } + if (!$messageId) + { + throw new RuntimeException("Unable to create development private message: $subject"); + } + $this->context->addId('messages', $messageId); + return $messageId; + } + + private function markMessageRead(int $messageId, int $userId): void + { + $db = $this->context->db; + $db->sql_query('UPDATE ' . PRIVMSGS_TO_TABLE . ' + SET pm_unread = 0, pm_new = 0, folder_id = ' . PRIVMSGS_INBOX . ' + WHERE msg_id = ' . $messageId . ' AND user_id = ' . $userId); + $db->sql_query('UPDATE ' . PRIVMSGS_TO_TABLE . ' + SET folder_id = ' . PRIVMSGS_SENTBOX . ' + WHERE msg_id = ' . $messageId . ' AND folder_id = ' . PRIVMSGS_OUTBOX); + $db->sql_query('UPDATE ' . USERS_TABLE . ' + SET user_new_privmsg = CASE WHEN user_new_privmsg > 0 THEN user_new_privmsg - 1 ELSE 0 END, + user_unread_privmsg = CASE WHEN user_unread_privmsg > 0 THEN user_unread_privmsg - 1 ELSE 0 END + WHERE user_id = ' . $userId); + } + + private function reportMessage(int $messageId, int $reporterId): void + { + $db = $this->context->db; + $result = $db->sql_query_limit('SELECT report_id FROM ' . REPORTS_TABLE . ' WHERE pm_id = ' . $messageId, 1); + $reportId = (int) $db->sql_fetchfield('report_id'); + $db->sql_freeresult($result); + if ($reportId) + { + $this->context->addId('reports', $reportId); + return; + } + $result = $db->sql_query_limit( + 'SELECT message_text, bbcode_uid, bbcode_bitfield, enable_bbcode, enable_smilies, enable_magic_url + FROM ' . PRIVMSGS_TABLE . ' WHERE msg_id = ' . $messageId, + 1 + ); + $message = $db->sql_fetchrow($result); + $db->sql_freeresult($result); + $db->sql_query('INSERT INTO ' . REPORTS_TABLE . ' ' . $db->sql_build_array('INSERT', [ + 'reason_id' => $this->reportReasonId(), + 'post_id' => 0, + 'pm_id' => $messageId, + 'user_id' => $reporterId, + 'user_notify' => 0, + 'report_closed' => 0, + 'report_time' => time(), + 'report_text' => 'This private message has been reported for moderator review.', + 'reported_post_text' => $message['message_text'], + 'reported_post_uid' => $message['bbcode_uid'], + 'reported_post_bitfield' => $message['bbcode_bitfield'], + 'reported_post_enable_bbcode' => $message['enable_bbcode'], + 'reported_post_enable_smilies' => $message['enable_smilies'], + 'reported_post_enable_magic_url' => $message['enable_magic_url'], + ])); + $reportId = (int) $db->sql_nextid(); + $this->context->addId('reports', $reportId); + $db->sql_query('UPDATE ' . PRIVMSGS_TABLE . ' SET message_reported = 1 WHERE msg_id = ' . $messageId); + } + + private function reportReasonId(): int + { + $result = $this->context->db->sql_query_limit( + 'SELECT reason_id FROM ' . REPORTS_REASONS_TABLE . ' ORDER BY reason_order ASC', + 1 + ); + $id = (int) $this->context->db->sql_fetchfield('reason_id'); + $this->context->db->sql_freeresult($result); + return $id ?: 1; + } + + private function seedUcpState(int $forumId, int $topicId): void + { + $db = $this->context->db; + $userId = $this->context->founderId(); + $this->insertIfMissing( + BOOKMARKS_TABLE, + 'topic_id = ' . $topicId . ' AND user_id = ' . $userId, + ['topic_id' => $topicId, 'user_id' => $userId] + ); + $this->insertIfMissing( + TOPICS_WATCH_TABLE, + 'topic_id = ' . $topicId . ' AND user_id = ' . $userId, + ['topic_id' => $topicId, 'user_id' => $userId, 'notify_status' => 0] + ); + $this->insertIfMissing( + FORUMS_WATCH_TABLE, + 'forum_id = ' . $forumId . ' AND user_id = ' . $userId, + ['forum_id' => $forumId, 'user_id' => $userId, 'notify_status' => 0] + ); + + $subject = $this->context->prefix . 'Saved draft'; + $result = $db->sql_query_limit( + 'SELECT draft_id FROM ' . DRAFTS_TABLE . " + WHERE user_id = $userId AND draft_subject = '" . $db->sql_escape($subject) . "'", + 1 + ); + $draftId = (int) $db->sql_fetchfield('draft_id'); + $db->sql_freeresult($result); + if (!$draftId) + { + $db->sql_query('INSERT INTO ' . DRAFTS_TABLE . ' ' . $db->sql_build_array('INSERT', [ + 'user_id' => $userId, + 'topic_id' => 0, + 'forum_id' => $forumId, + 'save_time' => time(), + 'draft_subject' => $subject, + 'draft_message' => 'This draft has been saved but not posted.', + ])); + $draftId = (int) $db->sql_nextid(); + } + $this->context->addId('drafts', $draftId); + $this->context->setMeta('ucp', ['user_id' => $userId, 'forum_id' => $forumId, 'topic_id' => $topicId]); + } + + private function insertIfMissing(string $table, string $where, array $row): void + { + $db = $this->context->db; + $result = $db->sql_query_limit("SELECT * FROM $table WHERE $where", 1); + $exists = (bool) $db->sql_fetchrow($result); + $db->sql_freeresult($result); + if (!$exists) + { + $db->sql_query("INSERT INTO $table " . $db->sql_build_array('INSERT', $row)); + } + } + + private function seedNotifications(array $users, array $forums, array $content, array $messages): void + { + $db = $this->context->db; + $result = $db->sql_query('SELECT notification_type_id, notification_type_name FROM ' . NOTIFICATION_TYPES_TABLE); + $types = []; + while ($row = $db->sql_fetchrow($result)) + { + $types[$row['notification_type_name']] = (int) $row['notification_type_id']; + } + $db->sql_freeresult($result); + $recipient = $this->context->founderId(); + $definitions = [ + [ + 'type' => 'notification.type.post', + 'item' => $content['normal']['post_id'], + 'parent' => $content['normal']['topic_id'], + 'data' => [ + 'poster_id' => $users['regular'], + 'post_username' => '', + 'post_subject' => $this->context->prefix . 'Normal topic', + 'topic_title' => $this->context->prefix . 'Normal topic', + 'forum_id' => $forums['lobby'], + ], + ], + [ + 'type' => 'notification.type.quote', + 'item' => $content['bbcode_reply']['post_id'], + 'parent' => $content['bbcode']['topic_id'], + 'data' => [ + 'poster_id' => $users['moderator'], + 'post_subject' => $this->context->prefix . 'Re: BBCode and smiley showcase', + 'topic_title' => $this->context->prefix . 'BBCode and smiley showcase', + 'forum_id' => $forums['lobby'], + ], + ], + [ + 'type' => 'notification.type.pm', + 'item' => $messages['unread'], + 'parent' => 0, + 'data' => [ + 'from_user_id' => $users['admin'], + 'message_subject' => $this->context->prefix . 'Unread private message', + ], + ], + ]; + foreach ($definitions as $definition) + { + if (!isset($types[$definition['type']])) + { + throw new RuntimeException('Required notification type is unavailable: ' . $definition['type']); + } + $typeId = $types[$definition['type']]; + $result = $db->sql_query_limit( + 'SELECT notification_id FROM ' . NOTIFICATIONS_TABLE . ' + WHERE notification_type_id = ' . $typeId . ' + AND item_id = ' . (int) $definition['item'] . ' + AND user_id = ' . $recipient, + 1 + ); + $notificationId = (int) $db->sql_fetchfield('notification_id'); + $db->sql_freeresult($result); + if (!$notificationId) + { + $db->sql_query('INSERT INTO ' . NOTIFICATIONS_TABLE . ' ' . $db->sql_build_array('INSERT', [ + 'notification_type_id' => $typeId, + 'item_id' => (int) $definition['item'], + 'item_parent_id' => (int) $definition['parent'], + 'user_id' => $recipient, + 'notification_read' => 0, + 'notification_time' => time(), + 'notification_data' => serialize($definition['data']), + ])); + $notificationId = (int) $db->sql_nextid(); + } + $this->context->addId('notifications', $notificationId); + } + } + + private function refreshSearchIndex(): void + { + $searchClass = (string) ($this->context->config['search_type'] ?? '\\phpbb\\search\\fulltext_native'); + if ($this->context->container->has('search.backend_factory')) + { + $search = $this->context->container->get('search.backend_factory')->get_active(); + } + else + { + if (!class_exists($searchClass)) + { + throw new RuntimeException("Configured phpBB search backend is unavailable: $searchClass"); + } + $error = false; + $search = new $searchClass( + $error, + $this->context->root, + $this->context->phpEx, + $this->context->auth, + $this->context->config, + $this->context->db, + $this->context->user, + $GLOBALS['phpbb_dispatcher'] + ); + if ($error) + { + throw new RuntimeException("Configured phpBB search backend failed: $error"); + } + } + if (!method_exists($search, 'index')) + { + throw new RuntimeException("Configured phpBB search backend cannot index posts: $searchClass"); + } + $postIds = $this->context->ids('posts'); + $result = $this->context->db->sql_query('SELECT post_id, post_subject, post_text, poster_id, forum_id + FROM ' . POSTS_TABLE . ' + WHERE ' . $this->context->db->sql_in_set('post_id', $postIds)); + while ($post = $this->context->db->sql_fetchrow($result)) + { + $search->index( + 'edit', + (int) $post['post_id'], + $post['post_text'], + $post['post_subject'], + (int) $post['poster_id'], + (int) $post['forum_id'] + ); + } + $this->context->db->sql_freeresult($result); + $this->context->setMeta('search_backend', $searchClass); + } +} diff --git a/src/QuickInstall/Sandbox/SeedRuntime/UserBuilder.php b/src/QuickInstall/Sandbox/SeedRuntime/UserBuilder.php new file mode 100644 index 00000000..c5cf3807 --- /dev/null +++ b/src/QuickInstall/Sandbox/SeedRuntime/UserBuilder.php @@ -0,0 +1,213 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstallSeed; + +use RuntimeException; + +class UserBuilder extends BaseBuilder +{ + public function seed(): array + { + $db = $this->context->db; + $passwords = $this->context->container->get('passwords.manager'); + $registered = $this->requiredGroup('REGISTERED'); + $administrators = $this->requiredGroup('ADMINISTRATORS'); + $moderators = $this->requiredGroup('GLOBAL_MODERATORS'); + $users = []; + + for ($index = 1; $index <= 25; $index++) + { + $username = sprintf('qi_dev_%d_user_%02d', $this->context->seed, $index); + $result = $db->sql_query_limit( + 'SELECT user_id FROM ' . USERS_TABLE . " + WHERE username_clean = '" . $db->sql_escape(utf8_clean_string($username)) . "'", + 1 + ); + $userId = (int) $db->sql_fetchfield('user_id'); + $db->sql_freeresult($result); + + if (!$userId) + { + $userId = (int) user_add([ + 'username' => $username, + 'user_password' => $passwords->hash(SeedContext::PASSWORD), + 'user_email' => sprintf('%s@example.test', $username), + 'group_id' => $registered, + 'user_type' => USER_NORMAL, + 'user_ip' => '127.0.0.1', + 'user_lang' => 'en', + ]); + } + if (!$userId) + { + throw new RuntimeException("Unable to create development user: $username"); + } + + $targetGroup = $index <= 2 ? $administrators : ($index <= 4 ? $moderators : $registered); + group_user_add($targetGroup, [$userId], false, false, true); + $this->applySignature($userId, $username); + $this->applyAvatar($userId, $index); + $this->context->addId('users', $userId); + $users[$index] = $userId; + } + + $this->markInactive($users[24]); + $this->markBanned($users[25]); + $this->addWarnings([$users[5], $users[6]]); + + $roles = [ + 'admin' => $users[1], + 'admin_2' => $users[2], + 'moderator' => $users[3], + 'moderator_2' => $users[4], + 'regular' => $users[5], + 'regular_2' => $users[6], + 'regular_3' => $users[7], + 'inactive' => $users[24], + 'banned' => $users[25], + 'posters' => array_values(array_slice($users, 0, 23, true)), + ]; + $this->context->setMeta('users', $roles); + return $roles; + } + + private function requiredGroup(string $name): int + { + $id = $this->context->groupId($name); + if (!$id) + { + throw new RuntimeException("Required phpBB group is unavailable: $name"); + } + return $id; + } + + private function applySignature(int $userId, string $username): void + { + $db = $this->context->db; + $signature = sprintf('[b]%s[/b] — sample signature with [i]formatted text[/i].', $username); + $uid = $bitfield = ''; + $options = 7; + generate_text_for_storage($signature, $uid, $bitfield, $options, true, true, true); + $db->sql_query('UPDATE ' . USERS_TABLE . " + SET user_sig = '" . $db->sql_escape($signature) . "', + user_sig_bbcode_uid = '" . $db->sql_escape($uid) . "', + user_sig_bbcode_bitfield = '" . $db->sql_escape($bitfield) . "' + WHERE user_id = $userId"); + } + + private function applyAvatar(int $userId, int $index): void + { + $db = $this->context->db; + $avatarPath = $this->context->storageDirectory( + 'avatar', + (string) ($this->context->config['avatar_path'] ?? 'images/avatars/upload') + ); + $directory = $this->context->root . $avatarPath; + if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) + { + throw new RuntimeException("Unable to create avatar directory: $directory"); + } + + $salt = (string) ($this->context->config['avatar_salt'] ?? ''); + $filename = $salt . '_' . $userId . '.png'; + $path = $directory . '/' . $filename; + $this->context->writeFile( + 'avatar', + $filename, + $path, + AssetFactory::png(80, 80, $this->context->seed * 100 + $index) + ); + + $avatar = sprintf('%d_qidev%d.png', $userId, $this->context->seed); + $db->sql_query('UPDATE ' . USERS_TABLE . " + SET user_avatar = '" . $db->sql_escape($avatar) . "', + user_avatar_type = 'avatar.driver.upload', + user_avatar_width = 80, + user_avatar_height = 80 + WHERE user_id = $userId"); + } + + private function markInactive(int $userId): void + { + $this->context->db->sql_query('UPDATE ' . USERS_TABLE . ' + SET user_type = ' . USER_INACTIVE . ', + user_inactive_reason = ' . INACTIVE_MANUAL . ', + user_inactive_time = ' . time() . ' + WHERE user_id = ' . $userId); + } + + private function markBanned(int $userId): void + { + $db = $this->context->db; + $table = $this->context->banTable(); + $result = $db->sql_query_limit('SELECT ban_id FROM ' . $table . ' WHERE ban_userid = ' . $userId, 1); + $banId = (int) $db->sql_fetchfield('ban_id'); + $db->sql_freeresult($result); + if (!$banId) + { + $row = [ + 'ban_userid' => $userId, + 'ban_start' => time(), + 'ban_end' => 0, + 'ban_reason' => 'Repeatedly ignored the community guidelines.', + ]; + if ($this->context->usesModernBans()) + { + $row += [ + 'ban_mode' => 'user', + 'ban_item' => (string) $userId, + 'ban_reason_display' => 'Please review the community guidelines before returning.', + ]; + } + else + { + $row += [ + 'ban_ip' => '', + 'ban_email' => '', + 'ban_exclude' => 0, + 'ban_give_reason' => 'Please review the community guidelines before returning.', + ]; + } + $db->sql_query('INSERT INTO ' . $table . ' ' . $db->sql_build_array('INSERT', $row)); + $banId = (int) $db->sql_nextid(); + } + $this->context->addId('bans', $banId); + } + + private function addWarnings(array $userIds): void + { + $db = $this->context->db; + foreach ($userIds as $userId) + { + $result = $db->sql_query_limit( + 'SELECT warning_id FROM ' . WARNINGS_TABLE . ' WHERE user_id = ' . (int) $userId . ' AND post_id = 0', + 1 + ); + $warningId = (int) $db->sql_fetchfield('warning_id'); + $db->sql_freeresult($result); + if (!$warningId) + { + $db->sql_query('INSERT INTO ' . WARNINGS_TABLE . ' ' . $db->sql_build_array('INSERT', [ + 'user_id' => (int) $userId, + 'post_id' => 0, + 'log_id' => 0, + 'warning_time' => time(), + ])); + $warningId = (int) $db->sql_nextid(); + $db->sql_query('UPDATE ' . USERS_TABLE . ' + SET user_warnings = user_warnings + 1, + user_last_warning = ' . time() . ' + WHERE user_id = ' . (int) $userId); + } + $this->context->addId('warnings', $warningId); + } + } +} diff --git a/src/QuickInstall/Sandbox/SeedRuntime/run.php b/src/QuickInstall/Sandbox/SeedRuntime/run.php new file mode 100644 index 00000000..f27019a6 --- /dev/null +++ b/src/QuickInstall/Sandbox/SeedRuntime/run.php @@ -0,0 +1,87 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +if (PHP_SAPI !== 'cli') +{ + fwrite(STDERR, "Development fixtures may only run from the CLI.\n"); + exit(1); +} + +$seed = (int) ($argv[1] ?? 1); +$action = $argv[2] ?? 'seed'; +if ($seed < 1) +{ + fwrite(STDERR, "Seed must be a positive integer.\n"); + exit(1); +} + +$phpbb_root_path = rtrim((string) getcwd(), '/') . '/'; +$phpEx = 'php'; +define('IN_PHPBB', true); + +require_once $phpbb_root_path . 'common.' . $phpEx; +require_once $phpbb_root_path . 'includes/functions_user.' . $phpEx; +require_once $phpbb_root_path . 'includes/functions_content.' . $phpEx; +require_once $phpbb_root_path . 'includes/functions_posting.' . $phpEx; +require_once $phpbb_root_path . 'includes/functions_admin.' . $phpEx; +require_once $phpbb_root_path . 'includes/functions_privmsgs.' . $phpEx; +require_once $phpbb_root_path . 'includes/functions_acp.' . $phpEx; + +$serverName = (string) ($config['server_name'] ?? ''); +if (!in_array($serverName, ['localhost', '127.0.0.1', '::1'], true) + && !preg_match('/\.(test|local|dev|localhost)$/i', $serverName)) +{ + fwrite(STDERR, "Development fixtures are restricted to local boards.\n"); + exit(1); +} + +$user->session_begin(); +$result = $db->sql_query_limit( + 'SELECT * FROM ' . USERS_TABLE . ' WHERE user_type = ' . USER_FOUNDER . ' ORDER BY user_id ASC', + 1 +); +$founder = $db->sql_fetchrow($result); +$db->sql_freeresult($result); +if (!$founder) +{ + fwrite(STDERR, "Development fixtures require a founder account.\n"); + exit(1); +} +$user->data = array_merge($user->data, $founder); +$user->data['is_registered'] = true; +$auth->acl($user->data); + +require_once __DIR__ . '/SeedContext.php'; +require_once __DIR__ . '/UserBuilder.php'; +require_once __DIR__ . '/ForumBuilder.php'; +require_once __DIR__ . '/ContentBuilder.php'; +require_once __DIR__ . '/StateBuilder.php'; +require_once __DIR__ . '/DevelopmentSeeder.php'; + +try +{ + $context = new \QuickInstallSeed\SeedContext( + $db, + $user, + $auth, + $config, + $phpbb_container, + $phpbb_root_path, + $phpEx, + $seed + ); + (new \QuickInstallSeed\DevelopmentSeeder($context))->run($action); + exit(0); +} +catch (\Throwable $exception) +{ + fwrite(STDERR, 'Development seed failed: ' . $exception->getMessage() . "\n"); + exit(1); +} diff --git a/src/QuickInstall/Sandbox/Web/Application.php b/src/QuickInstall/Sandbox/Web/Application.php index 6cd06cff..5aa2181a 100644 --- a/src/QuickInstall/Sandbox/Web/Application.php +++ b/src/QuickInstall/Sandbox/Web/Application.php @@ -21,6 +21,7 @@ use QuickInstall\Sandbox\DoctorService; use QuickInstall\Sandbox\ExtensionManager; use QuickInstall\Sandbox\Project; +use QuickInstall\Sandbox\SeedPresetCatalog; use QuickInstall\Sandbox\SourceService; use QuickInstall\Sandbox\StyleManager; use QuickInstall\Sandbox\UpdateService; @@ -216,6 +217,10 @@ private function handlePost(): void $port = (int) ($this->optional('port') ?: '8080'); $populate = $this->optional('populate') ?: 'none'; $this->validateBoardCreateOptions($db, $port, $populate); + if (SeedPresetCatalog::isDeprecated($populate)) + { + $this->output->write('Warning: ' . SeedPresetCatalog::deprecationMessage() . "\n"); + } (new BoardService($this->project, $this->output))->create($name, $version, $db, $port, $populate, $this->checked('debug'), $this->checked('replace')); $this->notice = "Created board: $name"; break; @@ -240,9 +245,13 @@ private function handlePost(): void case 'board_seed': $name = $this->required('name'); - $preset = $this->optional('preset') ?: 'extension-dev'; + $preset = $this->optional('preset') ?: SeedPresetCatalog::defaultSeed(); $seed = (int) ($this->optional('seed') ?: '1'); $this->validatePreset($preset); + if (SeedPresetCatalog::isDeprecated($preset)) + { + $this->output->write('Warning: ' . SeedPresetCatalog::deprecationMessage() . "\n"); + } if ($seed < 1) { throw new InvalidArgumentException('Seed must be a positive integer.'); @@ -416,10 +425,7 @@ private function validateBoardCreateOptions(string $db, int $port, string $popul private function validatePreset(string $preset): void { - if (!in_array($preset, ['tiny', 'extension-dev', 'load-test', 'random'], true)) - { - throw new InvalidArgumentException('Preset must be one of: tiny, extension-dev, load-test, random.'); - } + SeedPresetCatalog::validate($preset); } private function render(): void @@ -541,8 +547,8 @@ private function viewData(?UpdateService $updates = null): array 'sources' => $viewSources, 'versionOptions' => $this->versionOptions($versions, $sources), 'dbOptions' => ['mariadb', 'mysql', 'postgres', 'sqlite'], - 'populateOptions' => ['none', 'tiny', 'extension-dev', 'load-test', 'random'], - 'presetOptions' => ['tiny', 'extension-dev', 'load-test', 'random'], + 'populateOptions' => array_merge(['none'], SeedPresetCatalog::visible()), + 'presetOptions' => SeedPresetCatalog::visible(), 'seedActionOptions' => ['seed', 'replace', 'reset'], ]; } diff --git a/src/QuickInstall/Sandbox/bootstrap.php b/src/QuickInstall/Sandbox/bootstrap.php index 99f1daf2..17f98c4b 100644 --- a/src/QuickInstall/Sandbox/bootstrap.php +++ b/src/QuickInstall/Sandbox/bootstrap.php @@ -24,6 +24,8 @@ require_once __DIR__ . '/BoardService.php'; require_once __DIR__ . '/DockerComposeWriter.php'; require_once __DIR__ . '/StyleUninstallerWriter.php'; +require_once __DIR__ . '/SeedPresetCatalog.php'; +require_once __DIR__ . '/DevelopmentSeederWriter.php'; require_once __DIR__ . '/BoardRunner.php'; require_once __DIR__ . '/SeederWriter.php'; require_once __DIR__ . '/CustomisationManagerInterface.php'; diff --git a/tests/Integration/ApplicationTest.php b/tests/Integration/ApplicationTest.php index edddc201..f8c0228f 100644 --- a/tests/Integration/ApplicationTest.php +++ b/tests/Integration/ApplicationTest.php @@ -33,6 +33,16 @@ public function testCommandHelpPrintsSpecificUsage(): void self::assertStringContainsString('qi board:create ', $result['output']); } + public function testSeedHelpShowsDevelopmentAndHidesDeprecatedPreset(): void + { + $result = $this->runApplication($this->createTempProjectRoot(), ['qi', 'board:seed', '--help']); + + self::assertSame(0, $result['exit_code']); + self::assertStringContainsString('tiny|development|load-test|random', $result['output']); + self::assertStringContainsString('Default: development.', $result['output']); + self::assertStringNotContainsString('extension-dev', $result['output']); + } + public function testInitCreatesWorkspace(): void { $root = $this->createTempProjectRoot(); diff --git a/tests/Integration/WebApplicationTest.php b/tests/Integration/WebApplicationTest.php index 1de654c4..3da0c0c4 100644 --- a/tests/Integration/WebApplicationTest.php +++ b/tests/Integration/WebApplicationTest.php @@ -86,6 +86,8 @@ public function testRenderShowsCoreSandboxWorkflows(): void self::assertStringContainsString('title="phpBB selector to fetch or reuse.', $html); self::assertStringContainsString('title="Allow the path field to point outside the customisations directory.', $html); self::assertStringContainsString('Relative to customisations/', $html); + self::assertStringContainsString('', $html); + self::assertStringNotContainsString('