@cosmicdrift/kumiko-bundled-features 0.191.0 → 0.193.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 +11 -9
- package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +43 -1
- package/src/derivatives-sharp/__tests__/derivatives-sharp.integration.test.ts +124 -0
- package/src/derivatives-sharp/__tests__/render.test.ts +234 -0
- package/src/derivatives-sharp/changes.json +1 -0
- package/src/derivatives-sharp/feature.ts +22 -0
- package/src/derivatives-sharp/index.ts +2 -0
- package/src/derivatives-sharp/render.ts +201 -0
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +237 -0
- package/src/file-derivatives/feature.ts +142 -21
- package/src/file-derivatives/handlers/public-variant.query.ts +122 -0
- package/src/file-derivatives/index.ts +3 -2
- package/src/file-derivatives/presets.ts +7 -0
- package/src/form-draft/__tests__/cleanup.integration.test.ts +30 -0
- package/src/form-draft/__tests__/discard-file-refs.integration.test.ts +26 -1
- package/src/form-draft/__tests__/feature-optional-config.integration.test.ts +90 -0
- package/src/form-draft/db/queries/cleanup.ts +15 -14
- package/src/form-draft/db/queries/owned-file-refs.ts +13 -2
- package/src/form-draft/feature.ts +7 -1
- package/src/form-draft/handlers/__tests__/cleanup.job.test.ts +55 -0
- package/src/form-draft/handlers/cleanup.job.ts +57 -11
- package/src/form-draft/handlers/discard.write.ts +1 -0
- package/src/form-draft/lookup.ts +2 -0
- package/src/form-draft-user-data/__tests__/hooks-delete-failure.integration.test.ts +60 -0
- package/src/form-draft-user-data/hooks.ts +12 -1
- package/src/template-resolver/handlers/by-slug.query.ts +1 -0
- package/src/template-resolver/handlers/collection-shared.ts +2 -0
- package/src/template-resolver/web/__tests__/editor-read-only.test.tsx +25 -0
- package/src/template-resolver/web/client-plugin.tsx +22 -0
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +21 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.193.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>",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"./file-provider-s3-env": "./src/file-provider-s3-env/index.ts",
|
|
69
69
|
"./file-provider-inmemory": "./src/file-provider-inmemory/index.ts",
|
|
70
70
|
"./file-derivatives": "./src/file-derivatives/index.ts",
|
|
71
|
+
"./derivatives-sharp": "./src/derivatives-sharp/index.ts",
|
|
71
72
|
"./files": "./src/files/index.ts",
|
|
72
73
|
"./user-data-rights": "./src/user-data-rights/index.ts",
|
|
73
74
|
"./user-data-rights/web": "./src/user-data-rights/web/index.ts",
|
|
@@ -124,24 +125,25 @@
|
|
|
124
125
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
125
126
|
},
|
|
126
127
|
"dependencies": {
|
|
127
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
128
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
129
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
130
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
132
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
128
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.193.0",
|
|
129
|
+
"@cosmicdrift/kumiko-framework": "0.193.0",
|
|
130
|
+
"@cosmicdrift/kumiko-headless": "0.193.0",
|
|
131
|
+
"@cosmicdrift/kumiko-renderer": "0.193.0",
|
|
132
|
+
"@cosmicdrift/kumiko-renderer-web": "0.193.0",
|
|
133
|
+
"@cosmicdrift/kumiko-types": "0.193.0",
|
|
133
134
|
"@mollie/api-client": "^4.5.0",
|
|
134
|
-
"imapflow": "^1.3.3",
|
|
135
|
-
"mailparser": "^3.9.8",
|
|
136
135
|
"@node-rs/argon2": "^2.0.2",
|
|
137
136
|
"@types/mailparser": "^3.4.6",
|
|
138
137
|
"@types/nodemailer": "^8.0.0",
|
|
139
138
|
"clsx": "^2.1.1",
|
|
139
|
+
"imapflow": "^1.3.3",
|
|
140
140
|
"lucide-react": "^1.14.0",
|
|
141
|
+
"mailparser": "^3.9.8",
|
|
141
142
|
"marked": "^18.0.3",
|
|
142
143
|
"nodemailer": "^9.0.1",
|
|
143
144
|
"qrcode": "^1.5.4",
|
|
144
145
|
"react": "^19.2.6",
|
|
146
|
+
"sharp": "^0.35.3",
|
|
145
147
|
"stripe": "^22.1.1",
|
|
146
148
|
"tailwind-merge": "^3.6.0"
|
|
147
149
|
},
|
|
@@ -33,7 +33,7 @@ import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery
|
|
|
33
33
|
import { notificationPreferencesTable } from "../../delivery/tables";
|
|
34
34
|
import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
|
|
35
35
|
import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
|
|
36
|
-
import { hashPassword } from "../../shared";
|
|
36
|
+
import { decryptStoredPii, hashPassword } from "../../shared";
|
|
37
37
|
import { createTemplateResolverFeature } from "../../template-resolver/feature";
|
|
38
38
|
import { createTenantFeature } from "../../tenant";
|
|
39
39
|
import { tenantInvitationEntity, tenantInvitationsTable } from "../../tenant/invitation-table";
|
|
@@ -42,6 +42,7 @@ import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
|
|
|
42
42
|
import { seedTenant, seedTenantMembership } from "../../tenant/seeding";
|
|
43
43
|
import { createUserFeature } from "../../user/feature";
|
|
44
44
|
import { userEntity, userTable } from "../../user/schema/user";
|
|
45
|
+
import { tenantInvitationDeleteHook } from "../../user-data-rights-defaults";
|
|
45
46
|
import { AuthHandlers } from "../constants";
|
|
46
47
|
import { createAuthEmailPasswordFeature } from "../feature";
|
|
47
48
|
import { seedUser } from "../seeding";
|
|
@@ -298,4 +299,45 @@ describe("auth flows with active KMS + blind index", () => {
|
|
|
298
299
|
expect(isPiiCiphertext(list[0]?.invitedBy)).toBe(false);
|
|
299
300
|
expect(list[0]?.invitedBy).toBe(aliceId);
|
|
300
301
|
});
|
|
302
|
+
|
|
303
|
+
// fw-review #7: the inviter-forget arm compared a plaintext userId against
|
|
304
|
+
// the encrypted `invitedBy` column via selectMany's equality filter — with
|
|
305
|
+
// no lookupable/blind-index column on `invitedBy`, that filter matched
|
|
306
|
+
// zero rows under active KMS, so Alice's Art.17 forget never anonymized
|
|
307
|
+
// rows for invitations she sent. Fixed by loading the tenant's invitations
|
|
308
|
+
// and decrypting `invitedBy` per row for comparison instead.
|
|
309
|
+
test("Art.17 forget anonymizes invitedBy for an invite the requester sent, even under active KMS", async () => {
|
|
310
|
+
await inviteEmail(CAROL_EMAIL, "Editor");
|
|
311
|
+
|
|
312
|
+
const [rawBefore] = await asRawClient(stack.db).unsafe<Record<string, unknown>>(
|
|
313
|
+
`SELECT * FROM "${tenantInvitationsTable.tableName}" WHERE tenant_id = $1`,
|
|
314
|
+
[TENANT_A_ID],
|
|
315
|
+
);
|
|
316
|
+
if (!rawBefore) throw new Error("no invitation row seeded");
|
|
317
|
+
expect(isPiiCiphertext(rawBefore["invited_by"])).toBe(true);
|
|
318
|
+
|
|
319
|
+
await tenantInvitationDeleteHook(
|
|
320
|
+
{ db: stack.db, registry: stack.registry, tenantId: TENANT_A_ID, userId: aliceId },
|
|
321
|
+
"delete",
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
const [rawAfter] = await asRawClient(stack.db).unsafe<Record<string, unknown>>(
|
|
325
|
+
`SELECT * FROM "${tenantInvitationsTable.tableName}" WHERE tenant_id = $1`,
|
|
326
|
+
[TENANT_A_ID],
|
|
327
|
+
);
|
|
328
|
+
if (!rawAfter)
|
|
329
|
+
throw new Error("invitation row disappeared — inviter-forget only severs the link");
|
|
330
|
+
// The invitee link is untouched — this row belongs to Carol, not Alice.
|
|
331
|
+
expect(isPiiCiphertext(rawAfter["email"])).toBe(true);
|
|
332
|
+
// invitedBy is re-encrypted on write, so assert on the decrypted value —
|
|
333
|
+
// the sentinel itself lives as INVITED_BY_ANONYMIZED in the hook file.
|
|
334
|
+
expect(isPiiCiphertext(rawAfter["invited_by"])).toBe(true);
|
|
335
|
+
const decryptedInvitedBy = await decryptStoredPii(
|
|
336
|
+
String(rawAfter["invited_by"]),
|
|
337
|
+
"invitedBy",
|
|
338
|
+
"test",
|
|
339
|
+
);
|
|
340
|
+
expect(decryptedInvitedBy).toBe("anonymized");
|
|
341
|
+
expect(decryptedInvitedBy).not.toBe(aliceId);
|
|
342
|
+
});
|
|
301
343
|
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Proves the full derive-on-first-use loop through a real sharp render: a
|
|
2
|
+
// write-handler calls ctx.derivatives.variant(), the bytes it stores decode
|
|
3
|
+
// as the promised format/size, and a second call with the same spec hits the
|
|
4
|
+
// cache instead of rendering again.
|
|
5
|
+
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
9
|
+
import { createInMemoryFileProvider } from "@cosmicdrift/kumiko-framework/files";
|
|
10
|
+
import {
|
|
11
|
+
createTestUser,
|
|
12
|
+
setupTestStack,
|
|
13
|
+
type TestStack,
|
|
14
|
+
testTenantId,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
16
|
+
import {
|
|
17
|
+
buildMultipartBody,
|
|
18
|
+
patchFileInstanceofForBunTest,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
20
|
+
import sharp from "sharp";
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import { createConfigFeature } from "../../config";
|
|
23
|
+
import { fileDerivativesFeature } from "../../file-derivatives";
|
|
24
|
+
import { fileFoundationFeature } from "../../file-foundation";
|
|
25
|
+
import { createFilesFeature } from "../../files";
|
|
26
|
+
import { derivativesSharpFeature } from "../feature";
|
|
27
|
+
import { imageMetadata } from "../render";
|
|
28
|
+
|
|
29
|
+
const variantTestFeature = defineFeature("derivativessharptest", (r) => {
|
|
30
|
+
r.requires("file-derivatives");
|
|
31
|
+
r.writeHandler(
|
|
32
|
+
"make-thumb",
|
|
33
|
+
z.object({ fileRefId: z.string() }),
|
|
34
|
+
async (event, ctx) => {
|
|
35
|
+
if (!ctx.derivatives || !ctx.files) {
|
|
36
|
+
return writeFailure(
|
|
37
|
+
new InternalError({ message: "no ctx.derivatives/ctx.files on write-handler ctx" }),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const result = await ctx.derivatives.variant(
|
|
41
|
+
event.payload.fileRefId,
|
|
42
|
+
{ maxEdge: 160, fit: "cover", format: "webp" },
|
|
43
|
+
"thumb",
|
|
44
|
+
);
|
|
45
|
+
const bytes = await ctx.files.ref(result.storageKey).read();
|
|
46
|
+
const meta = await imageMetadata(bytes);
|
|
47
|
+
return { isSuccess: true as const, data: { ...result, ...meta } };
|
|
48
|
+
},
|
|
49
|
+
{ access: { openToAll: true } },
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
let stack: TestStack;
|
|
54
|
+
const tenantId = testTenantId(1);
|
|
55
|
+
const user = createTestUser({ id: 1, tenantId, roles: ["Admin"] });
|
|
56
|
+
|
|
57
|
+
async function jpegBytes(width: number, height: number): Promise<Uint8Array> {
|
|
58
|
+
return sharp({ create: { width, height, channels: 3, background: { r: 90, g: 140, b: 200 } } })
|
|
59
|
+
.jpeg()
|
|
60
|
+
.toBuffer();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
beforeAll(async () => {
|
|
64
|
+
patchFileInstanceofForBunTest();
|
|
65
|
+
stack = await setupTestStack({
|
|
66
|
+
features: [
|
|
67
|
+
createConfigFeature(),
|
|
68
|
+
fileFoundationFeature,
|
|
69
|
+
createFilesFeature(),
|
|
70
|
+
fileDerivativesFeature,
|
|
71
|
+
derivativesSharpFeature,
|
|
72
|
+
variantTestFeature,
|
|
73
|
+
],
|
|
74
|
+
files: { storageProvider: createInMemoryFileProvider() },
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterAll(async () => {
|
|
79
|
+
await stack.cleanup();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
async function uploadJpeg(): Promise<string> {
|
|
83
|
+
const token = await stack.jwt.sign(user);
|
|
84
|
+
const bytes = await jpegBytes(400, 200);
|
|
85
|
+
const fd = new FormData();
|
|
86
|
+
fd.append("file", new File([Buffer.from(bytes)], "photo.jpg", { type: "image/jpeg" }));
|
|
87
|
+
const { body, contentType } = await buildMultipartBody(fd);
|
|
88
|
+
const res = await stack.app.request("/api/files", {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
|
|
91
|
+
body,
|
|
92
|
+
});
|
|
93
|
+
expect(res.status).toBe(201);
|
|
94
|
+
const json = (await res.json()) as { id: string };
|
|
95
|
+
return json.id;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe("derivatives-sharp — end to end through ctx.derivatives.variant", () => {
|
|
99
|
+
test("first call renders a real webp thumb, second call hits the cache", async () => {
|
|
100
|
+
const fileId = await uploadJpeg();
|
|
101
|
+
|
|
102
|
+
const first = await stack.http.writeOk<{
|
|
103
|
+
storageKey: string;
|
|
104
|
+
mimeType: string;
|
|
105
|
+
rendered: boolean;
|
|
106
|
+
width: number;
|
|
107
|
+
height: number;
|
|
108
|
+
format: string;
|
|
109
|
+
}>("derivativessharptest:write:make-thumb", { fileRefId: fileId }, user);
|
|
110
|
+
|
|
111
|
+
expect(first.rendered).toBe(true);
|
|
112
|
+
expect(first.mimeType).toBe("image/webp");
|
|
113
|
+
expect(first.format).toBe("webp");
|
|
114
|
+
expect(first.width).toBe(160);
|
|
115
|
+
expect(first.height).toBe(160);
|
|
116
|
+
|
|
117
|
+
const second = await stack.http.writeOk<{ rendered: boolean }>(
|
|
118
|
+
"derivativessharptest:write:make-thumb",
|
|
119
|
+
{ fileRefId: fileId },
|
|
120
|
+
user,
|
|
121
|
+
);
|
|
122
|
+
expect(second.rendered).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Real sharp roundtrips — no mocked pipeline. Fixtures are built with
|
|
2
|
+
// `sharp({ create: {...} })` so every case decodes real bytes end-to-end.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from "bun:test";
|
|
5
|
+
import type { VariantSpec } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
6
|
+
import sharp from "sharp";
|
|
7
|
+
import { imageMetadata, renderImage } from "../render";
|
|
8
|
+
|
|
9
|
+
async function jpegFixture(
|
|
10
|
+
width: number,
|
|
11
|
+
height: number,
|
|
12
|
+
color = { r: 200, g: 40, b: 40 },
|
|
13
|
+
): Promise<Buffer> {
|
|
14
|
+
return sharp({ create: { width, height, channels: 3, background: color } })
|
|
15
|
+
.jpeg()
|
|
16
|
+
.toBuffer();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function orientedJpegFixture(width: number, height: number, orientation: number) {
|
|
20
|
+
return sharp({ create: { width, height, channels: 3, background: { r: 200, g: 40, b: 40 } } })
|
|
21
|
+
.jpeg()
|
|
22
|
+
.withMetadata({ orientation })
|
|
23
|
+
.toBuffer();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function pngFixture(
|
|
27
|
+
width: number,
|
|
28
|
+
height: number,
|
|
29
|
+
color = { r: 40, g: 120, b: 200 },
|
|
30
|
+
): Promise<Buffer> {
|
|
31
|
+
return sharp({ create: { width, height, channels: 3, background: color } })
|
|
32
|
+
.png()
|
|
33
|
+
.toBuffer();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Minimal well-formed SVG, valid input for sharp/librsvg — used to prove the
|
|
37
|
+
// renderer rejects it by sniffing bytes, not by trusting a declared mimeType.
|
|
38
|
+
const SVG_BYTES = new TextEncoder().encode(
|
|
39
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="red"/></svg>',
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// Real bytes just need to decode — the test is about the declared
|
|
43
|
+
// "image/bmp" mimeType having no entry in the source-format encoder map, not
|
|
44
|
+
// about the actual pixel format.
|
|
45
|
+
async function unmappedFormatFixture(width: number, height: number): Promise<Buffer> {
|
|
46
|
+
return sharp({
|
|
47
|
+
create: { width, height, channels: 3, background: { r: 100, g: 100, b: 100 } },
|
|
48
|
+
})
|
|
49
|
+
.tiff()
|
|
50
|
+
.toBuffer();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Two flat-colour halves straddled by the blur test's region, so blurring
|
|
54
|
+
// mixes visibly detectable pixels at the boundary without needing synthetic
|
|
55
|
+
// noise texture inside an otherwise-flat fixture.
|
|
56
|
+
async function twoColorPngFixture(width: number, height: number): Promise<Buffer> {
|
|
57
|
+
const left = { r: 220, g: 20, b: 20 };
|
|
58
|
+
const right = { r: 20, g: 20, b: 220 };
|
|
59
|
+
const rightHalf = await sharp({
|
|
60
|
+
create: { width: width / 2, height, channels: 3, background: right },
|
|
61
|
+
})
|
|
62
|
+
.png()
|
|
63
|
+
.toBuffer();
|
|
64
|
+
return sharp({ create: { width, height, channels: 3, background: left } })
|
|
65
|
+
.composite([{ input: rightHalf, left: width / 2, top: 0 }])
|
|
66
|
+
.png()
|
|
67
|
+
.toBuffer();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// composite() promotes the output to include an alpha channel even when
|
|
71
|
+
// every input was opaque RGB — read the channel count back instead of
|
|
72
|
+
// assuming it, or the byte offsets below silently point at the wrong pixel.
|
|
73
|
+
async function rawPixel(buffer: Uint8Array, x: number, y: number) {
|
|
74
|
+
const { data, info } = await sharp(buffer).raw().toBuffer({ resolveWithObject: true });
|
|
75
|
+
const offset = (y * info.width + x) * info.channels;
|
|
76
|
+
return Array.from(data.subarray(offset, offset + info.channels));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("renderImage — resize/fit", () => {
|
|
80
|
+
test("maxEdge + cover produces an exact square (the thumb preset)", async () => {
|
|
81
|
+
const input = await jpegFixture(400, 200);
|
|
82
|
+
const output = await renderImage(input, { maxEdge: 160, fit: "cover" }, "image/jpeg");
|
|
83
|
+
const meta = await sharp(output).metadata();
|
|
84
|
+
expect(meta.width).toBe(160);
|
|
85
|
+
expect(meta.height).toBe(160);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("size + inside preserves aspect ratio inside the box", async () => {
|
|
89
|
+
const input = await jpegFixture(400, 200);
|
|
90
|
+
const output = await renderImage(
|
|
91
|
+
input,
|
|
92
|
+
{ size: { width: 200, height: 200 }, fit: "inside" },
|
|
93
|
+
"image/jpeg",
|
|
94
|
+
);
|
|
95
|
+
const meta = await sharp(output).metadata();
|
|
96
|
+
expect(meta.width).toBe(200);
|
|
97
|
+
expect(meta.height).toBe(100);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("contain pads to fill the exact box", async () => {
|
|
101
|
+
const input = await jpegFixture(400, 200);
|
|
102
|
+
const output = await renderImage(
|
|
103
|
+
input,
|
|
104
|
+
{ size: { width: 100, height: 100 }, fit: "contain" },
|
|
105
|
+
"image/jpeg",
|
|
106
|
+
);
|
|
107
|
+
const meta = await sharp(output).metadata();
|
|
108
|
+
expect(meta.width).toBe(100);
|
|
109
|
+
expect(meta.height).toBe(100);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("inside with a maxEdge larger than the source never enlarges", async () => {
|
|
113
|
+
const input = await jpegFixture(400, 200);
|
|
114
|
+
const output = await renderImage(input, { maxEdge: 4000, fit: "inside" }, "image/jpeg");
|
|
115
|
+
const meta = await sharp(output).metadata();
|
|
116
|
+
expect(meta.width).toBe(400);
|
|
117
|
+
expect(meta.height).toBe(200);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("renderImage — format contract", () => {
|
|
122
|
+
test("without spec.format the source format is preserved", async () => {
|
|
123
|
+
const input = await jpegFixture(400, 200);
|
|
124
|
+
const output = await renderImage(input, { maxEdge: 100 }, "image/jpeg");
|
|
125
|
+
const meta = await sharp(output).metadata();
|
|
126
|
+
expect(meta.format).toBe("jpeg");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("with spec.format the output switches format", async () => {
|
|
130
|
+
const input = await jpegFixture(400, 200);
|
|
131
|
+
const output = await renderImage(input, { maxEdge: 100, format: "webp" }, "image/jpeg");
|
|
132
|
+
const meta = await sharp(output).metadata();
|
|
133
|
+
expect(meta.format).toBe("webp");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("the image/jpg upload alias is accepted without spec.format", async () => {
|
|
137
|
+
const input = await jpegFixture(400, 200);
|
|
138
|
+
const output = await renderImage(input, { maxEdge: 100 }, "image/jpg");
|
|
139
|
+
const meta = await sharp(output).metadata();
|
|
140
|
+
expect(meta.format).toBe("jpeg");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("renderImage — EXIF", () => {
|
|
145
|
+
test("all EXIF is stripped from the output", async () => {
|
|
146
|
+
const input = await sharp({
|
|
147
|
+
create: { width: 100, height: 100, channels: 3, background: { r: 10, g: 10, b: 10 } },
|
|
148
|
+
})
|
|
149
|
+
.jpeg()
|
|
150
|
+
.withExif({ IFD0: { Copyright: "kumiko-test" } })
|
|
151
|
+
.toBuffer();
|
|
152
|
+
|
|
153
|
+
const output = await renderImage(input, { maxEdge: 100 }, "image/jpeg");
|
|
154
|
+
const meta = await sharp(output).metadata();
|
|
155
|
+
expect(meta.exif).toBeUndefined();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("EXIF orientation is applied (dimensions swap) then normalized away", async () => {
|
|
159
|
+
const input = await orientedJpegFixture(400, 200, 6);
|
|
160
|
+
const output = await renderImage(input, {}, "image/jpeg");
|
|
161
|
+
const meta = await sharp(output).metadata();
|
|
162
|
+
expect(meta.width).toBe(200);
|
|
163
|
+
expect(meta.height).toBe(400);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("renderImage — blurRegions", () => {
|
|
168
|
+
test("burns blur into the region, leaves the rest byte-identical", async () => {
|
|
169
|
+
const input = await twoColorPngFixture(200, 200);
|
|
170
|
+
const baseline = await renderImage(input, {}, "image/png");
|
|
171
|
+
const spec: VariantSpec = {
|
|
172
|
+
blurRegions: [{ x: 0.35, y: 0.25, width: 0.3, height: 0.5 }],
|
|
173
|
+
};
|
|
174
|
+
const blurred = await renderImage(input, spec, "image/png");
|
|
175
|
+
|
|
176
|
+
// Region centre sits on the red/blue boundary (x≈0.5) — blur mixes the
|
|
177
|
+
// two colours there, so it must differ from the untouched baseline.
|
|
178
|
+
const centreBaseline = await rawPixel(baseline, 100, 100);
|
|
179
|
+
const centreBlurred = await rawPixel(blurred, 100, 100);
|
|
180
|
+
expect(centreBlurred).not.toEqual(centreBaseline);
|
|
181
|
+
|
|
182
|
+
// Far outside the region: composite never touched these pixels.
|
|
183
|
+
const farBaseline = await rawPixel(baseline, 10, 10);
|
|
184
|
+
const farBlurred = await rawPixel(blurred, 10, 10);
|
|
185
|
+
expect(farBlurred).toEqual(farBaseline);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("renderImage — validation", () => {
|
|
190
|
+
test("blur sigma below sharp's supported range throws", async () => {
|
|
191
|
+
const input = await jpegFixture(50, 50);
|
|
192
|
+
await expect(renderImage(input, { blur: 0.1 }, "image/jpeg")).rejects.toThrow(/blur sigma/);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("quality out of range throws", async () => {
|
|
196
|
+
const input = await jpegFixture(50, 50);
|
|
197
|
+
await expect(renderImage(input, { quality: 0 }, "image/jpeg")).rejects.toThrow(/quality/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("real SVG bytes are rejected even with a spoofed sourceMimeType", async () => {
|
|
201
|
+
await expect(renderImage(SVG_BYTES, {}, "image/jpeg")).rejects.toThrow(/svg/i);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("real PNG bytes with a spoofed sourceMimeType encode as the declared type", async () => {
|
|
205
|
+
const input = await pngFixture(50, 50);
|
|
206
|
+
const output = await renderImage(input, {}, "image/jpeg");
|
|
207
|
+
const meta = await sharp(output).metadata();
|
|
208
|
+
expect(meta.format).toBe("jpeg");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("a source mimeType with no encoder mapping throws unless spec.format is set", async () => {
|
|
212
|
+
const input = await unmappedFormatFixture(50, 50);
|
|
213
|
+
await expect(renderImage(input, {}, "image/bmp")).rejects.toThrow(/no output encoder/);
|
|
214
|
+
|
|
215
|
+
const output = await renderImage(input, { format: "webp" }, "image/bmp");
|
|
216
|
+
const meta = await sharp(output).metadata();
|
|
217
|
+
expect(meta.format).toBe("webp");
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("imageMetadata", () => {
|
|
222
|
+
test("returns width/height/format", async () => {
|
|
223
|
+
const input = await jpegFixture(320, 240);
|
|
224
|
+
const meta = await imageMetadata(input);
|
|
225
|
+
expect(meta).toEqual({ width: 320, height: 240, format: "jpeg" });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("returns auto-oriented dimensions for EXIF orientation 6", async () => {
|
|
229
|
+
const input = await orientedJpegFixture(400, 200, 6);
|
|
230
|
+
const meta = await imageMetadata(input);
|
|
231
|
+
expect(meta.width).toBe(200);
|
|
232
|
+
expect(meta.height).toBe(400);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// kumiko-feature-version: 1
|
|
2
|
+
|
|
3
|
+
import { defineFeature, EXT_DERIVATIVE_RENDERER } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import type { DerivativeRendererPlugin } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
5
|
+
import { renderImage } from "./render";
|
|
6
|
+
|
|
7
|
+
const FEATURE_NAME = "derivatives-sharp";
|
|
8
|
+
|
|
9
|
+
export const derivativesSharpFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
10
|
+
r.describe(
|
|
11
|
+
"Registers an `image/*` renderer against the `derivativeRenderer` extension point declared by `file-derivatives`, backed by sharp. Supports resize/fit (cover, inside, contain), format conversion with quality (webp, avif, jpeg), whole-image blur, and blurring individual regions burned in before resize (see BlurRegion). EXIF orientation is applied and then normalized away, and all other EXIF (including GPS) is dropped. Server-only: sharp is a native binding — never import this from client code.",
|
|
12
|
+
);
|
|
13
|
+
r.uiHints({
|
|
14
|
+
displayLabel: "File Derivatives · Sharp",
|
|
15
|
+
category: "storage",
|
|
16
|
+
recommended: false,
|
|
17
|
+
});
|
|
18
|
+
r.requires("file-derivatives");
|
|
19
|
+
|
|
20
|
+
const plugin: DerivativeRendererPlugin = { render: renderImage };
|
|
21
|
+
r.useExtension(EXT_DERIVATIVE_RENDERER, "image/*", plugin);
|
|
22
|
+
});
|