diff --git a/README.rst b/README.rst
index 696e252..645e45b 100644
--- a/README.rst
+++ b/README.rst
@@ -11,13 +11,16 @@ support.
This appliance includes all the standard features in `TurnKey Core`_,
and on top of that:
-- XOOPS configurations:
+- XOOPS 2.7 configuration:
- Installed from upstream source code to /var/www/xoops
+ - Pinned to the official stable release commit and archive digest.
+ - ``turnkey-xoops-update`` checks the official XOOPS 2.7 stable channel
+ and prepares supervised upgrades without replacing site data.
**Security note**: Updates to XOOPS may require supervision so
they **ARE NOT** configured to install automatically. See "Upgrading
- from previous versions" section of the `Xoops release notes`_.
+ from previous versions" section of the `XOOPS release notes`_.
- SSL support out of the box.
- `Adminer`_ administration frontend for MySQL (listening on port
@@ -36,5 +39,5 @@ Credentials *(passwords set at first boot)*
.. _XOOPS: https://xoops.org/
.. _TurnKey Core: https://www.turnkeylinux.org/core
-.. _XOOPS release notes: https://github.com/XOOPS/XoopsCore25/blob/master/release_notes.txt
+.. _XOOPS release notes: https://github.com/XOOPS/XoopsCore27/blob/master/release_notes.txt
.. _Adminer: https://www.adminer.org
diff --git a/changelog b/changelog
index 44fa0ac..67af415 100644
--- a/changelog
+++ b/changelog
@@ -1,3 +1,18 @@
+turnkey-xoops-19.0 (1) turnkey; urgency=low
+
+ * Rebase the appliance on Debian 13/Trixie.
+
+ * Install XOOPS 2.7.3 from its pinned official release commit with archive
+ integrity verification.
+
+ * Preserve the existing firstboot administrator and database credential
+ contract while storing the administrator password with PHP's current
+ password hash.
+
+ * Add a supervised updater for the official XOOPS 2.7 stable channel.
+
+ -- TurnKey Linux Developers Tue, 25 Aug 2026 00:00:00 +0000
+
turnkey-xoops-18.0 (1) turnkey; urgency=low
* Upgraded to latest upstream version of XOOPS - 2.5.11.
@@ -270,4 +285,3 @@ turnkey-xoops-12.0 (1) turnkey; urgency=low
appliances. Here we only describe changes specific to this appliance.
-- Alon Swartz Wed, 01 Aug 2012 08:00:00 +0200
-
diff --git a/conf.d/downloads b/conf.d/downloads
index b9fabc7..1f06352 100755
--- a/conf.d/downloads
+++ b/conf.d/downloads
@@ -1,13 +1,16 @@
-#!/bin/bash -ex
+#!/bin/bash -e
-dl() {
- [ "$FAB_HTTP_PROXY" ] && PROXY="--proxy $FAB_HTTP_PROXY"
- cd $2; curl -L -f -O $PROXY $1; cd -
-}
+set -x
+VERSION=2.7.3
+COMMIT=00fff34c11fd4bee606e734d3bccf52d36fe0fda
+SHA256=b49d935116352434a60e5dae93efce356d9e5f360ad92bc72dcc69d7ab82b8cb
+URL="https://github.com/XOOPS/XoopsCore27/archive/$COMMIT.tar.gz"
+ARCHIVE="/usr/local/src/$COMMIT.tar.gz"
-VERSION="v2.5.11"
-URL="https://github.com/XOOPS/XoopsCore25/archive/$VERSION.tar.gz"
-
-dl $URL /usr/local/src
-
+curl_args=(-fsSL)
+if [ -n "${FAB_HTTP_PROXY:-}" ]; then
+ curl_args+=(--proxy "$FAB_HTTP_PROXY")
+fi
+curl "${curl_args[@]}" "$URL" -o "$ARCHIVE"
+echo "$SHA256 $ARCHIVE" | sha256sum --check --status
diff --git a/conf.d/main b/conf.d/main
index 7faa68b..5aded27 100755
--- a/conf.d/main
+++ b/conf.d/main
@@ -1,118 +1,153 @@
-#!/bin/sh -ex
+#!/bin/bash -e
+
+set -x
DB_NAME=xoops
DB_USER=xoops
DB_PREFIX=xoops
-DB_PASS=$(mcookie)
ADMIN_NAME=admin
-ADMIN_PASS=turnkey
ADMIN_MAIL=admin@example.com
SRC=/usr/local/src
WEBROOT=/var/www/xoops
XOOPS=/var/local/lib/xoops
-VERSION=v2.5.11
+VERSION=2.7.3
+COMMIT=00fff34c11fd4bee606e734d3bccf52d36fe0fda
+ARCHIVE_SHA256=b49d935116352434a60e5dae93efce356d9e5f360ad92bc72dcc69d7ab82b8cb
+SOURCE_DIR="$SRC/XoopsCore27-$COMMIT"
-# unpack and configure
-tar -zxf $SRC/$VERSION.tar.gz -C $SRC
-cp -a $SRC/XoopsCore*/htdocs $WEBROOT
-cp $SRC/XoopsCore*/extras/mainfile.php $WEBROOT
-rm -rf $SRC/XoopsCore*
+# Unpack and configure the official XOOPS release.
+tar -zxf "$SRC/$COMMIT.tar.gz" -C "$SRC"
+install -d "$WEBROOT"
+cp -a "$SOURCE_DIR/htdocs/." "$WEBROOT/"
-mkdir -p $XOOPS
-mv $WEBROOT/xoops_lib $XOOPS/lib
-mv $WEBROOT/xoops_data $XOOPS/data
+install -d "$XOOPS"
+mv "$WEBROOT/xoops_lib" "$XOOPS/lib"
+mv "$WEBROOT/xoops_data" "$XOOPS/data"
+rm -rf "$SOURCE_DIR"
-chown -R www-data:www-data $WEBROOT $XOOPS
+chown -R www-data:www-data "$WEBROOT" "$XOOPS"
-# configure apache
+# Configure Apache and start the services required by the web installer.
a2dissite 000-default
a2ensite xoops
a2enmod rewrite
-
-# start services
service mysql start
service apache2 start
-# setup the database
-MYSQL_BATCH="mysql --user=root --password=$MYSQL_PASS --batch"
-MYSQL_ADMIN="mysqladmin --user=root --password=$MYSQL_PASS"
-
-$MYSQL_ADMIN create $DB_NAME
-$MYSQL_BATCH --execute "grant all privileges on $DB_NAME.* to $DB_USER@localhost identified by '$DB_PASS'; flush privileges;"
-
-# curl based install
-XMAIL=$(echo $ADMIN_MAIL | sed s/@/%40/)
-XROOT=$(echo $WEBROOT | sed "s/\//%2F/g")
-XDATA=$(echo $XOOPS/data | sed "s/\//%2F/g")
-XLIB=$(echo $XOOPS/lib | sed "s/\//%2F/g")
-
-URL="http://127.0.0.1/install"
-CURL="curl -c /tmp/cookie -b /tmp/cookie"
-
-$CURL ${URL}/index.php -H 'Cookie: xo_install_lang=english' --data 'lang=english'
-$CURL ${URL}/page_start.php
-$CURL ${URL}/page_modcheck.php
-$CURL ${URL}/page_pathsettings.php --data "root=$XROOT&data=$XDATA&lib=$XLIB&URL=http%3A%2F%2F127.0.0.1&COOKIE_DOMAIN"
-$CURL ${URL}/page_dbconnection.php --data "DB_TYPE=mysql&DB_HOST=localhost&DB_USER=$DB_USER&DB_PASS=$DB_PASS&DB_PCONNECT=1"
-$CURL ${URL}/page_dbsettings.php --data "DB_NAME=$DB_NAME&DB_PREFIX=$DB_PREFIX&DB_CHARSET=utf8&DB_COLLATION=utf8_general_ci"
-$CURL ${URL}/page_configsave.php
+# Keep generated credentials and installer requests out of the build trace.
+# Errexit remains active throughout this section.
+set +x
+DB_PASS=$(mcookie)
+ADMIN_PASS=turnkey
+MYSQL_BATCH=(mysql --user=root "--password=${MYSQL_PASS:-}" --batch)
+MYSQL_ADMIN=(mysqladmin --user=root "--password=${MYSQL_PASS:-}")
+
+"${MYSQL_ADMIN[@]}" create "$DB_NAME"
+"${MYSQL_BATCH[@]}" <Lets get you started...
- Log in as admin and visit the administration dashboard to customize your site.
- When ready, delete this welcome block and create some new ones.
For more information, check out the release notes and the online documentation
.'
-
-$MYSQL_BATCH --database=$DB_NAME --execute "INSERT INTO ${DB_PREFIX}_newblocks VALUES ('$BLOCK_ID', '0', '0', '', 'Custom Block \(HTML\)', 'Welcome to TurnKey Xoops', '$WELCOME', '5', '0', '1', 'C', 'H', '1', '', '', '', '', '', '0', '$DATE');"
-
-$MYSQL_BATCH --database=$DB_NAME --execute "INSERT INTO ${DB_PREFIX}_block_module_link VALUES ('$BLOCK_ID', '-1')"
-
-$MYSQL_BATCH --database=$DB_NAME --execute "INSERT INTO ${DB_PREFIX}_group_permission VALUES (NULL, '1', '$BLOCK_ID', '1', 'block_read');"
-$MYSQL_BATCH --database=$DB_NAME --execute "INSERT INTO ${DB_PREFIX}_group_permission VALUES (NULL, '2', '$BLOCK_ID', '1', 'block_read');"
-$MYSQL_BATCH --database=$DB_NAME --execute "INSERT INTO ${DB_PREFIX}_group_permission VALUES (NULL, '3', '$BLOCK_ID', '1', 'block_read');"
-
-# remove hardcoded IP
-$MYSQL_BATCH --database=$DB_NAME --execute "UPDATE ${DB_PREFIX}_banner SET imageurl=\"/images/banners/xoops_banner_2.gif\" WHERE bid=\"1\";"
-$MYSQL_BATCH --database=$DB_NAME --execute "UPDATE ${DB_PREFIX}_banner SET imageurl=\"/images/banners/xoopsifyIt.gif\" WHERE bid=\"2\";"
-# remove broken flash banners
-$MYSQL_BATCH --database=$DB_NAME --execute "DELETE FROM ${DB_PREFIX}_banner WHERE bid=\"3\";"
-$MYSQL_BATCH --database=$DB_NAME --execute "DELETE FROM ${DB_PREFIX}_banner WHERE bid=\"4\";"
-
-sed -i "s|XOOPS_URL.*|XOOPS_URL', '');|g" $WEBROOT/mainfile.php
-
-# secure sensitive files
-chown -R root:root $WEBROOT $XOOPS
-chown -R www-data:www-data $WEBROOT/uploads
-chown www-data:www-data $WEBROOT/mainfile.php
-chown www-data:www-data $WEBROOT/include/license.php
-chown -R www-data:www-data $XOOPS/data/caches
-chown -R www-data:www-data $XOOPS/data/data
-chown -R www-data:www-data $XOOPS/lib/modules/protector/configs/
-
-chown -R www-data:www-data $XOOPS/data/protector/
-
-chmod 440 $WEBROOT/mainfile.php
-chmod 440 $XOOPS/data/data/secure.php
+"${CURL[@]}" "$URL/page_moduleinstaller.php"
+"${CURL[@]}" "$URL/page_end.php"
+
+rm -f "$COOKIE"
+rm -rf "$WEBROOT/install"
+
+# Add one visible custom block that gives a new administrator a starting point.
+WELCOME='Lets get you started...
- Log in as admin and visit the administration dashboard to customize your site.
- When ready, delete this welcome block and create some new ones.
For more information, visit the XOOPS project.
'
+"${MYSQL_BATCH[@]}" --database="$DB_NAME" < /usr/local/share/turnkey-xoops/source <
Options +FollowSymLinks
AllowOverride All
- order allow,deny
- allow from all
+ Require all granted
-
diff --git a/overlay/usr/lib/inithooks/bin/xoops.py b/overlay/usr/lib/inithooks/bin/xoops.py
index ff9bc60..b15aa8c 100755
--- a/overlay/usr/lib/inithooks/bin/xoops.py
+++ b/overlay/usr/lib/inithooks/bin/xoops.py
@@ -2,7 +2,7 @@
"""Set Xoops admin password and email
Option:
- --pass= unless provided, will ask interactively
+ --pass-stdin read the password from standard input
--email= unless provided, will ask interactively
"""
@@ -10,7 +10,6 @@
import sys
import getopt
import subprocess
-import hashlib
from libinithooks import inithooks_cache
from libinithooks.dialog_wrapper import Dialog
@@ -26,21 +25,31 @@ def usage(s=None):
def main():
try:
opts, args = getopt.gnu_getopt(sys.argv[1:], "h",
- ['help', 'pass=', 'email='])
+ ['help', 'pass=', 'pass-stdin', 'email='])
except getopt.GetoptError as e:
usage(e)
password = ""
+ password_stdin = False
email = ""
for opt, val in opts:
if opt in ('-h', '--help'):
usage()
elif opt == '--pass':
password = val
+ elif opt == '--pass-stdin':
+ password_stdin = True
elif opt == '--email':
email = val
- if not password:
+ if password and password_stdin:
+ usage('--pass and --pass-stdin are mutually exclusive')
+
+ if password_stdin:
+ password = sys.stdin.read()
+ if not password:
+ usage('standard input did not contain a password')
+ elif not password:
d = Dialog('TurnKey Linux - First boot configuration')
password = d.get_password(
"Xoops Password",
@@ -57,14 +66,26 @@ def main():
inithooks_cache.write('APP_EMAIL', email)
- hash = hashlib.md5(password.encode('utf8')).hexdigest()
+ password_hash = subprocess.run(
+ [
+ '/usr/bin/php',
+ '-r',
+ 'echo password_hash(stream_get_contents(STDIN), PASSWORD_DEFAULT);',
+ ],
+ input=password,
+ text=True,
+ capture_output=True,
+ check=True,
+ ).stdout
+
+ if not password_hash.startswith('$'):
+ raise RuntimeError('PHP did not return a modern password hash')
m = MySQL()
- m.execute('UPDATE xoops.xoops_users SET pass=%s WHERE uname=\"admin\";', (hash,))
+ m.execute('UPDATE xoops.xoops_users SET pass=%s WHERE uname=\"admin\";', (password_hash,))
m.execute('UPDATE xoops.xoops_users SET email=%s WHERE uname=\"admin\";', (email,))
m.execute('UPDATE xoops.xoops_config SET conf_value=%s WHERE conf_name=\"adminmail\";', (email,))
if __name__ == "__main__":
main()
-
diff --git a/overlay/usr/lib/inithooks/firstboot.d/20regen-xoops-secrets b/overlay/usr/lib/inithooks/firstboot.d/20regen-xoops-secrets
index a834cc2..420375c 100755
--- a/overlay/usr/lib/inithooks/firstboot.d/20regen-xoops-secrets
+++ b/overlay/usr/lib/inithooks/firstboot.d/20regen-xoops-secrets
@@ -1,11 +1,14 @@
#!/bin/bash -e
# regenerate xoops mysql password
-. /etc/default/inithooks
-
CONF=/var/local/lib/xoops/data/data/secure.php
PASSWORD=$(mcookie)
-sed -i "s|XOOPS_DB_PASS.*|XOOPS_DB_PASS', '$PASSWORD');|" $CONF
-$INITHOOKS_PATH/bin/mysqlconf.py --user=xoops --pass="$PASSWORD"
-
+sed -i "s|XOOPS_DB_PASS.*|XOOPS_DB_PASS', '$PASSWORD');|" "$CONF"
+mysql --batch <&2
+ exit 2
+}
+
+source_value() {
+ local key=$1
+ sed -n "s/^${key}=//p" "$SOURCE_FILE" | head -n 1
+}
+
+[[ $# -ge 1 && $# -le 2 ]] || usage
+mode=$1
+dry_run=false
+case "$mode" in
+ --check)
+ [[ $# -eq 1 ]] || usage
+ ;;
+ --apply)
+ if [[ $# -eq 2 ]]; then
+ [[ $2 = --dry-run ]] || usage
+ dry_run=true
+ fi
+ ;;
+ *)
+ usage
+ ;;
+esac
+
+[[ -r $SOURCE_FILE && -r $APP_ROOT/include/version.php ]] || {
+ echo "XOOPS installation provenance is unavailable" >&2
+ exit 1
+}
+
+installed=$(source_value version)
+installed_commit=$(source_value commit)
+runtime=$(php -r 'define("XOOPS_ROOT_PATH", dirname($argv[1], 2)); require $argv[1]; echo XOOPS_VERSION;' \
+ "$APP_ROOT/include/version.php")
+[[ $installed =~ ^2\.7\.[0-9]+$ ]] || {
+ echo "Installed XOOPS source version is invalid" >&2
+ exit 1
+}
+[[ $installed_commit =~ ^[0-9a-f]{40}$ ]] || {
+ echo "Installed XOOPS source commit is invalid" >&2
+ exit 1
+}
+[[ $runtime == "XOOPS $installed"* ]] || {
+ echo "XOOPS runtime version differs from its source record" >&2
+ exit 1
+}
+
+release_json=$(curl -fsSL "$RELEASE_API")
+tag=$(php -r '
+ $release = json_decode(stream_get_contents(STDIN), true, 512, JSON_THROW_ON_ERROR);
+ if (($release["draft"] ?? true) || ($release["prerelease"] ?? true)) {
+ exit(1);
+ }
+ $tag = $release["tag_name"] ?? "";
+ if (!preg_match("/^v2\\.7\\.[0-9]+$/", $tag)) {
+ exit(1);
+ }
+ echo $tag;
+' <<<"$release_json")
+latest=${tag#v}
+
+candidate=$(git ls-remote "$UPSTREAM_REPO" "refs/tags/$tag^{}" \
+ | awk 'NR == 1 { print $1 }')
+if [[ -z $candidate ]]; then
+ candidate=$(git ls-remote "$UPSTREAM_REPO" "refs/tags/$tag" \
+ | awk 'NR == 1 { print $1 }')
+fi
+[[ $candidate =~ ^[0-9a-f]{40}$ ]] || {
+ echo "The official XOOPS release tag could not be resolved" >&2
+ exit 1
+}
+
+status=update-available
+if [[ $installed = "$latest" && $installed_commit = "$candidate" ]]; then
+ status=up-to-date
+fi
+
+if [[ $mode = --check ]]; then
+ printf 'installed=%s\ninstalled_commit=%s\nruntime=%s\n' \
+ "$installed" "$installed_commit" "$runtime"
+ printf 'latest=%s\ncandidate=%s\ntag=%s\n' "$latest" "$candidate" "$tag"
+ printf 'channel=official-xoops-core27-stable\nstatus=%s\n' "$status"
+ exit 0
+fi
+
+if $dry_run; then
+ printf 'mode=apply-dry-run\ntarget=%s\ncandidate=%s\ntag=%s\n' \
+ "$latest" "$candidate" "$tag"
+ printf 'verified=official-xoops-release-tag\nstatus=%s\n' "$status"
+ printf 'upgrade_path=/upgrade/\n'
+ exit 0
+fi
+
+[[ $EUID -eq 0 ]] || {
+ echo "--apply must run as root" >&2
+ exit 1
+}
+
+if [[ $status = up-to-date ]]; then
+ echo "XOOPS $installed is already current"
+ exit 0
+fi
+
+stage=$(mktemp -d /var/tmp/turnkey-xoops-update.XXXXXX)
+cleanup() {
+ rm -rf "$stage"
+}
+trap cleanup EXIT
+
+archive="$stage/$candidate.tar.gz"
+curl -fsSL "https://github.com/XOOPS/XoopsCore27/archive/$candidate.tar.gz" \
+ -o "$archive"
+archive_sha256=$(sha256sum "$archive" | awk '{ print $1 }')
+tar -xzf "$archive" -C "$stage"
+release_root="$stage/XoopsCore27-$candidate"
+[[ -d $release_root/htdocs && -d $release_root/upgrade ]] || {
+ echo "The official XOOPS archive has an unexpected layout" >&2
+ exit 1
+}
+grep -Fq "XOOPS $latest" "$release_root/htdocs/include/version.php" || {
+ echo "The official XOOPS archive version differs from its release tag" >&2
+ exit 1
+}
+
+backup_dir=/var/backups/turnkey-xoops
+backup_id=$(date -u +%Y%m%dT%H%M%SZ)
+install -d -m 0700 "$backup_dir"
+mysqldump --single-transaction "$DB_NAME" \
+ | gzip -c > "$backup_dir/xoops-$backup_id.sql.gz"
+tar -C / -czf "$backup_dir/xoops-$backup_id-files.tar.gz" \
+ var/www/xoops var/local/lib/xoops
+
+tar -C "$release_root/htdocs" \
+ --exclude=./mainfile.php \
+ --exclude=./uploads \
+ --exclude=./xoops_lib \
+ --exclude=./xoops_data \
+ -cf - . | tar -C "$APP_ROOT" -xf -
+tar -C "$release_root/htdocs/xoops_lib" -cf - . \
+ | tar -C "$DATA_ROOT/lib" -xf -
+tar -C "$release_root/htdocs/xoops_data" \
+ --exclude=./caches \
+ --exclude=./data/secure.php \
+ --exclude=./logs \
+ --exclude=./protector \
+ -cf - . | tar -C "$DATA_ROOT/data" -xf -
+cp -a "$release_root/upgrade" "$APP_ROOT/upgrade"
+
+chown -R root:root "$APP_ROOT" "$DATA_ROOT"
+chown -R www-data:www-data "$APP_ROOT/uploads"
+chown -R www-data:www-data "$DATA_ROOT/data/caches"
+chown -R www-data:www-data "$DATA_ROOT/data/data"
+chown -R www-data:www-data "$DATA_ROOT/data/logs"
+chown -R www-data:www-data "$DATA_ROOT/data/protector"
+chown -R www-data:www-data "$DATA_ROOT/lib/modules/protector/configs"
+chown root:www-data "$APP_ROOT/mainfile.php" "$DATA_ROOT/data/data/secure.php"
+chmod 0440 "$APP_ROOT/mainfile.php" "$DATA_ROOT/data/data/secure.php"
+
+updated_runtime=$(php -r 'define("XOOPS_ROOT_PATH", dirname($argv[1], 2)); require $argv[1]; echo XOOPS_VERSION;' \
+ "$APP_ROOT/include/version.php")
+cat > "$SOURCE_FILE" <
#include
+git
php-gd
php-cli
php-curl
php-intl
php-mbstring
php-xml
-
-unzip
-
diff --git a/tests/v19.sh b/tests/v19.sh
new file mode 100755
index 0000000..b288ec9
--- /dev/null
+++ b/tests/v19.sh
@@ -0,0 +1,162 @@
+#!/bin/bash
+set -euo pipefail
+
+: "${TKL_TEST_RESULT:?TKL_TEST_RESULT must name the result file}"
+: "${TKL_TEST_APP_PASS:?TKL_TEST_APP_PASS must contain the firstboot XOOPS password}"
+
+APP_ROOT=/var/www/xoops
+SOURCE_FILE=/usr/local/share/turnkey-xoops/source
+BASE_URL=https://127.0.0.1
+DB_NAME=xoops
+
+fail() {
+ echo "FAIL: $*" >&2
+ exit 1
+}
+
+require_contains() {
+ local text=$1
+ local expected=$2
+ local context=$3
+ [[ $text == *"$expected"* ]] || fail "$context did not contain: $expected"
+}
+
+source_value() {
+ local key=$1
+ sed -n "s/^${key}=//p" "$SOURCE_FILE" | head -n 1
+}
+
+[[ -r $SOURCE_FILE ]] || fail "XOOPS source provenance is missing"
+[[ -x /usr/local/bin/turnkey-xoops-update ]] || fail "XOOPS updater is not executable"
+
+version=$(source_value version)
+commit=$(source_value commit)
+archive_sha256=$(source_value archive_sha256)
+channel=$(source_value channel)
+recorded_runtime=$(source_value runtime_version)
+
+[[ $version = 2.7.3 ]] || fail "unexpected XOOPS source version"
+[[ $commit = 00fff34c11fd4bee606e734d3bccf52d36fe0fda ]] \
+ || fail "unexpected XOOPS source commit"
+[[ $archive_sha256 = b49d935116352434a60e5dae93efce356d9e5f360ad92bc72dcc69d7ab82b8cb ]] \
+ || fail "unexpected XOOPS archive digest"
+[[ $channel = official-xoops-core27-stable ]] || fail "unexpected XOOPS channel"
+
+runtime=$(php -r 'define("XOOPS_ROOT_PATH", dirname($argv[1], 2)); require $argv[1]; echo XOOPS_VERSION;' \
+ "$APP_ROOT/include/version.php")
+[[ $runtime = "$recorded_runtime" ]] || fail "runtime version differs from provenance"
+[[ $runtime == "XOOPS $version"* ]] || fail "runtime version differs from release tag"
+
+[[ $(stat -c '%U:%G:%a' "$APP_ROOT/mainfile.php") = root:www-data:440 ]] \
+ || fail "XOOPS mainfile permissions are incorrect"
+[[ $(stat -c '%U:%G:%a' /var/local/lib/xoops/data/data/secure.php) = root:www-data:440 ]] \
+ || fail "XOOPS database credential permissions are incorrect"
+
+systemctl is-active --quiet apache2 || fail "Apache is not active"
+systemctl is-active --quiet mariadb || fail "MariaDB is not active"
+apache2ctl configtest 2>&1 | grep -q 'Syntax OK' || fail "Apache configuration is invalid"
+
+home_page=$(curl --insecure -fsSL --max-time 30 "$BASE_URL/")
+require_contains "$home_page" "TurnKey XOOPS" "XOOPS HTTPS home page"
+require_contains "$home_page" "Welcome to TurnKey XOOPS" "XOOPS welcome block"
+
+adminer_page=$(curl --insecure -fsSL --max-time 30 https://127.0.0.1:12322/)
+require_contains "$adminer_page" "Adminer" "Adminer HTTPS endpoint"
+
+cookie=$(mktemp)
+pass_file=$(mktemp)
+trap 'rm -f "$cookie" "$pass_file"' EXIT
+chmod 0600 "$cookie" "$pass_file"
+printf '%s' "$TKL_TEST_APP_PASS" > "$pass_file"
+curl_args=(--insecure -fsS --max-time 30 -c "$cookie" -b "$cookie")
+
+curl "${curl_args[@]}" "$BASE_URL/user.php" -o /dev/null
+curl "${curl_args[@]}" "$BASE_URL/user.php" \
+ --data-urlencode 'uname=admin' \
+ --data-urlencode "pass@$pass_file" \
+ --data-urlencode 'op=login' \
+ -o /dev/null
+
+admin_page=$(curl "${curl_args[@]}" "$BASE_URL/admin.php")
+require_contains "$admin_page" "Control Panel" "XOOPS administrator login"
+
+block_form=$(curl "${curl_args[@]}" \
+ "$BASE_URL/modules/system/admin.php?fct=blocksadmin&op=add")
+require_contains "$block_form" "Blocks Administration" "XOOPS block administration"
+token=$(sed -n \
+ "s/.*name=['\"]XOOPS_TOKEN_REQUEST['\"][^>]*value=['\"]\([^'\"]*\)['\"].*/\1/p" \
+ <<<"$block_form" | head -n 1)
+[[ -n $token ]] || fail "XOOPS block form did not contain a request token"
+
+fixture_title='TurnKey XOOPS acceptance block'
+fixture_content='XOOPS content create and read verified'
+curl "${curl_args[@]}" "$BASE_URL/modules/system/admin.php" \
+ --data-urlencode "XOOPS_TOKEN_REQUEST=$token" \
+ --data-urlencode 'fct=blocksadmin' \
+ --data-urlencode 'op=save' \
+ --data-urlencode 'bid=0' \
+ --data-urlencode 'block_type=C' \
+ --data-urlencode 'mid=0' \
+ --data-urlencode 'func_num=0' \
+ --data-urlencode 'func_file=' \
+ --data-urlencode 'show_func=' \
+ --data-urlencode 'edit_func=' \
+ --data-urlencode 'template=' \
+ --data-urlencode 'dirname=' \
+ --data-urlencode 'name=' \
+ --data-urlencode 'side=0' \
+ --data-urlencode 'weight=0' \
+ --data-urlencode 'visible=1' \
+ --data-urlencode 'modules[]=-1' \
+ --data-urlencode 'groups[]=1' \
+ --data-urlencode 'groups[]=2' \
+ --data-urlencode 'groups[]=3' \
+ --data-urlencode "title=$fixture_title" \
+ --data-urlencode "content_block=$fixture_content" \
+ --data-urlencode 'c_type=H' \
+ --data-urlencode 'bcachetime=0' \
+ -o /dev/null
+
+public_fixture=$(curl --insecure -fsSL --max-time 30 "$BASE_URL/?v19-acceptance=1")
+require_contains "$public_fixture" "$fixture_title" "public XOOPS content"
+require_contains "$public_fixture" "$fixture_content" "public XOOPS content"
+
+db_fixture=$(mysql --batch --skip-column-names "$DB_NAME" --execute="
+ SELECT CONCAT(title, '|', content)
+ FROM xoops_newblocks
+ WHERE title='$fixture_title'
+ ORDER BY bid DESC LIMIT 1;")
+[[ $db_fixture = "$fixture_title|$fixture_content" ]] \
+ || fail "XOOPS database did not retain the created content"
+
+password_scheme=$(mysql --batch --skip-column-names "$DB_NAME" --execute="
+ SELECT LEFT(pass, 1) FROM xoops_users WHERE uname='admin';")
+[[ $password_scheme = '$' ]] || fail "firstboot did not store a modern admin password hash"
+
+check_output=$(turnkey-xoops-update --check)
+latest=$(awk -F= '$1 == "latest" { print $2 }' <<<"$check_output")
+candidate=$(awk -F= '$1 == "candidate" { print $2 }' <<<"$check_output")
+tag=$(awk -F= '$1 == "tag" { print $2 }' <<<"$check_output")
+[[ $latest =~ ^2\.7\.[0-9]+$ ]] || fail "updater returned an invalid stable version"
+[[ $candidate =~ ^[0-9a-f]{40}$ ]] || fail "updater returned an invalid commit"
+[[ $tag = "v$latest" ]] || fail "updater tag and version differ"
+require_contains "$check_output" "channel=official-xoops-core27-stable" "XOOPS updater"
+
+apply_plan=$(turnkey-xoops-update --apply --dry-run)
+require_contains "$apply_plan" "mode=apply-dry-run" "XOOPS updater plan"
+require_contains "$apply_plan" "target=$latest" "XOOPS updater plan"
+require_contains "$apply_plan" "candidate=$candidate" "XOOPS updater plan"
+require_contains "$apply_plan" "verified=official-xoops-release-tag" "XOOPS updater plan"
+require_contains "$apply_plan" "upgrade_path=/upgrade/" "XOOPS updater plan"
+
+echo "PASS: XOOPS HTTPS admin login, custom block create/read, MariaDB, and updater"
+echo "version=$version runtime=$runtime commit=$commit"
+cat > "$TKL_TEST_RESULT" <