diff --git a/scripts/ci-deploy.sh b/scripts/ci-deploy.sh index 265e84f4..8a9a1c80 100644 --- a/scripts/ci-deploy.sh +++ b/scripts/ci-deploy.sh @@ -146,6 +146,15 @@ if [ "$previous_name" != epicnext-cms-app ] && [ "$secondary_name" != epicnext-c fi cutover_started=1 +# The avatar/badge disk cache lives on the host bind and is written by uid 33 +# inside the container. Root-owned directories make every cache write fail +# silently, which turns each avatar into a fresh live render. +for cache_dir in avatars badges; do + if ! install -d -o 33 -g 33 -m 0750 "$deploy_dir/storage/imaging/$cache_dir" 2>/dev/null; then + mkdir -p "$deploy_dir/storage/imaging/$cache_dir" 2>/dev/null || true + fi +done +chown -R 33:33 "$deploy_dir/storage/imaging" 2>/dev/null || true # Both legacy Compose and CI containers can exist after earlier failed updates. # Preserve each before releasing the shared host port; never kill an arbitrary PID. if [ -n "$secondary_name" ]; then diff --git a/src/lib/imager-upstream.ts b/src/lib/imager-upstream.ts index 18dd4d74..321a1c58 100644 --- a/src/lib/imager-upstream.ts +++ b/src/lib/imager-upstream.ts @@ -14,8 +14,13 @@ export const FIGURE_MAX_PARTS = 24; const HABBO_PUBLIC_UPSTREAM = "https://www.habbo.com/habbo-imaging/avatarimage"; -const PRIMARY_TIMEOUT_MS = 4_000; -const FALLBACK_TIMEOUT_MS = 4_000; +// Effect renders (the client asks for effect=14 by default) are encoded as +// APNG animations and consistently need a little over 4s, so the primary +// budget has to stay well above that or every effected avatar times out. The +// public Habbo renderer is a best-effort safety net only: it is slow to fail, +// so it gets a short budget and the disk cache absorbs the difference. +export const PRIMARY_TIMEOUT_MS = 8_000; +export const FALLBACK_TIMEOUT_MS = 2_000; export type ImagerSource = | "primary" diff --git a/src/lib/imager.test.ts b/src/lib/imager.test.ts index f5dc6d54..af763267 100644 --- a/src/lib/imager.test.ts +++ b/src/lib/imager.test.ts @@ -1,6 +1,7 @@ // @ts-nocheck import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getAvatarUrl } from "./imager"; +import { FALLBACK_TIMEOUT_MS, PRIMARY_TIMEOUT_MS } from "./imager-upstream"; afterEach(() => { vi.unstubAllEnvs(); @@ -62,3 +63,13 @@ describe("getAvatarUrl", () => { expect(url).toContain("effect=14"); }); }); + +describe("upstream render budgets", () => { + it("keeps enough headroom for APNG effect renders", () => { + expect(PRIMARY_TIMEOUT_MS).toBeGreaterThanOrEqual(8_000); + }); + + it("does not stack a long fallback wait on a slow primary", () => { + expect(FALLBACK_TIMEOUT_MS).toBeLessThan(PRIMARY_TIMEOUT_MS); + }); +}); diff --git a/src/lib/proxy-access.test.ts b/src/lib/proxy-access.test.ts index d72708d9..e5514076 100644 --- a/src/lib/proxy-access.test.ts +++ b/src/lib/proxy-access.test.ts @@ -1,6 +1,9 @@ // @ts-nocheck import { describe, expect, it } from "vitest"; -import { shouldRedirectAdminRequest } from "./proxy-access"; +import { + isCacheableAssetPath, + shouldRedirectAdminRequest, +} from "./proxy-access"; describe("shouldRedirectAdminRequest", () => { it("redirects anonymous admin requests before rendering", () => { @@ -17,3 +20,20 @@ describe("shouldRedirectAdminRequest", () => { expect(shouldRedirectAdminRequest("/news", null)).toBe(false); }); }); + +describe("isCacheableAssetPath", () => { + it("keeps rendered avatars and uploaded media cacheable", () => { + expect(isCacheableAssetPath("/api/imaging/avatar")).toBe(true); + expect(isCacheableAssetPath("/api/imaging/badge")).toBe(true); + expect(isCacheableAssetPath("/api/media/1730000000000-abc.png")).toBe(true); + }); + + it("never lets a document or API route reuse the asset cache policy", () => { + expect(isCacheableAssetPath("/")).toBe(false); + expect(isCacheableAssetPath("/news")).toBe(false); + expect(isCacheableAssetPath("/api/news")).toBe(false); + expect(isCacheableAssetPath("/api/media-library")).toBe(false); + expect(isCacheableAssetPath("/api/imaging")).toBe(false); + expect(isCacheableAssetPath("/admin/media")).toBe(false); + }); +}); diff --git a/src/lib/proxy-access.ts b/src/lib/proxy-access.ts index 6f9da00d..a5fbceba 100644 --- a/src/lib/proxy-access.ts +++ b/src/lib/proxy-access.ts @@ -10,3 +10,9 @@ export function shouldRedirectAdminRequest( if (pathname !== "/admin" && !pathname.startsWith("/admin/")) return false; return token === null; } + +const CACHEABLE_ASSET_PREFIXES = ["/api/imaging/", "/api/media/"]; + +export function isCacheableAssetPath(pathname: string): boolean { + return CACHEABLE_ASSET_PREFIXES.some((prefix) => pathname.startsWith(prefix)); +} diff --git a/src/proxy.ts b/src/proxy.ts index 7212b68f..e9a2c4cd 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -3,7 +3,10 @@ import { getToken } from "next-auth/jwt"; import { env } from "@/env"; import { buildContentSecurityPolicy, createCspNonce } from "@/lib/csp"; import { ddosReject, enforceDdosRateLimit } from "@/lib/ddos-guard"; -import { shouldRedirectAdminRequest } from "@/lib/proxy-access"; +import { + isCacheableAssetPath, + shouldRedirectAdminRequest, +} from "@/lib/proxy-access"; const SECURITY_HEADERS: Record = { "X-Content-Type-Options": "nosniff", @@ -56,10 +59,14 @@ export const proxy = async (req: import("next/server").NextRequest) => { // always references the current build's chunks. Static assets are // content-hashed + immutable and can be cached aggressively; a stale HTML // document would reference chunk URLs that no longer exist after a rebuild. - response.headers.set( - "Cache-Control", - "private, no-cache, no-store, max-age=0, must-revalidate", - ); + // Rendered avatars and uploaded media are immutable per key and already + // carry their own long-lived Cache-Control, so they keep it here. + if (!isCacheableAssetPath(pathname)) { + response.headers.set( + "Cache-Control", + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + } for (const [key, value] of Object.entries(SECURITY_HEADERS)) { response.headers.set(key, value);