Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9af571aed6 | ||
|
|
f3407ebfdf |
No files matched your search
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Generated
+5
-4
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||||
|
}
|
||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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": "*",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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 },
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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("; ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
test-ci-runner
|
|
||||||
# final test
|
|
||||||
# CI test via Gitea runner
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# clean v5 docker install test
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# CI test na complete herinstallatie
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# test nieuwe label aanpak
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# test ubuntu-latest alleen
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
// test: runner actief
|
|
||||||
// second test
|
|
||||||
// third test - token sync fix
|
|
||||||
// fourth test - token config fix
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# test met ubuntu-latest label
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# test na volledige schone installatie v5
|
|
||||||
Reference in new issue
Block a user