Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
2746307
Adds a PHPUnit suite for code that needs no database
albertlast Jul 29, 2026
120f6a9
Merge branch 'fix/action-trait-subclass-instances' into tests/phpunit
albertlast Jul 29, 2026
10dfe94
Merge branch 'fix/createpost-notify-time-offset' into tests/phpunit
albertlast Jul 29, 2026
a142e5c
Runs the unit tests in CI and documents them
albertlast Jul 29, 2026
99ee104
Broadens the unit tests to the rest of the stateless surface
albertlast Jul 29, 2026
560dfb2
Compares URL schemes case insensitively
albertlast Jul 29, 2026
4caec1d
Merge branch 'fix/sapi-memory-return-bytes' into tests/phpunit
albertlast Jul 29, 2026
f9d38cd
Merge branch 'fix/url-is-scheme-case' into tests/phpunit
albertlast Jul 29, 2026
7d656ce
Turns the two noted defects into regression tests
albertlast Jul 29, 2026
3e45ec4
Marks the ActionTrait test as covering a trait
albertlast Jul 29, 2026
fa76555
Merge branch 'docs/agent-instructions' into tests/phpunit
albertlast Jul 30, 2026
b5f342f
Documents when the unit test suite can cover a change
albertlast Jul 31, 2026
64621b1
Merge branch 'release-3.0' into tests/install-cli
albertlast Aug 2, 2026
459b271
Reports maintenance tool failures on the command line
albertlast Aug 2, 2026
11fe20d
Stops the installer assuming there is a web request
albertlast Aug 2, 2026
516f453
Merge branch 'fix/install-finalize-user-not-loaded' into tests/instal…
albertlast Aug 2, 2026
7477a6c
Skips the browser sign-in when installing from the command line
albertlast Aug 2, 2026
89324b1
Reports the step a maintenance tool actually paused on
albertlast Aug 2, 2026
7665045
Installs the forum from the command line
albertlast Aug 2, 2026
7876ac1
Marks the dev environment scripts executable
albertlast Aug 2, 2026
170679c
Merge branch 'tests/phpunit' into tests/integration
albertlast Aug 2, 2026
b79d33c
Adds an integration suite that runs against a real forum
albertlast Aug 2, 2026
241250d
Lets the section comment fixer place its own banners
albertlast Aug 2, 2026
c7b53cd
Removes the trailing tabs from a blank line in PM search
albertlast Aug 2, 2026
61a35f5
Removes install.php once the forum is installed
albertlast Aug 2, 2026
78f54a1
Merge branch 'tests/install-cli' into tests/integration
albertlast Aug 2, 2026
c7b9f5a
Adds a script for checking and resetting account passwords
albertlast Aug 2, 2026
4ed7ed2
Merge branch 'tests/install-cli' into tests/integration
albertlast Aug 2, 2026
8c939ce
Merge remote-tracking branch 'origin/release-3.0' into tests/phpunit
albertlast Aug 5, 2026
ebd1d45
Merge branch 'tests/phpunit' into tests/integration
albertlast Aug 5, 2026
bcfcccb
Merges release-3.0 into the unit test branch
albertlast Aug 16, 2026
6e8bf15
Follows TimeInterval back to DateInterval's own constructor
albertlast Aug 16, 2026
1efc72f
Merges the updated Docker environment, and release-3.0 with it
albertlast Aug 16, 2026
b575593
Merges the updated install branch, and release-3.0 with it
albertlast Aug 16, 2026
270bece
Merges the updated unit test branch
albertlast Aug 16, 2026
29017ae
Merges release-3.0 into the command line install branch
albertlast Aug 24, 2026
f2fade7
Merges the command line install branch, and release-3.0 with it
albertlast Aug 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 117 additions & 2 deletions .docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,115 @@ 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.

### 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.
Expand Down Expand Up @@ -102,8 +211,8 @@ 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 compose down -v`, delete `Settings.php` and
`Settings_bak.php`, then `docker compose up -d`.
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

Expand Down Expand Up @@ -170,4 +279,10 @@ compose.yaml the stack
.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
```
205 changes: 205 additions & 0 deletions .docker/install-forum.sh
Original file line number Diff line number Diff line change
@@ -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})"
Loading
Loading