feat(docker): prepare portable images with runtime hotel configuration
CI / check (push) Successful in 1m6s
CI / deploy (push) Successful in 1m23s

This commit is contained in:
Simo committed 2026-09-07 22:37:53 +02:00
1 parent 745d0b7247
commit c4496e710b
28 files changed
+622 -104

No files matched your search

+19 -5
View File
@@ -13,11 +13,14 @@ package-lock.json
yarn.lock yarn.lock
bun.lockb bun.lockb
.npmrc.bak .npmrc.bak
# NOTE: .env is intentionally NOT ignored here — the build loads it (only inside # Installation secrets must never enter any image layer (including migrations).
# a build RUN layer) to produce NEXT_PUBLIC_* + validated build-time values. .env
# It is not copied into the runtime image (the runner stage copies only .env.*
# .next/standalone, public/, and .next/static). **/.env
# .env **/.env.*
!.env.example
*.pem
*.key
*.tsbuildinfo *.tsbuildinfo
# Runtime write targets; bound as RW volumes at runtime (see docker-compose.yml) # Runtime write targets; bound as RW volumes at runtime (see docker-compose.yml)
public/nitro-assets public/nitro-assets
@@ -25,3 +28,14 @@ public/swf
.deploy.lock .deploy.lock
logs logs
# Other installation data and local tool artifacts
public/cache
public/tmp
db_backup_*.sql
*.log
playwright-report
test-results
blob-report
.codex
.agents
+4 -2
View File
@@ -23,12 +23,14 @@ HOUSEKEEPING_NEXT_PREVIEW_ENABLED=false
# --- HOTEL & URLS --- # --- HOTEL & URLS ---
HOTEL_NAME=EPIC WEB CONTROL HOTEL_NAME=EPIC WEB CONTROL
APP_URL=http://localhost:3002 APP_URL=http://localhost:3002
NEXT_PUBLIC_APP_URL=http://localhost:3002
AUTH_URL=http://localhost:3002 AUTH_URL=http://localhost:3002
# --- IMAGER --- # --- IMAGER ---
IMAGING_UPSTREAM_URL=http://127.0.0.1:3030/imaging 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 --- # --- SECURITY & HASHING ---
AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
+34
View File
@@ -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:[email protected]: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
+9 -3
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# Pin the runtime to the supported engine; update both stages deliberately. # 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 WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
# Keep the bootstrap aligned with package.json packageManager. # 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 fetch --ignore-scripts
RUN pnpm install --frozen-lockfile --ignore-scripts --offline RUN pnpm install --frozen-lockfile --ignore-scripts --offline
COPY . . COPY . .
FROM migrations AS builder
ARG NEXT_DEPLOYMENT_ID="unknown" ARG NEXT_DEPLOYMENT_ID="unknown"
ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID" 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:[email protected]: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 FROM node:26.8.1-alpine AS runner
ARG NEXT_DEPLOYMENT_ID="unknown" 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/public ./public
COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static
COPY --chown=nextjs:nextjs scripts/docker-start.mjs ./docker-start.mjs
USER nextjs USER nextjs
EXPOSE 3002 EXPOSE 3002
# Self-contained healthcheck so `docker run` (ci-deploy) also gets Docker-level # 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 \ 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))"] 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", "--"] ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"] CMD ["node", "docker-start.mjs"]
+54 -15
View File
@@ -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 other services and persistent volumes are not pruned. Retain the history file between
updates. Before production migrations, retain your normal database backup. 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 Docker builds no longer load your installation's `.env`. The builder uses disposable
these changes before it can safely replace local builds: 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 | 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
| 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. | `NEXT_PUBLIC_BADGE_URL` names remain supported at runtime. If the imager points back
| Badge URL | Admin user badge components, `NEXT_PUBLIC_BADGE_URL` | Pass runtime configuration from the server to client components. | to the CMS `/imaging` proxy, set `IMAGING_UPSTREAM_URL` to the actual renderer to avoid
| Public application URL | Diagnostics and photo helpers, `NEXT_PUBLIC_APP_URL` fallback | Use server runtime `APP_URL`; audit any generated absolute URLs. | a loop. With no imager configured, the CMS uses Habbo's public renderer. Client
| Release identity | `next.config.ts`, `NEXT_PUBLIC_CMS_RELEASE` | Keep baked into the image: one commit identifies the same application binary everywhere. | requests use the existing `/api/imaging/avatar` proxy. Admin badges preserve their
| 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. | local `/swf/c_images/album1584` fallback. Public URL resolution uses runtime `APP_URL`.
| 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. | 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 To publish from Gitea:
run the **same image digest** for two installations with different names, domains,
imager/badge URLs and credentials, then check rendered pages and browser requests. 1. In the repository's Actions secrets, configure `CONTAINER_REGISTRY_USER` and
The registry publishing workflow is intentionally not enabled yet. `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 `<gitea-host>/<owner>/<repository-lowercase>:<full-commit>` and
`:<full-commit>-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 ### Diagnose an update that is not visible
+47
View File
@@ -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;
}
}
+23 -4
View File
@@ -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"; } log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"; }
die() { log "ERROR: $*"; exit 1; } die() { log "ERROR: $*"; exit 1; }
migration_image="" migration_image=""
remote_migration_image=""
previous_image="" previous_image=""
previous_release="" previous_release=""
rollback_tag="" rollback_tag=""
@@ -45,6 +46,7 @@ finish() {
fi fi
fi fi
if [[ -n "$migration_image" ]]; then docker image rm "$migration_image" >>"$LOG_FILE" 2>&1 || true; 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. # 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 if [[ ( "$status" = 0 || "$cutover" = 0 ) && -n "$rollback_tag" ]]; then docker image rm "epicnext-cms:$rollback_tag" >>"$LOG_FILE" 2>&1 || true; fi
exit "$status" exit "$status"
@@ -81,15 +83,27 @@ if [[ -n "$previous_container" ]]; then
docker image tag "$previous_image" "epicnext-cms:$rollback_tag" docker image tag "$previous_image" "epicnext-cms:$rollback_tag"
fi fi
log "Building release $CMS_RELEASE from $DIR" 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. # No Node/package manager installation on the host is required.
migration_image="epicnext-cms-migrations:$CMS_RELEASE" 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 if [[ -n "${CMS_IMAGE_REPOSITORY:-}" ]]; then
docker compose build --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" cms >>"$LOG_FILE" 2>&1 [[ "$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")" 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="$(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." [[ "$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." log "Build and migrations completed; recreating only the CMS service."
cutover=1 cutover=1
docker compose up -d --no-deps --no-build --force-recreate cms >>"$LOG_FILE" 2>&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 if [[ "${#kept[@]}" -lt 2 ]]; then kept+=("$release"); continue; fi
# Even stopped containers belonging to other deployments protect an image. # 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 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" log "Removed superseded release tag $release"
else else
pending+=("$release") pending+=("$release")
+30
View File
@@ -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"
+151
View File
@@ -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:[email protected]: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));
}
@@ -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" 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"
> >
<img <img
src={ src={`/api/imaging/badge?source=local&code=${encodeURIComponent(badge.badgeCode)}`}
process.env.NEXT_PUBLIC_BADGE_URL
? `${process.env.NEXT_PUBLIC_BADGE_URL}/${badge.badgeCode}.gif`
: `/swf/c_images/album1584/${badge.badgeCode}.gif`
}
alt={badge.badgeCode} alt={badge.badgeCode}
className="h-5 w-5" className="h-5 w-5"
onError={(e) => { onError={(e) => {
@@ -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" className="flex items-center gap-1.5 rounded-md border bg-card px-2 py-1 text-sm"
> >
<img <img
src={ src={`/api/imaging/badge?source=local&code=${encodeURIComponent(badge.badgeCode)}`}
process.env.NEXT_PUBLIC_BADGE_URL
? `${process.env.NEXT_PUBLIC_BADGE_URL}/${badge.badgeCode}.gif`
: `/swf/c_images/album1584/${badge.badgeCode}.gif`
}
alt={badge.badgeCode} alt={badge.badgeCode}
className="h-5 w-5" className="h-5 w-5"
onError={(e) => { onError={(e) => {
+1 -3
View File
@@ -19,9 +19,7 @@ const schema = z.object({
export async function POST(request: Request) { export async function POST(request: Request) {
if ( if (
request.headers.get("origin") !== request.headers.get("origin") !==
new URL( new URL(process.env.APP_URL || request.url).origin ||
process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL || request.url,
).origin ||
!request.headers.get("content-type")?.startsWith("application/json") !request.headers.get("content-type")?.startsWith("application/json")
) )
return new Response(null, { status: 403 }); return new Response(null, { status: 403 });
+5 -4
View File
@@ -1,7 +1,7 @@
import { type NextRequest, NextResponse } from "next/server"; 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_LEN = 512;
const FIGURE_MAX_PARTS = 24; const FIGURE_MAX_PARTS = 24;
const UPSTREAM_TIMEOUT_MS = 10_000; const UPSTREAM_TIMEOUT_MS = 10_000;
@@ -66,8 +66,9 @@ export async function GET(request: NextRequest) {
const imgFormat = searchParams.get("img_format"); const imgFormat = searchParams.get("img_format");
if (imgFormat) params.set("img_format", imgFormat); if (imgFormat) params.set("img_format", imgFormat);
const upstream = resolveImagerBase(); const upstream = resolveImagerBase(new URL(request.url).origin);
const upstreamUrl = `${upstream}?${params.toString()}`; const upstreamUrl = new URL(upstream);
for (const [key, value] of params) upstreamUrl.searchParams.set(key, value);
try { try {
const res = await fetch(upstreamUrl, { const res = await fetch(upstreamUrl, {
+8 -2
View File
@@ -1,5 +1,6 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { resolveBadgeBase } from "@/lib/runtime-asset-config";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
/** /**
@@ -28,9 +29,14 @@ export async function GET(request: NextRequest) {
} }
const configured = ( const configured = (
(await siteSettings.get("badge_base_url", "")) ?? "" (resolveBadgeBase() || (await siteSettings.get("badge_base_url", ""))) ??
""
).trim(); ).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 isAbsolute = /^https?:\/\//i.test(base);
const target = `${base}/${code}.gif`; const target = `${base}/${code}.gif`;
const absoluteTarget = isAbsolute const absoluteTarget = isAbsolute
+5 -4
View File
@@ -1,8 +1,8 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import sharp from "sharp"; 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_LEN = 512;
const FIGURE_MAX_PARTS = 24; const FIGURE_MAX_PARTS = 24;
const UPSTREAM_TIMEOUT_MS = 10_000; const UPSTREAM_TIMEOUT_MS = 10_000;
@@ -82,8 +82,9 @@ export async function GET(request: NextRequest) {
const format = resolveFormat(searchParams.get("format")); const format = resolveFormat(searchParams.get("format"));
const upstream = resolveImagerBase(); const upstream = resolveImagerBase(new URL(request.url).origin);
const upstreamUrl = `${upstream}?${params.toString()}`; const upstreamUrl = new URL(upstream);
for (const [key, value] of params) upstreamUrl.searchParams.set(key, value);
try { try {
const res = await fetch(upstreamUrl, { const res = await fetch(upstreamUrl, {
+3
View File
@@ -2,6 +2,9 @@ import type { MetadataRoute } from "next";
import { resolveHotelName } from "@/lib/hotel-name"; import { resolveHotelName } from "@/lib/hotel-name";
import { siteSettings } from "@/lib/services/site-settings"; 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 // Web app manifest — makes the hotel installable as a PWA (AtomCMS exposed PWA
// settings but the rewrite ships a real, themeable manifest). Name + theme // settings but the rewrite ships a real, themeable manifest). Name + theme
// colour follow the live website_settings; falls back to defaults with no DB. // colour follow the live website_settings; falls back to defaults with no DB.
+3
View File
@@ -1,5 +1,8 @@
import type { MetadataRoute } from "next"; 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 { export default function robots(): MetadataRoute.Robots {
const appUrl = process.env.APP_URL ?? "http://localhost:3000"; const appUrl = process.env.APP_URL ?? "http://localhost:3000";
return { return {
+3
View File
@@ -2,6 +2,9 @@ import { desc } from "drizzle-orm";
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { db, Guilds, WebsiteArticles } from "@/lib/db"; 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 // Built at request time — avoids competing with SSG workers for scarce DB
// connections during `next build` (pool timeouts killed deploy on sitemap). // connections during `next build` (pool timeouts killed deploy on sitemap).
+1 -5
View File
@@ -12,11 +12,7 @@ export async function tryRemoveLocalPhotoFile(url: string): Promise<boolean> {
if (/^https?:\/\//i.test(pathname)) { if (/^https?:\/\//i.test(pathname)) {
try { try {
const parsed = new URL(pathname); const parsed = new URL(pathname);
const app = ( const app = (process.env.APP_URL || "").replace(/\/$/, "");
process.env.APP_URL ||
process.env.NEXT_PUBLIC_APP_URL ||
""
).replace(/\/$/, "");
if (!app || !pathname.startsWith(app)) return false; if (!app || !pathname.startsWith(app)) return false;
pathname = parsed.pathname; pathname = parsed.pathname;
} catch { } catch {
+36
View File
@@ -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. // biome-ignore lint/suspicious/noTemplateCurlyInString: Docker Compose interpolation, not JavaScript.
expect(compose).toContain("NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}"); 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"',
);
}
});
+19
View File
@@ -50,6 +50,9 @@ function simulate(scenario: string) {
TEST_SHA: sha, TEST_SHA: sha,
SCENARIO: scenario, SCENARIO: scenario,
CMS_PUBLIC_URL: "https://example.test", CMS_PUBLIC_URL: "https://example.test",
CMS_IMAGE_REPOSITORY: scenario.startsWith("registry")
? "registry.test/team/cms"
: "",
}, },
}); });
if (result.error) throw result.error; if (result.error) throw result.error;
@@ -66,6 +69,22 @@ function simulate(scenario: string) {
} }
} }
describe("Docker clone updates", () => { 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", () => { it("removes only historical release tags beyond the current and previous", () => {
const r = simulate("success"); const r = simulate("success");
expect(r.calls).toContain(`docker image rm epicnext-cms:${"c".repeat(40)}`); expect(r.calls).toContain(`docker image rm epicnext-cms:${"c".repeat(40)}`);
+2 -2
View File
@@ -27,14 +27,14 @@ describe("avatarImageUrl", () => {
expect(url).toContain("figure=hr-100"); expect(url).toContain("figure=hr-100");
expect(url).toContain("size=l"); expect(url).toContain("size=l");
expect(url).toContain("headonly=1"); 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", () => { it("preserves the exact user look instead of replacing it with a default figure", () => {
const look = 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"; "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); expect(url.searchParams.get("figure")).toBe(look);
}); });
+4 -28
View File
@@ -1,32 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getAvatarUrl, resolveImagerBase } from "./imager"; import { getAvatarUrl } from "./imager";
afterEach(() => { afterEach(() => {
vi.unstubAllEnvs(); 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", () => { describe("getAvatarUrl", () => {
beforeEach(() => { beforeEach(() => {
vi.stubEnv("NEXT_PUBLIC_IMAGER_URL", "https://img.example.com"); 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)", () => { it("builds a query string with defaults (omits default params)", () => {
const url = getAvatarUrl("hd-180-1"); 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).toContain("figure=hd-180-1");
expect(url).not.toContain("direction=2"); expect(url).not.toContain("direction=2");
expect(url).not.toContain("head_direction=3"); expect(url).not.toContain("head_direction=3");
@@ -75,12 +53,10 @@ describe("getAvatarUrl", () => {
expect(url).toContain("effect=0"); 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", ""); vi.stubEnv("NEXT_PUBLIC_IMAGER_URL", "");
const url = getAvatarUrl("hd-180-1"); const url = getAvatarUrl("hd-180-1");
expect(url.startsWith("https://epicnabbo.nl/imaging/avatarimage?")).toBe( expect(url.startsWith("/api/imaging/avatar?")).toBe(true);
true,
);
expect(url).toContain("img_format=apng"); expect(url).toContain("img_format=apng");
expect(url).toContain("effect=14"); expect(url).toContain("effect=14");
}); });
+2 -17
View File
@@ -1,28 +1,13 @@
/** /**
* Avatar imager helpers. * Avatar imager helpers.
* *
* The public-facing imager URL is configured via NEXT_PUBLIC_IMAGER_URL env var. * Browser and server markup use the same runtime-configured avatar proxy.
* Defaults to epicnabbo.nl imager with effect=14 and img_format=apng.
*/ */
export type { AvatarOptions } from "@/types/admin"; export type { AvatarOptions } from "@/types/admin";
import 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. * Build an avatar image URL using the configured imager.
* Uses effect=14 and img_format=apng by default for epicnabbo.nl compatibility. * 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 (gesture) params.set("gesture", gesture);
if (action) params.set("action", action); if (action) params.set("action", action);
const base = resolveImagerBase(); const base = "/api/imaging/avatar";
return `${base}?${params.toString()}`; return `${base}?${params.toString()}`;
} }
+65
View File
@@ -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",
);
});
+47
View File
@@ -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");
}
+44
View File
@@ -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();
});
+1
View File
@@ -12,6 +12,7 @@ flock() { :; }
sleep() { :; } sleep() { :; }
docker() { docker() {
echo "docker $*" >> "$TEST_DIR/calls" echo "docker $*" >> "$TEST_DIR/calls"
if [ "$1" = pull ] && [ "$SCENARIO" = registry-failure ]; then return 1; fi
case "$1 ${2:-}" in case "$1 ${2:-}" in
'inspect --format') 'inspect --format')
if [ "${@: -1}" = epicnext-cms-app ]; then [ "$SCENARIO" = ci-active ] && echo true; return 0; fi if [ "${@: -1}" = epicnext-cms-app ]; then [ "$SCENARIO" = ci-active ] && echo true; return 0; fi