fix(imaging): support .hab assets, cache avatars/badges, harden routes
The hotel moved from .nitro to .hab bundles, so the imager must treat .hab like .nitro and the public avatar endpoints must accept the figure strings the client sends (including extra trailing segments such as `.0`, which the old FIGURE_RE rejected with a 400). - imager-upstream: relax FIGURE_RE, extend parseAvatarQuery test coverage - api/imaging/avatar + /imaging: shared validation, placeholder and ETag/304 - api/imaging/badge: single-flight upstream fetch and conditional responses - imager-http/imager-png: strong ETag helpers and APNG passthrough guard - csp + next/image: unoptimized external images, tightened CSP - deps: override postcss-selector-parser to ^7.1.6 - also fold in the pending drizzle draft snapshot and stray test cleanup
This commit is contained in:
1 parent
56676effc3
commit
f3407ebfdf
29 files changed
+15134
-93
No files matched your search
@@ -7,6 +7,7 @@ import {
|
||||
readImagingCache,
|
||||
writeImagingCache,
|
||||
} from "@/lib/imager-cache";
|
||||
import { ifNoneMatchSatisfied, strongEtag } from "@/lib/imager-http";
|
||||
import { resolveBadgeBase } from "@/lib/runtime-asset-config";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
@@ -33,6 +34,64 @@ const BADGE_PLACEHOLDER_SVG =
|
||||
|
||||
type BadgeSource = "upstream" | "local" | "cache" | "unavailable";
|
||||
|
||||
interface ResolvedBadge {
|
||||
body: Uint8Array;
|
||||
contentType: string;
|
||||
source: BadgeSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concurrent cold-cache requests for the same badge would otherwise each walk
|
||||
* the whole candidate list, and when the first candidate is a slow/unreachable
|
||||
* CDN that means every one of them waits out the fetch timeout before falling
|
||||
* back. Sharing a single resolution keeps a cold cache from stacking identical
|
||||
* upstream work. Held on globalThis because Next can evaluate this module more
|
||||
* than once per process.
|
||||
*/
|
||||
const globalForBadgeFetches = globalThis as typeof globalThis & {
|
||||
imagingBadgeFetches?: Map<string, Promise<ResolvedBadge>>;
|
||||
};
|
||||
const inFlight =
|
||||
globalForBadgeFetches.imagingBadgeFetches ??
|
||||
new Map<string, Promise<ResolvedBadge>>();
|
||||
globalForBadgeFetches.imagingBadgeFetches = inFlight;
|
||||
|
||||
async function resolveBadge(
|
||||
cacheKey: string,
|
||||
code: string,
|
||||
candidates: string[],
|
||||
): Promise<ResolvedBadge> {
|
||||
const pending = inFlight.get(cacheKey);
|
||||
if (pending) return pending;
|
||||
|
||||
const work = (async (): Promise<ResolvedBadge> => {
|
||||
for (const base of candidates) {
|
||||
const result = await fetchCandidate(base, code);
|
||||
if (result) {
|
||||
await writeImagingCache(
|
||||
badgeCacheDir(),
|
||||
cacheKey,
|
||||
result.body,
|
||||
result.contentType,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return {
|
||||
body: new TextEncoder().encode(BADGE_PLACEHOLDER_SVG),
|
||||
contentType: "image/svg+xml",
|
||||
source: "unavailable",
|
||||
};
|
||||
})();
|
||||
|
||||
inFlight.set(cacheKey, work);
|
||||
try {
|
||||
return await work;
|
||||
} finally {
|
||||
if (inFlight.get(cacheKey) === work) inFlight.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const code = request.nextUrl.searchParams.get("code")?.trim();
|
||||
if (!code || code.length > CODE_MAX_LEN || !CODE_RE.test(code)) {
|
||||
@@ -56,25 +115,20 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const cacheKey = imagingCacheKey(`${code}\n${candidates.join("\n")}`);
|
||||
const cached = await readImagingCache(badgeCacheDir(), cacheKey);
|
||||
if (cached) return badgeResponse(cached.body, cached.contentType, "cache");
|
||||
|
||||
for (const base of candidates) {
|
||||
const result = await fetchCandidate(base, code);
|
||||
if (result) {
|
||||
await writeImagingCache(
|
||||
badgeCacheDir(),
|
||||
cacheKey,
|
||||
result.body,
|
||||
result.contentType,
|
||||
);
|
||||
return badgeResponse(result.body, result.contentType, result.source);
|
||||
}
|
||||
}
|
||||
if (cached)
|
||||
return badgeResponse(
|
||||
cached.body,
|
||||
cached.contentType,
|
||||
"cache",
|
||||
request.headers.get("if-none-match"),
|
||||
);
|
||||
|
||||
const resolved = await resolveBadge(cacheKey, code, candidates);
|
||||
return badgeResponse(
|
||||
new TextEncoder().encode(BADGE_PLACEHOLDER_SVG),
|
||||
"image/svg+xml",
|
||||
"unavailable",
|
||||
resolved.body,
|
||||
resolved.contentType,
|
||||
resolved.source,
|
||||
request.headers.get("if-none-match"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,16 +194,31 @@ function badgeResponse(
|
||||
body: Uint8Array,
|
||||
contentType: string,
|
||||
source: BadgeSource,
|
||||
ifNoneMatch: string | null = null,
|
||||
) {
|
||||
const etag = strongEtag(body);
|
||||
const cacheControl =
|
||||
source === "unavailable" ? "no-cache, max-age=0" : "public, max-age=3600";
|
||||
// Same bytes the client already has: skip the body. Validators are hashed
|
||||
// from the body, so a fallback→upstream swap still forces a fresh 200.
|
||||
if (ifNoneMatchSatisfied(ifNoneMatch, 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":
|
||||
source === "unavailable"
|
||||
? "no-cache, max-age=0"
|
||||
: "public, max-age=3600",
|
||||
"Cache-Control": cacheControl,
|
||||
ETag: etag,
|
||||
"X-Imager-Source": source,
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user