diff --git a/.docker/README.md b/.docker/README.md index 2ba2f277fb..2556731adf 100644 --- a/.docker/README.md +++ b/.docker/README.md @@ -53,6 +53,57 @@ 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. + +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. + +### 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. @@ -77,6 +128,32 @@ 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. +## Running CI locally + +```sh +.docker/ci.sh # everything CI checks +.docker/ci.sh --full # style check over the whole tree, not just changes +.docker/ci.sh --fix # apply the style fixes rather than reporting them +``` + +Mirrors `php.yml` (sign-off, the four file integrity checks, phplint) and +`php-cs-fixer.yml`, and runs the test suite when the branch has one. Every check +runs even after one fails, because finding out about the second problem on the +next push is the thing this is meant to stop. + +`--full` is worth knowing about: the style workflow normally only looks at the +files a pull request changed, but switches to the whole tree when `composer.lock` +or the fixer config is in the diff. So a branch that touches a dependency +inherits every pre-existing violation in the repository. `--full` tells you that +before you push rather than after. + +Two things it cannot do for you: + +- **The other PHP version.** CI lints and tests on 8.4 *and* 8.5; the container + is whichever built it. To cover the other: + `PHP_VERSION=8.5 docker compose up -d --build web`. +- **The integration tests on both engines.** Use `.docker/test.sh` for that. + ## Everyday use ```sh @@ -102,8 +179,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 diff --git a/.docker/ci.sh b/.docker/ci.sh new file mode 100755 index 0000000000..101c5a5ee1 --- /dev/null +++ b/.docker/ci.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# Runs what CI runs, before you push instead of after. +# +# .docker/ci.sh every check +# .docker/ci.sh --full style check over the whole tree, not just changes +# .docker/ci.sh --fix apply the style fixes rather than reporting them +# +# The workflows this mirrors are php.yml (sign-off, the file integrity checks, +# phplint) and php-cs-fixer.yml. phpunit.yml is included when the branch has a +# test suite on it. +# +# Every check runs even after one fails, because finding out about the second +# problem on the next push is the thing this script exists to stop. +# +# One difference worth knowing: CI lints and tests on PHP 8.4 *and* 8.5, and the +# web container is whichever PHP_VERSION built it (8.4 by default). To cover the +# other one, rebuild against it: +# +# PHP_VERSION=8.5 docker compose up -d --build web +# +# Runs on the host. +set -uo pipefail + +. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" + +FULL=0 +FIX=0 + +while [ $# -gt 0 ]; do + case "$1" in + --full) FULL=1; shift ;; + --fix) FIX=1; shift ;; + -h|--help) sed -n '2,21p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done + +# Unlike the other scripts here this one does not set -e, so that a failing +# check does not stop the ones after it. That means cd has to be checked. +cd "$BOARD_DIR" || die "cannot enter $BOARD_DIR" + +docker compose ps --status running --services 2>/dev/null | grep -qx web \ + || die 'the web container is not running -- docker compose up -d' + +FAILED='' + +# $1 label, rest: the command to run in the web container. +check() { + local label="$1" + shift + + printf '\n[smf-dev] --- %s ---\n' "$label" + + if docker compose exec -T web "$@"; then + return 0 + fi + + FAILED="${FAILED}\n - ${label}" + + return 1 +} + +# ------------------------------------------------------------------- php.yml +check 'sign-off (DCO)' php ./vendor/simplemachines/build-tools/check-signed-off.php + +check 'file integrity' sh -c ' + set -e + php ./vendor/simplemachines/build-tools/check-smf-license.php + php ./vendor/simplemachines/build-tools/check-smf-languages.php + php ./vendor/simplemachines/build-tools/check-smf-index.php + php ./vendor/simplemachines/build-tools/check-version.php + echo "all four integrity checks passed" +' + +check "syntax ($(docker compose exec -T web php -r 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;' 2>/dev/null))" \ + vendor/bin/phplint --no-progress --exclude .git --exclude vendor . + +# ------------------------------------------------------------ php-cs-fixer.yml +# CI checks only the files a pull request changed, and switches to the whole +# tree when composer.lock or the fixer config is part of the diff. --full asks +# for that second behaviour, which is worth doing before touching a dependency: +# it surfaces anything already non-compliant on release-3.0. +FIXER_ARGS=(--config .php-cs-fixer.dist.php --allow-risky=yes --using-cache=no --show-progress=none) + +if [ "$FIX" -eq 1 ]; then + FIXER_MODE='fix' +else + FIXER_MODE='check' + FIXER_ARGS+=(--diff) +fi + +if [ "$FULL" -eq 1 ]; then + check 'code style (whole tree)' vendor/bin/php-cs-fixer "$FIXER_MODE" "${FIXER_ARGS[@]}" +else + # Same intersection CI builds, from the files this branch actually touches: + # committed since release-3.0, staged, unstaged, and - the one CI never has + # to think about - new files that are not in the index yet. + CHANGED=$( + { + git diff --name-only --diff-filter=d release-3.0...HEAD -- '*.php' + git diff --name-only --diff-filter=d HEAD -- '*.php' + git ls-files --others --exclude-standard -- '*.php' + } 2>/dev/null | sort -u | grep -v '^$' + ) + + if [ -z "$CHANGED" ]; then + printf '\n[smf-dev] --- code style --- no changed PHP files\n' + else + # shellcheck disable=SC2086 + check 'code style (changed files)' vendor/bin/php-cs-fixer "$FIXER_MODE" "${FIXER_ARGS[@]}" --path-mode=intersection $CHANGED + fi +fi + +# ---------------------------------------------------------------- phpunit.yml +if [ -f phpunit.xml.dist ]; then + check 'tests' vendor/bin/phpunit --no-coverage --colors=always +else + printf '\n[smf-dev] --- tests --- no phpunit.xml.dist on this branch, skipping\n' +fi + +# ---------------------------------------------------------------------- result +printf '\n' + +if [ -n "$FAILED" ]; then + # shellcheck disable=SC2059 + printf "[smf-dev] failed:${FAILED}\n" >&2 + exit 1 +fi + +log 'everything CI checks passes' diff --git a/.docker/install-forum.sh b/.docker/install-forum.sh new file mode 100755 index 0000000000..613b604c90 --- /dev/null +++ b/.docker/install-forum.sh @@ -0,0 +1,187 @@ +#!/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" + ) + + 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" + + 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 0000000000..5c13d41008 --- /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/reset.sh b/.docker/reset.sh new file mode 100755 index 0000000000..00f2de5a40 --- /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/use-engine.sh b/.docker/use-engine.sh new file mode 100755 index 0000000000..5fd2b7a96b --- /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/.gitignore b/.gitignore index 6b46b9c3a5..7bcc6bba3a 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,10 @@ Thumbs.db /.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 # ######################## diff --git a/Languages/en_US/Maintenance.php b/Languages/en_US/Maintenance.php index 3f3f4ee447..21dceeb9f5 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!

