Files
EpicNext-Cms/src/app/api/imaging/avatar/route.ts
T
openhands 3c8abb6fbf
CI / check (push) Successful in 39s
CI / tests-imaging-browsers (push) Successful in 2m9s
CI / tests-integration (push) Successful in 2m47s
CI / tests-unit (push) Failing after 3m22s
CI / tests-ui (push) Failing after 4m14s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
feat(assets): choose furniture bundle ext via FURNI_BUNDLE_EXT
Bundle the remaining imaging hardening and the host/CI fixes from the
"Doe alles" pass into one release.

Furniture:
- Resolve the local bundle extension from FURNI_BUNDLE_EXT (".hab" default,
  ".nitro" for a stock Nitro client). The configured extension is written by
  imports/repairs and preferred on lookup; the other stays a legacy fallback.

Imaging:
- Avatar proxy sends s-maxage=86400 + stale-while-revalidate (300/3600 for the
  fallback path) so the edge can hold a render a day.
- Cloudflare cache rule now covers /api/imaging/ (verified MISS->HIT live).
- Document that bare /imaging is shadowed by the edge proxy to the renderer.

Ops/CI:
- docker-update.sh skips a CI-managed host (exit 0) instead of failing cron.
- ci-deploy.sh runs the cross-browser imaging gate (chromium+firefox+webkit)
  against the candidate before cutover; DEPLOY_SKIP_CROSS_BROWSER=1 as escape.
- scripts/imager-health-alert.sh alerts (Discord) on imager up/down transitions;
  runs from cron every 5 minutes.

Tests:
- Furniture bundle-ext resolver + FURNI_BUNDLE_EXT wiring.
- Imaging performance budget (bytes + warm latency).
- classifyDdos: /api/imaging/* stays in the api bucket.
2026-10-14 21:46:56 +02:00

119 lines
3.6 KiB
TypeScript

import { type NextRequest, NextResponse } from "next/server";
import { AVATAR_FALLBACK_SRC } from "@/lib/imager";
import { ifNoneMatchSatisfied, strongEtag } from "@/lib/imager-http";
import {
fetchAvatarImage,
ImagerUnavailableError,
parseAvatarQuery,
} from "@/lib/imager-upstream";
// The same neutral silhouette the client fallback uses, served as a real 200
// response when no upstream can render the figure. Images on server-rendered
// pages therefore never show a broken-image glyph; browsers revalidate quickly
// (`no-cache`) and pick up real renders as soon as an upstream recovers.
const PLACEHOLDER_SVG_BYTES = new TextEncoder().encode(
decodeURIComponent(
AVATAR_FALLBACK_SRC.slice("data:image/svg+xml;utf8,".length),
),
);
function cacheControlFor(source: string): string {
switch (source) {
case "fallback":
case "cache-stale":
// Reuse the render briefly but revalidate the upstream soon. The
// shared `s-maxage` lets the edge (Cloudflare) hold it for an hour
// without pinning a degraded fallback in the browser.
return "public, max-age=300, s-maxage=3600, stale-while-revalidate=3600";
case "unavailable":
return "no-cache, max-age=0";
default:
// s-maxage=86400 lets the edge cache a day off the origin, matching
// the sibling /imaging route; the URL already keys per figure.
return "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400";
}
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const parsed = parseAvatarQuery(searchParams);
if (!parsed.ok) {
return NextResponse.json({ error: parsed.message }, { status: 400 });
}
try {
const { body, contentType, source } = await fetchAvatarImage(
new URL(request.url).origin,
parsed.params,
);
const etag = strongEtag(body);
const cacheControl = cacheControlFor(source);
if (ifNoneMatchSatisfied(request.headers.get("if-none-match"), etag)) {
return new NextResponse(null, {
status: 304,
headers: {
ETag: etag,
"Cache-Control": cacheControl,
"X-Imager-Source": source,
"Access-Control-Allow-Origin": "*",
},
});
}
return new NextResponse(body as unknown as BodyInit, {
status: 200,
headers: {
"Content-Type": contentType,
"Content-Length": String(body.length),
"Cache-Control": cacheControl,
ETag: etag,
"X-Imager-Source": source,
"Access-Control-Allow-Origin": "*",
},
});
} catch (error) {
if (error instanceof ImagerUnavailableError) {
const etag = strongEtag(PLACEHOLDER_SVG_BYTES);
if (ifNoneMatchSatisfied(request.headers.get("if-none-match"), etag)) {
return new NextResponse(null, {
status: 304,
headers: {
ETag: etag,
"Cache-Control": cacheControlFor("unavailable"),
"X-Imager-Source": "unavailable",
"Access-Control-Allow-Origin": "*",
},
});
}
return new NextResponse(PLACEHOLDER_SVG_BYTES as unknown as BodyInit, {
status: 200,
headers: {
"Content-Type": "image/svg+xml",
"Content-Length": String(PLACEHOLDER_SVG_BYTES.length),
"Cache-Control": cacheControlFor("unavailable"),
ETag: etag,
"X-Imager-Source": "unavailable",
"Access-Control-Allow-Origin": "*",
},
});
}
return NextResponse.json(
{ error: "Failed to fetch avatar from upstream" },
{ status: 502 },
);
}
}
export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, If-None-Match",
},
});
}