fix(imaging): support .hab assets, cache avatars/badges, harden routes
The hotel moved from .nitro to .hab bundles, so the imager must treat .hab like .nitro and the public avatar endpoints must accept the figure strings the client sends (including extra trailing segments such as `.0`, which the old FIGURE_RE rejected with a 400). - imager-upstream: relax FIGURE_RE, extend parseAvatarQuery test coverage - api/imaging/avatar + /imaging: shared validation, placeholder and ETag/304 - api/imaging/badge: single-flight upstream fetch and conditional responses - imager-http/imager-png: strong ETag helpers and APNG passthrough guard - csp + next/image: unoptimized external images, tightened CSP - deps: override postcss-selector-parser to ^7.1.6 - also fold in the pending drizzle draft snapshot and stray test cleanup
This commit is contained in:
1 parent
56676effc3
commit
f3407ebfdf
29 files changed
+15134
-93
No files matched your search
@@ -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,
|
||||
"tag": "0000_premium_spirit",
|
||||
"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/esm-loader': npm:tsx@^4.23.15
|
||||
source-map-js: 1.2.2
|
||||
postcss-selector-parser: ^7.1.6
|
||||
|
||||
importers:
|
||||
|
||||
@@ -3255,8 +3256,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UUlyE2KlxmvwMGq060onF/VWU3zD6dVW31FwokQ5v26jWd35fbs2MoTFzh+jXnPwAyV2MULMKXcBInGOl5TO6Q==}
|
||||
|
||||
postcss-selector-parser@6.0.10:
|
||||
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
|
||||
postcss-selector-parser@7.1.6:
|
||||
resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
[email protected]:
|
||||
@@ -4961,7 +4962,7 @@ snapshots:
|
||||
|
||||
'@tailwindcss/[email protected]([email protected])':
|
||||
dependencies:
|
||||
postcss-selector-parser: 6.0.10
|
||||
postcss-selector-parser: 7.1.6
|
||||
tailwindcss: 4.3.3
|
||||
|
||||
'@tanstack/[email protected]': {}
|
||||
@@ -6110,7 +6111,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
postcss-selector-parser@6.0.10:
|
||||
postcss-selector-parser@7.1.6:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
@@ -17,3 +17,8 @@ overrides:
|
||||
'@esbuild-kit/core-utils': 'npm:tsx@^4.23.15'
|
||||
'@esbuild-kit/esm-loader': 'npm:tsx@^4.23.15'
|
||||
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'
|
||||
@@ -99,6 +99,7 @@ async function NewsPage({
|
||||
fill
|
||||
sizes="(max-width: 1024px) 50vw, 300px"
|
||||
style={{ objectFit: "cover" }}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
+34
-23
@@ -170,12 +170,9 @@ function GlassPanel({
|
||||
}
|
||||
|
||||
async function getHotelData() {
|
||||
const [hotelName, logo] = await Promise.all([
|
||||
resolveHotelName(),
|
||||
siteSettings.get("cms_logo", ""),
|
||||
]);
|
||||
|
||||
const [
|
||||
hotelName,
|
||||
logo,
|
||||
online,
|
||||
users,
|
||||
rooms,
|
||||
@@ -185,6 +182,8 @@ async function getHotelData() {
|
||||
recentUsers,
|
||||
recentPhotos,
|
||||
] = await Promise.all([
|
||||
resolveHotelName(),
|
||||
siteSettings.get("cms_logo", ""),
|
||||
cachedOnlineCount().catch(publicReadFailure("home.online")),
|
||||
cached("total_users", 300_000, countUsers, { staleMs: 300000 }).catch(
|
||||
publicReadFailure("home.users"),
|
||||
@@ -243,22 +242,34 @@ export default async function Home() {
|
||||
const session = await auth();
|
||||
if (session?.user?.id) redirect("/me");
|
||||
|
||||
const tp = await getTranslations("pages.home");
|
||||
const te = await getTranslations("publicData");
|
||||
const th = await getTranslations("header");
|
||||
const tn = await getTranslations("nav");
|
||||
const {
|
||||
hotelName,
|
||||
online,
|
||||
users,
|
||||
rooms,
|
||||
totalPhotos,
|
||||
articleCount,
|
||||
articles,
|
||||
recentUsers: rawRecentUsers,
|
||||
recentPhotos,
|
||||
logo,
|
||||
} = await getHotelData();
|
||||
const [
|
||||
tp,
|
||||
te,
|
||||
th,
|
||||
tn,
|
||||
{
|
||||
hotelName,
|
||||
online,
|
||||
users,
|
||||
rooms,
|
||||
totalPhotos,
|
||||
articleCount,
|
||||
articles,
|
||||
recentUsers: rawRecentUsers,
|
||||
recentPhotos,
|
||||
logo,
|
||||
},
|
||||
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"
|
||||
// rails on the homepage either.
|
||||
@@ -271,8 +282,7 @@ export default async function Home() {
|
||||
: rawRecentUsers.filter(
|
||||
(u) => recentUserPrivacy.get(u.id)?.online !== false,
|
||||
);
|
||||
const captcha = await captchaConfig();
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
const nonce = nonceHeaders.get("x-nonce") ?? undefined;
|
||||
|
||||
const features = [
|
||||
{
|
||||
@@ -315,6 +325,7 @@ export default async function Home() {
|
||||
fill
|
||||
sizes={featured ? "100vw" : "(max-width: 640px) 100vw, 33vw"}
|
||||
className="rounded-2xl object-cover transition-transform duration-700 ease-out group-hover:scale-108"
|
||||
unoptimized
|
||||
/>
|
||||
<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"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { AVATAR_FALLBACK_SRC } from "@/lib/imager";
|
||||
import { ifNoneMatchSatisfied, strongEtag } from "@/lib/imager-http";
|
||||
import {
|
||||
fetchAvatarImage,
|
||||
ImagerUnavailableError,
|
||||
@@ -43,24 +44,52 @@ export async function GET(request: NextRequest) {
|
||||
parsed.params,
|
||||
);
|
||||
|
||||
const etag = strongEtag(body);
|
||||
const cacheControl = cacheControlFor(source);
|
||||
if (ifNoneMatchSatisfied(request.headers.get("if-none-match"), etag)) {
|
||||
return new NextResponse(null, {
|
||||
status: 304,
|
||||
headers: {
|
||||
ETag: etag,
|
||||
"Cache-Control": cacheControl,
|
||||
"X-Imager-Source": source,
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new NextResponse(body as unknown as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": String(body.length),
|
||||
"Cache-Control": cacheControlFor(source),
|
||||
"Cache-Control": cacheControl,
|
||||
ETag: etag,
|
||||
"X-Imager-Source": source,
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ImagerUnavailableError) {
|
||||
const etag = strongEtag(PLACEHOLDER_SVG_BYTES);
|
||||
if (ifNoneMatchSatisfied(request.headers.get("if-none-match"), etag)) {
|
||||
return new NextResponse(null, {
|
||||
status: 304,
|
||||
headers: {
|
||||
ETag: etag,
|
||||
"Cache-Control": cacheControlFor("unavailable"),
|
||||
"X-Imager-Source": "unavailable",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
return new NextResponse(PLACEHOLDER_SVG_BYTES as unknown as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "image/svg+xml",
|
||||
"Content-Length": String(PLACEHOLDER_SVG_BYTES.length),
|
||||
"Cache-Control": cacheControlFor("unavailable"),
|
||||
ETag: etag,
|
||||
"X-Imager-Source": "unavailable",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+172
-28
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+17
-1
@@ -1,5 +1,5 @@
|
||||
// @ts-nocheck
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildContentSecurityPolicy, createCspNonce } from "./csp";
|
||||
|
||||
describe("csp", () => {
|
||||
@@ -19,4 +19,20 @@ describe("csp", () => {
|
||||
expect(csp).toContain("https://challenges.cloudflare.com");
|
||||
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'",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"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:",
|
||||
"font-src 'self' data: https://fonts.gstatic.com",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
...(isDev ? [] : ["upgrade-insecure-requests"]),
|
||||
].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`,
|
||||
}));
|
||||
|
||||
import { fetchAvatarImage, ImagerUnavailableError } from "./imager-upstream";
|
||||
import {
|
||||
fetchAvatarImage,
|
||||
ImagerUnavailableError,
|
||||
parseAvatarQuery,
|
||||
} from "./imager-upstream";
|
||||
|
||||
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[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";
|
||||
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_PARTS = 24;
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -108,6 +108,28 @@ it("serves a previously rendered avatar from the disk cache without re-fetching"
|
||||
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 () => {
|
||||
const fetcher = vi.fn(
|
||||
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