Ship a repair command and a container
verify reports integrity problems and repair rebuilds derivatives from the Originals, which is what makes the file layout trustworthy without a database. The image runs TypeScript on Node 24 with no build step, writes only under MENU_ROOT, and the provision script writes the untracked site.env using the image's own hasher so the server needs no Node.
This commit is contained in:
1 parent
58ecfa0dc3
commit
bb9ddb437f
12 files changed
+419
-3
No files matched your search
Executable
+92
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Writes the one file that never goes in git: the admin password hash and the
|
||||
# session secret. Run this on the server, once per Site.
|
||||
#
|
||||
# scripts/provision-site-env.sh /var/lib/menu-app.hopsngrains.com
|
||||
#
|
||||
# The hash is produced by the app image itself, so the server needs no Node and
|
||||
# the password is never visible in the process list.
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
usage: provision-site-env.sh <data-root> [image] [--force]
|
||||
|
||||
<data-root> directory the app mounts as MENU_ROOT, e.g.
|
||||
/var/lib/menu-app.hopsngrains.com
|
||||
[image] app image to borrow Node from (default menu-admin:latest)
|
||||
--force overwrite an existing site.env
|
||||
USAGE
|
||||
}
|
||||
|
||||
DATA_ROOT="${1:-}"
|
||||
if [[ -z "$DATA_ROOT" || "$DATA_ROOT" == "-h" || "$DATA_ROOT" == "--help" ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
shift || true
|
||||
|
||||
IMAGE="menu-admin:latest"
|
||||
FORCE=0
|
||||
for argument in "$@"; do
|
||||
case "$argument" in
|
||||
--force) FORCE=1 ;;
|
||||
*) IMAGE="$argument" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
ENV_FILE="$DATA_ROOT/site.env"
|
||||
|
||||
if [[ -f "$ENV_FILE" && "$FORCE" -ne 1 ]]; then
|
||||
echo "$ENV_FILE already exists; pass --force to replace it" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "docker is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
|
||||
echo "image $IMAGE is not present; pull or build it first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -r -s -p "Admin password: " PASSWORD
|
||||
echo
|
||||
read -r -s -p "Repeat password: " REPEAT
|
||||
echo
|
||||
|
||||
if [[ -z "$PASSWORD" ]]; then
|
||||
echo "the password cannot be empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$PASSWORD" != "$REPEAT" ]]; then
|
||||
echo "the passwords do not match" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ${#PASSWORD} -lt 10 ]]; then
|
||||
echo "use at least 10 characters" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HASH="$(printf '%s' "$PASSWORD" | docker run --rm -i --entrypoint node "$IMAGE" \
|
||||
--input-type=module \
|
||||
-e "let s='';process.stdin.setEncoding('utf8');process.stdin.on('data',c=>s+=c);process.stdin.on('end',async()=>{const {pathToFileURL}=await import('node:url');const m=await import(pathToFileURL('/app/src/auth.ts').href);process.stdout.write(await m.hashPassword(s))})")"
|
||||
|
||||
SECRET="$(docker run --rm --entrypoint node "$IMAGE" --input-type=module \
|
||||
-e "import {randomBytes} from 'node:crypto';process.stdout.write(randomBytes(32).toString('base64url'))")"
|
||||
|
||||
if [[ -z "$HASH" || -z "$SECRET" ]]; then
|
||||
echo "the image failed to produce the hash or the secret" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$DATA_ROOT"
|
||||
umask 077
|
||||
printf 'ADMIN_PASSWORD_HASH=%s\nSESSION_SECRET=%s\n' "$HASH" "$SECRET" > "$ENV_FILE"
|
||||
unset PASSWORD REPEAT
|
||||
|
||||
echo "wrote $ENV_FILE (mode $(stat -c '%a' "$ENV_FILE" 2>/dev/null || echo 600))"
|
||||
echo "now (re)start the app container; changing the password later means running this again with --force"
|
||||
Reference in new issue
Block a user