@cosmicdrift/kumiko-bundled-features 0.285.2 → 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 +13 -9
- package/src/auth-email-password/changes.json +6 -0
- package/src/auth-email-password/invite-token-store.ts +47 -96
- package/src/auth-email-password/lockout-store.ts +13 -102
- package/src/auth-email-password/signed-token.ts +4 -86
- package/src/auth-email-password/signup-token-store.test.ts +29 -0
- package/src/auth-email-password/signup-token-store.ts +40 -103
- package/src/auth-mfa/changes.json +6 -0
- package/src/auth-mfa/mfa-verify-attempts.ts +10 -68
- package/src/billing-foundation/__tests__/billing-info-query.test.ts +103 -0
- package/src/billing-foundation/billing-info-query.ts +95 -0
- package/src/billing-foundation/changes.json +8 -1
- package/src/billing-foundation/index.ts +5 -0
- 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 +14 -0
- package/src/shared/lockout-counter.test.ts +91 -0
- package/src/shared/lockout-counter.ts +108 -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/shared/single-use-token-store.test.ts +75 -0
- package/src/shared/single-use-token-store.ts +136 -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
|
@@ -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",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { cachedResponse, computeRevisionEtag } from "@cosmicdrift/kumiko-framework/api";
|
|
10
10
|
import {
|
|
11
11
|
defineFeature,
|
|
12
|
+
EXT_DERIVATIVE_OVERLAY_RESOLVER,
|
|
12
13
|
EXT_DERIVATIVE_PUBLIC_PREDICATE,
|
|
13
14
|
EXT_DERIVATIVE_RENDERER,
|
|
14
15
|
type FeatureDefinition,
|
|
@@ -78,7 +79,7 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
|
|
|
78
79
|
|
|
79
80
|
return defineFeature(FEATURE_NAME, (r) => {
|
|
80
81
|
r.describe(
|
|
81
|
-
"Declares the `derivativeRenderer` extension point. `ctx.derivatives.variant(fileRefId, spec, name)` derives a variant of a tracked FileRef the first time it's requested and reuses the stored result afterwards (derive-on-first-use, keyed by a hash of the spec). Mount at least one `derivatives-*` renderer feature alongside this one — without a registered renderer for the FileRef's MIME type, every `variant(...)` call throws. Also declares the `derivativePublicPredicate` extension point (`r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, '<entityType>', { isPublic })`) and, when `createFileDerivativesFeature({resolveApexTenant})` is passed a host-resolver, mounts an anonymous `GET {basePath}/:fileRefId/:variant` route that serves any variant name the FileRef's field declared in its `variants` for a FileRef whose entityType has a registered predicate returning true — default-deny (404) otherwise, same as an unknown FileRef or an undeclared variant name. The route's only rate-limit (`per: \"ip\"`) trusts the first `x-forwarded-for` hop — deployers must ensure their ingress overwrites rather than appends to that header, or the throttle is bypassable by rotating it.",
|
|
82
|
+
"Declares the `derivativeRenderer` extension point. `ctx.derivatives.variant(fileRefId, spec, name)` derives a variant of a tracked FileRef the first time it's requested and reuses the stored result afterwards (derive-on-first-use, keyed by a hash of the spec). Mount at least one `derivatives-*` renderer feature alongside this one — without a registered renderer for the FileRef's MIME type, every `variant(...)` call throws. Also declares the `derivativePublicPredicate` extension point (`r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, '<entityType>', { isPublic })`) and, when `createFileDerivativesFeature({resolveApexTenant})` is passed a host-resolver, mounts an anonymous `GET {basePath}/:fileRefId/:variant` route that serves any variant name the FileRef's field declared in its `variants` for a FileRef whose entityType has a registered predicate returning true — default-deny (404) otherwise, same as an unknown FileRef or an undeclared variant name. The route's only rate-limit (`per: \"ip\"`) trusts the first `x-forwarded-for` hop — deployers must ensure their ingress overwrites rather than appends to that header, or the throttle is bypassable by rotating it. Also declares the `derivativeOverlayResolver` extension point (`r.useExtension(EXT_DERIVATIVE_OVERLAY_RESOLVER, '<entityType>', { resolve })`), used to turn a variant's `overlays[].dataToken` into the actual QR payload for that FileRef's entityType before the variant is rendered — a variant declaring a `qr` overlay throws at request-time if no resolver is registered for the FileRef's entityType.",
|
|
82
83
|
);
|
|
83
84
|
r.uiHints({
|
|
84
85
|
displayLabel: "File Derivatives",
|
|
@@ -108,6 +109,13 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
|
|
|
108
109
|
// entityType.
|
|
109
110
|
},
|
|
110
111
|
});
|
|
112
|
+
r.extendsRegistrar(EXT_DERIVATIVE_OVERLAY_RESOLVER, {
|
|
113
|
+
onRegister: () => {
|
|
114
|
+
// No side-effects at register-time — resolution happens in
|
|
115
|
+
// variant(), keyed by the FileRef's entityType, before the spec
|
|
116
|
+
// hash is computed.
|
|
117
|
+
},
|
|
118
|
+
});
|
|
111
119
|
|
|
112
120
|
// Registered ONLY when resolveApexTenant is supplied — r.queryHandler
|
|
113
121
|
// registers publicVariantQuery into the feature's dispatch table as a
|