diff --git a/.docker/README.md b/.docker/README.md new file mode 100644 index 0000000000..2ba2f277fb --- /dev/null +++ b/.docker/README.md @@ -0,0 +1,173 @@ +# 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 + +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 compose down -v`, delete `Settings.php` and +`Settings_bak.php`, then `docker compose up -d`. + +## 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 +``` diff --git a/.docker/env.example b/.docker/env.example new file mode 100644 index 0000000000..31736f2a18 --- /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/mysql/init/10-smf.sh b/.docker/mysql/init/10-smf.sh new file mode 100644 index 0000000000..6f1589bdaa --- /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 0000000000..fcfb0ed41d --- /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 0000000000..05d37fe70b --- /dev/null +++ b/.docker/php/entrypoint.sh @@ -0,0 +1,133 @@ +#!/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}';|" \ + "$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 0000000000..ce832dcf3b --- /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 0000000000..6b32f746e7 --- /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 0000000000..8be73bae7c --- /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 0000000000..0498c9270f --- /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/.gitignore b/.gitignore index 8a574e9b8d..6b46b9c3a5 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,12 @@ Thumbs.db *.lnk ._* +# Local dev environment # +########################## +/.env +/compose.override.yaml +/compose.override.yml + # Test / Private files # ######################## /nbproject/private/ diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000000..cac7f51584 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,127 @@ +# SMF development environment. +# +# docker compose up -d --build +# -> http://localhost:8080/install.php +# +# Both database engines SMF supports are running. MySQL is what the forum is +# pointed at by default; set SMF_DB_TYPE=postgresql in .env to use the other. +# +# Everything below has a working default, so no .env file is required. Copy +# .docker/env.example to .env if you want to change ports or credentials. + +name: smf-dev + +services: + web: + build: + context: . + dockerfile: .docker/php/Dockerfile + args: + PHP_VERSION: ${PHP_VERSION:-8.4} + ports: + - "${WEB_PORT:-8080}:80" + volumes: + # The checkout is bind-mounted, so edits on the host are live immediately. + - .:/var/www/html + environment: + # Which engine the generated Settings.php points at: mysql or postgresql. + SMF_DB_TYPE: ${SMF_DB_TYPE:-mysql} + SMF_DB_NAME: ${DB_NAME:-smf} + SMF_DB_USER: ${DB_USER:-smf} + SMF_DB_PASSWD: ${DB_PASSWORD:-smf} + # Per-engine host and port, so switching SMF_DB_TYPE is the only change + # needed. These are container-internal, not the host ports below. + SMF_MYSQL_SERVER: mysql + SMF_MYSQL_PORT: "3306" + SMF_POSTGRES_SERVER: postgres + SMF_POSTGRES_PORT: "5432" + SMF_BOARDURL: http://localhost:${WEB_PORT:-8080} + depends_on: + mysql: + condition: service_healthy + postgres: + condition: service_healthy + restart: unless-stopped + + mysql: + image: mysql:${MYSQL_VERSION:-8.4} + ports: + # Exposed on 3307 by default so it cannot collide with a local mysql. + - "${MYSQL_PORT:-3307}:3306" + environment: + MYSQL_DATABASE: ${DB_NAME:-smf} + MYSQL_USER: ${DB_USER:-smf} + MYSQL_PASSWORD: ${DB_PASSWORD:-smf} + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-smf} + # SMF creates its tables as InnoDB/utf8mb4, so match that at server level + # rather than relying on whichever defaults the image ships with. The + # collation is deliberately left to the charset default: SMF's own DDL says + # `DEFAULT CHARSET=utf8mb4` with no COLLATE, so forcing a different one here + # would only apply to objects SMF did not create, and the two would diverge. + command: + - --character-set-server=utf8mb4 + - --default-storage-engine=InnoDB + volumes: + - mysql-data:/var/lib/mysql + - ./.docker/mysql/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 --silent"] + interval: 3s + timeout: 5s + retries: 40 + start_period: 20s + restart: unless-stopped + + postgres: + image: postgres:${POSTGRES_VERSION:-17-alpine} + # Also reachable as `db`, which is what installs made before MySQL was + # added have in their Settings.php. + networks: + default: + aliases: + - db + ports: + # Exposed on 5433 by default so it cannot collide with a local postgres. + - "${POSTGRES_PORT:-5433}:5432" + environment: + POSTGRES_DB: ${DB_NAME:-smf} + POSTGRES_USER: ${DB_USER:-smf} + POSTGRES_PASSWORD: ${DB_PASSWORD:-smf} + # Dev-only: skip the password prompt cost for local connections. + POSTGRES_HOST_AUTH_METHOD: scram-sha-256 + volumes: + - db-data:/var/lib/postgresql/data + - ./.docker/postgres/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-smf} -d ${DB_NAME:-smf}"] + interval: 3s + timeout: 3s + retries: 20 + restart: unless-stopped + + # Catches every mail the forum sends. Nothing leaves the machine. + mailpit: + image: axllent/mailpit:latest + ports: + - "${MAILPIT_PORT:-8025}:8025" + restart: unless-stopped + + # Browse and query either database at http://localhost:8081. The server field + # is pre-filled with whichever engine the forum is using; type the other + # service name in to look at it instead. + adminer: + image: adminer:latest + ports: + - "${ADMINER_PORT:-8081}:8080" + environment: + # Service name, not engine name: mysql or postgres. + ADMINER_DEFAULT_SERVER: ${ADMINER_SERVER:-mysql} + ADMINER_DESIGN: dracula + depends_on: + - mysql + - postgres + restart: unless-stopped + +volumes: + db-data: + mysql-data: