@cosmicdrift/kumiko-bundled-features 0.286.0 → 0.287.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -9
- package/src/auth-email-password/signed-token.ts +4 -86
- package/src/derivatives-sharp/__tests__/render.test.ts +246 -1
- package/src/derivatives-sharp/changes.json +8 -1
- package/src/derivatives-sharp/render.ts +172 -5
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +15 -1
- package/src/file-derivatives/changes.json +6 -0
- package/src/file-derivatives/feature.ts +9 -1
- package/src/shared/__tests__/row-bound-grant.integration.test.ts +184 -0
- package/src/shared/changes.json +8 -1
- package/src/shared/index.ts +12 -0
- package/src/shared/row-bound-grant.test.ts +294 -0
- package/src/shared/row-bound-grant.ts +115 -0
- package/src/{auth-email-password/__tests__ → shared}/signed-token.test.ts +1 -1
- package/src/shared/signed-token.ts +101 -0
- package/src/tenant/seeding.ts +4 -0
- package/src/user-data-rights/__tests__/deletion-token-compat.test.ts +35 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +90 -1
- package/src/user-data-rights/changes.json +12 -0
- package/src/user-data-rights/deletion-token.ts +41 -47
- package/src/user-data-rights/feature.ts +27 -0
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +10 -20
- package/src/user-data-rights/run-export-jobs.ts +69 -1
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +198 -1
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +76 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.287.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -94,6 +94,8 @@
|
|
|
94
94
|
"./auth-email-password/testing": "./src/auth-email-password/testing.ts",
|
|
95
95
|
"./auth-email-password/web": "./src/auth-email-password/web/index.ts",
|
|
96
96
|
"./shared/single-use-token-store": "./src/shared/single-use-token-store.ts",
|
|
97
|
+
"./shared/signed-token": "./src/shared/signed-token.ts",
|
|
98
|
+
"./shared/row-bound-grant": "./src/shared/row-bound-grant.ts",
|
|
97
99
|
"./delivery": "./src/delivery/index.ts",
|
|
98
100
|
"./delivery/web": "./src/delivery/web/index.ts",
|
|
99
101
|
"./channel-in-app": "./src/channel-in-app/index.ts",
|
|
@@ -130,12 +132,12 @@
|
|
|
130
132
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
131
133
|
},
|
|
132
134
|
"dependencies": {
|
|
133
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
135
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.287.0",
|
|
136
|
+
"@cosmicdrift/kumiko-framework": "0.287.0",
|
|
137
|
+
"@cosmicdrift/kumiko-headless": "0.287.0",
|
|
138
|
+
"@cosmicdrift/kumiko-renderer": "0.287.0",
|
|
139
|
+
"@cosmicdrift/kumiko-renderer-web": "0.287.0",
|
|
140
|
+
"@cosmicdrift/kumiko-types": "0.287.0",
|
|
139
141
|
"@mollie/api-client": "^4.5.0",
|
|
140
142
|
"@node-rs/argon2": "^2.0.2",
|
|
141
143
|
"@types/mailparser": "^3.4.6",
|
|
@@ -164,7 +166,8 @@
|
|
|
164
166
|
],
|
|
165
167
|
"devDependencies": {
|
|
166
168
|
"@testing-library/user-event": "^14.6.1",
|
|
167
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
168
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
169
|
+
"@cosmicdrift/kumiko-locale-de": "0.287.0",
|
|
170
|
+
"@cosmicdrift/kumiko-locale-es": "0.287.0",
|
|
171
|
+
"jsqr": "^1.4.0"
|
|
169
172
|
}
|
|
170
173
|
}
|
|
@@ -1,86 +1,4 @@
|
|
|
1
|
-
//
|
|
2
|
-
// (
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
// The `purpose` is mixed INTO the HMAC input so a token minted for one
|
|
7
|
-
// purpose (e.g. password-reset) can't be replayed against an endpoint that
|
|
8
|
-
// expects another (e.g. verify-email), even if the caller knows the
|
|
9
|
-
// userId and a valid expiry. Purpose is NOT carried in the token body —
|
|
10
|
-
// verify() takes the purpose as argument and recomputes.
|
|
11
|
-
//
|
|
12
|
-
// Timing-safe comparison on verify so a valid-length forgery can't leak
|
|
13
|
-
// signal through a short-circuit.
|
|
14
|
-
|
|
15
|
-
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
16
|
-
import { Temporal } from "temporal-polyfill";
|
|
17
|
-
|
|
18
|
-
export type VerifyResult =
|
|
19
|
-
| { readonly ok: true; readonly userId: string; readonly expiresAtMs: number }
|
|
20
|
-
| { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
|
|
21
|
-
|
|
22
|
-
function sign(input: string, secret: string): string {
|
|
23
|
-
return createHmac("sha256", secret).update(input).digest("base64url");
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function payload(purpose: string, userId: string, expiresAtMs: number): string {
|
|
27
|
-
return `${purpose}:${userId}.${expiresAtMs}`;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function signToken(
|
|
31
|
-
userId: string,
|
|
32
|
-
purpose: string,
|
|
33
|
-
ttlMinutes: number,
|
|
34
|
-
secret: string,
|
|
35
|
-
now: Temporal.Instant = Temporal.Now.instant(),
|
|
36
|
-
): { token: string; expiresAt: Temporal.Instant } {
|
|
37
|
-
const expiresAt = now.add({ minutes: ttlMinutes });
|
|
38
|
-
const expiresAtMs = expiresAt.epochMilliseconds;
|
|
39
|
-
const signature = sign(payload(purpose, userId, expiresAtMs), secret);
|
|
40
|
-
return {
|
|
41
|
-
token: `${userId}.${expiresAtMs}.${signature}`,
|
|
42
|
-
expiresAt,
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function verifyToken(
|
|
47
|
-
token: string,
|
|
48
|
-
purpose: string,
|
|
49
|
-
secret: string,
|
|
50
|
-
now: Temporal.Instant = Temporal.Now.instant(),
|
|
51
|
-
): VerifyResult {
|
|
52
|
-
const parts = token.split(".");
|
|
53
|
-
if (parts.length !== 3) return { ok: false, reason: "malformed" };
|
|
54
|
-
const [userId, expiresAtRaw, providedSig] = parts;
|
|
55
|
-
if (!userId || !expiresAtRaw || !providedSig) return { ok: false, reason: "malformed" };
|
|
56
|
-
|
|
57
|
-
const expiresAtMs = Number(expiresAtRaw);
|
|
58
|
-
if (!Number.isFinite(expiresAtMs) || String(expiresAtMs) !== expiresAtRaw) {
|
|
59
|
-
return { ok: false, reason: "malformed" };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const expected = sign(payload(purpose, userId, expiresAtMs), secret);
|
|
63
|
-
const expectedBuf = Buffer.from(expected, "base64url");
|
|
64
|
-
const providedBuf = Buffer.from(providedSig, "base64url");
|
|
65
|
-
// Length mismatch fails BEFORE timingSafeEqual, which throws on different
|
|
66
|
-
// lengths — but that throw itself leaks via timing. Explicit length check
|
|
67
|
-
// keeps the path uniform.
|
|
68
|
-
if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
|
|
69
|
-
if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
|
|
70
|
-
|
|
71
|
-
if (Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(expiresAtMs)) > 0) {
|
|
72
|
-
return { ok: false, reason: "expired" };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// expiresAtMs surfaces so callers (burn-store TTL, telemetry, …) don't
|
|
76
|
-
// have to re-parse the token themselves.
|
|
77
|
-
return { ok: true, userId, expiresAtMs };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Canonical purposes baked into the framework. Features that introduce new
|
|
81
|
-
// flows extend this set (or just pass an inline string).
|
|
82
|
-
export const TokenPurpose = {
|
|
83
|
-
passwordReset: "reset",
|
|
84
|
-
emailVerification: "verify",
|
|
85
|
-
accountUnlock: "unlock",
|
|
86
|
-
} as const;
|
|
1
|
+
// Moved to shared/signed-token.ts — the mechanism was never specific to
|
|
2
|
+
// email/password auth (user-data-rights and any row-bound grant use it too).
|
|
3
|
+
// Re-exported so this feature's barrel and external importers keep working.
|
|
4
|
+
export * from "../shared/signed-token";
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
// `sharp({ create: {...} })` so every case decodes real bytes end-to-end.
|
|
3
3
|
|
|
4
4
|
import { describe, expect, test } from "bun:test";
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
ResolvedOverlayLayer,
|
|
7
|
+
VariantSpec,
|
|
8
|
+
} from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
9
|
+
import jsQR from "jsqr";
|
|
6
10
|
import sharp from "sharp";
|
|
7
11
|
import { imageMetadata, renderImage } from "../render";
|
|
8
12
|
|
|
@@ -285,6 +289,247 @@ describe("renderImage — validation", () => {
|
|
|
285
289
|
});
|
|
286
290
|
});
|
|
287
291
|
|
|
292
|
+
// jsQR needs a plain RGBA buffer, not sharp's own metadata/format handling —
|
|
293
|
+
// this is the one test proving the AC ("readable with a phone camera") for
|
|
294
|
+
// real: a pixel/byte-presence check would only prove placement, not that the
|
|
295
|
+
// QR survives resize + a lossy re-encode.
|
|
296
|
+
async function decodeQr(buffer: Uint8Array): Promise<string | null> {
|
|
297
|
+
const { data, info } = await sharp(buffer)
|
|
298
|
+
.ensureAlpha()
|
|
299
|
+
.raw()
|
|
300
|
+
.toBuffer({ resolveWithObject: true });
|
|
301
|
+
const result = jsQR(new Uint8ClampedArray(data), info.width, info.height);
|
|
302
|
+
return result?.data ?? null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function tinyImageOverlayBase64(
|
|
306
|
+
width: number,
|
|
307
|
+
height: number,
|
|
308
|
+
color: { r: number; g: number; b: number },
|
|
309
|
+
): Promise<string> {
|
|
310
|
+
const buffer = await sharp({ create: { width, height, channels: 3, background: color } })
|
|
311
|
+
.png()
|
|
312
|
+
.toBuffer();
|
|
313
|
+
return buffer.toString("base64");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
describe("renderImage — overlays", () => {
|
|
317
|
+
test("a qr overlay is actually scannable after resize + a lossy re-encode", async () => {
|
|
318
|
+
const input = await jpegFixture(800, 800);
|
|
319
|
+
const layer: ResolvedOverlayLayer = {
|
|
320
|
+
kind: "qr",
|
|
321
|
+
data: "https://example.com/v/abc123",
|
|
322
|
+
widthPct: 0.4,
|
|
323
|
+
gravity: "south-east",
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const output = await renderImage(
|
|
327
|
+
input,
|
|
328
|
+
{ maxEdge: 400, format: "webp", resolvedOverlays: [layer] },
|
|
329
|
+
"image/jpeg",
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
expect(await decodeQr(output)).toBe("https://example.com/v/abc123");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("an unresolved `overlays` field is inert for the renderer — only `resolvedOverlays` composites", async () => {
|
|
336
|
+
const input = await jpegFixture(400, 400);
|
|
337
|
+
const spec: VariantSpec = {
|
|
338
|
+
overlays: [{ kind: "qr", dataToken: "vehicle-1", widthPct: 0.4, gravity: "center" }],
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const output = await renderImage(input, spec, "image/jpeg");
|
|
342
|
+
const baseline = await renderImage(input, {}, "image/jpeg");
|
|
343
|
+
expect(Buffer.compare(output, baseline)).toBe(0);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("the overlay width is the same fraction of the output at two different aspect ratios", async () => {
|
|
347
|
+
// PNG in and out (spec.format unset preserves the source format) — the
|
|
348
|
+
// fraction assertion below needs exact pixel equality, which a lossy
|
|
349
|
+
// jpeg re-encode wouldn't guarantee.
|
|
350
|
+
const input = await pngFixture(800, 800, { r: 250, g: 250, b: 250 });
|
|
351
|
+
const overlayColor = { r: 10, g: 200, b: 10 };
|
|
352
|
+
const imageBase64 = await tinyImageOverlayBase64(40, 40, overlayColor);
|
|
353
|
+
const layer: ResolvedOverlayLayer = {
|
|
354
|
+
kind: "image",
|
|
355
|
+
imageBase64,
|
|
356
|
+
widthPct: 0.25,
|
|
357
|
+
gravity: "south-east",
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
async function overlayPixelWidth(width: number, height: number): Promise<number> {
|
|
361
|
+
const output = await renderImage(
|
|
362
|
+
input,
|
|
363
|
+
{ size: { width, height }, fit: "cover", resolvedOverlays: [layer] },
|
|
364
|
+
"image/png",
|
|
365
|
+
);
|
|
366
|
+
const { data, info } = await sharp(output).raw().toBuffer({ resolveWithObject: true });
|
|
367
|
+
const y = height - 1;
|
|
368
|
+
for (let x = 0; x < width; x++) {
|
|
369
|
+
const offset = (y * info.width + x) * info.channels;
|
|
370
|
+
if (
|
|
371
|
+
data[offset] === overlayColor.r &&
|
|
372
|
+
data[offset + 1] === overlayColor.g &&
|
|
373
|
+
data[offset + 2] === overlayColor.b
|
|
374
|
+
) {
|
|
375
|
+
return width - x;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
throw new Error("overlay color not found in bottom row");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
expect(await overlayPixelWidth(640, 360)).toBe(Math.round(640 * 0.25));
|
|
382
|
+
expect(await overlayPixelWidth(400, 400)).toBe(Math.round(400 * 0.25));
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("a square overlay on a wide output is clamped to the output height instead of overrunning it", async () => {
|
|
386
|
+
// widthPct alone would ask for an 800x800 layer (aspect-preserved from a
|
|
387
|
+
// square source) on a 1600x400 output — taller than the base image, which
|
|
388
|
+
// sharp's composite() rejects unless the resize also bounds height.
|
|
389
|
+
const outputWidth = 1600;
|
|
390
|
+
const outputHeight = 400;
|
|
391
|
+
const input = await pngFixture(outputWidth, outputHeight, { r: 250, g: 250, b: 250 });
|
|
392
|
+
const overlayColor = { r: 10, g: 200, b: 10 };
|
|
393
|
+
const imageBase64 = await tinyImageOverlayBase64(100, 100, overlayColor);
|
|
394
|
+
const layer: ResolvedOverlayLayer = {
|
|
395
|
+
kind: "image",
|
|
396
|
+
imageBase64,
|
|
397
|
+
widthPct: 0.5,
|
|
398
|
+
gravity: "center",
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const output = await renderImage(input, { resolvedOverlays: [layer] }, "image/png");
|
|
402
|
+
const { data, info } = await sharp(output).raw().toBuffer({ resolveWithObject: true });
|
|
403
|
+
|
|
404
|
+
const centerX = Math.floor(info.width / 2);
|
|
405
|
+
let minY = info.height;
|
|
406
|
+
let maxY = -1;
|
|
407
|
+
for (let y = 0; y < info.height; y++) {
|
|
408
|
+
const offset = (y * info.width + centerX) * info.channels;
|
|
409
|
+
if (
|
|
410
|
+
data[offset] === overlayColor.r &&
|
|
411
|
+
data[offset + 1] === overlayColor.g &&
|
|
412
|
+
data[offset + 2] === overlayColor.b
|
|
413
|
+
) {
|
|
414
|
+
minY = Math.min(minY, y);
|
|
415
|
+
maxY = Math.max(maxY, y);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
expect(maxY).toBeGreaterThan(-1);
|
|
420
|
+
expect(maxY - minY + 1).toBeLessThanOrEqual(outputHeight);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("an SVG overlay layer is rejected the same way a source SVG is", async () => {
|
|
424
|
+
const input = await jpegFixture(200, 200);
|
|
425
|
+
const layer: ResolvedOverlayLayer = {
|
|
426
|
+
kind: "image",
|
|
427
|
+
imageBase64: Buffer.from(SVG_BYTES).toString("base64"),
|
|
428
|
+
widthPct: 0.3,
|
|
429
|
+
gravity: "center",
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
await expect(renderImage(input, { resolvedOverlays: [layer] }, "image/jpeg")).rejects.toThrow(
|
|
433
|
+
/svg/i,
|
|
434
|
+
);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("more than MAX_OVERLAY_LAYERS throws instead of decoding+resizing each one", async () => {
|
|
438
|
+
const input = await jpegFixture(200, 200);
|
|
439
|
+
const overlays: ResolvedOverlayLayer[] = Array.from({ length: 9 }, () => ({
|
|
440
|
+
kind: "image",
|
|
441
|
+
imageBase64: "",
|
|
442
|
+
widthPct: 0.1,
|
|
443
|
+
gravity: "center",
|
|
444
|
+
}));
|
|
445
|
+
|
|
446
|
+
await expect(renderImage(input, { resolvedOverlays: overlays }, "image/jpeg")).rejects.toThrow(
|
|
447
|
+
/overlays has 9 entries/,
|
|
448
|
+
);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("widthPct outside (0, 1] throws", async () => {
|
|
452
|
+
const input = await jpegFixture(200, 200);
|
|
453
|
+
const layer: ResolvedOverlayLayer = {
|
|
454
|
+
kind: "image",
|
|
455
|
+
imageBase64: await tinyImageOverlayBase64(10, 10, { r: 1, g: 1, b: 1 }),
|
|
456
|
+
widthPct: 1.5,
|
|
457
|
+
gravity: "center",
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
await expect(renderImage(input, { resolvedOverlays: [layer] }, "image/jpeg")).rejects.toThrow(
|
|
461
|
+
/widthPct/,
|
|
462
|
+
);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test("marginPct outside [0, 0.5) throws", async () => {
|
|
466
|
+
const input = await jpegFixture(200, 200);
|
|
467
|
+
const layer: ResolvedOverlayLayer = {
|
|
468
|
+
kind: "image",
|
|
469
|
+
imageBase64: await tinyImageOverlayBase64(10, 10, { r: 1, g: 1, b: 1 }),
|
|
470
|
+
widthPct: 0.2,
|
|
471
|
+
marginPct: 0.5,
|
|
472
|
+
gravity: "north-west",
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
await expect(renderImage(input, { resolvedOverlays: [layer] }, "image/jpeg")).rejects.toThrow(
|
|
476
|
+
/marginPct/,
|
|
477
|
+
);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("a qr data length beyond MAX_QR_DATA_LENGTH throws", async () => {
|
|
481
|
+
const input = await jpegFixture(200, 200);
|
|
482
|
+
const layer: ResolvedOverlayLayer = {
|
|
483
|
+
kind: "qr",
|
|
484
|
+
data: "x".repeat(1025),
|
|
485
|
+
widthPct: 0.3,
|
|
486
|
+
gravity: "center",
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
await expect(renderImage(input, { resolvedOverlays: [layer] }, "image/jpeg")).rejects.toThrow(
|
|
490
|
+
/qr overlay data length/,
|
|
491
|
+
);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("an empty qr data throws instead of encoding an empty QR", async () => {
|
|
495
|
+
const input = await jpegFixture(200, 200);
|
|
496
|
+
const layer: ResolvedOverlayLayer = { kind: "qr", data: "", widthPct: 0.3, gravity: "center" };
|
|
497
|
+
|
|
498
|
+
await expect(renderImage(input, { resolvedOverlays: [layer] }, "image/jpeg")).rejects.toThrow(
|
|
499
|
+
/qr overlay data length/,
|
|
500
|
+
);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("an overlay image beyond MAX_OVERLAY_IMAGE_BYTES throws", async () => {
|
|
504
|
+
const input = await jpegFixture(200, 200);
|
|
505
|
+
const oversized = Buffer.alloc(513 * 1024, 1).toString("base64");
|
|
506
|
+
const layer: ResolvedOverlayLayer = {
|
|
507
|
+
kind: "image",
|
|
508
|
+
imageBase64: oversized,
|
|
509
|
+
widthPct: 0.3,
|
|
510
|
+
gravity: "center",
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
await expect(renderImage(input, { resolvedOverlays: [layer] }, "image/jpeg")).rejects.toThrow(
|
|
514
|
+
/overlay image is/,
|
|
515
|
+
);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("a qr overlay that would render below MIN_QR_PIXEL_WIDTH throws instead of caching an unscannable image", async () => {
|
|
519
|
+
const input = await jpegFixture(200, 200);
|
|
520
|
+
const layer: ResolvedOverlayLayer = {
|
|
521
|
+
kind: "qr",
|
|
522
|
+
data: "https://example.com/v/abc123",
|
|
523
|
+
widthPct: 0.05,
|
|
524
|
+
gravity: "center",
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
await expect(
|
|
528
|
+
renderImage(input, { maxEdge: 200, resolvedOverlays: [layer] }, "image/jpeg"),
|
|
529
|
+
).rejects.toThrow(/below the 160px minimum/);
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
|
|
288
533
|
describe("imageMetadata", () => {
|
|
289
534
|
test("returns width/height/format", async () => {
|
|
290
535
|
const input = await jpegFixture(320, 240);
|
|
@@ -1 +1,8 @@
|
|
|
1
|
-
[
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.287.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Render overlay layers (QR codes, badge images) onto image variants",
|
|
6
|
+
"detail": "The sharp renderer composites resolved overlay layers after resize and before final encoding. Overlay bytes go through the same SVG rejection as the source image, with DoS caps on layer count, size, and QR data length enforced in assertRenderSpecBounds."
|
|
7
|
+
}
|
|
8
|
+
]
|
|
@@ -5,9 +5,12 @@
|
|
|
5
5
|
import type {
|
|
6
6
|
BlurRegion,
|
|
7
7
|
DerivativeRendererPlugin,
|
|
8
|
+
OverlayGravity,
|
|
9
|
+
ResolvedOverlayLayer,
|
|
8
10
|
VariantFit,
|
|
9
11
|
VariantSpec,
|
|
10
12
|
} from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
13
|
+
import QRCode from "qrcode";
|
|
11
14
|
import type { OverlayOptions, ResizeOptions, Sharp } from "sharp";
|
|
12
15
|
import sharp from "sharp";
|
|
13
16
|
|
|
@@ -25,6 +28,16 @@ const MAX_OUTPUT_EDGE = 8192;
|
|
|
25
28
|
// unbounded region list is a CPU/RAM-multiplying DoS knob independent of
|
|
26
29
|
// output size.
|
|
27
30
|
const MAX_BLUR_REGIONS = 64;
|
|
31
|
+
// Every layer decodes and resizes separately before the composite — an
|
|
32
|
+
// unbounded list is the same CPU/RAM multiplier MAX_BLUR_REGIONS caps.
|
|
33
|
+
const MAX_OVERLAY_LAYERS = 8;
|
|
34
|
+
// Layer bytes ride inside the spec, which is hashed on every variant() call.
|
|
35
|
+
const MAX_OVERLAY_IMAGE_BYTES = 512 * 1024;
|
|
36
|
+
const MAX_QR_DATA_LENGTH = 1024;
|
|
37
|
+
// Below roughly this, a QR's modules land on too few pixels to survive
|
|
38
|
+
// camera capture — the AC claims phone-scannable, so an under-sized layer
|
|
39
|
+
// throws instead of caching a silently unusable image.
|
|
40
|
+
const MIN_QR_PIXEL_WIDTH = 160;
|
|
28
41
|
// sequentialRead trades random pixel access for lower peak memory on
|
|
29
42
|
// streamed formats — applied at every decode entry point below since none
|
|
30
43
|
// of them need non-sequential access to the *original* bytes (extract runs
|
|
@@ -39,6 +52,18 @@ function clampSigma(sigma: number): number {
|
|
|
39
52
|
return Math.min(MAX_SHARP_SIGMA, Math.max(MIN_SHARP_SIGMA, sigma));
|
|
40
53
|
}
|
|
41
54
|
|
|
55
|
+
// Shared trust boundary for every image byte source this renderer decodes —
|
|
56
|
+
// the original upload (renderImage) and an overlay layer's bytes (applyOverlays)
|
|
57
|
+
// alike: a base64 string in a field declaration isn't more trustworthy than
|
|
58
|
+
// an upload just because it came from app code.
|
|
59
|
+
function assertNotSvg(format: string | undefined): void {
|
|
60
|
+
if (format === "svg") {
|
|
61
|
+
throw new Error(
|
|
62
|
+
"derivatives-sharp: SVG is not supported — rendering untrusted SVG through libvips is a trust boundary this renderer doesn't need to cross.",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
42
67
|
function assertValidRegion(region: BlurRegion): void {
|
|
43
68
|
const values = [region.x, region.y, region.width, region.height];
|
|
44
69
|
if (values.some((v) => !Number.isFinite(v)) || region.width <= 0 || region.height <= 0) {
|
|
@@ -86,6 +111,102 @@ async function applyBlurRegions(
|
|
|
86
111
|
return sharp(data, SHARP_INPUT_OPTIONS).composite(overlays).toBuffer();
|
|
87
112
|
}
|
|
88
113
|
|
|
114
|
+
// Placement is anchored to the OUTPUT box (overlays run after resize, see
|
|
115
|
+
// applyOverlays), then clamped the same way applyBlurRegions clamps its
|
|
116
|
+
// region coordinates — a caller-declared marginPct can't push a layer
|
|
117
|
+
// off-canvas.
|
|
118
|
+
function overlayPosition(
|
|
119
|
+
gravity: OverlayGravity,
|
|
120
|
+
marginPx: number,
|
|
121
|
+
outputWidth: number,
|
|
122
|
+
outputHeight: number,
|
|
123
|
+
layerWidth: number,
|
|
124
|
+
layerHeight: number,
|
|
125
|
+
): { left: number; top: number } {
|
|
126
|
+
let left: number;
|
|
127
|
+
let top: number;
|
|
128
|
+
switch (gravity) {
|
|
129
|
+
case "north-west":
|
|
130
|
+
left = marginPx;
|
|
131
|
+
top = marginPx;
|
|
132
|
+
break;
|
|
133
|
+
case "north-east":
|
|
134
|
+
left = outputWidth - layerWidth - marginPx;
|
|
135
|
+
top = marginPx;
|
|
136
|
+
break;
|
|
137
|
+
case "south-west":
|
|
138
|
+
left = marginPx;
|
|
139
|
+
top = outputHeight - layerHeight - marginPx;
|
|
140
|
+
break;
|
|
141
|
+
case "south-east":
|
|
142
|
+
left = outputWidth - layerWidth - marginPx;
|
|
143
|
+
top = outputHeight - layerHeight - marginPx;
|
|
144
|
+
break;
|
|
145
|
+
case "center":
|
|
146
|
+
left = (outputWidth - layerWidth) / 2;
|
|
147
|
+
top = (outputHeight - layerHeight) / 2;
|
|
148
|
+
break;
|
|
149
|
+
default:
|
|
150
|
+
throw new Error(`derivatives-sharp: unknown overlay gravity "${gravity satisfies never}".`);
|
|
151
|
+
}
|
|
152
|
+
const maxLeft = Math.max(0, outputWidth - layerWidth);
|
|
153
|
+
const maxTop = Math.max(0, outputHeight - layerHeight);
|
|
154
|
+
return {
|
|
155
|
+
left: Math.min(Math.max(Math.round(left), 0), maxLeft),
|
|
156
|
+
top: Math.min(Math.max(Math.round(top), 0), maxTop),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Runs AFTER resize (see the AC in renderImage) so a layer sized as a
|
|
161
|
+
// fraction of the output is a fraction of what the caller actually gets,
|
|
162
|
+
// regardless of the source's own dimensions or `fit` crop.
|
|
163
|
+
async function applyOverlays(
|
|
164
|
+
pipeline: Sharp,
|
|
165
|
+
layers: readonly ResolvedOverlayLayer[],
|
|
166
|
+
): Promise<Sharp> {
|
|
167
|
+
const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
|
|
168
|
+
|
|
169
|
+
const composites: OverlayOptions[] = [];
|
|
170
|
+
for (const layer of layers) {
|
|
171
|
+
const layerBytes =
|
|
172
|
+
layer.kind === "qr"
|
|
173
|
+
? await QRCode.toBuffer(layer.data, { margin: 1, errorCorrectionLevel: "M" })
|
|
174
|
+
: Buffer.from(layer.imageBase64, "base64");
|
|
175
|
+
|
|
176
|
+
const layerMeta = await sharp(layerBytes, SHARP_INPUT_OPTIONS).metadata();
|
|
177
|
+
assertNotSvg(layerMeta.format);
|
|
178
|
+
|
|
179
|
+
const targetWidth = Math.max(1, Math.round(info.width * layer.widthPct));
|
|
180
|
+
if (layer.kind === "qr" && targetWidth < MIN_QR_PIXEL_WIDTH) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`derivatives-sharp: qr overlay would render at ${targetWidth}px wide, below the ${MIN_QR_PIXEL_WIDTH}px minimum a camera can reliably scan.`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// PNG (not the layer's own format) to keep any alpha through the resize.
|
|
187
|
+
// height caps the other axis too — sharp's composite() rejects an input
|
|
188
|
+
// larger than the base image, which a tall/square layer would otherwise
|
|
189
|
+
// hit once widthPct alone drives its width past the output's height.
|
|
190
|
+
const resized = await sharp(layerBytes, SHARP_INPUT_OPTIONS)
|
|
191
|
+
.resize({ width: targetWidth, height: info.height, fit: "inside" })
|
|
192
|
+
.png()
|
|
193
|
+
.toBuffer({ resolveWithObject: true });
|
|
194
|
+
|
|
195
|
+
const marginPx = Math.round(info.width * (layer.marginPct ?? 0));
|
|
196
|
+
const { left, top } = overlayPosition(
|
|
197
|
+
layer.gravity,
|
|
198
|
+
marginPx,
|
|
199
|
+
info.width,
|
|
200
|
+
info.height,
|
|
201
|
+
resized.info.width,
|
|
202
|
+
resized.info.height,
|
|
203
|
+
);
|
|
204
|
+
composites.push({ input: resized.data, left, top });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return sharp(data, SHARP_INPUT_OPTIONS).composite(composites);
|
|
208
|
+
}
|
|
209
|
+
|
|
89
210
|
function buildResizeOptions(width: number, height: number, fit: VariantFit): ResizeOptions {
|
|
90
211
|
const options: ResizeOptions = { width, height, fit };
|
|
91
212
|
if (fit === "inside") {
|
|
@@ -153,6 +274,51 @@ function applyEncoder(pipeline: Sharp, spec: VariantSpec, sourceMimeType: string
|
|
|
153
274
|
return encode(pipeline, quality);
|
|
154
275
|
}
|
|
155
276
|
|
|
277
|
+
// Split out of assertRenderSpecBounds (not inlined there) purely to stay
|
|
278
|
+
// under the AST-guard complexity budget — see assertRenderSpecBounds's own
|
|
279
|
+
// comment. Still the single call site for every overlay bound.
|
|
280
|
+
function assertOverlayLayerBounds(layer: ResolvedOverlayLayer): void {
|
|
281
|
+
if (!Number.isFinite(layer.widthPct) || layer.widthPct <= 0 || layer.widthPct > 1) {
|
|
282
|
+
throw new Error(
|
|
283
|
+
`derivatives-sharp: overlay widthPct ${layer.widthPct} is out of range — must be a finite number in (0, 1].`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (layer.marginPct !== undefined && (layer.marginPct < 0 || layer.marginPct >= 0.5)) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`derivatives-sharp: overlay marginPct ${layer.marginPct} is out of range — must be within [0, 0.5).`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
if (layer.kind === "qr") {
|
|
292
|
+
if (layer.data.length === 0 || layer.data.length > MAX_QR_DATA_LENGTH) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`derivatives-sharp: qr overlay data length ${layer.data.length} is out of range — must be 1–${MAX_QR_DATA_LENGTH} characters.`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
// skip: qr layers have no imageBase64 field to validate; this return also
|
|
298
|
+
// narrows the union below to the image variant without a cast.
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const byteLength = Buffer.byteLength(layer.imageBase64, "base64");
|
|
302
|
+
if (byteLength > MAX_OVERLAY_IMAGE_BYTES) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`derivatives-sharp: overlay image is ${byteLength} bytes, exceeding the limit of ${MAX_OVERLAY_IMAGE_BYTES}.`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function assertOverlayBounds(layers: readonly ResolvedOverlayLayer[] | undefined): void {
|
|
310
|
+
// skip: no overlays configured for this variant, nothing to validate
|
|
311
|
+
if (!layers) return;
|
|
312
|
+
if (layers.length > MAX_OVERLAY_LAYERS) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`derivatives-sharp: overlays has ${layers.length} entries, exceeding the limit of ${MAX_OVERLAY_LAYERS} — each layer decodes and resizes separately before the composite.`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
for (const layer of layers) {
|
|
318
|
+
assertOverlayLayerBounds(layer);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
156
322
|
// Input-validation for the DoS caps above — kept out of renderImage so the
|
|
157
323
|
// complexity hotspot stays under the AST-guard budget.
|
|
158
324
|
function assertRenderSpecBounds(spec: VariantSpec): void {
|
|
@@ -181,6 +347,7 @@ function assertRenderSpecBounds(spec: VariantSpec): void {
|
|
|
181
347
|
);
|
|
182
348
|
}
|
|
183
349
|
}
|
|
350
|
+
assertOverlayBounds(spec.resolvedOverlays);
|
|
184
351
|
}
|
|
185
352
|
|
|
186
353
|
export const renderImage: DerivativeRendererPlugin["render"] = async (
|
|
@@ -194,11 +361,7 @@ export const renderImage: DerivativeRendererPlugin["render"] = async (
|
|
|
194
361
|
// the SVG rejection from the actual bytes so a mislabeled upload can't
|
|
195
362
|
// reach libvips as SVG.
|
|
196
363
|
const sniffed = await sharp(input, SHARP_INPUT_OPTIONS).metadata();
|
|
197
|
-
|
|
198
|
-
throw new Error(
|
|
199
|
-
"derivatives-sharp: SVG is not supported — rendering untrusted SVG through libvips is a trust boundary this renderer doesn't need to cross.",
|
|
200
|
-
);
|
|
201
|
-
}
|
|
364
|
+
assertNotSvg(sniffed.format);
|
|
202
365
|
|
|
203
366
|
assertRenderSpecBounds(spec);
|
|
204
367
|
|
|
@@ -225,6 +388,10 @@ export const renderImage: DerivativeRendererPlugin["render"] = async (
|
|
|
225
388
|
pipeline = pipeline.blur(spec.blur);
|
|
226
389
|
}
|
|
227
390
|
|
|
391
|
+
if (spec.resolvedOverlays && spec.resolvedOverlays.length > 0) {
|
|
392
|
+
pipeline = await applyOverlays(pipeline, spec.resolvedOverlays);
|
|
393
|
+
}
|
|
394
|
+
|
|
228
395
|
pipeline = applyEncoder(pipeline, spec, normalizedSource);
|
|
229
396
|
|
|
230
397
|
const buffer = await pipeline.toBuffer();
|
|
@@ -77,9 +77,23 @@ const widgetEntity = createEntity({
|
|
|
77
77
|
},
|
|
78
78
|
});
|
|
79
79
|
|
|
80
|
+
// Registered as a real entity (so upload's registry-hardening resolves it)
|
|
81
|
+
// but with no EXT_DERIVATIVE_PUBLIC_PREDICATE registration — the "no
|
|
82
|
+
// predicate registered for this entityType" default-deny case below needs
|
|
83
|
+
// an entity that upload can attach to, distinct from "the entityType string
|
|
84
|
+
// isn't registered at all" (which upload now rejects before the file ever
|
|
85
|
+
// reaches file_refs, see kumiko-framework#3005).
|
|
86
|
+
const noPredicateEntity = createEntity({
|
|
87
|
+
table: "public_variant_no_predicate",
|
|
88
|
+
fields: {
|
|
89
|
+
img: createImageField({ variants: { thumb: { maxEdge: 160, format: "webp" } } }),
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
80
93
|
let predicateCalls = 0;
|
|
81
94
|
const widgetPredicateFeature = defineFeature("publicvariantroutetest", (r) => {
|
|
82
95
|
r.entity("widget", widgetEntity);
|
|
96
|
+
r.entity("nopredicate", noPredicateEntity);
|
|
83
97
|
r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, "widget", {
|
|
84
98
|
isPublic: (args: DerivativePublicPredicateArgs) => {
|
|
85
99
|
predicateCalls++;
|
|
@@ -161,7 +175,7 @@ async function uploadFile(
|
|
|
161
175
|
|
|
162
176
|
describe("GET /media/:fileRefId/:variant (anonymous, default-deny)", () => {
|
|
163
177
|
test("no predicate registered for the FileRef's entityType → 404", async () => {
|
|
164
|
-
const fileId = await uploadFile(userA, { entityType: "
|
|
178
|
+
const fileId = await uploadFile(userA, { entityType: "nopredicate", entityId: "x" });
|
|
165
179
|
|
|
166
180
|
const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/thumb`);
|
|
167
181
|
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.287.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Add overlay stage (QR codes, badge images) to derived image variants",
|
|
6
|
+
"detail": "VariantSpec now supports declarative overlay layers (qr via a resolver-token, or a base64 image) composited onto derived image variants. QR values are never free text — only a dataToken resolved server-side via a new extension point, keeping the anonymous derivative route from becoming an open image generator."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.194.0",
|
|
4
10
|
"type": "breaking",
|