From c4496e710b65d1265320f6a9dcf8ff0eaf660a2d Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 7 Sep 2026 22:37:53 +0200 Subject: [PATCH] feat(docker): prepare portable images with runtime hotel configuration --- .dockerignore | 24 ++- .env.example | 6 +- .gitea/workflows/container.yaml | 34 ++++ Dockerfile | 12 +- README.md | 69 ++++++-- scripts/docker-start.mjs | 47 ++++++ scripts/docker-update.sh | 27 +++- scripts/publish-container.sh | 30 ++++ scripts/verify-portable-image.mjs | 151 ++++++++++++++++++ .../admin/users/[id]/user-badges-section.tsx | 6 +- .../_components/user-badges-readonly.tsx | 6 +- src/app/api/diagnostics/errors/route.ts | 4 +- src/app/api/imaging/avatar/route.ts | 9 +- src/app/api/imaging/badge/route.ts | 10 +- src/app/imaging/route.ts | 9 +- src/app/manifest.ts | 3 + src/app/robots.ts | 3 + src/app/sitemap.ts | 3 + src/lib/admin/photo-files.ts | 6 +- src/lib/docker-build-contract.test.ts | 36 +++++ src/lib/docker-update.test.ts | 19 +++ src/lib/format.test.ts | 4 +- src/lib/imager.test.ts | 32 +--- src/lib/imager.ts | 19 +-- src/lib/runtime-asset-config.test.ts | 65 ++++++++ src/lib/runtime-asset-config.ts | 47 ++++++ src/lib/runtime-avatar-route.test.ts | 44 +++++ src/test/docker-update-harness.sh | 1 + 28 files changed, 622 insertions(+), 104 deletions(-) create mode 100644 .gitea/workflows/container.yaml create mode 100644 scripts/docker-start.mjs create mode 100644 scripts/publish-container.sh create mode 100644 scripts/verify-portable-image.mjs create mode 100644 src/lib/runtime-asset-config.test.ts create mode 100644 src/lib/runtime-asset-config.ts create mode 100644 src/lib/runtime-avatar-route.test.ts diff --git a/.dockerignore b/.dockerignore index 2b7fc52c70..265d8fd4d7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,11 +13,14 @@ package-lock.json yarn.lock bun.lockb .npmrc.bak -# NOTE: .env is intentionally NOT ignored here — the build loads it (only inside -# a build RUN layer) to produce NEXT_PUBLIC_* + validated build-time values. -# It is not copied into the runtime image (the runner stage copies only -# .next/standalone, public/, and .next/static). -# .env +# Installation secrets must never enter any image layer (including migrations). +.env +.env.* +**/.env +**/.env.* +!.env.example +*.pem +*.key *.tsbuildinfo # Runtime write targets; bound as RW volumes at runtime (see docker-compose.yml) public/nitro-assets @@ -25,3 +28,14 @@ public/swf .deploy.lock logs + +# Other installation data and local tool artifacts +public/cache +public/tmp +db_backup_*.sql +*.log +playwright-report +test-results +blob-report +.codex +.agents diff --git a/.env.example b/.env.example index 14d9448f36..6acc298174 100644 --- a/.env.example +++ b/.env.example @@ -23,12 +23,14 @@ HOUSEKEEPING_NEXT_PREVIEW_ENABLED=false # --- HOTEL & URLS --- HOTEL_NAME=EPIC WEB CONTROL APP_URL=http://localhost:3002 -NEXT_PUBLIC_APP_URL=http://localhost:3002 AUTH_URL=http://localhost:3002 # --- IMAGER --- IMAGING_UPSTREAM_URL=http://127.0.0.1:3030/imaging -NEXT_PUBLIC_IMAGER_URL=http://localhost:3002/imaging +# Runtime values: changing these only requires recreating the container. +IMAGER_URL=http://127.0.0.1:3030/imaging +BADGE_URL=/swf/c_images/album1584 +# Legacy NEXT_PUBLIC_IMAGER_URL / NEXT_PUBLIC_BADGE_URL are still read at runtime. # --- SECURITY & HASHING --- AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars diff --git a/.gitea/workflows/container.yaml b/.gitea/workflows/container.yaml new file mode 100644 index 0000000000..e35cbcf77b --- /dev/null +++ b/.gitea/workflows/container.yaml @@ -0,0 +1,34 @@ +name: Publish portable container + +on: + workflow_dispatch: + +jobs: + publish: + runs-on: self-hosted + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + repository: ${{ gitea.repository }} + token: ${{ gitea.token }} + - name: Install and verify + run: | + node scripts/check-node-toolchain.mjs + pnpm install --frozen-lockfile + pnpm typecheck + pnpm biome:lint + pnpm test + env: + SKIP_ENV_VALIDATION: 1 + NODE_ENV: test + DATABASE_URL: mysql://test:test@127.0.0.1:9/test + AUTH_SECRET: container-test-secret-at-least-32-characters + - name: Build, verify portability and publish + shell: bash + env: + REGISTRY_SERVER: ${{ gitea.server_url }} + REGISTRY_REPOSITORY: ${{ gitea.repository }} + REGISTRY_USER: ${{ secrets.CONTAINER_REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.CONTAINER_REGISTRY_TOKEN }} + run: bash scripts/publish-container.sh diff --git a/Dockerfile b/Dockerfile index 8106645cf4..a573737e6b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 # Pin the runtime to the supported engine; update both stages deliberately. -FROM node:26.8.1-alpine AS builder +FROM node:26.8.1-alpine AS migrations WORKDIR /app ENV NEXT_TELEMETRY_DISABLED=1 # Keep the bootstrap aligned with package.json packageManager. @@ -15,9 +15,14 @@ COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./ RUN pnpm fetch --ignore-scripts RUN pnpm install --frozen-lockfile --ignore-scripts --offline COPY . . +FROM migrations AS builder ARG NEXT_DEPLOYMENT_ID="unknown" ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID" -RUN pnpm run build +# Fixture values exist only for this build command; production secrets are runtime-only. +RUN DATABASE_URL="mysql://build:build@127.0.0.1:9/build" \ + HOTEL_NAME="Build fixture" APP_URL="http://localhost:3002" \ + AUTH_SECRET="build-fixture-not-for-runtime-use-000000000000" \ + pnpm run build FROM node:26.8.1-alpine AS runner ARG NEXT_DEPLOYMENT_ID="unknown" @@ -34,6 +39,7 @@ RUN apk add --no-cache tini \ COPY --from=builder --chown=nextjs:nextjs /app/public ./public COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static +COPY --chown=nextjs:nextjs scripts/docker-start.mjs ./docker-start.mjs USER nextjs EXPOSE 3002 # Self-contained healthcheck so `docker run` (ci-deploy) also gets Docker-level @@ -41,4 +47,4 @@ EXPOSE 3002 HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] ENTRYPOINT ["/sbin/tini", "--"] -CMD ["node", "server.js"] \ No newline at end of file +CMD ["node", "docker-start.mjs"] \ No newline at end of file diff --git a/README.md b/README.md index 72c979d9e4..05c86bf821 100644 --- a/README.md +++ b/README.md @@ -200,24 +200,63 @@ are preserved and retried on later updates. Untracked historical images, build c other services and persistent volumes are not pruned. Retain the history file between updates. Before production migrations, retain your normal database backup. -### Shared prebuilt image audit +### Portable images on the Gitea registry -The current Docker build is installation-specific. A shared registry image needs -these changes before it can safely replace local builds: +Docker builds no longer load your installation's `.env`. The builder uses disposable +fixture values; the runtime reads `HOTEL_NAME`, `APP_URL`, `AUTH_SECRET`, database and +asset settings when the container starts. Missing/invalid required runtime values +stop startup with the setting names, without printing credentials. -| Configuration | Current source | Required preparation | -| --- | --- | --- | -| Avatar imager URL | `src/lib/imager.ts`, direct `NEXT_PUBLIC_IMAGER_URL` access | Supply public runtime configuration; remove the Epicnabbo-specific fallback for other installations. | -| Badge URL | Admin user badge components, `NEXT_PUBLIC_BADGE_URL` | Pass runtime configuration from the server to client components. | -| Public application URL | Diagnostics and photo helpers, `NEXT_PUBLIC_APP_URL` fallback | Use server runtime `APP_URL`; audit any generated absolute URLs. | -| Release identity | `next.config.ts`, `NEXT_PUBLIC_CMS_RELEASE` | Keep baked into the image: one commit identifies the same application binary everywhere. | -| Environment validation | `src/env.ts` requires database URL, hotel name and production auth secret | Build with isolated fixture configuration, then validate each installation at startup. Do not publish production environment files or builder images. | -| Build context | `.dockerignore` currently includes `.env`; Dockerfile copies the context into the builder | Separate build inputs from runtime secrets; inspect final image and build layers before registry publication. | +Configure `IMAGER_URL` with the actual avatar renderer endpoint and `BADGE_URL` with +the badge directory URL (or a local path). The legacy `NEXT_PUBLIC_IMAGER_URL` and +`NEXT_PUBLIC_BADGE_URL` names remain supported at runtime. If the imager points back +to the CMS `/imaging` proxy, set `IMAGING_UPSTREAM_URL` to the actual renderer to avoid +a loop. With no imager configured, the CMS uses Habbo's public renderer. Client +requests use the existing `/api/imaging/avatar` proxy. Admin badges preserve their +local `/swf/c_images/album1584` fallback. Public URL resolution uses runtime `APP_URL`. +After changing `.env`, recreate the container; an image rebuild is not required. +`NEXT_PUBLIC_CMS_RELEASE` deliberately remains compiled into the image. -This is a source audit, not a validated portable image. The next verification is to -run the **same image digest** for two installations with different names, domains, -imager/badge URLs and credentials, then check rendered pages and browser requests. -The registry publishing workflow is intentionally not enabled yet. +To publish from Gitea: + +1. In the repository's Actions secrets, configure `CONTAINER_REGISTRY_USER` and + `CONTAINER_REGISTRY_TOKEN`. Use a Gitea access token with package read/write + permission belonging to an account allowed to publish under the repository owner. +2. Run **Publish portable container** manually on the commit/branch to distribute. + The workflow runs checks, builds from committed source only, and verifies the same + application image with two runtime avatar/badge configurations before pushing. + It does not deploy to production or move a `latest` tag. +3. The images are `//:` and + `:-migrations`. Only the application image runs the website; the + migrations image is used temporarily for the matching database migrations. + +For this repository the image base is +`gitlab.epicnabbo.nl/remco/epicnext-cms`. Package access is controlled by Gitea. +For private packages, run `docker login gitlab.epicnabbo.nl` on the installation +with a token that can read packages. Then update with: + +```bash +CMS_IMAGE_REPOSITORY=gitlab.epicnabbo.nl/remco/epicnext-cms \ +CMS_PUBLIC_URL=https://your-hotel.example \ +bash scripts/docker-update.sh +``` + +The updater pulls the configured Git upstream and requires both images for that +exact commit. A missing image or failed login stops before replacing the running +CMS. Local builds remain the default when `CMS_IMAGE_REPOSITORY` is unset. Both +paths retain the existing image/HTTP checks and automatic application rollback. +Migration secrets are mounted read-only for the temporary migration container; +they are never copied into its image. Registry images currently target the Linux +architecture of the self-hosted build runner; this is not a multi-architecture release. + +The portability gate checks release identity, runtime avatar/badge routing and +absence of installation environment files in the application image. It uses an +unreachable fixture database and does not replace a full live database/site smoke +test. See `scripts/verify-portable-image.mjs`. Production deployment remains verified +separately by the existing CI workflow. + +References: [Gitea container registry](https://docs.gitea.com/usage/packages/container/) +and [Next.js runtime environment variables](https://nextjs.org/docs/app/guides/self-hosting). ### Diagnose an update that is not visible diff --git a/scripts/docker-start.mjs b/scripts/docker-start.mjs new file mode 100644 index 0000000000..11ca5b6628 --- /dev/null +++ b/scripts/docker-start.mjs @@ -0,0 +1,47 @@ +// Fail before listening if an installation has no valid runtime configuration. +import { spawn } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +export function validateRuntime(settings) { + const invalid = []; + if (!settings.HOTEL_NAME?.trim() || settings.HOTEL_NAME === "Build fixture") + invalid.push("HOTEL_NAME"); + if ( + !settings.AUTH_SECRET || + settings.AUTH_SECRET.length < 32 || + settings.AUTH_SECRET.startsWith("build-fixture-") + ) + invalid.push("AUTH_SECRET"); + for (const [key, protocols] of [ + ["DATABASE_URL", ["mysql:"]], + ["APP_URL", ["http:", "https:"]], + ]) { + try { + if (!protocols.includes(new URL(settings[key]).protocol)) + invalid.push(key); + } catch { + invalid.push(key); + } + } + if (invalid.length) + throw new Error(`Invalid runtime configuration: ${invalid.join(", ")}`); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + validateRuntime(process.env); + const child = spawn(process.execPath, ["server.js"], { stdio: "inherit" }); + for (const signal of ["SIGTERM", "SIGINT"]) + process.on(signal, () => child.kill(signal)); + child.on("error", () => { + console.error("CMS process could not start"); + process.exitCode = 1; + }); + child.on("exit", (code) => { + process.exitCode = code ?? 1; + }); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/docker-update.sh b/scripts/docker-update.sh index 77995de4ac..645d60ce78 100755 --- a/scripts/docker-update.sh +++ b/scripts/docker-update.sh @@ -10,6 +10,7 @@ mkdir -p "$(dirname "$LOG_FILE")" log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"; } die() { log "ERROR: $*"; exit 1; } migration_image="" +remote_migration_image="" previous_image="" previous_release="" rollback_tag="" @@ -45,6 +46,7 @@ finish() { fi fi if [[ -n "$migration_image" ]]; then docker image rm "$migration_image" >>"$LOG_FILE" 2>&1 || true; fi + if [[ -n "$remote_migration_image" ]]; then docker image rm "$remote_migration_image" >>"$LOG_FILE" 2>&1 || true; fi # Keep the recovery tag on failure for manual recovery, including same-commit rebuilds. if [[ ( "$status" = 0 || "$cutover" = 0 ) && -n "$rollback_tag" ]]; then docker image rm "epicnext-cms:$rollback_tag" >>"$LOG_FILE" 2>&1 || true; fi exit "$status" @@ -81,15 +83,27 @@ if [[ -n "$previous_container" ]]; then docker image tag "$previous_image" "epicnext-cms:$rollback_tag" fi log "Building release $CMS_RELEASE from $DIR" -# The builder contains the matching migration source and locked dependencies. +# The migrations stage contains matching source and locked dependencies. # No Node/package manager installation on the host is required. migration_image="epicnext-cms-migrations:$CMS_RELEASE" -docker build --network=host --target builder --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" -t "$migration_image" . >>"$LOG_FILE" 2>&1 -docker compose build --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" cms >>"$LOG_FILE" 2>&1 +if [[ -n "${CMS_IMAGE_REPOSITORY:-}" ]]; then + [[ "$CMS_IMAGE_REPOSITORY" =~ ^[a-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$ ]] || die "Invalid CMS_IMAGE_REPOSITORY; use registry/owner/image without a tag." + log "Pulling prebuilt application and matching migrations for $CMS_RELEASE" + docker pull "$CMS_IMAGE_REPOSITORY:$CMS_RELEASE" >>"$LOG_FILE" 2>&1 + remote_migration_image="$CMS_IMAGE_REPOSITORY:$CMS_RELEASE-migrations" + docker pull "$remote_migration_image" >>"$LOG_FILE" 2>&1 + docker tag "$CMS_IMAGE_REPOSITORY:$CMS_RELEASE" "epicnext-cms:$CMS_RELEASE" + docker tag "$CMS_IMAGE_REPOSITORY:$CMS_RELEASE-migrations" "$migration_image" +else + docker build --network=host --target migrations --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" -t "$migration_image" . >>"$LOG_FILE" 2>&1 + docker compose build --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" cms >>"$LOG_FILE" 2>&1 +fi expected_image="$(docker image inspect --format '{{.Id}}' "epicnext-cms:$CMS_RELEASE")" revision="$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$expected_image")" [[ "$revision" = "$CMS_RELEASE" ]] || die "Built image has revision $revision, expected $CMS_RELEASE." -docker run --rm --network host --entrypoint pnpm "$migration_image" db:migrate >>"$LOG_FILE" 2>&1 +migration_mounts=(--mount "type=bind,source=$DIR/.env,target=/app/.env,readonly") +if [[ -f "$DIR/.env.local" ]]; then migration_mounts+=(--mount "type=bind,source=$DIR/.env.local,target=/app/.env.local,readonly"); fi +docker run --rm --network host "${migration_mounts[@]}" --entrypoint pnpm "$migration_image" db:migrate >>"$LOG_FILE" 2>&1 log "Build and migrations completed; recreating only the CMS service." cutover=1 docker compose up -d --no-deps --no-build --force-recreate cms >>"$LOG_FILE" 2>&1 @@ -120,6 +134,11 @@ for release in "${releases[@]}"; do if [[ "${#kept[@]}" -lt 2 ]]; then kept+=("$release"); continue; fi # Even stopped containers belonging to other deployments protect an image. if users="$(docker ps -aq --filter "ancestor=epicnext-cms:$release")" && [[ -z "$users" ]] && docker image rm "epicnext-cms:$release" >>"$LOG_FILE" 2>&1; then + if [[ -n "${CMS_IMAGE_REPOSITORY:-}" ]]; then + if remote_users="$(docker ps -aq --filter "ancestor=$CMS_IMAGE_REPOSITORY:$release")" && [[ -z "$remote_users" ]]; then + docker image rm "$CMS_IMAGE_REPOSITORY:$release" >>"$LOG_FILE" 2>&1 || true + fi + fi log "Removed superseded release tag $release" else pending+=("$release") diff --git a/scripts/publish-container.sh b/scripts/publish-container.sh new file mode 100644 index 0000000000..577af4555a --- /dev/null +++ b/scripts/publish-container.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +: "${REGISTRY_SERVER:?Missing Gitea server URL}" +: "${REGISTRY_REPOSITORY:?Missing owner/repository}" +: "${REGISTRY_USER:?Configure CONTAINER_REGISTRY_USER}" +: "${REGISTRY_TOKEN:?Configure CONTAINER_REGISTRY_TOKEN with package write access}" +sha="$(git rev-parse HEAD)" +[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || exit 1 +registry="${REGISTRY_SERVER#https://}" +registry="${registry%/}" +[[ "$REGISTRY_SERVER" = https://* && "$registry" != */* ]] || { echo "Registry must use HTTPS at the Gitea server root" >&2; exit 1; } +repository="${REGISTRY_REPOSITORY,,}" +[[ "$repository" =~ ^[a-z0-9._-]+/[a-z0-9._-]+$ ]] || exit 1 +image="$registry/$repository:$sha" +# Isolate credentials from the self-hosted runner's normal Docker configuration. +export DOCKER_CONFIG +DOCKER_CONFIG="$(mktemp -d)" +context="$(mktemp -d)" +trap 'rm -rf -- "$DOCKER_CONFIG" "$context"' EXIT +# Build only the committed source, never untracked files from a shared runner. +git archive HEAD | tar -x -C "$context" +printf '%s' "$REGISTRY_TOKEN" | docker login "$registry" --username "$REGISTRY_USER" --password-stdin +unset REGISTRY_TOKEN +docker build --network=host --build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" "$context" +docker build --network=host --target migrations -t "$image-migrations" "$context" +node scripts/verify-portable-image.mjs "$image" "$sha" +# Publish only after the same application image passed both runtime configurations. +docker push "$image-migrations" +docker push "$image" +echo "Published application and migrations: $image" diff --git a/scripts/verify-portable-image.mjs b/scripts/verify-portable-image.mjs new file mode 100644 index 0000000000..1e8c26947a --- /dev/null +++ b/scripts/verify-portable-image.mjs @@ -0,0 +1,151 @@ +// Runs only on a Linux Docker host. No production DB, network or volumes are used. +import { execFileSync } from "node:child_process"; +import { createServer } from "node:http"; +import { setTimeout as delay } from "node:timers/promises"; + +const [image, release] = process.argv.slice(2); +if (!image || !/^[0-9a-f]{40}$/.test(release ?? "")) + throw new Error("Usage: verify-portable-image.mjs IMAGE COMMIT"); +const docker = (...args) => + execFileSync("docker", args, { encoding: "utf8", timeout: 60000 }).trim(); +const id = docker("image", "inspect", "--format", "{{.Id}}", image); +const inspect = JSON.parse(docker("image", "inspect", image))[0]; +if ( + (inspect.Config.Env ?? []).some((value) => + /^(DATABASE_URL|AUTH_SECRET|HOTEL_NAME)=/.test(value), + ) +) + throw new Error("Image contains installation configuration"); +docker( + "run", + "--rm", + "--entrypoint", + "node", + image, + "-e", + 'for(const p of [".env",".env.local",".env.production",".env.production.local"]){if(require("fs").existsSync(p))throw Error("Environment file in image: "+p)}', +); +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a9xkAAAAASUVORK5CYII=", + "base64", +); +const requests = []; +const upstream = createServer((req, res) => { + requests.push(req.url); + res.writeHead(200, { "content-type": "image/png" }); + res.end(png); +}); +await new Promise((resolve) => upstream.listen(0, "127.0.0.1", resolve)); +const origin = `http://127.0.0.1:${upstream.address().port}`; +try { + for (const hotel of ["alpha", "beta"]) { + const reservation = createServer(); + await new Promise((resolve) => reservation.listen(0, "127.0.0.1", resolve)); + const port = reservation.address().port; + await new Promise((resolve) => reservation.close(resolve)); + const name = `cms-portability-${process.pid}-${hotel}`; + const base = `http://127.0.0.1:${port}`; + try { + docker( + "run", + "--detach", + "--name", + name, + "--network", + "host", + "--env", + `PORT=${port}`, + "--env", + "HOSTNAME=127.0.0.1", + "--env", + `HOTEL_NAME=Fixture ${hotel}`, + "--env", + `APP_URL=${base}`, + "--env", + `AUTH_SECRET=portability-${hotel}-not-a-production-secret-000000`, + "--env", + "DATABASE_URL=mysql://fixture:fixture@127.0.0.1:9/fixture", + "--env", + "DATABASE_CONNECT_TIMEOUT_MS=500", + "--env", + "RCON_PORT=9", + "--env", + "RCON_TIMEOUT_MS=100", + "--env", + "RCON_MAX_RETRIES=1", + "--env", + "AUTH_TRUST_HOST=true", + "--env", + `IMAGER_URL=${origin}/${hotel}/avatar`, + "--env", + `BADGE_URL=${origin}/${hotel}/badges`, + id, + ); + let ready = false; + for (let attempt = 0; attempt < 30; attempt++) { + try { + const r = await fetch(`${base}/api/health`, { + signal: AbortSignal.timeout(2000), + }); + if ((await r.json()).release === release) { + ready = true; + break; + } + } catch {} + await delay(1000); + } + if (!ready) throw new Error(`${hotel}: expected HTTP release not served`); + const page = await fetch(`${base}/login`, { + signal: AbortSignal.timeout(30000), + }); + const html = await page.text(); + if ( + !page.ok || + !html.includes(`Fixture ${hotel}`) || + !html.includes(base) + ) + throw new Error( + `${hotel}: hotel name/domain were not resolved at runtime`, + ); + const manifest = await fetch(`${base}/manifest.webmanifest`, { + signal: AbortSignal.timeout(15000), + }); + if ((await manifest.json()).name !== `Fixture ${hotel}`) + throw new Error(`${hotel}: manifest contains build-time settings`); + for (const path of ["/robots.txt", "/sitemap.xml"]) { + const response = await fetch(base + path, { + signal: AbortSignal.timeout(15000), + }); + if (!response.ok || !(await response.text()).includes(base)) + throw new Error(`${hotel}: ${path} contains build-time domain`); + } + const avatar = await fetch( + `${base}/api/imaging/avatar?figure=hd-180-1&img_format=png`, + { signal: AbortSignal.timeout(15000) }, + ); + if ( + !avatar.ok || + !requests.some((url) => url.startsWith(`/${hotel}/avatar?`)) + ) + throw new Error(`${hotel}: wrong avatar upstream`); + const badge = await fetch(`${base}/api/imaging/badge?code=ADM`, { + redirect: "manual", + signal: AbortSignal.timeout(15000), + }); + if (badge.headers.get("location") !== `${origin}/${hotel}/badges/ADM.gif`) + throw new Error(`${hotel}: wrong badge URL`); + console.log( + `Verified ${hotel}: same image ${id}, runtime avatar and badge configuration, release ${release}`, + ); + } catch (error) { + console.error(docker("logs", name, "--tail", "40")); + throw error; + } finally { + try { + docker("rm", "--force", name); + } catch {} + } + } +} finally { + await new Promise((resolve) => upstream.close(resolve)); +} diff --git a/src/app/admin/users/[id]/user-badges-section.tsx b/src/app/admin/users/[id]/user-badges-section.tsx index 9b936766eb..feffc101d4 100644 --- a/src/app/admin/users/[id]/user-badges-section.tsx +++ b/src/app/admin/users/[id]/user-badges-section.tsx @@ -61,11 +61,7 @@ export function UserBadgesSection({ userId, badges }: UserBadgesSectionProps) { className="group flex items-center gap-1.5 rounded-md border bg-card px-2 py-1 text-sm hover:border-destructive/50 transition-colors" > {badge.badgeCode} { diff --git a/src/app/admin/users/_components/user-badges-readonly.tsx b/src/app/admin/users/_components/user-badges-readonly.tsx index 5b753d46e0..50886d1bd2 100644 --- a/src/app/admin/users/_components/user-badges-readonly.tsx +++ b/src/app/admin/users/_components/user-badges-readonly.tsx @@ -18,11 +18,7 @@ export function UserBadgesReadonly({ badges }: { badges: Badge[] }) { className="flex items-center gap-1.5 rounded-md border bg-card px-2 py-1 text-sm" > {badge.badgeCode} { diff --git a/src/app/api/diagnostics/errors/route.ts b/src/app/api/diagnostics/errors/route.ts index 8b393f8a46..f14eb07bb1 100644 --- a/src/app/api/diagnostics/errors/route.ts +++ b/src/app/api/diagnostics/errors/route.ts @@ -19,9 +19,7 @@ const schema = z.object({ export async function POST(request: Request) { if ( request.headers.get("origin") !== - new URL( - process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL || request.url, - ).origin || + new URL(process.env.APP_URL || request.url).origin || !request.headers.get("content-type")?.startsWith("application/json") ) return new Response(null, { status: 403 }); diff --git a/src/app/api/imaging/avatar/route.ts b/src/app/api/imaging/avatar/route.ts index a02aef775c..60b447c479 100644 --- a/src/app/api/imaging/avatar/route.ts +++ b/src/app/api/imaging/avatar/route.ts @@ -1,7 +1,7 @@ import { type NextRequest, NextResponse } from "next/server"; -import { resolveImagerBase } from "@/lib/imager"; +import { resolveImagerBase } from "@/lib/runtime-asset-config"; -const FIGURE_RE = /^[a-z]{2}-\d+(-\d+)?(\.[a-z]{2}-\d+(-\d+)?)*$/i; +const FIGURE_RE = /^[a-z]{2}-\d+(?:-\d+)*(?:\.[a-z]{2}-\d+(?:-\d+)*)*$/i; const FIGURE_MAX_LEN = 512; const FIGURE_MAX_PARTS = 24; const UPSTREAM_TIMEOUT_MS = 10_000; @@ -66,8 +66,9 @@ export async function GET(request: NextRequest) { const imgFormat = searchParams.get("img_format"); if (imgFormat) params.set("img_format", imgFormat); - const upstream = resolveImagerBase(); - const upstreamUrl = `${upstream}?${params.toString()}`; + const upstream = resolveImagerBase(new URL(request.url).origin); + const upstreamUrl = new URL(upstream); + for (const [key, value] of params) upstreamUrl.searchParams.set(key, value); try { const res = await fetch(upstreamUrl, { diff --git a/src/app/api/imaging/badge/route.ts b/src/app/api/imaging/badge/route.ts index 6c7b31b5a1..fcf516e0dd 100644 --- a/src/app/api/imaging/badge/route.ts +++ b/src/app/api/imaging/badge/route.ts @@ -1,5 +1,6 @@ import { type NextRequest, NextResponse } from "next/server"; import { clientIp, rateLimit } from "@/lib/rate-limit"; +import { resolveBadgeBase } from "@/lib/runtime-asset-config"; import { siteSettings } from "@/lib/services/site-settings"; /** @@ -28,9 +29,14 @@ export async function GET(request: NextRequest) { } const configured = ( - (await siteSettings.get("badge_base_url", "")) ?? "" + (resolveBadgeBase() || (await siteSettings.get("badge_base_url", ""))) ?? + "" ).trim(); - const base = (configured || DEFAULT_BASE).replace(/\/+$/, ""); + const fallback = + request.nextUrl.searchParams.get("source") === "local" + ? "/swf/c_images/album1584" + : DEFAULT_BASE; + const base = (configured || fallback).replace(/\/+$/, ""); const isAbsolute = /^https?:\/\//i.test(base); const target = `${base}/${code}.gif`; const absoluteTarget = isAbsolute diff --git a/src/app/imaging/route.ts b/src/app/imaging/route.ts index c6cf7871ce..e3c47e8319 100644 --- a/src/app/imaging/route.ts +++ b/src/app/imaging/route.ts @@ -1,8 +1,8 @@ import { type NextRequest, NextResponse } from "next/server"; import sharp from "sharp"; -import { resolveImagerBase } from "@/lib/imager"; +import { resolveImagerBase } from "@/lib/runtime-asset-config"; -const FIGURE_RE = /^([a-z]{2}-\d+)(\.[a-z]{2}-\d+)*$/i; +const FIGURE_RE = /^[a-z]{2}-\d+(?:-\d+)*(?:\.[a-z]{2}-\d+(?:-\d+)*)*$/i; const FIGURE_MAX_LEN = 512; const FIGURE_MAX_PARTS = 24; const UPSTREAM_TIMEOUT_MS = 10_000; @@ -82,8 +82,9 @@ export async function GET(request: NextRequest) { const format = resolveFormat(searchParams.get("format")); - const upstream = resolveImagerBase(); - const upstreamUrl = `${upstream}?${params.toString()}`; + const upstream = resolveImagerBase(new URL(request.url).origin); + const upstreamUrl = new URL(upstream); + for (const [key, value] of params) upstreamUrl.searchParams.set(key, value); try { const res = await fetch(upstreamUrl, { diff --git a/src/app/manifest.ts b/src/app/manifest.ts index 884d1a8463..e6ad331251 100644 --- a/src/app/manifest.ts +++ b/src/app/manifest.ts @@ -2,6 +2,9 @@ import type { MetadataRoute } from "next"; import { resolveHotelName } from "@/lib/hotel-name"; import { siteSettings } from "@/lib/services/site-settings"; +// Installation metadata must be resolved from the running container. +export const dynamic = "force-dynamic"; + // Web app manifest — makes the hotel installable as a PWA (AtomCMS exposed PWA // settings but the rewrite ships a real, themeable manifest). Name + theme // colour follow the live website_settings; falls back to defaults with no DB. diff --git a/src/app/robots.ts b/src/app/robots.ts index b55476e797..9709aaaeb6 100644 --- a/src/app/robots.ts +++ b/src/app/robots.ts @@ -1,5 +1,8 @@ import type { MetadataRoute } from "next"; +// Installation metadata must be resolved from the running container. +export const dynamic = "force-dynamic"; + export default function robots(): MetadataRoute.Robots { const appUrl = process.env.APP_URL ?? "http://localhost:3000"; return { diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 8c915316ef..81f4445f60 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -2,6 +2,9 @@ import { desc } from "drizzle-orm"; import type { MetadataRoute } from "next"; import { db, Guilds, WebsiteArticles } from "@/lib/db"; +// Installation metadata must be resolved from the running container. +export const dynamic = "force-dynamic"; + // Built at request time — avoids competing with SSG workers for scarce DB // connections during `next build` (pool timeouts killed deploy on sitemap). diff --git a/src/lib/admin/photo-files.ts b/src/lib/admin/photo-files.ts index 5941fe2ad2..df3c0b4c5a 100644 --- a/src/lib/admin/photo-files.ts +++ b/src/lib/admin/photo-files.ts @@ -12,11 +12,7 @@ export async function tryRemoveLocalPhotoFile(url: string): Promise { if (/^https?:\/\//i.test(pathname)) { try { const parsed = new URL(pathname); - const app = ( - process.env.APP_URL || - process.env.NEXT_PUBLIC_APP_URL || - "" - ).replace(/\/$/, ""); + const app = (process.env.APP_URL || "").replace(/\/$/, ""); if (!app || !pathname.startsWith(app)) return false; pathname = parsed.pathname; } catch { diff --git a/src/lib/docker-build-contract.test.ts b/src/lib/docker-build-contract.test.ts index 15185905f9..f0024a9232 100644 --- a/src/lib/docker-build-contract.test.ts +++ b/src/lib/docker-build-contract.test.ts @@ -42,3 +42,39 @@ it("passes a compiled release to both the application build and final image", () // biome-ignore lint/suspicious/noTemplateCurlyInString: Docker Compose interpolation, not JavaScript. expect(compose).toContain("NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}"); }); + +it("builds with fixtures and excludes installation secrets from every stage", () => { + const ignored = readFileSync(".dockerignore", "utf8"); + expect(ignored).toMatch(/^\.env$/m); + expect(ignored).toMatch(/^\.env\.\*$/m); + expect(dockerfile).toContain("FROM migrations AS builder"); + expect(dockerfile).toContain('HOTEL_NAME="Build fixture"'); + expect(dockerfile).not.toMatch( + /^ENV.*(?:AUTH_SECRET|DATABASE_URL|HOTEL_NAME)/m, + ); + expect(dockerfile).toContain('CMD ["node", "docker-start.mjs"]'); + const updater = readFileSync("scripts/docker-update.sh", "utf8"); + expect(updater).toContain("target=/app/.env,readonly"); + expect(updater).toContain("--target migrations"); +}); +it("verifies portability before publishing and uses committed build context", () => { + const publish = readFileSync("scripts/publish-container.sh", "utf8"); + expect(publish).toContain("git archive HEAD"); + expect( + publish.indexOf("node scripts/verify-portable-image.mjs"), + ).toBeLessThan(publish.indexOf("docker push")); + expect(publish).toContain("--password-stdin"); + expect(publish).not.toContain(":latest"); +}); + +it("does not prerender installation metadata into a shared image", () => { + for (const path of [ + "src/app/robots.ts", + "src/app/sitemap.ts", + "src/app/manifest.ts", + ]) { + expect(readFileSync(path, "utf8")).toContain( + 'export const dynamic = "force-dynamic"', + ); + } +}); diff --git a/src/lib/docker-update.test.ts b/src/lib/docker-update.test.ts index a9b46b6a25..c0f716ceb4 100644 --- a/src/lib/docker-update.test.ts +++ b/src/lib/docker-update.test.ts @@ -50,6 +50,9 @@ function simulate(scenario: string) { TEST_SHA: sha, SCENARIO: scenario, CMS_PUBLIC_URL: "https://example.test", + CMS_IMAGE_REPOSITORY: scenario.startsWith("registry") + ? "registry.test/team/cms" + : "", }, }); if (result.error) throw result.error; @@ -66,6 +69,22 @@ function simulate(scenario: string) { } } describe("Docker clone updates", () => { + it("pulls matching prebuilt application and migrations without building", () => { + const r = simulate("registry"); + expect(r.status, r.output).toBe(0); + expect(r.calls).toContain(`docker pull registry.test/team/cms:${sha}`); + expect(r.calls).toContain( + `docker pull registry.test/team/cms:${sha}-migrations`, + ); + expect(r.calls).not.toContain("docker build"); + expect(r.calls).not.toContain("docker compose build"); + expect(r.calls).toContain("target=/app/.env,readonly"); + }); + it("keeps the current container when the registry pull fails", () => { + const r = simulate("registry-failure"); + expect(r.status).not.toBe(0); + expect(r.calls).not.toContain("compose up"); + }); it("removes only historical release tags beyond the current and previous", () => { const r = simulate("success"); expect(r.calls).toContain(`docker image rm epicnext-cms:${"c".repeat(40)}`); diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts index 63c9043f21..f931598c2a 100644 --- a/src/lib/format.test.ts +++ b/src/lib/format.test.ts @@ -27,14 +27,14 @@ describe("avatarImageUrl", () => { expect(url).toContain("figure=hr-100"); expect(url).toContain("size=l"); expect(url).toContain("headonly=1"); - expect(url.startsWith("https://img.example.com?")).toBe(true); + expect(url.startsWith("/api/imaging/avatar?")).toBe(true); }); it("preserves the exact user look instead of replacing it with a default figure", () => { const look = "hr-11782-40-40.hd-180-7-14.ch-11592-66.lg-10726-79-1408.sh-11764-1408.ha-11958-70-1408.wa-2007-0"; - const url = new URL(avatarImageUrl(look)); + const url = new URL(avatarImageUrl(look), "https://hotel.test"); expect(url.searchParams.get("figure")).toBe(look); }); diff --git a/src/lib/imager.test.ts b/src/lib/imager.test.ts index ab9a1de04d..85fb4f97ee 100644 --- a/src/lib/imager.test.ts +++ b/src/lib/imager.test.ts @@ -1,32 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { getAvatarUrl, resolveImagerBase } from "./imager"; +import { getAvatarUrl } from "./imager"; afterEach(() => { vi.unstubAllEnvs(); }); -describe("resolveImagerBase", () => { - beforeEach(() => { - vi.stubEnv("NEXT_PUBLIC_IMAGER_URL", "https://img.example.com"); - }); - - it("returns configured imager env var", () => { - expect(resolveImagerBase()).toBe("https://img.example.com"); - }); - - it("strips trailing slashes", () => { - vi.stubEnv("NEXT_PUBLIC_IMAGER_URL", "https://img.example.com/"); - expect(resolveImagerBase()).toBe("https://img.example.com"); - }); - - it("falls back to epicnabbo.nl when env var is not set", () => { - vi.stubEnv("NEXT_PUBLIC_IMAGER_URL", ""); - expect(resolveImagerBase()).toBe( - "https://epicnabbo.nl/imaging/avatarimage", - ); - }); -}); - describe("getAvatarUrl", () => { beforeEach(() => { vi.stubEnv("NEXT_PUBLIC_IMAGER_URL", "https://img.example.com"); @@ -34,7 +12,7 @@ describe("getAvatarUrl", () => { it("builds a query string with defaults (omits default params)", () => { const url = getAvatarUrl("hd-180-1"); - expect(url.startsWith("https://img.example.com?")).toBe(true); + expect(url.startsWith("/api/imaging/avatar?")).toBe(true); expect(url).toContain("figure=hd-180-1"); expect(url).not.toContain("direction=2"); expect(url).not.toContain("head_direction=3"); @@ -75,12 +53,10 @@ describe("getAvatarUrl", () => { expect(url).toContain("effect=0"); }); - it("uses epicnabbo.nl when no env var is set", () => { + it("uses the local runtime proxy when no env var is set", () => { vi.stubEnv("NEXT_PUBLIC_IMAGER_URL", ""); const url = getAvatarUrl("hd-180-1"); - expect(url.startsWith("https://epicnabbo.nl/imaging/avatarimage?")).toBe( - true, - ); + expect(url.startsWith("/api/imaging/avatar?")).toBe(true); expect(url).toContain("img_format=apng"); expect(url).toContain("effect=14"); }); diff --git a/src/lib/imager.ts b/src/lib/imager.ts index 861cfe0f02..3384be9696 100644 --- a/src/lib/imager.ts +++ b/src/lib/imager.ts @@ -1,28 +1,13 @@ /** * Avatar imager helpers. * - * The public-facing imager URL is configured via NEXT_PUBLIC_IMAGER_URL env var. - * Defaults to epicnabbo.nl imager with effect=14 and img_format=apng. + * Browser and server markup use the same runtime-configured avatar proxy. */ export type { AvatarOptions } from "@/types/admin"; import type { AvatarOptions } from "@/types/admin"; -const DEFAULT_IMAGER_URL = "https://epicnabbo.nl/imaging/avatarimage"; - -/** - * Resolve the public-facing imager base URL from env. - * Falls back to epicnabbo.nl if not configured. - */ -export function resolveImagerBase(): string { - const fromEnv = process.env.NEXT_PUBLIC_IMAGER_URL?.trim(); - if (!fromEnv) { - return DEFAULT_IMAGER_URL; - } - return fromEnv.replace(/\/+$/, ""); -} - /** * Build an avatar image URL using the configured imager. * Uses effect=14 and img_format=apng by default for epicnabbo.nl compatibility. @@ -59,6 +44,6 @@ export function getAvatarUrl( if (gesture) params.set("gesture", gesture); if (action) params.set("action", action); - const base = resolveImagerBase(); + const base = "/api/imaging/avatar"; return `${base}?${params.toString()}`; } diff --git a/src/lib/runtime-asset-config.test.ts b/src/lib/runtime-asset-config.test.ts new file mode 100644 index 0000000000..1775371426 --- /dev/null +++ b/src/lib/runtime-asset-config.test.ts @@ -0,0 +1,65 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { validateRuntime } from "../../scripts/docker-start.mjs"; +import { resolveBadgeBase, resolveImagerBase } from "./runtime-asset-config"; + +afterEach(() => vi.unstubAllEnvs()); +it("reads different hotel asset settings without reloading the module", () => { + for (const hotel of ["alpha", "beta"]) { + vi.stubEnv("IMAGER_URL", `https://${hotel}.test/avatar/`); + vi.stubEnv("BADGE_URL", `https://${hotel}.test/badges`); + expect(resolveImagerBase()).toBe(`https://${hotel}.test/avatar`); + expect(resolveBadgeBase()).toBe(`https://${hotel}.test/badges`); + } +}); +it("supports legacy runtime aliases", () => { + vi.stubEnv("IMAGER_URL", ""); + vi.stubEnv("BADGE_URL", ""); + vi.stubEnv("NEXT_PUBLIC_IMAGER_URL", "https://legacy.test/avatar"); + vi.stubEnv("NEXT_PUBLIC_BADGE_URL", "https://legacy.test/badges"); + expect(resolveImagerBase()).toBe("https://legacy.test/avatar"); + expect(resolveBadgeBase()).toBe("https://legacy.test/badges"); +}); +it("resolves a local proxy to its configured upstream and rejects loops", () => { + vi.stubEnv("IMAGER_URL", "https://hotel.test/imaging"); + vi.stubEnv("IMAGING_UPSTREAM_URL", "http://127.0.0.1:3030/imaging"); + expect(resolveImagerBase("https://hotel.test")).toBe( + "http://127.0.0.1:3030/imaging", + ); + vi.stubEnv("IMAGING_UPSTREAM_URL", "https://hotel.test/api/imaging/avatar"); + expect(() => resolveImagerBase("https://hotel.test")).toThrow("proxy loop"); +}); +it("rejects unsupported asset protocols", () => { + vi.stubEnv("IMAGER_URL", "file:///private"); + expect(() => resolveImagerBase()).toThrow("protocol"); +}); +it("requires runtime configuration and never reports secret values", () => { + expect(() => validateRuntime({})).toThrow( + "HOTEL_NAME, AUTH_SECRET, DATABASE_URL, APP_URL", + ); + expect(() => + validateRuntime({ + HOTEL_NAME: "Hotel", + AUTH_SECRET: "x".repeat(32), + DATABASE_URL: "mysql://u:p@db/hotel", + APP_URL: "https://hotel.test", + }), + ).not.toThrow(); + expect(() => + validateRuntime({ + HOTEL_NAME: "Build fixture", + AUTH_SECRET: "build-fixture-".padEnd(40, "x"), + }), + ).toThrow("HOTEL_NAME, AUTH_SECRET"); +}); + +it("uses the neutral renderer when no installation setting is present", () => { + for (const name of [ + "IMAGER_URL", + "NEXT_PUBLIC_IMAGER_URL", + "IMAGING_UPSTREAM_URL", + ]) + vi.stubEnv(name, ""); + expect(resolveImagerBase()).toBe( + "https://www.habbo.com/habbo-imaging/avatarimage", + ); +}); diff --git a/src/lib/runtime-asset-config.ts b/src/lib/runtime-asset-config.ts new file mode 100644 index 0000000000..77800c0816 --- /dev/null +++ b/src/lib/runtime-asset-config.ts @@ -0,0 +1,47 @@ +import "server-only"; + +// Dynamic lookup keeps legacy NEXT_PUBLIC aliases out of build-time inlining. +function setting(name: string): string | undefined { + return process.env[name]?.trim() || undefined; +} + +export function resolveImagerBase(origin?: string): string { + const base = + setting("IMAGER_URL") || + setting("NEXT_PUBLIC_IMAGER_URL") || + setting("IMAGING_UPSTREAM_URL") || + "https://www.habbo.com/habbo-imaging/avatarimage"; + const target = new URL(base, origin); + const ownOrigins = new Set([origin]); + const appUrl = setting("APP_URL"); + if (appUrl) ownOrigins.add(new URL(appUrl).origin); + if (!["http:", "https:"].includes(target.protocol)) + throw new Error("Invalid imager URL protocol"); + if ( + ownOrigins.has(target.origin) && + ["/imaging", "/api/imaging/avatar"].includes( + target.pathname.replace(/\/+$/, ""), + ) + ) { + const upstream = setting("IMAGING_UPSTREAM_URL"); + if (!upstream) + throw new Error( + "IMAGING_UPSTREAM_URL is required when the imager points to the CMS proxy", + ); + const resolved = new URL(upstream); + if ( + !["http:", "https:"].includes(resolved.protocol) || + (ownOrigins.has(resolved.origin) && + ["/imaging", "/api/imaging/avatar"].includes( + resolved.pathname.replace(/\/+$/, ""), + )) + ) + throw new Error("Imager configuration creates a proxy loop"); + return resolved.href.replace(/\/+$/, ""); + } + return target.href.replace(/\/+$/, ""); +} + +export function resolveBadgeBase(): string | undefined { + return setting("BADGE_URL") || setting("NEXT_PUBLIC_BADGE_URL"); +} diff --git a/src/lib/runtime-avatar-route.test.ts b/src/lib/runtime-avatar-route.test.ts new file mode 100644 index 0000000000..cb08ab967a --- /dev/null +++ b/src/lib/runtime-avatar-route.test.ts @@ -0,0 +1,44 @@ +import { NextRequest } from "next/server"; +import { afterEach, expect, it, vi } from "vitest"; +import { GET } from "../app/api/imaging/avatar/route"; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); +it("forwards multicolor figures and preserves configured query options at runtime", async () => { + const figure = "hr-11782-40-40.hd-180-7-14.ch-11592-66.lg-10726-79-1408"; + const fetcher = vi.fn( + async (_input: string | URL) => + new Response(new Uint8Array([1, 2, 3]), { + headers: { "content-type": "image/png" }, + }), + ); + vi.stubGlobal("fetch", fetcher); + for (const hotel of ["alpha", "beta"]) { + vi.stubEnv("IMAGER_URL", `https://${hotel}.test/avatar?renderer=custom`); + const result = await GET( + new NextRequest( + `https://hotel.test/api/imaging/avatar?figure=${figure}&headonly=1&effect=0`, + ), + ); + expect(result.status).toBe(200); + const target = new URL(String(fetcher.mock.calls.at(-1)?.[0])); + expect(target.origin).toBe(`https://${hotel}.test`); + expect(target.searchParams.get("figure")).toBe(figure); + expect(target.searchParams.get("renderer")).toBe("custom"); + expect(target.searchParams.get("headonly")).toBe("1"); + } +}); +it("rejects malformed figure input without contacting a renderer", async () => { + const fetcher = vi.fn(); + vi.stubGlobal("fetch", fetcher); + expect( + ( + await GET( + new NextRequest("https://hotel.test/api/imaging/avatar?figure=../bad"), + ) + ).status, + ).toBe(400); + expect(fetcher).not.toHaveBeenCalled(); +}); diff --git a/src/test/docker-update-harness.sh b/src/test/docker-update-harness.sh index ff23a3eafe..0e90e4e8b7 100644 --- a/src/test/docker-update-harness.sh +++ b/src/test/docker-update-harness.sh @@ -12,6 +12,7 @@ flock() { :; } sleep() { :; } docker() { echo "docker $*" >> "$TEST_DIR/calls" + if [ "$1" = pull ] && [ "$SCENARIO" = registry-failure ]; then return 1; fi case "$1 ${2:-}" in 'inspect --format') if [ "${@: -1}" = epicnext-cms-app ]; then [ "$SCENARIO" = ci-active ] && echo true; return 0; fi