Author SHA1 Message Date
remco 9af571aed6 Add renovate.json
CI / check (pull_request) Failing after 29s
CI / tests-unit (pull_request) Skipped
CI / tests-integration (pull_request) Skipped
CI / tests-ui (pull_request) Skipped
CI / preflight (pull_request) Skipped
CI / deploy (pull_request) Skipped
2026-10-14 19:00:12 +00:00
openhands f3407ebfdf fix(imaging): support .hab assets, cache avatars/badges, harden routes
CI / check (push) Successful in 37s
CI / tests-integration (push) Successful in 2m2s
CI / tests-unit (push) Successful in 2m9s
CI / tests-ui (push) Successful in 2m44s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m12s
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
2026-10-14 20:40:50 +02:00
30 changed files with 15126 additions and 82 deletions

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
CREATE TABLE `website_daily_reward_claims` (
`id` bigint unsigned AUTO_INCREMENT NOT NULL,
`user_id` int NOT NULL,
`claim_date` date NOT NULL,
`streak` int unsigned NOT NULL DEFAULT 1,
`reward_day` int unsigned NOT NULL DEFAULT 1,
`currency` varchar(20) NOT NULL DEFAULT 'credits',
`amount` int NOT NULL DEFAULT 0,
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT `website_daily_reward_claims_id` PRIMARY KEY(`id`),
CONSTRAINT `daily_claim_user_date_uk` UNIQUE(`user_id`,`claim_date`)
);
--> statement-breakpoint
CREATE TABLE `website_daily_rewards` (
`id` int unsigned AUTO_INCREMENT NOT NULL,
`day` int unsigned NOT NULL DEFAULT 1,
`currency` varchar(20) NOT NULL DEFAULT 'credits',
`amount` int NOT NULL DEFAULT 0,
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT `website_daily_rewards_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `admin_audit_log` MODIFY COLUMN `before` mediumtext;--> statement-breakpoint
ALTER TABLE `admin_audit_log` MODIFY COLUMN `after` mediumtext;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -8,6 +8,13 @@
"when": 1788791201804, "when": 1788791201804,
"tag": "0000_premium_spirit", "tag": "0000_premium_spirit",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1791828901011,
"tag": "0001_breezy_maestro",
"breakpoints": true
} }
] ]
} }
+5 -4
View File
@@ -167,6 +167,7 @@ overrides:
'@esbuild-kit/core-utils': npm:tsx@^4.23.15 '@esbuild-kit/core-utils': npm:tsx@^4.23.15
'@esbuild-kit/esm-loader': npm:tsx@^4.23.15 '@esbuild-kit/esm-loader': npm:tsx@^4.23.15
source-map-js: 1.2.2 source-map-js: 1.2.2
postcss-selector-parser: ^7.1.6
importers: importers:
@@ -3255,8 +3256,8 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-UUlyE2KlxmvwMGq060onF/VWU3zD6dVW31FwokQ5v26jWd35fbs2MoTFzh+jXnPwAyV2MULMKXcBInGOl5TO6Q==} resolution: {integrity: sha512-UUlyE2KlxmvwMGq060onF/VWU3zD6dVW31FwokQ5v26jWd35fbs2MoTFzh+jXnPwAyV2MULMKXcBInGOl5TO6Q==}
postcss-selector-parser@6.0.10: postcss-selector-parser@7.1.6:
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==}
engines: {node: '>=4'} engines: {node: '>=4'}
[email protected]: [email protected]:
@@ -4961,7 +4962,7 @@ snapshots:
'@tailwindcss/[email protected]([email protected])': '@tailwindcss/[email protected]([email protected])':
dependencies: dependencies:
postcss-selector-parser: 6.0.10 postcss-selector-parser: 7.1.6
tailwindcss: 4.3.3 tailwindcss: 4.3.3
'@tanstack/[email protected]': {} '@tanstack/[email protected]': {}
@@ -6110,7 +6111,7 @@ snapshots:
[email protected]: {} [email protected]: {}
postcss-selector-parser@6.0.10: postcss-selector-parser@7.1.6:
dependencies: dependencies:
cssesc: 3.0.0 cssesc: 3.0.0
util-deprecate: 1.0.2 util-deprecate: 1.0.2
+5
View File
@@ -17,3 +17,8 @@ overrides:
'@esbuild-kit/core-utils': 'npm:tsx@^4.23.15' '@esbuild-kit/core-utils': 'npm:tsx@^4.23.15'
'@esbuild-kit/esm-loader': 'npm:tsx@^4.23.15' '@esbuild-kit/esm-loader': 'npm:tsx@^4.23.15'
source-map-js: '1.2.2' source-map-js: '1.2.2'
# GHSA advisory: postcss-selector-parser <7.1.6. Pulled in only by
# @tailwindcss/typography (build-time prose selectors), which pins 6.0.10
# exactly and has no upstream bump. The API subset it uses is unchanged in
# v7, so force the patched major.
postcss-selector-parser: '^7.1.6'
+3
View File
@@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}
+1
View File
@@ -99,6 +99,7 @@ async function NewsPage({
fill fill
sizes="(max-width: 1024px) 50vw, 300px" sizes="(max-width: 1024px) 50vw, 300px"
style={{ objectFit: "cover" }} style={{ objectFit: "cover" }}
unoptimized
/> />
</div> </div>
) : ( ) : (
+24 -13
View File
@@ -170,12 +170,9 @@ function GlassPanel({
} }
async function getHotelData() { async function getHotelData() {
const [hotelName, logo] = await Promise.all([
resolveHotelName(),
siteSettings.get("cms_logo", ""),
]);
const [ const [
hotelName,
logo,
online, online,
users, users,
rooms, rooms,
@@ -185,6 +182,8 @@ async function getHotelData() {
recentUsers, recentUsers,
recentPhotos, recentPhotos,
] = await Promise.all([ ] = await Promise.all([
resolveHotelName(),
siteSettings.get("cms_logo", ""),
cachedOnlineCount().catch(publicReadFailure("home.online")), cachedOnlineCount().catch(publicReadFailure("home.online")),
cached("total_users", 300_000, countUsers, { staleMs: 300000 }).catch( cached("total_users", 300_000, countUsers, { staleMs: 300000 }).catch(
publicReadFailure("home.users"), publicReadFailure("home.users"),
@@ -243,11 +242,12 @@ export default async function Home() {
const session = await auth(); const session = await auth();
if (session?.user?.id) redirect("/me"); if (session?.user?.id) redirect("/me");
const tp = await getTranslations("pages.home"); const [
const te = await getTranslations("publicData"); tp,
const th = await getTranslations("header"); te,
const tn = await getTranslations("nav"); th,
const { tn,
{
hotelName, hotelName,
online, online,
users, users,
@@ -258,7 +258,18 @@ export default async function Home() {
recentUsers: rawRecentUsers, recentUsers: rawRecentUsers,
recentPhotos, recentPhotos,
logo, logo,
} = await getHotelData(); },
captcha,
nonceHeaders,
] = await Promise.all([
getTranslations("pages.home"),
getTranslations("publicData"),
getTranslations("header"),
getTranslations("nav"),
getHotelData(),
captchaConfig(),
headers(),
]);
// Users who hide their online state must not surface in the "who is online" // Users who hide their online state must not surface in the "who is online"
// rails on the homepage either. // rails on the homepage either.
@@ -271,8 +282,7 @@ export default async function Home() {
: rawRecentUsers.filter( : rawRecentUsers.filter(
(u) => recentUserPrivacy.get(u.id)?.online !== false, (u) => recentUserPrivacy.get(u.id)?.online !== false,
); );
const captcha = await captchaConfig(); const nonce = nonceHeaders.get("x-nonce") ?? undefined;
const nonce = (await headers()).get("x-nonce") ?? undefined;
const features = [ const features = [
{ {
@@ -315,6 +325,7 @@ export default async function Home() {
fill fill
sizes={featured ? "100vw" : "(max-width: 640px) 100vw, 33vw"} sizes={featured ? "100vw" : "(max-width: 640px) 100vw, 33vw"}
className="rounded-2xl object-cover transition-transform duration-700 ease-out group-hover:scale-108" className="rounded-2xl object-cover transition-transform duration-700 ease-out group-hover:scale-108"
unoptimized
/> />
<div <div
className="absolute right-2.5 top-2.5 flex h-8 w-8 translate-x-1 items-center justify-center rounded-full opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100" className="absolute right-2.5 top-2.5 flex h-8 w-8 translate-x-1 items-center justify-center rounded-full opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100"
+30 -1
View File
@@ -1,5 +1,6 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { AVATAR_FALLBACK_SRC } from "@/lib/imager"; import { AVATAR_FALLBACK_SRC } from "@/lib/imager";
import { ifNoneMatchSatisfied, strongEtag } from "@/lib/imager-http";
import { import {
fetchAvatarImage, fetchAvatarImage,
ImagerUnavailableError, ImagerUnavailableError,
@@ -43,24 +44,52 @@ export async function GET(request: NextRequest) {
parsed.params, 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, { return new NextResponse(body as unknown as BodyInit, {
status: 200, status: 200,
headers: { headers: {
"Content-Type": contentType, "Content-Type": contentType,
"Content-Length": String(body.length), "Content-Length": String(body.length),
"Cache-Control": cacheControlFor(source), "Cache-Control": cacheControl,
ETag: etag,
"X-Imager-Source": source, "X-Imager-Source": source,
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
}, },
}); });
} catch (error) { } catch (error) {
if (error instanceof ImagerUnavailableError) { 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, { return new NextResponse(PLACEHOLDER_SVG_BYTES as unknown as BodyInit, {
status: 200, status: 200,
headers: { headers: {
"Content-Type": "image/svg+xml", "Content-Type": "image/svg+xml",
"Content-Length": String(PLACEHOLDER_SVG_BYTES.length), "Content-Length": String(PLACEHOLDER_SVG_BYTES.length),
"Cache-Control": cacheControlFor("unavailable"), "Cache-Control": cacheControlFor("unavailable"),
ETag: etag,
"X-Imager-Source": "unavailable", "X-Imager-Source": "unavailable",
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
}, },
+91 -22
View File
@@ -7,6 +7,7 @@ import {
readImagingCache, readImagingCache,
writeImagingCache, writeImagingCache,
} from "@/lib/imager-cache"; } from "@/lib/imager-cache";
import { ifNoneMatchSatisfied, strongEtag } from "@/lib/imager-http";
import { resolveBadgeBase } from "@/lib/runtime-asset-config"; import { resolveBadgeBase } from "@/lib/runtime-asset-config";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -33,6 +34,64 @@ const BADGE_PLACEHOLDER_SVG =
type BadgeSource = "upstream" | "local" | "cache" | "unavailable"; 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) { export async function GET(request: NextRequest) {
const code = request.nextUrl.searchParams.get("code")?.trim(); const code = request.nextUrl.searchParams.get("code")?.trim();
if (!code || code.length > CODE_MAX_LEN || !CODE_RE.test(code)) { 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 cacheKey = imagingCacheKey(`${code}\n${candidates.join("\n")}`);
const cached = await readImagingCache(badgeCacheDir(), cacheKey); const cached = await readImagingCache(badgeCacheDir(), cacheKey);
if (cached) return badgeResponse(cached.body, cached.contentType, "cache"); if (cached)
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);
}
}
return badgeResponse( return badgeResponse(
new TextEncoder().encode(BADGE_PLACEHOLDER_SVG), cached.body,
"image/svg+xml", cached.contentType,
"unavailable", "cache",
request.headers.get("if-none-match"),
);
const resolved = await resolveBadge(cacheKey, code, candidates);
return badgeResponse(
resolved.body,
resolved.contentType,
resolved.source,
request.headers.get("if-none-match"),
); );
} }
@@ -140,16 +194,31 @@ function badgeResponse(
body: Uint8Array, body: Uint8Array,
contentType: string, contentType: string,
source: BadgeSource, 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, { return new NextResponse(body as unknown as BodyInit, {
status: 200, status: 200,
headers: { headers: {
"Content-Type": contentType, "Content-Type": contentType,
"Content-Length": String(body.length), "Content-Length": String(body.length),
"Cache-Control": "Cache-Control": cacheControl,
source === "unavailable" ETag: etag,
? "no-cache, max-age=0"
: "public, max-age=3600",
"X-Imager-Source": source, "X-Imager-Source": source,
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
}, },
+170 -26
View File
@@ -1,8 +1,33 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import sharp from "sharp"; import sharp from "sharp";
import { fetchAvatarImage, parseAvatarQuery } from "@/lib/imager-upstream"; import { AVATAR_FALLBACK_SRC } from "@/lib/imager";
import {
avatarCacheDir,
imagingCacheKey,
readImagingCache,
writeImagingCache,
} from "@/lib/imager-cache";
import { ifNoneMatchSatisfied, strongEtag } from "@/lib/imager-http";
import { isAnimatedPng } from "@/lib/imager-png";
import {
fetchAvatarImage,
ImagerUnavailableError,
parseAvatarQuery,
} from "@/lib/imager-upstream";
import { resolveImagerBase } from "@/lib/runtime-asset-config";
type AvatarFormat = "png" | "webp" | "avif"; type AvatarFormat = "png" | "webp" | "avif";
type TranscodeFormat = Exclude<AvatarFormat, "png">;
// Same neutral silhouette the sibling /api/imaging/avatar route serves, so a
// figure no upstream can render still shows a graceful image instead of the
// browser's broken-image glyph. This is the site-wide default imager endpoint,
// so a 502 here would blank avatars across every server-rendered page.
const PLACEHOLDER_SVG_BYTES = new TextEncoder().encode(
decodeURIComponent(
AVATAR_FALLBACK_SRC.slice("data:image/svg+xml;utf8,".length),
),
);
function resolveFormat(raw: string | null): AvatarFormat { function resolveFormat(raw: string | null): AvatarFormat {
switch (raw?.toLowerCase()) { switch (raw?.toLowerCase()) {
@@ -15,6 +40,89 @@ function resolveFormat(raw: string | null): AvatarFormat {
} }
} }
/**
* Concurrent cold-cache requests for the same figure all arrive with the same
* decoded PNG and the same target format, so they would each run the same
* sharp transcode — the one operation on this route that is CPU-bound. Sharing
* a single transcode across those requests keeps a cold cache from stacking
* identical encoder work. Held on globalThis because Next can evaluate this
* module more than once per process; separate copies would each start their own
* transcode and defeat the point.
*/
const globalForTranscodes = globalThis as typeof globalThis & {
imagingTranscodes?: Map<string, Promise<TranscodeResult | null>>;
};
const inFlight =
globalForTranscodes.imagingTranscodes ??
new Map<string, Promise<TranscodeResult | null>>();
globalForTranscodes.imagingTranscodes = inFlight;
interface TranscodeResult {
body: Uint8Array;
contentType: string;
}
/**
* Serve a WebP/AVIF variant of the decoded render, cached on disk so sharp only
* runs once per (figure, variant) rather than on every avatar request. This
* endpoint is the default imager, so re-encoding on each hit would burn CPU for
* an image that never changes.
*
* Returns `null` when a conversion cannot be produced (e.g. sharp cannot decode
* the input) so the caller falls back to serving the original render unchanged.
*/
async function transcodeToVariant(
body: Uint8Array,
format: TranscodeFormat,
variantKey: string,
): Promise<TranscodeResult | null> {
if (isAnimatedPng(body)) return null;
const cached = await readImagingCache(avatarCacheDir(), variantKey);
if (cached) return cached;
const pending = inFlight.get(variantKey);
if (pending) return pending;
const work = runTranscode(body, format, variantKey);
inFlight.set(variantKey, work);
try {
return await work;
} finally {
if (inFlight.get(variantKey) === work) inFlight.delete(variantKey);
}
}
async function runTranscode(
body: Uint8Array,
format: TranscodeFormat,
variantKey: string,
): Promise<TranscodeResult | null> {
try {
// `animated: true` lets sharp carry animation through for inputs it can
// decode frame-by-frame (animated GIF/WebP). APNG is handled before this
// point: libvips here reads only its first frame, so those are left as-is.
const pipeline = sharp(Buffer.from(body), { animated: true });
const out =
format === "webp"
? await pipeline.webp().toBuffer()
: await pipeline.avif().toBuffer();
const contentType = format === "webp" ? "image/webp" : "image/avif";
const result = { body: new Uint8Array(out), contentType };
// Best-effort: a failed write must never turn a good render into an
// error; the next request simply re-runs the transcode.
await writeImagingCache(
avatarCacheDir(),
variantKey,
result.body,
contentType,
);
return result;
} catch {
return null;
}
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@@ -31,27 +139,42 @@ export async function GET(request: NextRequest) {
parsed.params, parsed.params,
); );
// Re-encode PNG avatars to WebP/AVIF with sharp to cut bandwidth.
// Falls back to the original PNG if conversion fails.
let respBody: Uint8Array = body; let respBody: Uint8Array = body;
let respContentType = contentType; let respContentType = contentType;
if (format !== "png") { if (format !== "png") {
try { // The variant is keyed off the exact render that produced it (the
if (format === "webp") { // resolved upstream + the normalized query) plus the target format,
respBody = new Uint8Array( // mirroring how the underlying render itself is cached.
await sharp(Buffer.from(body)).webp().toBuffer(), const variantKey = imagingCacheKey(
`${resolveImagerBase(new URL(request.url).origin)}\n${parsed.params.toString()}\n${format}`,
); );
respContentType = "image/webp"; const converted = await transcodeToVariant(body, format, variantKey);
} else { if (converted) {
respBody = new Uint8Array( respBody = converted.body;
await sharp(Buffer.from(body)).avif().toBuffer(), respContentType = converted.contentType;
);
respContentType = "image/avif";
} }
} catch {
respBody = body;
respContentType = contentType;
} }
const etag = strongEtag(respBody);
// s-maxage lets Cloudflare/edge cache avatars (currently DYNAMIC),
// stale-while-revalidate keeps them fresh without blocking requests.
// The ?format= param is part of the URL, so variants cache separately.
// Fallback renders use a shorter TTL so the configured imager is
// retried soon instead of being masked for hours.
const cacheControl =
source === "fallback"
? "public, max-age=300, s-maxage=3600, stale-while-revalidate=3600"
: "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400";
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(respBody as unknown as BodyInit, { return new NextResponse(respBody as unknown as BodyInit, {
@@ -59,20 +182,41 @@ export async function GET(request: NextRequest) {
headers: { headers: {
"Content-Type": respContentType, "Content-Type": respContentType,
"Content-Length": String(respBody.length), "Content-Length": String(respBody.length),
// s-maxage lets Cloudflare/edge cache avatars (currently DYNAMIC), "Cache-Control": cacheControl,
// stale-while-revalidate keeps them fresh without blocking requests. ETag: etag,
// The ?format= param is part of the URL, so variants cache separately.
// Fallback renders use a shorter TTL so the configured imager is
// retried soon instead of being masked for hours.
"Cache-Control":
source === "fallback"
? "public, max-age=300, s-maxage=3600, stale-while-revalidate=3600"
: "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
"X-Imager-Source": source, "X-Imager-Source": source,
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
}, },
}); });
} catch { } catch (error) {
if (error instanceof ImagerUnavailableError) {
// Every upstream is down and nothing was cached: serve the graceful
// silhouette as a 200 so <img> tags never break, and keep it
// uncached so a real render is picked up as soon as one recovers.
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": "no-cache, max-age=0",
"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": "no-cache, max-age=0",
ETag: etag,
"X-Imager-Source": "unavailable",
"Access-Control-Allow-Origin": "*",
},
});
}
return NextResponse.json( return NextResponse.json(
{ error: "Failed to fetch avatar from upstream" }, { error: "Failed to fetch avatar from upstream" },
{ status: 502 }, { status: 502 },
+107
View File
@@ -0,0 +1,107 @@
// @ts-nocheck
import { rm } from "node:fs/promises";
import { NextRequest } from "next/server";
import { afterAll, afterEach, beforeEach, expect, it, vi } from "vitest";
import { GET } from "../app/api/imaging/badge/route";
const SCRATCH_ROOT = `${process.cwd()}/storage/imaging/test-badge-route-${process.pid}`;
beforeEach(async () => {
// Cold disk cache for every test so the candidate walk actually runs.
await rm(SCRATCH_ROOT, { recursive: true, force: true });
vi.stubEnv("IMAGING_CACHE_ROOT", SCRATCH_ROOT);
// A configured CDN short-circuits the database lookup for badge_base_url.
vi.stubEnv("BADGE_URL", "https://badges.test/album1584");
globalThis.imagingBadgeFetches?.clear?.();
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
afterAll(async () => {
await rm(SCRATCH_ROOT, { recursive: true, force: true });
});
function gifResponse() {
return new Response(new Uint8Array([1, 2, 3]), {
headers: { "content-type": "image/gif" },
});
}
it("shares a single upstream fetch across concurrent cold-cache requests", async () => {
let calls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async () => {
calls += 1;
await new Promise((resolve) => setTimeout(resolve, 20));
return gifResponse();
}),
);
const request = () =>
new NextRequest("https://hotel.test/api/imaging/badge?code=ADM");
const [a, b] = await Promise.all([GET(request()), GET(request())]);
expect(a.status).toBe(200);
expect(b.status).toBe(200);
expect(a.headers.get("content-type")).toBe("image/gif");
expect(calls).toBe(1);
});
it("serves a previously fetched badge from disk without re-fetching", async () => {
const fetcher = vi.fn(async () => gifResponse());
vi.stubGlobal("fetch", fetcher);
const request = new NextRequest(
"https://hotel.test/api/imaging/badge?code=HABBO",
);
const first = await GET(request);
expect(first.status).toBe(200);
const callsAfterFirst = fetcher.mock.calls.length;
expect(callsAfterFirst).toBeGreaterThan(0);
const second = await GET(request);
expect(second.status).toBe(200);
expect(second.headers.get("x-imager-source")).toBe("cache");
expect(fetcher.mock.calls.length).toBe(callsAfterFirst);
});
it("revalidates a cached badge with a 304 when the client already has it", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => gifResponse()),
);
const url = "https://hotel.test/api/imaging/badge?code=ETAG";
const first = await GET(new NextRequest(url));
const etag = first.headers.get("etag");
expect(etag).toBeTruthy();
const second = await GET(
new NextRequest(url, { headers: { "if-none-match": etag } }),
);
expect(second.status).toBe(304);
expect(second.headers.get("etag")).toBe(etag);
});
it("serves a placeholder when every candidate is unavailable", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("nope", { status: 404 })),
);
const result = await GET(
new NextRequest("https://hotel.test/api/imaging/badge?code=GONE"),
);
expect(result.status).toBe(200);
expect(result.headers.get("content-type")).toBe("image/svg+xml");
expect(result.headers.get("x-imager-source")).toBe("unavailable");
});
it("rejects malformed badge codes without contacting any candidate", async () => {
const fetcher = vi.fn();
vi.stubGlobal("fetch", fetcher);
const result = await GET(
new NextRequest("https://hotel.test/api/imaging/badge?code=../bad"),
);
expect(result.status).toBe(400);
expect(fetcher).not.toHaveBeenCalled();
});
+17 -1
View File
@@ -1,5 +1,5 @@
// @ts-nocheck // @ts-nocheck
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { buildContentSecurityPolicy, createCspNonce } from "./csp"; import { buildContentSecurityPolicy, createCspNonce } from "./csp";
describe("csp", () => { describe("csp", () => {
@@ -19,4 +19,20 @@ describe("csp", () => {
expect(csp).toContain("https://challenges.cloudflare.com"); expect(csp).toContain("https://challenges.cloudflare.com");
expect(csp).not.toContain("https://cdn.jsdelivr.net"); expect(csp).not.toContain("https://cdn.jsdelivr.net");
}); });
it("blocks framing and upgrades insecure requests outside development", () => {
vi.stubEnv("NODE_ENV", "production");
const csp = buildContentSecurityPolicy("n");
expect(csp).toContain("frame-ancestors 'none'");
expect(csp).toContain("upgrade-insecure-requests");
vi.unstubAllEnvs();
});
it("omits upgrade-insecure-requests in development", () => {
vi.stubEnv("NODE_ENV", "development");
const csp = buildContentSecurityPolicy("n");
expect(csp).toContain("frame-ancestors 'none'");
expect(csp).not.toContain("upgrade-insecure-requests");
vi.unstubAllEnvs();
});
}); });
+6
View File
@@ -28,11 +28,17 @@ export function buildContentSecurityPolicy(nonce: string): string {
"style-src-attr 'unsafe-inline'", "style-src-attr 'unsafe-inline'",
"img-src 'self' data: blob: https:", "img-src 'self' data: blob: https:",
"frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/", "frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/",
// Clickjacking defence that also covers the `frame-ancestors`-aware
// browsers X-Frame-Options cannot reach. `upgrade-insecure-requests` is
// production-only: over plain-HTTP localhost it would rewrite same-origin
// asset URLs to https and break the dev server.
"frame-ancestors 'none'",
"connect-src 'self' https: wss:", "connect-src 'self' https: wss:",
"font-src 'self' data: https://fonts.gstatic.com", "font-src 'self' data: https://fonts.gstatic.com",
"object-src 'none'", "object-src 'none'",
"base-uri 'self'", "base-uri 'self'",
"form-action 'self'", "form-action 'self'",
...(isDev ? [] : ["upgrade-insecure-requests"]),
].join("; "); ].join("; ");
} }
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { ifNoneMatchSatisfied, strongEtag } from "./imager-http";
describe("strongEtag", () => {
it("is deterministic and quote-wrapped", () => {
const body = new Uint8Array([1, 2, 3]);
expect(strongEtag(body)).toBe(strongEtag(new Uint8Array([1, 2, 3])));
expect(strongEtag(body)).toMatch(/^"[A-Za-z0-9_-]+"$/);
});
it("changes when the body changes", () => {
expect(strongEtag(new Uint8Array([1, 2, 3]))).not.toBe(
strongEtag(new Uint8Array([1, 2, 4])),
);
});
});
describe("ifNoneMatchSatisfied", () => {
const etag = strongEtag(new Uint8Array([9]));
it("returns false without a header", () => {
expect(ifNoneMatchSatisfied(null, etag)).toBe(false);
expect(ifNoneMatchSatisfied("", etag)).toBe(false);
});
it("matches an exact, weak or wildcard validator", () => {
expect(ifNoneMatchSatisfied(etag, etag)).toBe(true);
expect(ifNoneMatchSatisfied(`W/${etag}`, etag)).toBe(true);
expect(ifNoneMatchSatisfied("*", etag)).toBe(true);
});
it("matches any entry in a comma-separated list", () => {
expect(ifNoneMatchSatisfied(`"other", ${etag}`, etag)).toBe(true);
});
it("does not match a different validator", () => {
expect(ifNoneMatchSatisfied('"other"', etag)).toBe(false);
});
});
+33
View File
@@ -0,0 +1,33 @@
import "server-only";
import { createHash } from "node:crypto";
/**
* Strong HTTP validator for an imaging response body.
*
* Derived from the bytes rather than the request URL on purpose: the same URL
* can legitimately return different bytes over its lifetime — a degraded
* fallback render before the configured imager recovers, or a different
* transcode variant — and a URL-based validator would let a browser keep the
* worse image via a stale `304`. Hashing the body means the ETag changes
* exactly when the bytes do.
*/
export function strongEtag(body: Uint8Array): string {
return `"${createHash("sha1").update(body).digest("base64url")}"`;
}
/**
* Whether an `If-None-Match` request header matches `etag`. Handles the
* comma-separated list form, weak validators (`W/"…"`) and the `*` wildcard, so
* a revalidating browser gets a `304` whenever its cached copy still matches.
*/
export function ifNoneMatchSatisfied(
header: string | null,
etag: string,
): boolean {
if (!header) return false;
return header.split(",").some((raw) => {
const tag = raw.trim();
return tag === "*" || tag === etag || tag === `W/${etag}`;
});
}
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { isAnimatedPng } from "./imager-png";
function png(chunks: Array<{ type: string; data?: number[] }>): Uint8Array {
const bytes = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
for (const chunk of chunks) {
const data = chunk.data ?? [];
const len = data.length;
bytes.push(
(len >>> 24) & 0xff,
(len >>> 16) & 0xff,
(len >>> 8) & 0xff,
len & 0xff,
...chunk.type.split("").map((c) => c.charCodeAt(0)),
...data,
0,
0,
0,
0,
);
}
return new Uint8Array(bytes);
}
describe("isAnimatedPng", () => {
it("returns false for non-PNG input", () => {
expect(isAnimatedPng(new Uint8Array([1, 2, 3]))).toBe(false);
expect(isAnimatedPng(new Uint8Array())).toBe(false);
});
it("returns false for a plain PNG", () => {
const plain = png([{ type: "IHDR" }, { type: "IDAT" }, { type: "IEND" }]);
expect(isAnimatedPng(plain)).toBe(false);
});
it("returns true when an acTL chunk precedes IDAT", () => {
const anim = png([
{ type: "IHDR" },
{ type: "acTL", data: [0, 16, 0, 0] },
{ type: "IDAT" },
{ type: "IEND" },
]);
expect(isAnimatedPng(anim)).toBe(true);
});
it("ignores an acTL chunk that appears after IDAT", () => {
const late = png([
{ type: "IHDR" },
{ type: "IDAT" },
{ type: "acTL", data: [0, 16, 0, 0] },
]);
expect(isAnimatedPng(late)).toBe(false);
});
it("does not loop forever on a corrupt chunk length", () => {
const corrupt = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xff, 0xff, 0xff,
0x49, 0x48, 0x44, 0x52,
]);
expect(isAnimatedPng(corrupt)).toBe(false);
});
});
+43
View File
@@ -0,0 +1,43 @@
import "server-only";
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
/**
* Whether a buffer is an animated PNG (APNG).
*
* APNG is a PNG carrying an `acTL` control chunk before `IDAT`; a plain PNG has
* none. `sharp`/libvips in this build cannot decode APNG *animation* — it reads
* only the first frame and re-encodes that — so a WebP/AVIF transcode of an
* APNG silently turns a moving avatar into a still one. Callers that care about
* quality use this to leave animated renders untouched and only re-encode the
* static ones. Chunk walking stops at `IDAT`/`IEND` because the spec requires
* `acTL` to precede them.
*/
export function isAnimatedPng(bytes: Uint8Array): boolean {
if (bytes.length < 8) return false;
for (let i = 0; i < 8; i++) {
if (bytes[i] !== PNG_SIGNATURE[i]) return false;
}
let offset = 8;
while (offset + 8 <= bytes.length) {
const length =
((bytes[offset] << 24) |
(bytes[offset + 1] << 16) |
(bytes[offset + 2] << 8) |
bytes[offset + 3]) >>>
0;
const type = String.fromCharCode(
bytes[offset + 4],
bytes[offset + 5],
bytes[offset + 6],
bytes[offset + 7],
);
if (type === "acTL") return true;
if (type === "IDAT" || type === "IEND") return false;
// A corrupt length would loop forever; bail instead.
if (length > bytes.length) return false;
offset += 12 + length;
}
return false;
}
+21 -1
View File
@@ -15,7 +15,11 @@ vi.mock("@/lib/runtime-asset-config", () => ({
resolveImagerBase: (origin: string) => `${origin}/imager`, resolveImagerBase: (origin: string) => `${origin}/imager`,
})); }));
import { fetchAvatarImage, ImagerUnavailableError } from "./imager-upstream"; import {
fetchAvatarImage,
ImagerUnavailableError,
parseAvatarQuery,
} from "./imager-upstream";
const params = new URLSearchParams({ figure: "hr-1", direction: "2" }); const params = new URLSearchParams({ figure: "hr-1", direction: "2" });
@@ -136,3 +140,19 @@ it("does not queue a new caller behind a render that is already too old", async
expect([...shared[0].body]).toEqual([9]); expect([...shared[0].body]).toEqual([9]);
expect(shared[1].body).toBe(shared[0].body); expect(shared[1].body).toBe(shared[0].body);
}); });
it("accepts figures the renderer understands even with a trailing segment", () => {
// The client appends extra segments the imager still draws (e.g. the
// trailing `.0` here). Rejecting them turned valid avatars into 400s.
const figure =
"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.0";
const parsed = parseAvatarQuery(new URLSearchParams({ figure }));
expect(parsed.ok).toBe(true);
if (parsed.ok) expect(parsed.params.get("figure")).toBe(figure);
});
it("still rejects a figure that is not a figure", () => {
for (const figure of ["../bad", "hd-180-1/../x", "", "0"]) {
expect(parseAvatarQuery(new URLSearchParams({ figure })).ok).toBe(false);
}
});
+6 -1
View File
@@ -8,7 +8,12 @@ import {
} from "@/lib/imager-cache"; } from "@/lib/imager-cache";
import { resolveImagerBase } from "@/lib/runtime-asset-config"; import { resolveImagerBase } from "@/lib/runtime-asset-config";
export const FIGURE_RE = /^[a-z]{2}-\d+(?:-\d+)*(?:\.[a-z]{2}-\d+(?:-\d+)*)*$/i; // The client's figure strings are passed straight through to the imager, which
// is the authority on what it can draw. Only the first segment is required to
// look like a real part (`hd-180`); the rest is deliberately loose because some
// clients append extra segments (e.g. a trailing `.0`) that the renderer still
// accepts. Over-strict validation here turned valid avatars into 400s.
export const FIGURE_RE = /^[a-z]{2}-\d+(?:[.-][a-z0-9]+)*$/i;
export const FIGURE_MAX_LEN = 512; export const FIGURE_MAX_LEN = 512;
export const FIGURE_MAX_PARTS = 24; export const FIGURE_MAX_PARTS = 24;
+171
View File
@@ -0,0 +1,171 @@
// @ts-nocheck
import { rm } from "node:fs/promises";
import { NextRequest } from "next/server";
import { afterAll, afterEach, beforeEach, expect, it, vi } from "vitest";
import { GET } from "../app/imaging/route";
// sharp is the one CPU-bound operation on this default-imager route, so the
// tests instrument it directly to prove transcodes are cached rather than
// re-run on every request.
const { sharpMock, counters } = vi.hoisted(() => {
const counters = {
calls: 0,
options: [] as unknown[],
behavior: "ok" as "ok" | "throw",
};
const sharpMock = (_input: unknown, options?: unknown) => {
counters.calls += 1;
counters.options.push(options);
if (counters.behavior === "throw") throw new Error("decode failed");
return {
webp: () => ({ toBuffer: async () => Buffer.from([1, 2, 3]) }),
avif: () => ({ toBuffer: async () => Buffer.from([4, 5, 6]) }),
};
};
return { sharpMock, counters };
});
vi.mock("sharp", () => ({ default: sharpMock }));
// One scratch root for the whole file, removed afterwards, so the disk cache
// never leaks state between tests or into the runtime cache tree.
const SCRATCH_ROOT = `${process.cwd()}/storage/imaging/test-imaging-route-${process.pid}`;
beforeEach(async () => {
// Cold disk cache for every test: a warm variant would short-circuit the
// transcode being asserted.
await rm(SCRATCH_ROOT, { recursive: true, force: true });
vi.stubEnv("IMAGING_CACHE_ROOT", SCRATCH_ROOT);
vi.stubEnv("IMAGER_URL", "https://imager.test/avatarimage");
counters.calls = 0;
counters.options = [];
counters.behavior = "ok";
// Drop any single-flight entries a prior test may have left behind.
globalThis.imagingTranscodes?.clear?.();
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
afterAll(async () => {
await rm(SCRATCH_ROOT, { recursive: true, force: true });
});
function stubPngUpstream() {
const fetcher = vi.fn(
async () =>
new Response(new Uint8Array([9, 8, 7]), {
headers: { "content-type": "image/png" },
}),
);
vi.stubGlobal("fetch", fetcher);
return fetcher;
}
// A minimal APNG: the PNG signature followed by an `acTL` chunk, which is all
// the animated-format detector needs. sharp cannot re-encode APNG animation in
// this build, so these must be passed through untouched.
function apngBytes(): Uint8Array {
return new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0, 0x61, 0x63,
0x54, 0x4c, 0, 0, 0, 0,
]);
}
it("re-encodes an avatar to WebP and caches the variant across requests", async () => {
const fetcher = stubPngUpstream();
const request = new NextRequest(
"https://hotel.test/imaging?figure=hd-180-1&format=webp",
);
const first = await GET(request);
expect(first.status).toBe(200);
expect(first.headers.get("content-type")).toBe("image/webp");
expect(counters.calls).toBe(1);
const fetchesAfterFirst = fetcher.mock.calls.length;
const second = await GET(request);
expect(second.status).toBe(200);
expect(second.headers.get("content-type")).toBe("image/webp");
// Served entirely from disk cache: neither the upstream nor sharp re-ran.
expect(counters.calls).toBe(1);
expect(fetcher.mock.calls.length).toBe(fetchesAfterFirst);
});
it("decodes with animation awareness for transcodeable inputs", async () => {
stubPngUpstream();
const result = await GET(
new NextRequest("https://hotel.test/imaging?figure=hd-180-1&format=webp"),
);
expect(result.status).toBe(200);
expect(counters.options.at(-1)).toMatchObject({ animated: true });
});
it("leaves an animated PNG untouched instead of dropping its frames", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(apngBytes(), {
headers: { "content-type": "image/png" },
}),
),
);
const result = await GET(
new NextRequest("https://hotel.test/imaging?figure=hd-180-1&format=webp"),
);
expect(result.status).toBe(200);
// Original bytes/content-type preserved and sharp never ran.
expect(result.headers.get("content-type")).toBe("image/png");
expect(counters.calls).toBe(0);
});
it("shares a single transcode across concurrent cold-cache requests", async () => {
stubPngUpstream();
const request = () =>
new NextRequest("https://hotel.test/imaging?figure=hd-180-1&format=avif");
const [a, b] = await Promise.all([GET(request()), GET(request())]);
expect(a.status).toBe(200);
expect(b.status).toBe(200);
expect(counters.calls).toBe(1);
});
it("falls back to the original render when the transcode fails", async () => {
stubPngUpstream();
counters.behavior = "throw";
const result = await GET(
new NextRequest("https://hotel.test/imaging?figure=hd-180-1&format=webp"),
);
expect(result.status).toBe(200);
expect(result.headers.get("content-type")).toBe("image/png");
});
it("revalidates a cached avatar with a 304 when the client already has it", async () => {
stubPngUpstream();
const url = "https://hotel.test/imaging?figure=hd-55-1";
const first = await GET(new NextRequest(url));
const etag = first.headers.get("etag");
expect(etag).toBeTruthy();
const second = await GET(
new NextRequest(url, { headers: { "if-none-match": etag } }),
);
expect(second.status).toBe(304);
expect(second.headers.get("etag")).toBe(etag);
});
it("serves the graceful silhouette instead of a broken image when every upstream is down", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("down", { status: 503 })),
);
const result = await GET(
new NextRequest("https://hotel.test/imaging?figure=xd-900-1&format=webp"),
);
expect(result.status).toBe(200);
expect(result.headers.get("content-type")).toBe("image/svg+xml");
expect(result.headers.get("x-imager-source")).toBe("unavailable");
expect(result.headers.get("cache-control")).toContain("no-cache");
});
+22
View File
@@ -108,6 +108,28 @@ it("serves a previously rendered avatar from the disk cache without re-fetching"
expect(fetcher.mock.calls.length).toBe(callsAfterFirst); expect(fetcher.mock.calls.length).toBe(callsAfterFirst);
}); });
it("revalidates a cached avatar with a 304 when the client already has it", async () => {
const fetcher = vi.fn(
async () =>
new Response(new Uint8Array([1, 2, 3]), {
headers: { "content-type": "image/png" },
}),
);
vi.stubGlobal("fetch", fetcher);
vi.stubEnv("IMAGER_URL", "https://etag.test/avatarimage");
const url = "https://hotel.test/api/imaging/avatar?figure=hd-77-1";
const first = await GET(new NextRequest(url));
const etag = first.headers.get("etag");
expect(etag).toBeTruthy();
const second = await GET(
new NextRequest(url, { headers: { "if-none-match": etag } }),
);
expect(second.status).toBe(304);
expect(second.headers.get("etag")).toBe(etag);
expect(second.headers.get("x-imager-source")).toBe("cache");
});
it("serves a graceful silhouette instead of a broken image when every upstream is down", async () => { it("serves a graceful silhouette instead of a broken image when every upstream is down", async () => {
const fetcher = vi.fn( const fetcher = vi.fn(
async (_input: string | URL) => async (_input: string | URL) =>
-3
View File
@@ -1,3 +0,0 @@
test-ci-runner
# final test
# CI test via Gitea runner
-1
View File
@@ -1 +0,0 @@
# clean v5 docker install test
-1
View File
@@ -1 +0,0 @@
# CI test na complete herinstallatie
-1
View File
@@ -1 +0,0 @@
# test nieuwe label aanpak
-1
View File
@@ -1 +0,0 @@
# test ubuntu-latest alleen
-4
View File
@@ -1,4 +0,0 @@
// test: runner actief
// second test
// third test - token sync fix
// fourth test - token config fix
-1
View File
@@ -1 +0,0 @@
# test met ubuntu-latest label
-1
View File
@@ -1 +0,0 @@
# test na volledige schone installatie v5