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

@@ -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) => {
+1 -3
View File
@@ -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 });
+5 -4
View File
@@ -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, {
+8 -2
View File
@@ -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
+5 -4
View File
@@ -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, {
+3
View File
@@ -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.
+3
View File
@@ -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 {
+3
View File
@@ -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).
+1 -5
View File
@@ -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 {
+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.
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,
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)}`);
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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()}`;
}
+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() { :; }
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