#!/usr/bin/env bash # ========================================================================== # Flarum 2.0 self-contained entrypoint. # # Idempotent: safe to re-run on every container restart. On first boot it # installs Flarum (composer create-project + flarum install), requires + enables # the full feature set (uploads, redis, Horizon, realtime, audit, extension # manager), wires redis cache+queue and (optional) SMTP mail, runs migrations, # then exec's supervisord. On subsequent boots it skips the install and just # re-syncs config + migrates. # # Config comes entirely from the container env (.env). No network fetch, no # external secrets, no callbacks. # ========================================================================== set -u log() { echo "[+] $*"; } warn() { echo "[!] WARNING: $*"; } die() { echo "[!] FATAL: $*"; exit 1; } run_as_www() { if command -v sudo >/dev/null 2>&1; then sudo -u www-data "$@"; else su -s /bin/bash www-data -c "$*"; fi } composer_as_www() { run_as_www env COMPOSER_HOME="$COMPOSER_CACHE_DIR" COMPOSER_MEMORY_LIMIT=-1 composer "$@" } # Import a (optionally gzipped) SQL dump into the database. import_db() { local f="$1" log "Importing database dump: $f" if [ "$DB_DRIVER" = pgsql ]; then case "$f" in *.dump) PGPASSWORD="$DB_PASS" pg_restore -h"$DB_HOST" -p"$DB_PORT" -U"$DB_USER" -d"$DB_NAME" --no-owner "$f" ;; *.gz) zcat "$f" | PGPASSWORD="$DB_PASS" psql -q -h"$DB_HOST" -p"$DB_PORT" -U"$DB_USER" -d"$DB_NAME" ;; *) PGPASSWORD="$DB_PASS" psql -q -h"$DB_HOST" -p"$DB_PORT" -U"$DB_USER" -d"$DB_NAME" < "$f" ;; esac else case "$f" in *.gz) zcat "$f" | mysql -h"$DB_HOST" -P"$DB_PORT" -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" ;; *) mysql -h"$DB_HOST" -P"$DB_PORT" -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" < "$f" ;; esac fi } # Write a fresh Flarum config.php from env (used by the restore path, which # imports the DB dump instead of running `flarum install`). write_config_php() { cat > "$CONFIG_FILE" < false, 'database' => [ 'driver' => '${DB_DRIVER}', 'host' => '${DB_HOST}', 'port' => ${DB_PORT}, 'database' => '${DB_NAME}', 'username' => '${DB_USER}', 'password' => '${DB_PASS}', 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', 'prefix' => '', 'prefix_indexes' => true, 'strict' => false, 'engine' => null, ], 'url' => '${APP_URL}', 'paths' => ['api' => 'api', 'admin' => 'admin'], ]; PHP chown www-data:www-data "$CONFIG_FILE" } # ── Variables ─────────────────────────────────────────────────────────────── WORKDIR="/var/www/html" CONFIG_FILE="$WORKDIR/config.php" SUPERVISOR_CONF="/etc/supervisor/conf.d/supervisord.conf" LOG_DIR="$WORKDIR/storage/logs" COMPOSER_CACHE_DIR="$WORKDIR/storage/composer-cache" clean() { echo "$1" | tr -d '\r' | xargs; } APP_URL=$(clean "${APP_URL:-}") FORUM_TITLE=$(clean "${FORUM_TITLE:-My Forum}") ADMIN_USER=$(clean "${ADMIN_USER:-}") ADMIN_PASS=$(clean "${ADMIN_PASS:-}") ADMIN_EMAIL=$(clean "${ADMIN_EMAIL:-}") DB_HOST=$(clean "${DB_HOST:-mariadb}") DB_NAME=$(clean "${DB_NAME:-flarum}") DB_USER=$(clean "${DB_USER:-flarum}") DB_PASS=$(clean "${DB_PASS:-}") # Which database engine: mariadb (the default) or pgsql. The port defaults per # driver. DB_ROOT_USER/DB_ROOT_PASS are optional: when given, the database and # the role above are created with them (needed on Postgres, where an ordinary # role cannot create its own database; on MariaDB the role usually can). DB_DRIVER=$(clean "${DB_DRIVER:-mariadb}") DB_ROOT_PASS=$(clean "${DB_ROOT_PASS:-}") case "$DB_DRIVER" in pgsql) DB_PORT=$(clean "${DB_PORT:-5432}"); DB_ROOT_USER=$(clean "${DB_ROOT_USER:-postgres}"); PDO_SCHEME=pgsql ;; mariadb|mysql) DB_PORT=$(clean "${DB_PORT:-3306}"); DB_ROOT_USER=$(clean "${DB_ROOT_USER:-root}"); PDO_SCHEME=mysql ;; *) die "DB_DRIVER must be mariadb, mysql or pgsql (got '$DB_DRIVER')." ;; esac DB_DSN="$PDO_SCHEME:host=$DB_HOST;port=$DB_PORT" DB_DSN_DB="$DB_DSN;dbname=$DB_NAME" if [ "$DB_DRIVER" = pgsql ]; then DB_DSN_ROOT="$DB_DSN;dbname=postgres"; else DB_DSN_ROOT="$DB_DSN"; fi export DB_HOST DB_NAME DB_USER DB_PASS DB_DRIVER DB_PORT DB_ROOT_USER DB_DSN DB_DSN_DB DB_DSN_ROOT REDIS_HOST=$(clean "${REDIS_HOST:-valkey}") REDIS_PORT=$(clean "${REDIS_PORT:-6379}") REDIS_PASSWORD=$(clean "${REDIS_PASSWORD:-}") # Whether the operator pinned a version themselves. Checked BEFORE defaulting, # because the default is exactly what "they said nothing" looks like afterwards. # Setting TARGET_FLARUM_VERSION is the deliberate opt-out from the baked # skeleton: it means "go and resolve this from Packagist instead", which is the # old behaviour and still supported. TARGET_FLARUM_VERSION_SET=$([ -n "${TARGET_FLARUM_VERSION:-}" ] && echo true || echo false) TARGET_FLARUM_VERSION=$(clean "${TARGET_FLARUM_VERSION:-^2.0}") # Flarum 1.x is refused, deliberately and early. The 1.x path this variable # appeared to offer was broken three independent ways (issue #8): # • install.yml pins `driver: mariadb`, which 1.8's installer rejects outright, # so a fresh 1.8 install can never complete # • the hard-require list carries flarum/realtime and flarum/audit, neither of # which has a 1.x release, so the boot dies even after a manual install # • the every-boot config sync rewrites the driver back to mariadb, so even a # correct 1.8 config.php is clobbered into "Unsupported driver" on restart # Each needs a different workaround to get past, and 1.8 is near end of life # regardless. One clear refusal beats three confusing failures in a row. flarum_major() { # First run of digits in the constraint: ^1.8, 1.8.*, ~1.8.0 and ">=1.8 <2.0" # all yield 1. A constraint carrying no digits (dev-main, a branch alias) # yields nothing and is left alone rather than guessed at. echo "$1" | grep -oE '[0-9]+' | head -1 } if [ "$TARGET_FLARUM_VERSION_SET" = "true" ]; then REQUESTED_MAJOR=$(flarum_major "$TARGET_FLARUM_VERSION") if [ -n "$REQUESTED_MAJOR" ] && [ "$REQUESTED_MAJOR" -lt 2 ]; then die "TARGET_FLARUM_VERSION='${TARGET_FLARUM_VERSION}' asks for Flarum ${REQUESTED_MAJOR}.x, which this image does not support. Flarum 1.x cannot install or boot here (linkrobins/flarum-docker#8), and 1.8 is near end of life. Use Flarum 2.x (e.g. TARGET_FLARUM_VERSION=^2.0), or unset it to install the version baked into this image." fi fi # Seed from the image unless asked otherwise. The guard on composer.json keeps # this correct for an image built before the skeleton existed, where the # directory is simply absent. FLARUM_SKELETON="${FLARUM_SKELETON:-/opt/flarum-skeleton}" FLARUM_SKELETON_VERSION="${FLARUM_SKELETON_VERSION:-unknown}" if [ "$TARGET_FLARUM_VERSION_SET" = "false" ] && [ -f "$FLARUM_SKELETON/composer.json" ]; then FLARUM_FROM_SKELETON=true else FLARUM_FROM_SKELETON=false fi REALTIME_ENABLED=$(echo "${REALTIME_ENABLED:-true}" | tr '[:upper:]' '[:lower:]') # supervisord is exec'd at the end of this script and inherits our environment, # where its realtime program re-reads REALTIME_ENABLED to decide whether to # serve or idle. Without the export that check sees an unset variable whenever # the compose file omits it, so the extension gets enabled and the websocket # config written while nothing ever listens on :6001. export REALTIME_ENABLED # Restore-on-deploy: drop a backup in the mounted /restore dir (database.sql[.gz] # + optional storage.tar.gz, as produced by backup.sh) and it's imported instead # of installing fresh. Paths can be overridden via env; otherwise the # conventional /restore layout is auto-detected. RESTORE_DB=$(clean "${RESTORE_DB:-}") RESTORE_FILES=$(clean "${RESTORE_FILES:-}") RESTORE_FORCE=$(echo "${RESTORE_FORCE:-false}" | tr '[:upper:]' '[:lower:]') if [ -z "$RESTORE_DB" ]; then for f in /restore/database.sql.gz /restore/database.sql; do [ -f "$f" ] && RESTORE_DB="$f" && break done fi [ -z "$RESTORE_FILES" ] && [ -f /restore/storage.tar.gz ] && RESTORE_FILES="/restore/storage.tar.gz" # Was a forum already installed before this boot? HAD_CONFIG=false; [ -f "$CONFIG_FILE" ] && HAD_CONFIG=true # Decide whether to restore this boot: # • fresh volume (no install) + a backup present -> restore # • existing forum + RESTORE_FORCE=true + a backup we haven't # already imported (different sha256) -> restore OVER it # The consumed-hash marker makes a forced restore one-shot, so a plain restart # can never silently re-clobber a live forum (only a NEW backup restores again). DO_RESTORE=false RESTORE_HASH="" CONSUMED_FILE="$WORKDIR/storage/.restore_consumed" if [ -n "$RESTORE_DB" ]; then RESTORE_HASH=$(sha256sum "$RESTORE_DB" 2>/dev/null | cut -d' ' -f1) if [ "$HAD_CONFIG" = "false" ]; then DO_RESTORE=true elif [ "$RESTORE_FORCE" = "true" ]; then PREV=$( [ -f "$CONSUMED_FILE" ] && cat "$CONSUMED_FILE" 2>/dev/null || echo "" ) [ "$RESTORE_HASH" != "$PREV" ] && DO_RESTORE=true fi fi [ -n "$APP_URL" ] || die "APP_URL is required." export COMPOSER_MEMORY_LIMIT=-1 # ── PHP tuning ──────────────────────────────────────────────────────────────── cat > /usr/local/etc/php/conf.d/flarum.ini <<'INI' memory_limit = 512M upload_max_filesize = 64M post_max_size = 64M max_execution_time = 900 opcache.enable = 1 opcache.memory_consumption = 128 opcache.max_accelerated_files = 24000 opcache.validate_timestamps = 1 opcache.revalidate_freq = 60 realpath_cache_size = 4096k realpath_cache_ttl = 600 INI # php-fpm pool: ondemand workers. FPM_POOL="/usr/local/etc/php-fpm.d/www.conf" if [ -f "$FPM_POOL" ]; then sed -i -e 's/^pm = .*/pm = ondemand/' -e 's/^pm.max_children = .*/pm.max_children = 8/' "$FPM_POOL" fi ln -sf /dev/stdout /var/log/nginx/access.log 2>/dev/null || true ln -sf /dev/stderr /var/log/nginx/error.log 2>/dev/null || true mkdir -p "$LOG_DIR" "$COMPOSER_CACHE_DIR" /var/log/supervisor chown -R www-data:www-data "$WORKDIR/storage" 2>/dev/null || true chmod -R 775 "$WORKDIR/storage" 2>/dev/null || true # ── Wait for MariaDB + ensure the database exists ───────────────────────────── log "Waiting for the database ($DB_DRIVER at $DB_HOST:$DB_PORT)..." if [ -n "$DB_ROOT_PASS" ]; then # An administrator was given: create the role and the database with it. for i in $(seq 1 60); do if DB_ROOT_PASS="$DB_ROOT_PASS" php -r 'new PDO(getenv("DB_DSN_ROOT"), getenv("DB_ROOT_USER"), getenv("DB_ROOT_PASS"));' >/dev/null 2>&1; then break; fi sleep 2 done DB_ROOT_PASS="$DB_ROOT_PASS" php -r ' try { $n = getenv("DB_NAME"); $u = getenv("DB_USER"); $p = getenv("DB_PASS"); $pdo = new PDO(getenv("DB_DSN_ROOT"), getenv("DB_ROOT_USER"), getenv("DB_ROOT_PASS"), [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); $qp = $pdo->quote($p); if (getenv("DB_DRIVER") === "pgsql") { // Role first, then a database it OWNS: on Postgres 15+ only the // owner may create tables in the public schema. $qu = "\"" . str_replace("\"", "\"\"", $u) . "\""; $qn = "\"" . str_replace("\"", "\"\"", $n) . "\""; $has = $pdo->query("SELECT 1 FROM pg_roles WHERE rolname = " . $pdo->quote($u))->fetchColumn(); $pdo->exec($has ? "ALTER ROLE $qu WITH LOGIN PASSWORD $qp" : "CREATE ROLE $qu WITH LOGIN PASSWORD $qp"); $hasDb = $pdo->query("SELECT 1 FROM pg_database WHERE datname = " . $pdo->quote($n))->fetchColumn(); $pdo->exec($hasDb ? "ALTER DATABASE $qn OWNER TO $qu" : "CREATE DATABASE $qn OWNER $qu ENCODING \"UTF8\""); } else { $pdo->exec("CREATE DATABASE IF NOT EXISTS `$n` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); $pdo->exec("CREATE USER IF NOT EXISTS `$u`@`%` IDENTIFIED BY $qp"); $pdo->exec("ALTER USER `$u`@`%` IDENTIFIED BY $qp"); $pdo->exec("GRANT ALL ON `$n`.* TO `$u`@`%`"); $pdo->exec("FLUSH PRIVILEGES"); } echo "DB + user ready." . PHP_EOL; } catch (Exception $e) { echo $e->getMessage() . PHP_EOL; exit(1); } ' || die "Could not bootstrap the database and user as $DB_ROOT_USER" elif [ "$DB_DRIVER" = pgsql ]; then # No administrator: the database must already exist for this role. for i in $(seq 1 60); do if php -r 'new PDO(getenv("DB_DSN_DB"), getenv("DB_USER"), getenv("DB_PASS"));' >/dev/null 2>&1; then break; fi sleep 2 done php -r 'new PDO(getenv("DB_DSN_DB"), getenv("DB_USER"), getenv("DB_PASS")); echo "DB ready." . PHP_EOL;' \ || die "Could not reach database $DB_NAME as $DB_USER (on Postgres, create it first or pass DB_ROOT_USER/DB_ROOT_PASS)" else for i in $(seq 1 60); do if php -r 'new PDO(getenv("DB_DSN"), getenv("DB_USER"), getenv("DB_PASS"));' >/dev/null 2>&1; then break; fi sleep 2 done php -r ' try { $pdo = new PDO(getenv("DB_DSN"), getenv("DB_USER"), getenv("DB_PASS")); $pdo->exec("CREATE DATABASE IF NOT EXISTS `" . getenv("DB_NAME") . "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); echo "DB ready." . PHP_EOL; } catch (Exception $e) { echo $e->getMessage() . PHP_EOL; exit(1); } ' || die "Could not reach/create the MariaDB database" fi # ── Fresh install (only when config.php is absent) ──────────────────────────── if [ ! -f "$CONFIG_FILE" ]; then if [ "$DO_RESTORE" = "true" ]; then log "No config.php — RESTORING from backup ($RESTORE_DB)." else log "No config.php — fresh Flarum install." [ -n "$ADMIN_USER" ] && [ -n "$ADMIN_PASS" ] && [ -n "$ADMIN_EMAIL" ] \ || die "ADMIN_USER / ADMIN_PASS / ADMIN_EMAIL are required for a fresh install." fi TMP_INSTALL="$WORKDIR/storage/flarum_stage" rm -rf "$TMP_INSTALL" && mkdir -p "$TMP_INSTALL" && chown www-data:www-data "$TMP_INSTALL" # Seed from the skeleton baked into the image. No Packagist, no resolution, # and the same image tag always produces the same forum — which was the # whole point of issue #2. Falls back to resolving over the network only # when the operator deliberately asked for a different version, or when the # image predates the skeleton. if [ "$FLARUM_FROM_SKELETON" = "true" ]; then log "Seeding Flarum ${FLARUM_SKELETON_VERSION} from the baked skeleton (no network)." cp -a "$FLARUM_SKELETON/." "$TMP_INSTALL/" \ || die "could not copy the baked skeleton from $FLARUM_SKELETON" chown -R www-data:www-data "$TMP_INSTALL" else log "composer create-project flarum/flarum:${TARGET_FLARUM_VERSION}..." composer_as_www create-project "flarum/flarum:${TARGET_FLARUM_VERSION}" --stability=beta "$TMP_INSTALL" --no-interaction \ >> "$LOG_DIR/composer_install.log" 2>&1 || die "composer create-project failed — see $LOG_DIR/composer_install.log" fi cp -an "$TMP_INSTALL/." "$WORKDIR/" && rm -rf "$TMP_INSTALL" chown -R www-data:www-data "$WORKDIR" find "$WORKDIR" -type d -exec chmod 775 {} + ; find "$WORKDIR" -type f -exec chmod 664 {} + chmod -R 775 "$WORKDIR/storage" "$WORKDIR/public/assets" if [ "$DO_RESTORE" = "true" ]; then # Restore: import the backup DB instead of running a fresh install, and # write config.php from env (the dump carries the forum's data/settings). import_db "$RESTORE_DB" >> "$LOG_DIR/db_restore.log" 2>&1 \ || die "Database import failed — see $LOG_DIR/db_restore.log" write_config_php log "Database restored from backup." else cat > "$WORKDIR/install.yml" <> "$LOG_DIR/flarum_install.log" 2>&1 \ || die "Flarum install failed — see $LOG_DIR/flarum_install.log" rm -f "$WORKDIR/install.yml" log "Flarum installed." fi fi # Keep config.php in sync with the DB + base URL on every boot. if [ -f "$CONFIG_FILE" ]; then sed -i \ -e "s/'driver' => '[^']*'/'driver' => '${DB_DRIVER}'/g" \ -e "s/'host' => '[^']*'/'host' => '${DB_HOST}'/g" \ -e "s/'port' => [0-9]*/'port' => ${DB_PORT}/g" \ -e "s/'database' => '[^']*'/'database' => '${DB_NAME}'/g" \ -e "s/'username' => '[^']*'/'username' => '${DB_USER}'/g" \ -e "s/'password' => '[^']*'/'password' => '${DB_PASS}'/g" \ -e "s~'url' => '[^']*'~'url' => '${APP_URL}'~g" \ "$CONFIG_FILE" # The connection options the installer wrote are per engine: a config.php # born on MariaDB says utf8mb4/InnoDB, which Postgres refuses at connect # time before Flarum prints a word. Rewrite them for the driver in use so # moving between engines is an environment change, nothing more. if [ "$DB_DRIVER" = pgsql ]; then sed -i -e "s/'charset' => '[^']*'/'charset' => 'utf8'/" -e "/'collation' => /d" -e "/'engine' => /d" "$CONFIG_FILE" else sed -i -e "s/'charset' => '[^']*'/'charset' => 'utf8mb4'/" "$CONFIG_FILE" grep -q "'collation' =>" "$CONFIG_FILE" || sed -i "s/'charset' => 'utf8mb4',/'charset' => 'utf8mb4',\n 'collation' => 'utf8mb4_unicode_ci',\n 'engine' => 'InnoDB',/" "$CONFIG_FILE" fi fi # Forced restore OVER an existing forum (RESTORE_FORCE=true). The fresh-volume # case is handled in the install branch above; this covers "I already set up the # site and now want to restore a backup into it." mysqldump's DROP TABLE IF # EXISTS replaces the tables; the migrate step below reconciles the schema. if [ "$DO_RESTORE" = "true" ] && [ "$HAD_CONFIG" = "true" ]; then warn "RESTORE_FORCE: importing the backup OVER the existing forum ($RESTORE_DB)." import_db "$RESTORE_DB" >> "$LOG_DIR/db_restore.log" 2>&1 \ || die "Forced database import failed — see $LOG_DIR/db_restore.log" log "Existing database replaced from backup." fi # Restore uploaded files (storage/ + public/assets — avatars, fof/upload files) # from the backup archive (both the fresh and forced restore paths). if [ "$DO_RESTORE" = "true" ] && [ -n "$RESTORE_FILES" ] && [ -f "$RESTORE_FILES" ]; then log "Restoring files from $RESTORE_FILES..." tar -xzf "$RESTORE_FILES" -C "$WORKDIR" >> "$LOG_DIR/files_restore.log" 2>&1 \ || warn "File restore failed (non-fatal) — see $LOG_DIR/files_restore.log" chown -R www-data:www-data "$WORKDIR/storage" "$WORKDIR/public/assets" 2>/dev/null || true fi # ── Version drift: what this image ships vs what this volume actually runs ──── # The baked skeleton only ever seeds an EMPTY volume (see the install branch # above), so an existing forum keeps whatever flarum/core it was installed with, # including a version an admin later upgraded to through the Extension Manager, # which this image never hears about. Pulling a new image tag therefore does not # move a live forum — deliberately, see issue #7 — and until now nothing said so, # which left the tag describing a Flarum the operator was not running. # # This reports, and never acts. Upgrading stays a deliberate act. # # Read before the hard-require block below, so the number reflects what the # volume booted with rather than anything this same boot goes on to install. installed_core_version() { php -r ' $f = $argv[1]; if (! is_readable($f)) exit(1); $lock = json_decode(file_get_contents($f), true); foreach (($lock["packages"] ?? []) as $p) { if (($p["name"] ?? "") === "flarum/core") { echo ltrim($p["version"], "v"); exit; } } exit(1); ' "$1" 2>/dev/null } VERSION_STATUS_FILE="$WORKDIR/storage/.flarum_version" INSTALLED_CORE=$(installed_core_version "$WORKDIR/composer.lock") || INSTALLED_CORE="" [ -n "$INSTALLED_CORE" ] || INSTALLED_CORE="unknown" if [ "$INSTALLED_CORE" = "unknown" ] || [ "$FLARUM_SKELETON_VERSION" = "unknown" ]; then VERSION_DRIFT="unknown" log "Flarum core: running '${INSTALLED_CORE}', image ships '${FLARUM_SKELETON_VERSION}' (not comparable)." elif [ "$INSTALLED_CORE" = "$FLARUM_SKELETON_VERSION" ]; then VERSION_DRIFT="false" log "Flarum core ${INSTALLED_CORE} matches the version baked into this image." else VERSION_DRIFT="true" warn "VERSION DRIFT: this forum runs Flarum ${INSTALLED_CORE}, this image ships ${FLARUM_SKELETON_VERSION}." warn " Pulling an image never upgrades an existing forum. The tag describes what a FRESH install" warn " would get, not what is running here. Nothing is broken; they are simply not the same." warn " To upgrade deliberately, back up first, then update through composer:" warn " docker compose exec flarum backup.sh" warn " docker compose exec -u www-data flarum composer update 'flarum/*' --with-all-dependencies" warn " docker compose exec -u www-data flarum php flarum migrate" warn " docker compose exec -u www-data flarum php flarum cache:clear" warn " Update 'flarum/*', not flarum/core alone: the skeleton requires every bundled extension" warn " at '*', so a core-only update leaves all of them on the version they were installed at." warn " (or use the Extension Manager in admin). Background: linkrobins/flarum-docker#7" fi printf 'installed=%s\nimage=%s\ndrift=%s\n' \ "$INSTALLED_CORE" "$FLARUM_SKELETON_VERSION" "$VERSION_DRIFT" > "$VERSION_STATUS_FILE" 2>/dev/null || true chown www-data:www-data "$VERSION_STATUS_FILE" 2>/dev/null || true # ── Required extensions (hard-require — fail the boot on a composer error) ───── composer_require() { local pkg="$1" if ! grep -q "\"$pkg\"" "$WORKDIR/composer.json" 2>/dev/null; then log "Requiring $pkg..." composer_as_www require "${pkg}:*" --no-interaction \ >> "$LOG_DIR/composer_require_${pkg//\//_}.log" 2>&1 \ || die "$pkg require failed — see $LOG_DIR/composer_require_${pkg//\//_}.log" else log "$pkg already present." fi } composer_require "fof/upload" composer_require "fof/redis" composer_require "fof/horizon" composer_require "flarum/realtime" # audit + extension-manager may ship with flarum/flarum already; require is a # no-op if present, and guarantees they exist otherwise. composer_require "flarum/audit" composer_require "flarum/extension-manager" # ── extend.php: redis cache/queue (fof/redis) + Horizon worker config ───────── # fof/redis points Flarum's cache + queue at redis; Horizon manages the workers. REDIS_PASS_PHP=$( [ -n "$REDIS_PASSWORD" ] && echo "'${REDIS_PASSWORD}'" || echo "null" ) cat > "$WORKDIR/extend.php" < 'phpredis', 'host' => '${REDIS_HOST}', 'password' => ${REDIS_PASS_PHP}, 'port' => ${REDIS_PORT}, 'database' => 1, ]), (new Horizon())->environment([ 'supervisor-1' => [ 'connection' => 'redis', 'queue' => ['digest', 'default'], 'balance' => 'auto', 'minProcesses' => 1, 'maxProcesses' => 2, 'processes' => 2, 'tries' => 3, 'memory' => 128, ], ]), ]; EPHP chown www-data:www-data "$WORKDIR/extend.php" log "extend.php written (redis + horizon)." # ── Realtime websocket config (port 6001) ───────────────────────────────────── # Honour APP_URL's scheme: an https base URL implies a TLS-terminating proxy in # front, so the JS client must connect securely on 443; otherwise plain on 80. if [ "$REALTIME_ENABLED" = "true" ]; then log "Applying realtime websocket config..." DOMAIN_ONLY=$(echo "$APP_URL" | sed 's~https\?://~~' | sed 's~/.*~~') case "$APP_URL" in https://*) JS_PORT=443; JS_SECURE=true ;; *) JS_PORT=80; JS_SECURE=false ;; esac php -r " \$cfg = include '$CONFIG_FILE'; \$cfg['websocket'] = [ 'server-host' => '0.0.0.0', 'server-port' => 6001, 'js-client-host' => '$DOMAIN_ONLY', 'js-client-port' => $JS_PORT, 'js-client-secure' => $JS_SECURE, 'php-client-host' => '127.0.0.1', 'php-client-port' => 6001, 'php-client-scheme' => 'http', 'php-client-secure' => false, ]; file_put_contents('$CONFIG_FILE', 'prepare(SETTINGS_UPSERT); foreach ([ "mail_driver" => "smtp", "mail_host" => getenv("MAIL_HOST_VAL"), "mail_port" => getenv("MAIL_PORT"), "mail_encryption" => getenv("MAIL_ENCRYPTION"), "mail_username" => getenv("MAIL_USERNAME"), "mail_password" => getenv("MAIL_PASSWORD_VAL"), "mail_from" => getenv("MAIL_FROM"), ] as $k => $v) $s->execute([$k, $v]); echo "Mail settings saved." . PHP_EOL; ' || warn "Failed to write mail settings" fi # ── Migrate + enable extensions ─────────────────────────────────────────────── log "Running Flarum migrations..." run_as_www php "$WORKDIR/flarum" migrate >> "$LOG_DIR/flarum_migrate.log" 2>&1 \ || die "Flarum migration failed — see $LOG_DIR/flarum_migrate.log" enable_ext() { log "Enabling extension: $1" run_as_www php "$WORKDIR/flarum" extension:enable "$1" 2>/dev/null || warn "Could not enable $1" } enable_ext "fof-upload" enable_ext "fof-redis" enable_ext "fof-horizon" enable_ext "flarum-audit" enable_ext "flarum-extension-manager" [ "$REALTIME_ENABLED" = "true" ] && enable_ext "flarum-realtime" # Route the queue through redis (Horizon picks it up). php -r ' require "/usr/local/bin/lr-db.php"; $pdo->prepare(SETTINGS_UPSERT)->execute(["queue_driver", "redis"]); ' || warn "Failed to set queue driver" log "Clearing Flarum cache..." run_as_www php "$WORKDIR/flarum" cache:clear >> "$LOG_DIR/cache_clear.log" 2>&1 || warn "cache:clear failed (non-fatal)" # Mark this backup consumed so a forced restore is one-shot: a plain restart # won't re-import it (only a different backup will). Cleared with the volume. if [ "$DO_RESTORE" = "true" ] && [ -n "$RESTORE_HASH" ]; then echo "$RESTORE_HASH" > "$CONSUMED_FILE" 2>/dev/null || true chown www-data:www-data "$CONSUMED_FILE" 2>/dev/null || true fi # ── Flarum scheduler cron (runs as www-data) ────────────────────────────────── ( crontab -u www-data -l 2>/dev/null | grep -v "schedule:run" echo "* * * * * /usr/local/bin/php /var/www/html/flarum schedule:run >> /dev/null 2>&1" ) | crontab -u www-data - log "All done. Starting supervisord." exec /usr/bin/supervisord -c "$SUPERVISOR_CONF"