feat(docker): prepare portable images with runtime hotel configuration
This commit is contained in:
1 parent
745d0b7247
commit
c4496e710b
28 files changed
+622
-104
No files matched your search
+19
-5
@@ -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
|
||||
+4
-2
@@ -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
|
||||
|
||||
@@ -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
@@ -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:[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
|
||||
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"]
|
||||
CMD ["node", "docker-start.mjs"]
|
||||
@@ -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 `<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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
>
|
||||
<img
|
||||
src={
|
||||
process.env.NEXT_PUBLIC_BADGE_URL
|
||||
? `${process.env.NEXT_PUBLIC_BADGE_URL}/${badge.badgeCode}.gif`
|
||||
: `/swf/c_images/album1584/${badge.badgeCode}.gif`
|
||||
}
|
||||
src={`/api/imaging/badge?source=local&code=${encodeURIComponent(badge.badgeCode)}`}
|
||||
alt={badge.badgeCode}
|
||||
className="h-5 w-5"
|
||||
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"
|
||||
>
|
||||
<img
|
||||
src={
|
||||
process.env.NEXT_PUBLIC_BADGE_URL
|
||||
? `${process.env.NEXT_PUBLIC_BADGE_URL}/${badge.badgeCode}.gif`
|
||||
: `/swf/c_images/album1584/${badge.badgeCode}.gif`
|
||||
}
|
||||
src={`/api/imaging/badge?source=local&code=${encodeURIComponent(badge.badgeCode)}`}
|
||||
alt={badge.badgeCode}
|
||||
className="h-5 w-5"
|
||||
onError={(e) => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -12,11 +12,7 @@ export async function tryRemoveLocalPhotoFile(url: string): Promise<boolean> {
|
||||
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 {
|
||||
|
||||
@@ -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"',
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
+4
-28
@@ -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");
|
||||
});
|
||||
|
||||
+2
-17
@@ -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()}`;
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user