fix(imaging): keep avatars rendering, cacheable and reliably timed
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m40s
CI / tests-unit (push) Successful in 1m51s
CI / tests-ui (push) Successful in 2m43s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m10s

Effect renders need a little over 4s, which the 4s primary timeout cut off,
so every avatar with the default effect fell through to an unreachable
public fallback and rendered as a placeholder. Raise the primary budget
above the observed render cost and shorten the fallback budget.

Also stop the proxy from stamping no-store over the avatar and media
responses, so browsers keep the long-lived Cache-Control the route already
sends, and recreate the imaging cache directories with the container user
on every deploy, since root ownership made those cache writes fail
silently.
This commit is contained in:
openhands committed 2026-09-24 23:22:28 +02:00
1 parent 8486ac4053
commit fe5a7a6185
6 files changed
+66 -8

No files matched your search

+7 -2
View File
@@ -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"
+11
View File
@@ -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);
});
});
+21 -1
View File
@@ -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);
});
});
+6
View File
@@ -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));
}
+12 -5
View File
@@ -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<string, string> = {
"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);