diff --git a/.docker/README.md b/.docker/README.md
new file mode 100644
index 00000000000..df3e6170317
--- /dev/null
+++ b/.docker/README.md
@@ -0,0 +1,457 @@
+# SMF development environment
+
+A throwaway, reproducible local stack for working on SMF 3.0. Nothing here is
+part of the shipped forum — it lives in `.docker/` precisely so the CI checks
+(`check-smf-index.php`, `check-smf-license.php`) skip it.
+
+Both database engines SMF supports are in the stack. **MySQL is the default.**
+
+## Requirements
+
+Docker Desktop (Linux containers). Nothing else — no local PHP, Composer, MySQL
+or PostgreSQL install is needed.
+
+## Start
+
+```sh
+docker compose up -d --build
+```
+
+First boot takes a few minutes: it builds the PHP image and runs
+`composer install` into `vendor/`. Watch it with `docker compose logs -f web`
+and wait for the `[smf-dev] ready` line.
+
+| Service | URL / address | Notes |
+| ---------- | ----------------------- | --------------------------------------- |
+| Forum | http://localhost:8080 | The forum itself |
+| Mailpit | http://localhost:8025 | Every mail the forum sends lands here |
+| Adminer | http://localhost:8081 | Database browser, pre-pointed at MySQL |
+| MySQL | `localhost:3307` | For a client on the host |
+| PostgreSQL | `localhost:5433` | For a client on the host |
+
+Credentials are `smf` / `smf` / database `smf` on both engines.
+
+## Choosing the engine
+
+Both database services always start. `SMF_DB_TYPE` decides which one the forum
+is pointed at, and it defaults to `mysql`:
+
+```sh
+# In .env, or inline:
+SMF_DB_TYPE=postgresql docker compose up -d
+```
+
+This only affects the `Settings.php` the entrypoint *generates*. Once the forum
+is installed, `Settings.php` is what counts and changing the variable does
+nothing — the entrypoint says so in the log rather than leaving you guessing. To
+move to the other engine, delete `Settings.php` and `Settings_bak.php`, then
+restart `web` and reinstall.
+
+Because both engines run side by side with separate volumes, you can install on
+one, switch, install on the other, and switch back: each database keeps its own
+forum.
+
+## Installing the forum
+
+```sh
+.docker/install-forum.sh --engine mysql
+.docker/install-forum.sh --engine postgresql
+.docker/install-forum.sh --engine both
+```
+
+That resets the engine's database and installs a forum into it, with no browser
+involved. It takes about a minute. Log in at http://localhost:8080 as
+`admin` / `password`.
+
+SMF 3.0's installer is CLI-native: `Maintenance::parseCliArguments()` turns
+`--name=value` into `$_POST`, and `Maintenance::execute()` then runs every step
+in one process, stopping at the first that still needs input. The script makes
+two passes, because `databasePopulation()` always stops the first time even
+though it succeeded — it pauses so a human can read its "N duplicate tables
+ignored" report, and the form's `pop_done` field is the short-circuit past it.
+Passing `pop_done` on the first pass would skip building the schema entirely.
+
+It then deletes `install.php`, which the installer asks for but cannot do
+itself — its `?delete` link is a GET, and command line arguments only ever reach
+`$_POST`. That matters more than it sounds: while the file is there
+`Settings.php` redirects every request back into the installer, and SMF puts a
+"MAJOR SECURITY RISK" box on every page it shows an administrator. Reinstalling
+still works, because `reset.sh` runs first and does not return until the
+entrypoint has staged a fresh copy.
+
+Two flags worth knowing:
+
+- `--force` reinstalls even when a forum is already there. Without it the
+ script leaves an existing install alone.
+- `--pin-secrets` fixes `auth_secret` and `image_proxy_secret` to known values
+ instead of the random ones `ForumSettings()` generates. Both installs then
+ differ only in their database, so a login cookie survives `use-engine.sh`.
+ Dev-only values for a throwaway forum: never reuse them.
+
+### Two forums at once
+
+`--engine both` installs MySQL first and PostgreSQL second, one after the other.
+It has to be sequential: `Settings.php` pins a single `$db_type`, and
+`Db::load()` hands back the connection it already made, so only one engine can
+ever be live in a process.
+
+Both installs are kept. Switch between them with:
+
+```sh
+.docker/use-engine.sh postgresql
+```
+
+That puts the saved `Settings.php` back and clears `cache/`. No restart is
+needed — the entrypoint only writes `Settings.php` when there is not one, so it
+leaves whatever is in place alone. The copies live in `.docker/settings/` and
+are gitignored.
+
+`reset.sh` is the other half: it empties one engine's database and restages the
+installer, discarding that forum. `use-engine.sh` switches between forums,
+`reset.sh` throws one away.
+
+## Accounts and passwords
+
+Two forums, each with its own administrator, and a password chosen months ago is
+a recipe for an afternoon of hand written SQL. `user.sh` is there so it is not:
+
+```sh
+.docker/user.sh list
+.docker/user.sh check admin 'password'
+.docker/user.sh reset admin 'a new password'
+```
+
+`check` exits 0 when SMF would accept the password and 1 when it would not, so
+it works in a conditional as well as by eye. It also points out an account that
+is not activated, which fails to log in with a correct password and looks
+exactly like a wrong one.
+
+`--engine mysql|postgresql` reads the settings `use-engine.sh` saved for that
+engine, so the *other* forum can be inspected without switching to it:
+
+```sh
+.docker/user.sh check admin 'password' --engine mysql
+```
+
+The hashing goes through SMF's own `Security` class rather than being written
+here, so what `reset` puts in the table is by construction what `Login2` expects
+to find. It clears `passwd_flood` at the same time: SMF locks an account out for
+a while after enough wrong guesses, and a fresh password behind a lockout looks
+exactly like a password that did not take.
+
+## Running the tests
+
+```sh
+.docker/test.sh # both engines
+.docker/test.sh --engine postgresql
+.docker/test.sh --engine both --filter ModSettings
+```
+
+Anything it does not recognise is passed on to PHPUnit. It installs a forum for
+an engine that has not got one, and puts the previously active engine back when
+it finishes.
+
+Running on both is the point rather than a thoroughness exercise. The counter
+regression in `tests/Integration/ModSettingsTest.php` **passes on MySQL with the
+bug still in place** and only fails on PostgreSQL, because MySQL coerces text to
+a number where PostgreSQL refuses. A suite that only ever sees one engine proves
+considerably less than it looks like it does.
+
+The unit suite needs none of this — `composer test` runs everything, and the
+integration tests skip themselves when there is no forum to talk to.
+
+Some of the tests sign in, so they need to know the administrator. They default
+to what `install-forum.sh` creates (`admin` / `password`); if your forum has
+different credentials, export them:
+
+```sh
+SMF_ADMIN_USER=admin SMF_ADMIN_PASS='…' .docker/test.sh
+```
+
+Getting that wrong makes those tests **skip**, with a message saying so, rather
+than fail — a password the suite does not know is a misconfigured forum, not a
+regression. `user.sh check admin '…'` settles which it is, and
+`user.sh reset admin password` puts a forum installed some other way back on the
+credentials the suite expects.
+
+## Writing a test
+
+### Which suite
+
+Three of them, and picking the wrong one is the usual reason a test is harder to
+write than it should be:
+
+| Suite | Has | Use it for |
+| ------------------------ | -------------------------------------- | ----------------------------- |
+| `tests/Unit` | nothing — no database, no request | pure functions, value objects |
+| `tests/Integration` | `Db::$db`, `$modSettings`, `User::$me` | anything needing real data |
+| `tests/Integration/Http` | all of that, plus a real request | proving a *page* works |
+
+Work down the list and stop at the first that can hold the test. An HTTP test
+costs about a second and cannot be rolled back; a unit test costs nothing. The
+limits of the unit suite are spelled out in `AGENTS.md`.
+
+Reach for HTTP only when the thing worth proving is in the parts nothing else
+touches: the session, the cookies, the security token, the theme and the
+templates. `User::setMe()` skips every one of them, which is exactly why the
+plain integration tests are cheap.
+
+### The shape of an HTTP test
+
+Four beats: fetch a page, submit a form on it, assert on what came back, assert
+nothing was logged.
+
+```php
+#[CoversNothing]
+class ProfileTest extends HttpTestCase
+{
+ public function testAMemberCanChangeTheirSignature(): void
+ {
+ $this->signInAsAdmin();
+
+ $form = $this->fetch('?action=profile;area=forumprofile');
+
+ $response = $this->submitForm($form, [
+ 'signature' => 'Set by the integration suite.',
+ 'save' => 'Change profile', // the button
+ ], '//form[contains(@action, "area=forumprofile")]');
+
+ $this->assertLessThan(400, $response->status, $response->errorText());
+ $this->assertNoErrorsLogged('saving a signature logged something.' . "\n");
+ }
+}
+```
+
+`#[CoversNothing]` is not optional. These cross dozens of classes, so naming one
+would be untrue, and `failOnRisky` wants an attribute either way.
+
+Four things that are easy to get wrong:
+
+- **Submit through `submitForm()`, not `HttpClient::submit()`.** Only the former
+ waits out flood control. `Security::spamProtection()` gives a moderator two
+ seconds between posts, per IP, and the tests all arrive from the same one far
+ faster than a person would; without the wait you get a suite that fails about
+ one run in five for no reproducible reason.
+- **Name the button you are pressing.** `formFields()` leaves every button out on
+ purpose, because the posting form carries both `preview` and `post` and sending
+ the pair means preview quietly wins — no post, and a perfectly good 200 to show
+ for it.
+- **Clean up whatever you write.** There is no transaction here; see
+ `HttpTestCase::usesTransaction()` for why one would not help. `PostingTest`
+ deletes through `Topic::remove()` rather than by hand, so the board and member
+ counters go back as well.
+- **`assertNoErrorsLogged()` is the point of the test**, not a formality. A page
+ can return exactly the right HTML while logging an undefined index, and that is
+ the failure mode this whole suite exists to catch.
+
+One thing to rule out before believing a failure: if `install.php` is still in
+the board root, SMF puts a "MAJOR SECURITY RISK" box on every page it shows an
+administrator. That is an `errorbox`, so it fails `assertLooksLikeAForumPage()`
+and turns up in `errorText()` in front of whatever the test was actually looking
+at. `install-forum.sh` removes the file once it is done; a forum installed
+through the browser needs it deleting by hand.
+
+### Who the request is
+
+The identity of an HTTP request is the cookie jar and nothing else. There are two
+states out of the box: a guest, which is what `setUp()` leaves you, and the
+administrator, through `signInAsAdmin()`.
+
+**`actingAs()` does not work here.** It is inherited from `IntegrationTestCase`
+and it repoints `User::$me` in the PHPUnit process — but the request is handled
+by Apache in a different process, which knows only the cookie. Calling it in an
+HTTP test changes nothing about the request and leaves the assertions describing
+a guest, confidently.
+
+So:
+
+- **Two users at once** means two `HttpClient` instances. Each opens its own
+ cookie jar, so they are independent browsers — which is how to test one member
+ sending another a PM.
+- **Back to being a guest** is `$this->http->forgetCookies()`, then
+ `$this->http->get('')` to pick up a fresh session.
+- **A member who is not the administrator** has to be made first, through
+ `Register2::registerMember()` with `interface => 'admin'` (which needs
+ `actingAs($this->adminId())` first, as it checks `moderate_forum`). That member
+ outlives the test, so delete it in `tearDown()`.
+
+### Finding the endpoint and the field names
+
+Endpoints are looked up; field names are not.
+
+`Forum::$actions` in `Sources/Forum.php` is the authoritative list of every
+`?action=` the forum answers and the class behind it. Sub-actions — the `;area=`
+and `;sa=` parts — are a `$subactions` property on that class. So
+`?action=profile;area=forumprofile` resolves as `$actions['profile']` →
+`Actions\Profile\Main` → its `$subactions`. That is quicker and more reliable
+than reading templates.
+
+Field names you are deliberately not meant to know. `HttpClient::submit()`
+scrapes every input, textarea and select out of the form it was handed and sends
+them back, the way a browser does. That is what carries the session check and the
+security token, both named unpredictably per session and neither hardcodable. All
+a test supplies is the few values it is choosing, plus the button.
+
+When you do need to see them, ask the page rather than the template:
+
+```sh
+docker compose exec web php -r '
+ require "tests/bootstrap.php";
+ $c = new SMF\Tests\Support\HttpClient();
+ $c->get("");
+ $p = $c->get("?action=login");
+ print_r($p->formFields("//form[contains(@action, \"login2\")]"));'
+```
+
+```
+Array
+(
+ [user] =>
+ [passwrd] =>
+ [d0004e1655] => b2f5189a9b3014cadee7bcb0b8d697f2
+ [b8ae8fd32d] => 8f6026ed9a1b91bf6fec315801a2a93c
+)
+```
+
+Two named fields, which are the ones a test writes, and two whose names are
+different for every session — the session check and the security token, and
+running the command twice gives two different pairs. That is what
+`submit()` is for, and why a test that builds its own POST body by hand gets a
+403 it cannot fix.
+
+The paths are relative because the container's working directory is
+`/var/www/html` already. Spelling them absolutely also works, but not from Git
+Bash on Windows, which rewrites anything that looks like a Unix path before
+Docker sees it.
+
+Note the throwaway `get("")` before the form is fetched. The very first request
+of a new session regenerates it, so a token minted on the first page a visitor
+ever sees is bound to a session that no longer exists by the time it comes back.
+The symptom is a 403 about the token, when the token was never the problem.
+
+### Installing in a browser instead
+
+On first boot the entrypoint writes a `Settings.php` pre-filled for the chosen
+engine and copies `other/install.php` to the web root, so
+http://localhost:8080 redirects into the installer.
+
+The installer does **not** read its form defaults from `Settings.php` — it uses
+the hardcoded defaults in the database API class. On the *Database Server
+Settings* step, enter:
+
+| Field | MySQL | PostgreSQL |
+| ------------- | ------- | ------------ |
+| Database type | `MySQL` | `PostgreSQL` |
+| Server | `mysql` | `postgres` |
+| Port | `3306` | `5432` |
+| Username | `smf` | `smf` |
+| Password | `smf` | `smf` |
+| Database name | `smf` | `smf` |
+
+The internal ports are correct here: `3307` and `5433` are only how the *host*
+reaches the databases from outside Docker. Containers talk to each other on the
+compose network.
+
+When the installer finishes, delete `install.php` from the repo root — while it
+exists, `Settings.php` redirects every request back into the installer.
+
+## Everyday use
+
+```sh
+docker compose logs -f web # apache + php errors, live
+docker compose logs -f postgres # every failing query, with its SQL
+docker compose exec web bash # shell in the web container
+
+docker compose exec mysql mysql -usmf -psmf smf # mysql client
+docker compose exec postgres psql -U smf # psql
+
+docker compose exec web composer install
+docker compose exec web composer lint
+
+docker compose up -d --build web # after changing anything in .docker/php/
+docker compose down # stop, keep both databases
+docker compose down -v # stop and destroy both databases
+```
+
+`php.ini`, the vhost and the entrypoint are copied into the image at build time,
+not bind-mounted, so a plain `restart` will not pick up edits to them. Rebuild.
+
+The repository is bind-mounted at `/var/www/html`, so edits on the host are
+live on the next request. Opcache is on but revalidates every request, so you
+never need to restart for a PHP change.
+
+To reinstall from scratch: `.docker/install-forum.sh --engine mysql --force`.
+To wipe everything including the volumes: `docker compose down -v`.
+
+## Debugging SQL with the PostgreSQL log
+
+The `postgres` log is the best tool in the stack for tracking down a broken
+query. PostgreSQL logs every statement that errors together with the SQL that
+caused it, always and without any configuration:
+
+```
+2026-01-01 12:00:00.000 UTC [98] ERROR: relation "nope" does not exist at character 15
+2026-01-01 12:00:00.000 UTC [98] STATEMENT: select * from nope;
+```
+
+Nothing is written to a file inside the container, so the compose log above is
+where to look. Add `--since 5m` to it to skip past the startup noise.
+
+MySQL has no equivalent: it logs server errors only, never the client statement
+that failed, so a query SMF gets wrong leaves no trace in its log. Since MySQL
+is the default engine, a suspected SQL problem is worth reproducing against
+PostgreSQL — install on it once and you can switch back and forth, because each
+database keeps its own forum.
+
+Only failing statements are logged. Successful ones, timings and connections
+are not, so this shows you the queries that break, not the ones that merely
+return the wrong thing.
+
+## Configuration
+
+`compose.yaml` works with no `.env` file. To change ports, versions, the engine
+or credentials, copy `.docker/env.example` to `.env` in the repository root.
+
+The `postgres` service also answers to the hostname `db`, which is what
+`Settings.php` files generated before MySQL was added point at.
+
+## What is in the image
+
+PHP 8.4 on Apache, with everything `other/requirements.md` lists:
+
+- Required: `mbstring`, `fileinfo`, and both `mysqli` and `pgsql` (SMF checks
+ for `pg_connect`), so either engine can be chosen at install time.
+- Recommended: `gd`, `intl`, `curl`, `exif`, `ftp`, `xsl`, and `zip`.
+- Both database command line clients, for the `docker compose exec` recipes
+ above and for the entrypoint's readiness check.
+- `mail()` is routed through msmtp into Mailpit, so no mail can escape the
+ machine.
+
+Engine settings SMF asks for are pinned at server level rather than left to the
+image defaults:
+
+- PostgreSQL: `standard_conforming_strings = on`, as `requirements.md` requires.
+- MySQL: `utf8mb4` and InnoDB, matching SMF's own table DDL. The collation is
+ deliberately left at the charset default, because SMF sets `CHARSET` without
+ `COLLATE`; forcing a different one here would diverge from the tables it
+ creates.
+
+## Files
+
+```
+compose.yaml the stack
+.docker/php/Dockerfile PHP + Apache image
+.docker/php/php.ini dev php settings, per requirements.md
+.docker/php/vhost.conf apache vhost
+.docker/php/msmtprc mail() -> mailpit
+.docker/php/entrypoint.sh composer install, Settings.php, permissions
+.docker/mysql/init/10-smf.sh runs once on first mysql database creation
+.docker/postgres/init/10-smf.sh runs once on first postgres database creation
+.docker/env.example optional overrides
+
+.docker/lib.sh paths, credentials and engine names, shared
+.docker/install-forum.sh install a forum with no browser involved
+.docker/reset.sh empty one engine and restage the installer
+.docker/use-engine.sh switch which installed forum is live
+.docker/user.sh inspect accounts, check and reset passwords
+```
diff --git a/.docker/env.example b/.docker/env.example
new file mode 100644
index 00000000000..31736f2a186
--- /dev/null
+++ b/.docker/env.example
@@ -0,0 +1,31 @@
+# Copy to the repository root as `.env` to override any of the defaults.
+# compose.yaml works without this file.
+
+# Which engine the forum runs on: mysql (default) or postgresql.
+# Both database services start either way. This only decides what the generated
+# Settings.php points at, so it has no effect once the forum is installed --
+# Settings.php wins from then on.
+SMF_DB_TYPE=mysql
+
+# Host ports
+WEB_PORT=8080
+ADMINER_PORT=8081
+MAILPIT_PORT=8025
+MYSQL_PORT=3307
+POSTGRES_PORT=5433
+
+# Versions
+PHP_VERSION=8.4
+MYSQL_VERSION=8.4
+POSTGRES_VERSION=17-alpine
+
+# Database credentials (dev only). Shared by both engines so that switching
+# SMF_DB_TYPE needs no other change.
+DB_NAME=smf
+DB_USER=smf
+DB_PASSWORD=smf
+DB_ROOT_PASSWORD=smf
+
+# Which service Adminer pre-fills in its server field. Service name, not engine
+# name: mysql or postgres.
+ADMINER_SERVER=mysql
diff --git a/.docker/install-forum.sh b/.docker/install-forum.sh
new file mode 100755
index 00000000000..8bc55700270
--- /dev/null
+++ b/.docker/install-forum.sh
@@ -0,0 +1,205 @@
+#!/usr/bin/env bash
+# Installs the forum without a browser.
+#
+# .docker/install-forum.sh --engine mysql
+# .docker/install-forum.sh --engine postgresql
+# .docker/install-forum.sh --engine both
+#
+# SMF 3.0's installer is CLI-native: Maintenance::parseCliArguments() turns
+# --name=value into $_POST, and Maintenance::execute() then runs every step in
+# one process, stopping at the first that still needs input. So unlike 2.1,
+# which needs a five-request curl driver, this is two invocations:
+#
+# pass 1 Welcome -> Writable -> Database settings -> Forum settings
+# -> Database population, which builds the schema and then stops
+# pass 2 the same again, plus --pop_done, which walks straight past the
+# population report into the admin account and finalise
+#
+# databasePopulation() always stops the first time even though it succeeded: it
+# pauses so a human can read its "N duplicate tables ignored" report, and the
+# form's pop_done field is the short-circuit that skips it. Passing pop_done on
+# pass 1 would skip building the schema altogether, which is why this is two
+# passes and not one.
+#
+# Every step re-runs on pass 2. They are all idempotent given the same input --
+# the settings steps rewrite the same values, and adminAccount() stops if an
+# administrator already exists.
+#
+# Runs on the host.
+set -euo pipefail
+
+. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
+
+ENGINE=''
+PIN_SECRETS=0
+FORCE=0
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --engine) ENGINE="$2"; shift 2 ;;
+ --engine=*) ENGINE="${1#*=}"; shift ;;
+ --pin-secrets) PIN_SECRETS=1; shift ;;
+ --force) FORCE=1; shift ;;
+ -h|--help) sed -n '2,27p' "${BASH_SOURCE[0]}"; exit 0 ;;
+ *) die "unknown argument: $1" ;;
+ esac
+done
+
+[ -n "$ENGINE" ] || die 'need --engine mysql|postgresql|both'
+ENGINES=$(engine_list "$ENGINE") || die "unknown engine: $ENGINE"
+
+cd "$BOARD_DIR"
+
+# The installer's own name for each engine, which is the key of the array it
+# builds from the drivers it found. These are capitalised, and a lowercase
+# db_type is rejected outright -- so they are spelled exactly as the installer
+# spells them rather than reusing the SMF type.
+installer_db_type() {
+ case "$1" in
+ mysql) echo 'MySQL' ;;
+ postgresql) echo 'PostgreSQL' ;;
+ *) return 1 ;;
+ esac
+}
+
+install_one() {
+ local smf_type="$1" db_type server port args
+
+ db_type=$(installer_db_type "$smf_type")
+ server=$(engine_server "$smf_type")
+ port=$(engine_port "$smf_type")
+
+ if [ "$FORCE" -eq 0 ] && [ -n "$(installed_version "$smf_type" || true)" ]; then
+ log "${smf_type}: already installed (SMF $(installed_version "$smf_type")), nothing to do"
+
+ return 0
+ fi
+
+ log "${smf_type}: resetting"
+ "$DOCKER_DIR/reset.sh" --engine "$smf_type" >/dev/null
+
+ args=(
+ --contbutt=1
+ --db_type="$db_type"
+ --db_server="$server"
+ --db_port="$port"
+ --db_name="$DB_NAME"
+ --db_user="$DB_USER"
+ --db_passwd="$DB_PASSWORD"
+ --db_prefix="$DB_PREFIX"
+ --boardurl="$SMF_BOARDURL"
+ --mbname="$SMF_MBNAME"
+ --username="$SMF_ADMIN_USER"
+ --email="$SMF_ADMIN_EMAIL"
+ --server_email="$SMF_ADMIN_EMAIL"
+ --password1="$SMF_ADMIN_PASS"
+ --password2="$SMF_ADMIN_PASS"
+ )
+
+ # reset.sh does not return until the entrypoint has staged this, so its
+ # absence means something went wrong there rather than here. Worth saying so:
+ # without it php reports "Could not open input file: install.php", which reads
+ # like a broken script rather than a forum that was never made installable.
+ docker compose exec -T web test -f install.php \
+ || die "${smf_type}: install.php is not staged, so there is nothing to run (docker compose logs web)"
+
+ log "${smf_type}: building the schema"
+ docker compose exec -T web php install.php "${args[@]}" >/dev/null
+
+ log "${smf_type}: creating the administrator and finalising"
+ docker compose exec -T web php install.php "${args[@]}" --pop_done=1 >/dev/null
+
+ local version
+ version=$(installed_version "$smf_type" || true)
+
+ [ -n "$version" ] || die "${smf_type}: the installer finished but the forum is not installed"
+
+ # The installer tells you to delete this and cannot do it itself: its ?delete
+ # link is a GET, and command line arguments only ever reach $_POST. Leaving it
+ # is not cosmetic - Settings.php redirects every request back into the
+ # installer while it is there, and SMF puts a "MAJOR SECURITY RISK: you have
+ # not removed install.php" box on every page it shows an administrator.
+ #
+ # Safe to delete even though a reinstall needs it again: install_one() always
+ # calls reset.sh first, and reset.sh clears Settings.php and waits for the
+ # entrypoint to put a fresh copy back before returning.
+ rm -f install.php
+
+ log "${smf_type}: installed SMF ${version}"
+
+ if [ "$PIN_SECRETS" -eq 1 ]; then
+ pin_secrets
+ fi
+
+ save_settings "$smf_type"
+}
+
+# ForumSettings() generates auth_secret and image_proxy_secret with
+# random_bytes() and stores them nowhere but Settings.php, so the two engines
+# end up with different ones and a login cookie stops being valid the moment
+# use-engine.sh switches. Pinning them leaves the database as the only thing
+# that differs between the two installs.
+#
+# The cookie name needs no such help: createCookieName() is a crc32 of the
+# database name and prefix, which are the same on both.
+#
+# Dev-only values for a throwaway forum, published here deliberately. Never
+# reuse them anywhere real.
+pin_secrets() {
+ log 'pinning auth_secret and image_proxy_secret'
+
+ # The values have to be handed over with -e. Exporting them on the host does
+ # nothing: docker compose exec starts a fresh environment, so getenv() came
+ # back empty and this wrote two empty secrets over the generated ones.
+ docker compose exec -T \
+ -e PIN_AUTH_SECRET="$PIN_AUTH_SECRET" \
+ -e PIN_IMAGE_PROXY_SECRET="$PIN_IMAGE_PROXY_SECRET" \
+ web php -r '
+ define("SMF", 1);
+ define("SMF_SETTINGS_FILE", "/var/www/html/Settings.php");
+ define("SMF_SETTINGS_BACKUP_FILE", "/var/www/html/Settings_bak.php");
+ require_once "/var/www/html/index.php";
+
+ $auth = (string) getenv("PIN_AUTH_SECRET");
+ $proxy = (string) getenv("PIN_IMAGE_PROXY_SECRET");
+
+ if ($auth === "" || $proxy === "") {
+ fwrite(STDERR, "pin-secrets: the secrets did not reach the container\n");
+ exit(1);
+ }
+
+ exit(SMF\Config::updateSettingsFile([
+ "auth_secret" => $auth,
+ "image_proxy_secret" => $proxy,
+ ]) ? 0 : 1);
+ ' >/dev/null
+}
+
+# Keep each engine's Settings.php so use-engine.sh can put it back without a
+# reinstall. Gitignored: generated secrets and a machine-specific board URL.
+save_settings() {
+ local smf_type="$1"
+
+ mkdir -p "$SETTINGS_DIR"
+ cp Settings.php "$SETTINGS_DIR/Settings.${smf_type}.php"
+ cp Settings_bak.php "$SETTINGS_DIR/Settings_bak.${smf_type}.php"
+
+ log "${smf_type}: settings saved to .docker/settings/"
+}
+
+PIN_AUTH_SECRET="${PIN_AUTH_SECRET:-0b6e5f3c1a94d27e8f5b0c3a76d1e94f2b8c5a03e7d146f9b2c8a501d3e7f4c69}"
+PIN_IMAGE_PROXY_SECRET="${PIN_IMAGE_PROXY_SECRET:-7f2a9c4e0b6d18a35c92}"
+
+# Sequential on purpose. Settings.php pins one $db_type and Db::load() returns
+# the connection it already made, so only one engine can be live at a time --
+# "both" is a chain, never two connections.
+for smf_type in $ENGINES; do
+ install_one "$smf_type"
+done
+
+# Leave the first engine of a "both" run active rather than whichever happened
+# to go last, so the result does not depend on the order.
+FIRST_ENGINE="${ENGINES%% *}"
+"$DOCKER_DIR/use-engine.sh" "$FIRST_ENGINE" >/dev/null
+
+log "active engine: ${FIRST_ENGINE} -- ${SMF_BOARDURL} (${SMF_ADMIN_USER} / ${SMF_ADMIN_PASS})"
diff --git a/.docker/lib.sh b/.docker/lib.sh
new file mode 100644
index 00000000000..5c13d410080
--- /dev/null
+++ b/.docker/lib.sh
@@ -0,0 +1,123 @@
+#!/usr/bin/env bash
+# Shared settings and helpers for the .docker scripts. Sourced, never run.
+#
+# Host-side scripts (reset.sh, install-forum.sh, use-engine.sh) source this from
+# wherever the caller happens to be standing; everything below resolves paths
+# for itself rather than assuming a working directory.
+#
+# Everything defined here is consumed by the scripts that source this file, and
+# a linter reading it on its own cannot see any of those uses -- hence the
+# blanket disable below. Keep it on its own, with nothing after it that starts
+# with the linter's name, or the following line gets parsed as a directive too.
+#
+# shellcheck disable=SC2034
+
+# Git Bash on Windows rewrites anything that looks like a Unix path before
+# handing it to a program, so a container-side path like /var/www/html/... is
+# silently turned into C:/Program Files/Git/var/www/html/... and the command
+# fails with "Could not open input file". These two switch that off. They mean
+# nothing on Linux and macOS.
+export MSYS_NO_PATHCONV=1
+export MSYS2_ARG_CONV_EXCL='*'
+
+# Repository root, regardless of where the caller was standing.
+DOCKER_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
+BOARD_DIR=$(cd -- "$DOCKER_DIR/.." && pwd)
+
+# Where use-engine.sh keeps each engine's Settings.php. Gitignored: these hold
+# generated secrets and a machine-specific board URL.
+SETTINGS_DIR="$DOCKER_DIR/settings"
+
+# ---------------------------------------------------------------- credentials
+# These match compose.yaml's defaults. Override them in the environment if you
+# changed them in .env.
+DB_NAME="${DB_NAME:-smf}"
+DB_USER="${DB_USER:-smf}"
+DB_PASSWORD="${DB_PASSWORD:-smf}"
+DB_ROOT_PASSWORD="${DB_ROOT_PASSWORD:-smf}"
+DB_PREFIX="${DB_PREFIX:-smf_}"
+
+WEB_PORT="${WEB_PORT:-8080}"
+SMF_BOARDURL="${SMF_BOARDURL:-http://localhost:${WEB_PORT}}"
+SMF_MBNAME="${SMF_MBNAME:-SMF Dev}"
+
+# The administrator the installer creates. Dev-only values for a throwaway
+# forum; never reuse them anywhere real.
+SMF_ADMIN_USER="${SMF_ADMIN_USER:-admin}"
+SMF_ADMIN_PASS="${SMF_ADMIN_PASS:-password}"
+# example.com is reserved by RFC 2606, so this can never reach a real inbox.
+# SMF's validator rejects dotless domains, so 'admin@localhost' is not an option.
+SMF_ADMIN_EMAIL="${SMF_ADMIN_EMAIL:-admin@example.com}"
+
+# --------------------------------------------------------------------- output
+log() { printf '[smf-dev] %s\n' "$*"; }
+warn() { printf '[smf-dev] %s\n' "$*" >&2; }
+die() { printf '[smf-dev] error: %s\n' "$*" >&2; exit 1; }
+
+# Engine name normalisation. Everything downstream uses either the SMF type
+# ('mysql' / 'postgresql') or the compose service name ('mysql' / 'postgres'),
+# and mixing them up is an easy way to waste an afternoon.
+engine_smf_type() {
+ case "$1" in
+ mysql|mysqli|mariadb) echo 'mysql' ;;
+ postgres|postgresql|pgsql) echo 'postgresql' ;;
+ *) return 1 ;;
+ esac
+}
+
+engine_service() {
+ case "$1" in
+ mysql|mysqli|mariadb) echo 'mysql' ;;
+ postgres|postgresql|pgsql) echo 'postgres' ;;
+ *) return 1 ;;
+ esac
+}
+
+# Container-internal host and port for an engine. Not the host-side ports in
+# compose.yaml: these are what Settings.php has to contain.
+engine_server() {
+ case "$(engine_smf_type "$1")" in
+ mysql) echo "${SMF_MYSQL_SERVER:-mysql}" ;;
+ postgresql) echo "${SMF_POSTGRES_SERVER:-postgres}" ;;
+ *) return 1 ;;
+ esac
+}
+
+engine_port() {
+ case "$(engine_smf_type "$1")" in
+ mysql) echo "${SMF_MYSQL_PORT:-3306}" ;;
+ postgresql) echo "${SMF_POSTGRES_PORT:-5432}" ;;
+ *) return 1 ;;
+ esac
+}
+
+# Expands "both" into the engines to act on, in the order they run. Only one
+# engine can be live at a time -- Settings.php pins $db_type and Db::load()
+# early-returns once the connection exists -- so "both" is a sequential chain,
+# never two connections.
+engine_list() {
+ case "$1" in
+ both|all) echo 'mysql postgresql' ;;
+ *) engine_smf_type "$1" ;;
+ esac
+}
+
+# The installed version for one engine, empty if the forum is not installed.
+# Asks the database directly rather than trusting the presence of a file:
+# Settings.php exists from the moment the entrypoint writes it, long before
+# there is a forum behind it.
+installed_version() {
+ local engine service
+ engine=$(engine_smf_type "$1") || return 1
+ service=$(engine_service "$1")
+
+ if [ "$engine" = 'mysql' ]; then
+ docker compose exec -T -e MYSQL_PWD="$DB_PASSWORD" "$service" \
+ mysql -u"$DB_USER" -D "$DB_NAME" -N -B -e \
+ "SELECT value FROM ${DB_PREFIX}settings WHERE variable = 'smfVersion';" 2>/dev/null
+ else
+ docker compose exec -T "$service" \
+ psql -U "$DB_USER" -d "$DB_NAME" -tAX -c \
+ "SELECT value FROM ${DB_PREFIX}settings WHERE variable = 'smfVersion';" 2>/dev/null
+ fi
+}
diff --git a/.docker/mysql/init/10-smf.sh b/.docker/mysql/init/10-smf.sh
new file mode 100644
index 00000000000..6f1589bdaa5
--- /dev/null
+++ b/.docker/mysql/init/10-smf.sh
@@ -0,0 +1,16 @@
+#!/bin/bash
+# Runs once, on first initialisation of the mysql data volume.
+set -eu
+
+# SMF creates its own tables as InnoDB/utf8mb4, but the database's own default is
+# what anything created outside that path inherits. Pinning it means it cannot
+# drift out from under the forum, the same reason the postgres side pins
+# standard_conforming_strings.
+#
+# The collation is left to whatever utf8mb4 defaults to on this server, because
+# that is what SMF's tables get: its DDL sets CHARSET but never COLLATE.
+mysql --protocol=socket -uroot -p"$MYSQL_ROOT_PASSWORD" <<-EOSQL
+ ALTER DATABASE \`${MYSQL_DATABASE}\` CHARACTER SET utf8mb4;
+EOSQL
+
+echo "[smf-dev] database ${MYSQL_DATABASE} initialised"
diff --git a/.docker/php/Dockerfile b/.docker/php/Dockerfile
new file mode 100644
index 00000000000..fcfb0ed41dd
--- /dev/null
+++ b/.docker/php/Dockerfile
@@ -0,0 +1,48 @@
+# SMF development image: PHP + Apache with everything other/requirements.md asks for.
+ARG PHP_VERSION=8.4
+FROM php:${PHP_VERSION}-apache
+
+COPY --from=mlocati/php-extension-installer:2 /usr/bin/install-php-extensions /usr/local/bin/
+COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
+
+# Required by SMF: mbstring, fileinfo, pgsql (pg_connect), mysqli.
+# Recommended by SMF: gd, intl, curl, exif, ftp, xsl.
+RUN install-php-extensions \
+ pgsql \
+ pdo_pgsql \
+ mysqli \
+ mbstring \
+ fileinfo \
+ gd \
+ intl \
+ curl \
+ exif \
+ ftp \
+ xsl \
+ zip \
+ opcache \
+ && a2enmod rewrite headers expires \
+ && apt-get update \
+ && apt-get install -y --no-install-recommends \
+ git \
+ unzip \
+ msmtp \
+ msmtp-mta \
+ postgresql-client \
+ default-mysql-client \
+ && rm -rf /var/lib/apt/lists/*
+
+# Route mail() at Mailpit so outgoing forum mail is captured, never sent.
+COPY .docker/php/msmtprc /etc/msmtprc
+RUN chmod 0644 /etc/msmtprc
+
+COPY .docker/php/php.ini /usr/local/etc/php/conf.d/zz-smf.ini
+COPY .docker/php/vhost.conf /etc/apache2/sites-available/000-default.conf
+COPY .docker/php/entrypoint.sh /usr/local/bin/smf-entrypoint
+
+RUN chmod +x /usr/local/bin/smf-entrypoint
+
+WORKDIR /var/www/html
+
+ENTRYPOINT ["/usr/local/bin/smf-entrypoint"]
+CMD ["apache2-foreground"]
diff --git a/.docker/php/entrypoint.sh b/.docker/php/entrypoint.sh
new file mode 100644
index 00000000000..1609dcc31ee
--- /dev/null
+++ b/.docker/php/entrypoint.sh
@@ -0,0 +1,134 @@
+#!/bin/sh
+# Prepares the bind-mounted SMF checkout so the forum is ready to install/serve.
+# Everything here is idempotent: it is safe to restart the container at any time.
+set -eu
+
+BOARD_DIR=/var/www/html
+
+log() {
+ echo "[smf-dev] $*"
+}
+
+# ---------------------------------------------------------------- dependencies
+if [ ! -f "$BOARD_DIR/vendor/autoload.php" ]; then
+ log 'vendor/ is missing, running composer install (this takes a minute the first time)'
+ composer install \
+ --working-dir="$BOARD_DIR" \
+ --no-interaction \
+ --no-progress \
+ --prefer-dist \
+ --ansi
+fi
+
+# ------------------------------------------------------------------- database
+# SMF_DB_TYPE picks the engine. Both are running; only the one the forum is
+# pointed at gets waited for and written into Settings.php.
+case "${SMF_DB_TYPE:-mysql}" in
+ mysql|mysqli|mariadb)
+ DB_TYPE=mysql
+ DB_SERVER="${SMF_MYSQL_SERVER:-mysql}"
+ DB_PORT="${SMF_MYSQL_PORT:-3306}"
+ ;;
+
+ postgresql|postgres|pgsql)
+ DB_TYPE=postgresql
+ DB_SERVER="${SMF_POSTGRES_SERVER:-postgres}"
+ DB_PORT="${SMF_POSTGRES_PORT:-5432}"
+ ;;
+
+ *)
+ log "SMF_DB_TYPE='${SMF_DB_TYPE}' is not a type SMF supports (mysql, postgresql)"
+ exit 1
+ ;;
+esac
+
+log "waiting for ${DB_TYPE} at ${DB_SERVER}:${DB_PORT}"
+
+if [ "$DB_TYPE" = 'postgresql' ]; then
+ until pg_isready -h "$DB_SERVER" -p "$DB_PORT" -U "$SMF_DB_USER" -q; do
+ sleep 1
+ done
+else
+ # Any answer at all means the server is listening; this deliberately does
+ # not authenticate, so it works before the init scripts have finished.
+ until mysqladmin ping -h "$DB_SERVER" -P "$DB_PORT" --silent >/dev/null 2>&1; do
+ sleep 1
+ done
+fi
+
+log "${DB_TYPE} is accepting connections"
+
+# --------------------------------------------------------------- installer bits
+# Settings.php redirects to install.php whenever install.php is present, so both
+# files only get placed while the forum has not been installed yet.
+if [ ! -f "$BOARD_DIR/Settings.php" ]; then
+ log "generating Settings.php pre-filled for the ${DB_TYPE} service"
+
+ sed \
+ -e "s|^\$db_type = 'mysql';|\$db_type = '${DB_TYPE}';|" \
+ -e "s|^\$db_port = 0;|\$db_port = ${DB_PORT};|" \
+ -e "s|^\$db_server = 'localhost';|\$db_server = '${DB_SERVER}';|" \
+ -e "s|^\$db_name = 'smf';|\$db_name = '${SMF_DB_NAME}';|" \
+ -e "s|^\$db_user = 'root';|\$db_user = '${SMF_DB_USER}';|" \
+ -e "s|^\$db_passwd = '';|\$db_passwd = '${SMF_DB_PASSWD}';|" \
+ -e "s|^\$boardurl = 'http://127.0.0.1/smf';|\$boardurl = '${SMF_BOARDURL}';|" \
+ -e "s|^\$mbname = 'My Community';|\$mbname = 'SMF Dev';|" \
+ "$BOARD_DIR/other/Settings.php" > "$BOARD_DIR/Settings.php"
+
+ cp "$BOARD_DIR/other/install.php" "$BOARD_DIR/install.php"
+
+ log "installer ready -- open ${SMF_BOARDURL}/install.php"
+else
+ # Already installed, and Settings.php wins over SMF_DB_TYPE. Say so rather
+ # than leaving someone wondering why switching the variable did nothing.
+ # The installer writes this back with its own capitalisation ('PostgreSQL'),
+ # so compare case-insensitively or the note fires on every restart.
+ installed_type=$(sed -n "s|^\\\$db_type = '\\([^']*\\)';.*|\\1|p" "$BOARD_DIR/Settings.php" | head -n 1 | tr '[:upper:]' '[:lower:]')
+
+ if [ -n "$installed_type" ] && [ "$installed_type" != "$DB_TYPE" ]; then
+ log "note: Settings.php is installed against ${installed_type}, not ${DB_TYPE}"
+ log ' to move, delete Settings.php and Settings_bak.php, then restart'
+ fi
+fi
+
+[ -f "$BOARD_DIR/Settings_bak.php" ] || cp "$BOARD_DIR/Settings.php" "$BOARD_DIR/Settings_bak.php"
+
+# ------------------------------------------------------------------ writability
+# The installer refuses to continue unless all of these are writable, and the
+# forum needs them at runtime too.
+for path in \
+ attachments \
+ avatars \
+ custom_avatar \
+ cache \
+ Packages \
+ Smileys \
+ Themes \
+ Languages \
+ Sources \
+ Settings.php \
+ Settings_bak.php \
+ Languages/en_US/agreement.txt
+do
+ [ -e "$BOARD_DIR/$path" ] || mkdir -p "$BOARD_DIR/$path"
+done
+
+[ -f "$BOARD_DIR/cache/db_last_error.php" ] || cp "$BOARD_DIR/db_last_error.php" "$BOARD_DIR/cache/db_last_error.php" 2>/dev/null || true
+
+# Bind mounts from the Windows host ignore chown/chmod, which is harmless. On
+# Linux/macOS hosts these calls are what makes the checkout writable by Apache.
+chown -R www-data:www-data \
+ "$BOARD_DIR/attachments" \
+ "$BOARD_DIR/avatars" \
+ "$BOARD_DIR/custom_avatar" \
+ "$BOARD_DIR/cache" \
+ "$BOARD_DIR/Packages" \
+ "$BOARD_DIR/Smileys" \
+ "$BOARD_DIR/Themes" \
+ "$BOARD_DIR/Languages" \
+ "$BOARD_DIR/Settings.php" \
+ "$BOARD_DIR/Settings_bak.php" 2>/dev/null || true
+
+log 'ready'
+
+exec "$@"
diff --git a/.docker/php/msmtprc b/.docker/php/msmtprc
new file mode 100644
index 00000000000..ce832dcf3b4
--- /dev/null
+++ b/.docker/php/msmtprc
@@ -0,0 +1,11 @@
+defaults
+auth off
+tls off
+logfile /dev/stderr
+
+account mailpit
+host mailpit
+port 1025
+from smf@localhost
+
+account default : mailpit
diff --git a/.docker/php/php.ini b/.docker/php/php.ini
new file mode 100644
index 00000000000..6b32f746e7a
--- /dev/null
+++ b/.docker/php/php.ini
@@ -0,0 +1,34 @@
+; SMF development settings.
+; Values follow other/requirements.md (Requirements + Recommendations).
+
+[PHP]
+engine = On
+file_uploads = On
+memory_limit = 512M
+max_execution_time = 30
+max_input_time = 60
+post_max_size = 128M
+upload_max_filesize = 128M
+max_file_uploads = 50
+date.timezone = UTC
+
+; Dev-only: surface every problem instead of hiding it.
+display_errors = On
+display_startup_errors = On
+error_reporting = E_ALL
+log_errors = On
+error_log = /dev/stderr
+
+[Session]
+session.use_trans_sid = Off
+session.save_path = "/tmp"
+
+[mail function]
+sendmail_path = "/usr/bin/msmtp -t -i"
+
+[opcache]
+; Keep opcache on for realistic behaviour, but always re-read changed files.
+opcache.enable = 1
+opcache.enable_cli = 0
+opcache.validate_timestamps = 1
+opcache.revalidate_freq = 0
diff --git a/.docker/php/vhost.conf b/.docker/php/vhost.conf
new file mode 100644
index 00000000000..8be73bae7ce
--- /dev/null
+++ b/.docker/php/vhost.conf
@@ -0,0 +1,19 @@
+ServerName localhost
+
+
+ DocumentRoot /var/www/html
+
+
+ Options -Indexes +FollowSymLinks
+ AllowOverride All
+ Require all granted
+
+
+ # Nothing under these paths should ever be served.
+
+ Require all denied
+
+
+ ErrorLog /dev/stderr
+ CustomLog /dev/stdout combined
+
diff --git a/.docker/postgres/init/10-smf.sh b/.docker/postgres/init/10-smf.sh
new file mode 100644
index 00000000000..0498c9270ff
--- /dev/null
+++ b/.docker/postgres/init/10-smf.sh
@@ -0,0 +1,12 @@
+#!/bin/bash
+# Runs once, on first initialisation of the postgres data volume.
+set -eu
+
+# SMF requires standard_conforming_strings to be on (other/requirements.md).
+# It is already the default on modern postgres; setting it at database level
+# makes it explicit so it cannot drift out from under the forum.
+psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
+ ALTER DATABASE "$POSTGRES_DB" SET standard_conforming_strings = on;
+EOSQL
+
+echo "[smf-dev] database $POSTGRES_DB initialised"
diff --git a/.docker/reset.sh b/.docker/reset.sh
new file mode 100755
index 00000000000..00f2de5a404
--- /dev/null
+++ b/.docker/reset.sh
@@ -0,0 +1,99 @@
+#!/usr/bin/env bash
+# Returns the stack to "installable": no forum, an empty database for the chosen
+# engine, and a Settings.php regenerated for it.
+#
+# .docker/reset.sh --engine mysql
+# .docker/reset.sh --engine postgresql
+#
+# This is also how you move an install between engines. Settings.php pins one
+# engine and wins over SMF_DB_TYPE, so switching means throwing it away and
+# letting the entrypoint write a new one. To keep an install rather than
+# discard it, use use-engine.sh instead.
+#
+# Only the chosen engine's database is touched. The two engines keep separate
+# volumes, so a MySQL reset can never disturb a PostgreSQL install or vice
+# versa.
+#
+# Runs on the host.
+set -euo pipefail
+
+. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
+
+ENGINE=''
+KEEP_FILES=0
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --engine) ENGINE="$2"; shift 2 ;;
+ --engine=*) ENGINE="${1#*=}"; shift ;;
+ --keep-files) KEEP_FILES=1; shift ;;
+ -h|--help) sed -n '2,17p' "${BASH_SOURCE[0]}"; exit 0 ;;
+ *) die "unknown argument: $1" ;;
+ esac
+done
+
+[ -n "$ENGINE" ] || die 'need --engine mysql|postgresql'
+SERVICE=$(engine_service "$ENGINE") || die "unknown engine: $ENGINE"
+SMF_TYPE=$(engine_smf_type "$ENGINE")
+
+cd "$BOARD_DIR"
+
+log "resetting for ${SMF_TYPE}"
+
+# ------------------------------------------------------------------ the forum
+# Stop the web container first: Apache holding a half-installed forum open while
+# its database vanishes underneath produces confusing errors in the log.
+docker compose stop web >/dev/null 2>&1 || true
+
+rm -f Settings.php Settings_bak.php install.php upgrade.php
+
+# SMF's cache holds a serialised copy of $modSettings, which would otherwise
+# outlive the database it describes.
+find cache -type f ! -name 'index.php' ! -name '.htaccess' -delete 2>/dev/null || true
+
+if [ "$KEEP_FILES" -eq 0 ]; then
+ for dir in attachments custom_avatar; do
+ find "$dir" -type f ! -name 'index.php' ! -name '.htaccess' ! -name 'blank.png' -delete 2>/dev/null || true
+ done
+ rm -f Packages/installed.list
+fi
+
+# --------------------------------------------------------------- the database
+docker compose up -d "$SERVICE" >/dev/null
+
+if [ "$SMF_TYPE" = 'mysql' ]; then
+ # As root: the smf user has rights on the smf database but cannot drop and
+ # recreate it. utf8mb4 matches what compose.yaml asks the server for and
+ # what SMF's own DDL emits.
+ docker compose exec -T -e MYSQL_PWD="$DB_ROOT_PASSWORD" "$SERVICE" mysql -uroot -e "
+ DROP DATABASE IF EXISTS \`${DB_NAME}\`;
+ CREATE DATABASE \`${DB_NAME}\` CHARACTER SET utf8mb4;
+ GRANT ALL ON \`${DB_NAME}\`.* TO '${DB_USER}'@'%';
+ "
+else
+ # The database itself cannot be dropped while we are connected to it, and
+ # dropping the schema is enough: it takes the tables, sequences, functions
+ # and operators with it. smf owns the database, so it may recreate public.
+ docker compose exec -T "$SERVICE" psql -v ON_ERROR_STOP=1 -q -U "$DB_USER" -d "$DB_NAME" -c '
+ DROP SCHEMA IF EXISTS public CASCADE;
+ CREATE SCHEMA public;
+ ' >/dev/null
+fi
+
+log "${SMF_TYPE} database ${DB_NAME} is empty"
+
+# Bring web back up so the entrypoint regenerates Settings.php for this engine
+# and stages the installer.
+SMF_DB_TYPE="$SMF_TYPE" docker compose up -d web >/dev/null
+
+# The entrypoint waits for the database before it writes anything, so give it a
+# moment to get there rather than racing whatever runs next.
+for _ in $(seq 1 60); do
+ if docker compose exec -T web test -f install.php 2>/dev/null; then
+ log 'installer staged, ready to install'
+ exit 0
+ fi
+ sleep 1
+done
+
+die 'timed out waiting for the entrypoint to stage install.php (docker compose logs web)'
diff --git a/.docker/test.sh b/.docker/test.sh
new file mode 100755
index 00000000000..8d6d818e206
--- /dev/null
+++ b/.docker/test.sh
@@ -0,0 +1,87 @@
+#!/usr/bin/env bash
+# Runs the test suite against a real forum, on one engine or on both.
+#
+# .docker/test.sh both engines, whole suite
+# .docker/test.sh --engine postgresql
+# .docker/test.sh --engine both --filter ModSettings
+#
+# Anything after the recognised options is handed straight to PHPUnit, so
+# --filter, --testsuite and friends work as usual.
+#
+# Installs a forum for an engine that has not got one yet. Use
+# .docker/install-forum.sh --force to start any of them over.
+#
+# Running on both engines is the point rather than a thoroughness exercise: the
+# two disagree often enough that a suite which only ever sees one of them
+# proves considerably less than it appears to. The counter regression in
+# tests/Integration/ModSettingsTest.php passes on MySQL with the bug still in
+# place, and fails on PostgreSQL.
+#
+# Runs on the host.
+set -euo pipefail
+
+. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
+
+ENGINE='both'
+PHPUNIT_ARGS=()
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --engine) ENGINE="$2"; shift 2 ;;
+ --engine=*) ENGINE="${1#*=}"; shift ;;
+ -h|--help) sed -n '2,19p' "${BASH_SOURCE[0]}"; exit 0 ;;
+ *) PHPUNIT_ARGS+=("$1"); shift ;;
+ esac
+done
+
+ENGINES=$(engine_list "$ENGINE") || die "unknown engine: $ENGINE"
+
+cd "$BOARD_DIR"
+
+# Remember what was active, and put it back afterwards however this ends. A test
+# run should not silently leave the forum pointed somewhere else.
+ORIGINAL=''
+
+if [ -f Settings.php ]; then
+ ORIGINAL=$(sed -n "s|^\$db_type = '\([^']*\)';.*|\1|p" Settings.php | head -n 1 | tr '[:upper:]' '[:lower:]')
+fi
+
+restore_engine() {
+ if [ -n "$ORIGINAL" ] && [ -f "$SETTINGS_DIR/Settings.$(engine_smf_type "$ORIGINAL").php" ]; then
+ "$DOCKER_DIR/use-engine.sh" "$ORIGINAL" >/dev/null 2>&1 || true
+ fi
+}
+
+trap restore_engine EXIT
+
+FAILED=''
+
+for smf_type in $ENGINES; do
+ if [ -z "$(installed_version "$smf_type" || true)" ]; then
+ log "${smf_type}: no forum yet, installing one"
+ "$DOCKER_DIR/install-forum.sh" --engine "$smf_type" >/dev/null
+ fi
+
+ "$DOCKER_DIR/use-engine.sh" "$smf_type" >/dev/null
+
+ log "${smf_type}: running the tests"
+
+ # The HTTP tests sign in, so they need to be told who the administrator is.
+ # These default to what install-forum.sh created; export them to point the
+ # suite at a forum that was set up some other way.
+ if docker compose exec -T \
+ -e SMF_ADMIN_USER="$SMF_ADMIN_USER" \
+ -e SMF_ADMIN_PASS="$SMF_ADMIN_PASS" \
+ web vendor/bin/phpunit --no-coverage --colors=always "${PHPUNIT_ARGS[@]+"${PHPUNIT_ARGS[@]}"}"; then
+ log "${smf_type}: passed"
+ else
+ warn "${smf_type}: FAILED"
+ FAILED="${FAILED} ${smf_type}"
+ fi
+done
+
+if [ -n "$FAILED" ]; then
+ die "failed on:${FAILED}"
+fi
+
+log "passed on: ${ENGINES}"
diff --git a/.docker/use-engine.sh b/.docker/use-engine.sh
new file mode 100755
index 00000000000..5fd2b7a96b5
--- /dev/null
+++ b/.docker/use-engine.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+# Switches which installed forum is live, without reinstalling either.
+#
+# .docker/use-engine.sh mysql
+# .docker/use-engine.sh postgresql
+#
+# Both database services always run, on separate volumes, so each keeps its own
+# forum. What decides which one you get is Settings.php: it pins $db_type, and
+# it wins over SMF_DB_TYPE. install-forum.sh files a copy per engine, and this
+# puts one of them back.
+#
+# No container restart is needed. The entrypoint only writes Settings.php when
+# there is not one, so it leaves whatever is in place alone.
+#
+# To throw an install away and start over, use reset.sh instead.
+#
+# Runs on the host.
+set -euo pipefail
+
+. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
+
+[ $# -eq 1 ] || die 'usage: use-engine.sh mysql|postgresql'
+
+case "$1" in
+ -h|--help) sed -n '2,16p' "${BASH_SOURCE[0]}"; exit 0 ;;
+esac
+
+SMF_TYPE=$(engine_smf_type "$1") || die "unknown engine: $1"
+SAVED="$SETTINGS_DIR/Settings.${SMF_TYPE}.php"
+
+cd "$BOARD_DIR"
+
+[ -f "$SAVED" ] || die "no saved settings for ${SMF_TYPE} -- run .docker/install-forum.sh --engine ${SMF_TYPE}"
+
+cp "$SAVED" Settings.php
+cp "$SETTINGS_DIR/Settings_bak.${SMF_TYPE}.php" Settings_bak.php
+
+# SMF's cache holds a serialised copy of $modSettings, which describes the
+# database we are switching away from. $cache_enable defaults to 0, so there is
+# usually nothing there -- but the directory also holds db_last_error.php and
+# the generated CSS and JS, and clearing it costs nothing.
+find cache -type f ! -name 'index.php' ! -name '.htaccess' -delete 2>/dev/null || true
+
+VERSION=$(installed_version "$SMF_TYPE" || true)
+
+[ -n "$VERSION" ] || warn "${SMF_TYPE} has no forum installed -- Settings.php now points at an empty database"
+
+log "active engine: ${SMF_TYPE}${VERSION:+ (SMF ${VERSION})} -- ${SMF_BOARDURL}"
diff --git a/.docker/user.sh b/.docker/user.sh
new file mode 100755
index 00000000000..eebf4d1a8e5
--- /dev/null
+++ b/.docker/user.sh
@@ -0,0 +1,204 @@
+#!/usr/bin/env bash
+# Looks at forum accounts and fixes their passwords, so "which password did this
+# forum end up with?" does not turn into a session of hand written SQL.
+#
+# .docker/user.sh list
+# .docker/user.sh check admin 'password'
+# .docker/user.sh reset admin 'a new password'
+# .docker/user.sh check admin 'password' --engine postgresql
+#
+# check exits 0 when SMF would accept the password and 1 when it would not, so
+# it is usable in a conditional as well as by eye.
+#
+# Everything goes through SMF's own Security class rather than writing a hash
+# from here: what this puts in the table is by construction what Login2 expects
+# to find there. Nothing is ever printed that would reveal an existing password;
+# hashes are one way and this does not try to be clever about that.
+#
+# Without --engine it acts on the forum Settings.php currently points at. With
+# it, it reads the copy use-engine.sh saved for that engine instead, which means
+# the other forum can be inspected without switching to it.
+#
+# Runs on the host.
+set -euo pipefail
+
+. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
+
+ENGINE=''
+ACTION=''
+NAME=''
+PASSWORD=''
+POSITIONAL=()
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --engine) ENGINE="$2"; shift 2 ;;
+ --engine=*) ENGINE="${1#*=}"; shift ;;
+ -h|--help) sed -n '2,22p' "${BASH_SOURCE[0]}"; exit 0 ;;
+ -*) die "unknown argument: $1" ;;
+ *) POSITIONAL+=("$1"); shift ;;
+ esac
+done
+
+[ "${#POSITIONAL[@]}" -gt 0 ] || die "need an action: list, check or reset (see --help)"
+
+ACTION="${POSITIONAL[0]}"
+NAME="${POSITIONAL[1]:-}"
+PASSWORD="${POSITIONAL[2]:-}"
+
+case "$ACTION" in
+ list) ;;
+ check|reset)
+ [ -n "$NAME" ] || die "${ACTION}: need a member name"
+ [ -n "$PASSWORD" ] || die "${ACTION}: need a password"
+ ;;
+ *) die "unknown action: ${ACTION} (expected list, check or reset)" ;;
+esac
+
+# The settings file to read, as the container sees it. Empty means "whichever
+# forum is live", which is the common case and needs no explanation in the log.
+SETTINGS='/var/www/html/Settings.php'
+
+if [ -n "$ENGINE" ]; then
+ SMF_TYPE=$(engine_smf_type "$ENGINE") || die "unknown engine: $ENGINE"
+ SAVED="$DOCKER_DIR/settings/Settings.${SMF_TYPE}.php"
+
+ [ -f "$SAVED" ] || die "no saved settings for ${SMF_TYPE}; install it first with install-forum.sh --engine ${SMF_TYPE}"
+
+ SETTINGS="/var/www/html/.docker/settings/Settings.${SMF_TYPE}.php"
+fi
+
+cd "$BOARD_DIR"
+
+# The password goes through the environment rather than the argument list:
+# arguments are visible to anything that can read the process table, and a
+# password typed at a shell is quite enough exposure already.
+docker compose exec -T \
+ -e SMF_USER_ACTION="$ACTION" \
+ -e SMF_USER_NAME="$NAME" \
+ -e SMF_USER_PASSWORD="$PASSWORD" \
+ -e SMF_USER_SETTINGS="$SETTINGS" \
+ web php <<-'PHP'
+ query(
+ 'SELECT id_member, member_name, real_name, email_address, id_group, is_activated
+ FROM {db_prefix}members
+ ORDER BY id_member',
+ [],
+ );
+
+ printf("%-5s %-20s %-28s %-7s %s\n", 'id', 'member_name', 'email', 'group', 'activated');
+
+ while ($row = $db->fetch_assoc($request)) {
+ printf(
+ "%-5d %-20s %-28s %-7d %s\n",
+ $row['id_member'],
+ $row['member_name'],
+ $row['email_address'],
+ $row['id_group'],
+ // 1 is the only value that can log in; the rest are awaiting
+ // activation, awaiting approval, banned or deleted.
+ $row['is_activated'] == 1 ? 'yes' : 'no (' . $row['is_activated'] . ')',
+ );
+ }
+
+ $db->free_result($request);
+
+ exit(0);
+ }
+
+ $request = $db->query(
+ 'SELECT id_member, member_name, passwd, is_activated
+ FROM {db_prefix}members
+ WHERE member_name = {string:name} OR email_address = {string:name}
+ LIMIT 1',
+ [
+ 'name' => $name,
+ ],
+ );
+
+ $member = $db->fetch_assoc($request);
+ $db->free_result($request);
+
+ if (!is_array($member)) {
+ fwrite(STDERR, 'error: no member called "' . $name . '" (try: user.sh list)' . "\n");
+
+ exit(1);
+ }
+
+ if ($action === 'check') {
+ $ok = SMF\Security::hashVerifyPassword($password, $member['passwd']);
+
+ echo $member['member_name'], ': ', $ok ? 'password is correct' : 'password is WRONG', "\n";
+
+ // Being right about the password is not the same as being able to log
+ // in, and the difference is worth saying out loud before someone spends
+ // an afternoon on it.
+ if ($ok && $member['is_activated'] != 1) {
+ echo ' note: the account is not active (is_activated = ', $member['is_activated'], '), so it cannot log in', "\n";
+ }
+
+ exit($ok ? 0 : 1);
+ }
+
+ $db->query(
+ 'UPDATE {db_prefix}members
+ SET passwd = {string:passwd}, passwd_flood = {string:empty}
+ WHERE id_member = {int:id}',
+ [
+ 'passwd' => SMF\Security::hashPassword($password),
+ // Cleared as well: SMF locks an account out for a while after
+ // enough wrong guesses, and resetting the password while leaving
+ // the lockout in place looks exactly like the password not working.
+ 'empty' => '',
+ 'id' => (int) $member['id_member'],
+ ],
+ );
+
+ echo $member['member_name'], ': password changed', "\n";
+ PHP
diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml
new file mode 100644
index 00000000000..398c9f9ae16
--- /dev/null
+++ b/.github/workflows/phpunit.yml
@@ -0,0 +1,40 @@
+name: PHPUnit
+
+on:
+ push:
+ branches:
+ - release-3.0
+ pull_request:
+
+jobs:
+ phpunit:
+ name: Unit tests
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ php: [ 8.4, 8.5 ]
+
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #4.2.2
+
+ - name: Setup PHP ${{ matrix.php }}
+ uses: shivammathur/setup-php@9e72090525849c5e82e596468b86eb55e9cc5401 #2.32.0
+ with:
+ php-version: ${{ matrix.php }}
+ coverage: none
+
+ - name: Cache Composer packages
+ id: composer-cache
+ uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf #4.2.2
+ with:
+ path: vendor
+ key: ${{ runner.os }}-php${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
+ restore-keys: ${{ runner.os }}-php${{ matrix.php }}-
+
+ - name: Install dependencies
+ if: steps.composer-cache.outputs.cache-hit != 'true'
+ run: composer install --prefer-dist --no-progress --ansi
+
+ - name: Run the unit tests
+ run: vendor/bin/phpunit --no-coverage --colors=always
diff --git a/.gitignore b/.gitignore
index 8a574e9b8d5..23f4dc34e89 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,6 +21,7 @@ attachments/
!/attachments/.htaccess
!/attachments/index.php
/upgrade.php
+/install.php
Themes/default/css/minified*.css
Themes/default/scripts/minified*.js
Themes/default/scripts/minified_deferred*.js
@@ -72,6 +73,16 @@ Thumbs.db
*.lnk
._*
+# Local dev environment #
+##########################
+/.env
+/compose.override.yaml
+/compose.override.yml
+# One saved Settings.php per engine, so use-engine.sh can switch between two
+# installs without reinstalling. Generated secrets and a machine-specific
+# board URL: local to whoever ran the installer.
+/.docker/settings/
+
# Test / Private files #
########################
/nbproject/private/
@@ -87,3 +98,7 @@ vendor/
.phplint-cache
.phplint.cache
composer.phar
+
+# PHPUnit
+.phpunit.cache/
+.phpunit.result.cache
diff --git a/AGENTS.md b/AGENTS.md
index 70a078fe6a9..09fbf5d8363 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -79,12 +79,145 @@ param, throws, return.
## Verifying a change
-**There is no test suite.** No PHPUnit, no `tests/` directory, nothing in the history.
-CI only proves that the code parses (`phplint` on 8.4 and 8.5) and is formatted
-correctly. It never executes SMF. Do not assume green checks mean a change works.
+### Tests
-So verify by running the forum. The repository ships a Docker environment, documented
-in full in `.docker/README.md`:
+There is a unit test suite. It is small and deliberately narrow, but where it reaches,
+it is the only automated proof that a change does what it claims:
+
+```bash
+composer test # or: vendor/bin/phpunit
+```
+
+CI runs it on every pull request, and on pushes to `release-3.0`, via
+`.github/workflows/phpunit.yml`. Feature branches are only checked once they are in a PR,
+so run it locally.
+
+**The expectation: if the code you touched is reachable from this suite, your change
+adds or updates a test in the same commit.** A bug fix lands as a regression test that
+fails before the fix and passes after it, with a comment saying what went wrong — see
+`SapiTest::testAPlainByteCountKeepsItsLastDigit()` for the shape. When the code is not
+reachable, say so explicitly in the PR description rather than leaving it unsaid; do not
+contort production code, add mocks or fake a database to force something under test.
+
+#### When a test is possible
+
+`tests/bootstrap.php` defines the constants `index.php` would define, points the
+autoloader at `Sources/` and sets `Config::$boarddir`, `$sourcedir`, `$packagesdir`,
+`$languagesdir`, `$cachedir` and `$language`. That is all. No `Settings.php`, no
+database, no request. Within those limits the following are all testable, and each has a
+worked example in `tests/Unit/`:
+
+- **Pure and static helpers**: `Utils::buildRegex()`, `Sapi::memoryReturnBytes()`,
+ `Security::hashPassword()`. Cheap to cover with a `#[DataProvider]`.
+- **Value objects that parse or normalise a string**: `IP`, `Url`, `Uuid`,
+ `TimeInterval`, `Punycode`. Construct one and assert on the result.
+- **Class-level behaviour that needs no state**: late static binding, shared statics,
+ what `Foo::load()` returns. `ActionTraitTest` is entirely this.
+- **Protected and private helpers**, through `ReflectionMethod`, when the public entry
+ point around them needs a database but the helper itself does not
+ (`CreatePostNotifyTest::getTimeOffset()`).
+- **Code that reads a few `Config::$modSettings` keys.** Set them in `setUp()` and
+ `unset()` them in `tearDown()`. PHPUnit does not reset SMF's statics between tests, so
+ a key left behind leaks into every test that follows.
+- **Anything that only needs the language or Unicode data files**, since the bootstrap
+ sets the paths they look in.
+
+#### When it is not
+
+- Anything calling `Db::$db` — there is no connection, and faking one is not worth it.
+ This belongs in the integration suite below.
+- Anything reading `User::$me`, the session, `$_GET`/`$_POST`/`$_SERVER`, or expecting a
+ loaded theme or `Utils::$context`.
+- Anything that emits output or sends headers. `beStrictAboutOutputDuringTests` is on, so
+ a stray `echo` fails the test rather than being swallowed.
+
+`failOnRisky` and `failOnWarning` are on as well: a test that asserts nothing is a
+failure, not a pass.
+
+### The integration suite
+
+`tests/Integration/` runs against a forum that is actually installed, so it reaches the
+things above: `Db::$db`, `Config::$modSettings` as the database holds it, and `User::$me`.
+
+```bash
+.docker/test.sh # both engines
+.docker/test.sh --engine postgresql
+```
+
+`composer test` still runs everything. When there is no forum to talk to the integration
+tests **skip** rather than fail, so it stays useful on a machine with no Docker. To get
+one: `.docker/install-forum.sh --engine mysql`.
+
+Extend `SMF\Tests\Integration\IntegrationTestCase`, which gives you:
+
+- a transaction per test, rolled back afterwards, so tests do not have to order
+ themselves around each other;
+- `actingAs($id)` and `adminId()` for a current user, via `User::setMe()` — the same seam
+ `Login2::DoLogin()` uses;
+- `hook($name, $function)`, registered in `$modSettings` only, so it disappears with the
+ rollback;
+- `assertNoErrorsLogged()`, which is usually the most valuable line in the test: SMF
+ records most of what goes wrong in `log_errors` rather than showing it, so a page that
+ returned the right thing while quietly logging an undefined index has still regressed;
+- `queryRow()` and `rawSetting()`, which read past `$modSettings` and its cache and fail
+ with a readable message instead of a `TypeError` when a query fails.
+
+Two things the rollback does not cover: **DDL**, since MySQL commits implicitly on
+`CREATE`/`ALTER`/`DROP`; and anything happening in another process, such as a request made
+over HTTP, which runs on its own connection.
+
+#### HTTP tests
+
+`tests/Integration/Http/` drives the forum over the wire, through `HttpTestCase`. Use it
+when the thing worth proving is that a *page* works: the session, the cookies, the theme
+and the templates are all in the path, and none of them are otherwise reachable.
+
+They cannot use a transaction and do not try to - see `HttpTestCase::usesTransaction()` -
+so a test that writes cleans up after itself. Four things about SMF make writing them
+harder than it looks, all of them handled in the base class:
+
+- **Arrive at the forum before submitting anything.** The first request of a new session
+ regenerates it, so a security token minted on the very first page a visitor sees can
+ never be validated. The symptom is a 403 "Token verification failed" that looks like a
+ broken token rather than a replaced session.
+- **Send the button you mean to press.** `HttpResponse::formFields()` deliberately leaves
+ buttons out. The posting form has both `preview` and `post`; submitting the pair means
+ preview wins, the post is never made, and the response is a perfectly ordinary 200.
+- **Flood control will hit you.** `Security::spamProtection()` allows a moderator one
+ login or post every two seconds per IP, and tests are far faster than people.
+ `submitForm()` waits it out once rather than failing at random.
+- **Quote `errorText()` in failure messages, not the body.** A fatal error in SMF is a
+ normal page, and its first few hundred characters are the menu.
+
+**Run both engines.** This is not thoroughness for its own sake — the two disagree often
+enough to matter. `ModSettingsTest` pins a bug that *passes on MySQL with the bug still
+in place*, because MySQL silently coerces text to a number where PostgreSQL refuses.
+On PostgreSQL a failed query also poisons the rest of the transaction, so one swallowed
+error turns every later query in the test into `false`.
+
+#### Writing one
+
+`tests/Unit/Test.php`, namespace `SMF\Tests\Unit`, `declare(strict_types=1)`,
+extending `PHPUnit\Framework\TestCase`, with `#[CoversClass]` (or `#[CoversTrait]` for a
+trait) on the class. Name the test after the behaviour, not the method —
+`testItNormalisesIPv6ToItsShortestForm()`, not `testConstruct()`. New directories need
+the usual `index.php` stub.
+
+The code style rules apply to tests too, so run `composer lint-fix` on them. Two
+consequences of the fixer worth knowing before you fight it:
+
+- Data providers are `public static`, so `ordered_class_elements` moves them *below* the
+ public test methods, into their own `Public static methods` banner.
+- The `SMF/section_comments` fixer inserts a banner between an attribute and the method
+ it belongs to. Do not let a method carrying `#[DataProvider]` be the first one in its
+ group; `CreatePostNotifyTest` carries a note about this.
+
+### Running the forum
+
+The rest of CI only proves the code parses (`phplint` on 8.4 and 8.5) and is formatted.
+So a fully green PR still tells you very little about whether a change works. Verify by
+running the forum. The repository ships a Docker environment, documented in full in
+`.docker/README.md`:
```bash
docker compose up -d --build
@@ -115,10 +248,6 @@ docker compose exec postgres psql -U smf -d smf -c 'SELECT * FROM smf_log_errors
`smf_log_errors` is the first place to look. Many failures are recorded there rather
than shown, especially anything in a background task.
-Some code is reachable with only the autoloader plus the constants that `index.php`
-defines, which is enough to exercise pure helpers without a database. Anything that
-touches `User::$me` or `Db::$db` needs a real request or fixtures.
-
## Things that bite in this codebase
- **Typed properties with no default throw when read before assignment.** Several are
diff --git a/Languages/en_US/Maintenance.php b/Languages/en_US/Maintenance.php
index 3f3f4ee4477..21dceeb9f5b 100644
--- a/Languages/en_US/Maintenance.php
+++ b/Languages/en_US/Maintenance.php
@@ -124,6 +124,7 @@
It is recommended that you visit the Simple Machines website to ensure you are installing the latest version.';
$txt['error_already_installed'] = 'The installer has detected that you already have SMF installed. It is strongly advised that you do not try to overwrite an existing installation, continuing with installation may result in the loss or corruption of existing data.
If you wish to upgrade please visit the Simple Machines Website and download the latest upgrade package.
If you wish to overwrite your existing installation, including all data, it is recommended that you delete the existing database tables and replace Settings.php and try again.';
$txt['error_db_missing'] = 'The installer was unable to detect any database support in PHP. Please ask your host to ensure that PHP was compiled with the desired database, or that the proper extension is being loaded.';
+$txt['error_db_type_unknown'] = '“{db_type}” is not a database type this server supports. Supported types: {supported}.';
$txt['error_session_missing'] = 'The installer was unable to detect sessions support in your server’s installation of PHP. Please ask your host to ensure that PHP was compiled with session support (which in fact is the PHP default, meaning your host currently has explicitly disabled it).';
$txt['error_missing_files'] = 'Unable to find crucial installation files in the directory of this script!