Please make sure you uploaded the entire installation package, including the sql file, and then try again.'; $txt['error_session_save_path'] = 'Please inform your host that the session.save_path specified in php.ini is not valid! It needs to be changed to a directory that exists and is writable by the user PHP is running under.
'; diff --git a/Sources/Maintenance/Maintenance.php b/Sources/Maintenance/Maintenance.php index 8809ee6f5f..8c47056f45 100644 --- a/Sources/Maintenance/Maintenance.php +++ b/Sources/Maintenance/Maintenance.php @@ -808,6 +808,56 @@ public static function setQueryString(): string */ public static function exit(bool $fallthrough = false): void { + // On the command line there is no template to render, so everything the + // tool wanted to tell us has nowhere to go: a scripted install that died + // on step three looks exactly like one that finished. Put the problems on + // stderr and leave a non-zero status behind instead. + // + // A step that simply needs more input sets neither of these, so pausing + // part way through is still a success -- the installer is meant to be + // called more than once. + if ($fallthrough && Sapi::isCLI()) { + foreach (self::$warnings as $warning) { + fwrite(STDERR, 'warning: ' . self::plainText($warning) . "\n"); + } + + $problems = self::$errors; + + if (self::$fatal_error !== '') { + array_unshift($problems, self::$fatal_error); + } + + if ($problems !== []) { + foreach ($problems as $problem) { + fwrite(STDERR, 'error: ' . self::plainText($problem) . "\n"); + } + + exit(1); + } + + // Nothing went wrong, but we may not be finished either: a step can + // stop because it wanted input it was not given. Say which one, so + // a script that has to be run more than once can tell where it got + // to. The step numbers its own id from one, which is what every + // other line of output uses. + // + // The last step is excluded on purpose. Tools end by returning false + // from it so that the web flow stops and renders its "all done" + // template, which means reaching it is success, not a pause. + $steps = isset(self::$tool) ? self::$tool->getSteps() : []; + + if (isset($steps[self::getCurrentStep()]) && self::getCurrentStep() < \count($steps) - 1) { + $stopped = $steps[self::getCurrentStep()]; + + fwrite( + STDERR, + 'stopped at step ' . $stopped->getId() + . ' of ' . \count($steps) + . ' (' . $stopped->getName() . ")\n", + ); + } + } + // We usually dump our templates out. if (!$fallthrough) { // Send character set. @@ -920,4 +970,20 @@ private static function setCurrentStep(?int $step = null): void { $_GET['step'] = $step ?? (self::getCurrentStep() + 1); } + + /** + * Flattens one of our messages into something worth reading in a terminal. + * + * The steps build these for a browser, so they arrive carrying markup: the + * database errors in particular wrap the driver's own message in a div. + * + * @param string $message The message, as the step wrote it. + * @return string The same message, without the markup. + */ + private static function plainText(string $message): string + { + $message = preg_replace('~~i', "\n", $message) ?? $message; + + return trim(html_entity_decode(strip_tags($message), ENT_QUOTES | ENT_HTML5, 'UTF-8')); + } } diff --git a/Sources/Maintenance/Tools/Install.php b/Sources/Maintenance/Tools/Install.php index 7cc6ee38b4..4e543bc495 100644 --- a/Sources/Maintenance/Tools/Install.php +++ b/Sources/Maintenance/Tools/Install.php @@ -439,7 +439,19 @@ public function databaseSettings(): bool $db_prefix = $_POST['db_prefix']; if (!isset(Maintenance::$context['databases'][$db_type])) { - Maintenance::$fatal_error = Lang::getTxt('upgrade_unknown_error', file: 'Maintenance'); + // upgrade_unknown_error, which used to be reported here, does not + // exist -- so this produced an empty fatal error and left no clue + // what had gone wrong. Naming the type and the alternatives matters + // most on the command line, where the type is typed out by hand + // rather than picked from a list of exactly these keys. + Maintenance::$fatal_error = Lang::getTxt( + 'error_db_type_unknown', + [ + 'db_type' => $db_type, + 'supported' => Lang::sentenceList(array_keys(Maintenance::$context['databases'])), + ], + file: 'Maintenance', + ); $this->logProgress(Maintenance::$fatal_error); return false; @@ -609,7 +621,15 @@ public function forumSettings(): bool Db::load(); // Now, to put what we've learned together... and add a path. - Maintenance::$context['detected_url'] = 'http' . (Sapi::httpsOn() ? 's' : '') . '://' . $this->defaultHost() . substr(Maintenance::getSelf(), 0, strrpos(Maintenance::getSelf(), '/')); + // getSelf() is $_SERVER['PHP_SELF'], which in a request is a rooted path + // but on the command line is whatever was typed -- usually a bare + // 'install.php' with no directory in it at all. strrpos() then returns + // false, and substr() with a false length is fatal on PHP 8, so the + // installer died here on every CLI run. + $self = Maintenance::getSelf(); + $last_slash = strrpos($self, '/'); + + Maintenance::$context['detected_url'] = 'http' . (Sapi::httpsOn() ? 's' : '') . '://' . $this->defaultHost() . ($last_slash === false ? '' : substr($self, 0, $last_slash)); // Check if the database sessions will even work. Maintenance::$context['test_dbsession'] = (\ini_get('session.auto_start') != 1); @@ -1182,48 +1202,58 @@ public function finalize(): bool Db::$db->free_result($request); } - // Automatically log them in ;) - if (isset(Maintenance::$context['id_member'], Maintenance::$context['password_salt'])) { - Cookie::setLoginCookie(3153600 * 60, Maintenance::$context['id_member'], Cookie::encrypt($_POST['password1'], Maintenance::$context['password_salt'])); - } + // Sign the new administrator in, so the browser that just ran the + // installer lands on an admin session rather than a login form. + // + // None of that means anything on the command line: there is no browser + // to hold the cookie, and no user agent to record against the session. + // Attempting it anyway sent headers after output had already started and + // left four warnings on every run, then wrote a session row keyed on an + // undefined HTTP_USER_AGENT. + if (!Sapi::isCLI()) { + // Automatically log them in ;) + if (isset(Maintenance::$context['id_member'], Maintenance::$context['password_salt'])) { + Cookie::setLoginCookie(3153600 * 60, Maintenance::$context['id_member'], Cookie::encrypt($_POST['password1'], Maintenance::$context['password_salt'])); + } - $result = Db::$db->query( - 'SELECT value - FROM {db_prefix}settings - WHERE variable = {string:db_sessions}', - [ - 'db_sessions' => 'databaseSession_enable', - 'db_error_skip' => true, - ], - ); + $result = Db::$db->query( + 'SELECT value + FROM {db_prefix}settings + WHERE variable = {string:db_sessions}', + [ + 'db_sessions' => 'databaseSession_enable', + 'db_error_skip' => true, + ], + ); - if (Db::$db->num_rows($result) != 0) { - list($db_sessions) = Db::$db->fetch_row($result); - } - Db::$db->free_result($result); + if (Db::$db->num_rows($result) != 0) { + list($db_sessions) = Db::$db->fetch_row($result); + } + Db::$db->free_result($result); - if (empty($db_sessions)) { - $_SESSION['admin_time'] = time(); - } else { - $_SERVER['HTTP_USER_AGENT'] = substr($_SERVER['HTTP_USER_AGENT'], 0, 211); + if (empty($db_sessions)) { + $_SESSION['admin_time'] = time(); + } else { + $_SERVER['HTTP_USER_AGENT'] = substr($_SERVER['HTTP_USER_AGENT'], 0, 211); - Db::$db->insert( - 'replace', - '{db_prefix}sessions', - [ - 'session_id' => 'string', - 'last_update' => 'int', - 'data' => 'string', - ], - [ + Db::$db->insert( + 'replace', + '{db_prefix}sessions', [ - session_id(), - time(), - 'USER_AGENT|s:' . \strlen($_SERVER['HTTP_USER_AGENT']) . ':"' . $_SERVER['HTTP_USER_AGENT'] . '";admin_time|i:' . time() . ';', + 'session_id' => 'string', + 'last_update' => 'int', + 'data' => 'string', ], - ], - ['session_id'], - ); + [ + [ + session_id(), + time(), + 'USER_AGENT|s:' . \strlen($_SERVER['HTTP_USER_AGENT']) . ':"' . $_SERVER['HTTP_USER_AGENT'] . '";admin_time|i:' . time() . ';', + ], + ], + ['session_id'], + ); + } } Logging::updateStats('member'); @@ -1424,7 +1454,19 @@ private function saveProgress(): bool */ private function defaultHost(): string { - return empty($_SERVER['HTTP_HOST']) ? $_SERVER['SERVER_NAME'] . (empty($_SERVER['SERVER_PORT']) || $_SERVER['SERVER_PORT'] == '80' ? '' : ':' . $_SERVER['SERVER_PORT']) : $_SERVER['HTTP_HOST']; + if (!empty($_SERVER['HTTP_HOST'])) { + return $_SERVER['HTTP_HOST']; + } + + // On the command line there is no request to describe, so neither of + // these is set. This value only seeds the suggested board URL on the + // form, and a scripted install passes its own boardurl in, so a + // placeholder is enough -- but reading the keys unguarded was a warning + // on every CLI run. + $host = $_SERVER['SERVER_NAME'] ?? 'localhost'; + $port = $_SERVER['SERVER_PORT'] ?? ''; + + return $host . (empty($port) || $port == '80' ? '' : ':' . $port); } /** diff --git a/Sources/Maintenance/Tools/ToolsBase.php b/Sources/Maintenance/Tools/ToolsBase.php index e2b14b1aea..61ae5bd414 100644 --- a/Sources/Maintenance/Tools/ToolsBase.php +++ b/Sources/Maintenance/Tools/ToolsBase.php @@ -612,10 +612,11 @@ public function updateSettingsFile(array $config_vars, ?bool $keep_quotes = null if (!Config::updateSettingsFile($config_vars, $keep_quotes, $rebuild)) { $this->logProgress(Lang::getTxt('log_failed_with_error', ['error' => Lang::getTxt('settings_error', file: 'Maintenance')], file: 'Maintenance')); - if (Sapi::isCLI()) { - die(); - } - + // This used to die() outright on the command line, which reported + // nothing and exited 0 -- a scripted install that could not write + // Settings.php looked exactly like one that worked. Recording the + // error and returning stops the run just as firmly, and now the + // caller gets to say why. Maintenance::$fatal_error = Lang::getTxt('settings_error', file: 'Maintenance'); return false;