@manablox/media 0.2.0 → 0.3.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/src/edits.ts DELETED
@@ -1,112 +0,0 @@
1
- import {
2
- type AssetCrop,
3
- type AssetImageEdits,
4
- type FocalPoint,
5
- ManabloxError,
6
- } from '@manablox/core';
7
-
8
- /** The edits kept in an asset's `meta`, read leniently so an odd value is ignored, not fatal. */
9
- export function readImageEdits(meta: Record<string, unknown> | null | undefined): AssetImageEdits {
10
- const edits: AssetImageEdits = {};
11
- const crop = meta?.crop as Partial<AssetCrop> | undefined;
12
- if (crop && [crop.left, crop.top, crop.width, crop.height].every(isFiniteNumber)) {
13
- edits.crop = {
14
- left: crop.left as number,
15
- top: crop.top as number,
16
- width: crop.width as number,
17
- height: crop.height as number,
18
- };
19
- }
20
- const focal = meta?.focalPoint as Partial<FocalPoint> | undefined;
21
- if (focal && isFiniteNumber(focal.x) && isFiniteNumber(focal.y)) {
22
- edits.focalPoint = { x: focal.x as number, y: focal.y as number };
23
- }
24
- return edits;
25
- }
26
-
27
- /**
28
- * Checks edits against the image they apply to. A crop must be a whole-pixel rectangle
29
- * inside the image; a focal point is a fraction of the *cropped* image, so it is always
30
- * inside the unit square whatever the crop.
31
- */
32
- export function validateImageEdits(
33
- edits: AssetImageEdits,
34
- image: { width: number; height: number },
35
- ): AssetImageEdits {
36
- const out: AssetImageEdits = {};
37
-
38
- if (edits.crop) {
39
- const crop = edits.crop;
40
- const whole = [crop.left, crop.top, crop.width, crop.height].every(Number.isInteger);
41
- const inside =
42
- crop.left >= 0 &&
43
- crop.top >= 0 &&
44
- crop.width >= 1 &&
45
- crop.height >= 1 &&
46
- crop.left + crop.width <= image.width &&
47
- crop.top + crop.height <= image.height;
48
- if (!whole || !inside) {
49
- throw ManabloxError.badRequest('asset.crop.outOfBounds', { ...crop, ...image });
50
- }
51
- // A crop that is the whole image is no crop at all.
52
- if (
53
- crop.left !== 0 ||
54
- crop.top !== 0 ||
55
- crop.width !== image.width ||
56
- crop.height !== image.height
57
- ) {
58
- out.crop = crop;
59
- }
60
- }
61
-
62
- if (edits.focalPoint) {
63
- const { x, y } = edits.focalPoint;
64
- if (!(x >= 0 && x <= 1 && y >= 0 && y <= 1)) {
65
- throw ManabloxError.badRequest('asset.focalPoint.outOfBounds', { x, y });
66
- }
67
- out.focalPoint = { x: round3(x), y: round3(y) };
68
- }
69
-
70
- return out;
71
- }
72
-
73
- /**
74
- * The window of a source image that a `cover` resize to `target` keeps, placed so the
75
- * focal point stays in view — as close to the window's centre as the edges allow.
76
- *
77
- * `cover` scales the image until both target sides are covered and trims the rest;
78
- * which part is trimmed is the whole point of a focal point. Sharp's own `position`
79
- * only knows edges and centres, so the window is computed here and extracted before
80
- * the resize.
81
- */
82
- export function coverWindow(
83
- source: { width: number; height: number },
84
- target: { width: number; height: number },
85
- focal: FocalPoint = { x: 0.5, y: 0.5 },
86
- ): AssetCrop {
87
- const ratio = target.width / target.height;
88
- let width = source.width;
89
- let height = Math.round(source.width / ratio);
90
- if (height > source.height) {
91
- height = source.height;
92
- width = Math.round(source.height * ratio);
93
- }
94
- width = Math.max(1, Math.min(width, source.width));
95
- height = Math.max(1, Math.min(height, source.height));
96
-
97
- const left = clamp(Math.round(focal.x * source.width - width / 2), 0, source.width - width);
98
- const top = clamp(Math.round(focal.y * source.height - height / 2), 0, source.height - height);
99
- return { left, top, width, height };
100
- }
101
-
102
- function clamp(value: number, min: number, max: number): number {
103
- return Math.min(Math.max(value, min), max);
104
- }
105
-
106
- function round3(value: number): number {
107
- return Math.round(value * 1000) / 1000;
108
- }
109
-
110
- function isFiniteNumber(value: unknown): value is number {
111
- return typeof value === 'number' && Number.isFinite(value);
112
- }
package/src/index.ts DELETED
@@ -1,5 +0,0 @@
1
- export * from './edits.js';
2
- export * from './limits.js';
3
- export * from './service.js';
4
- export * from './signing.js';
5
- export * from './urls.js';
package/src/limits.ts DELETED
@@ -1,56 +0,0 @@
1
- import {
2
- mimeTypeAllowed,
3
- type SpaceAssetSettings,
4
- type StorageConfig,
5
- withinInstance,
6
- } from '@manablox/core';
7
-
8
- export interface AssetLimits {
9
- /** Empty means every type the instance allows. */
10
- allowedMimeTypes: string[];
11
- maxFileSize: number;
12
- }
13
-
14
- export { mimeTypeAllowed, withinInstance };
15
-
16
- /**
17
- * The limits an upload into a space is held to: the space's own where it has set them,
18
- * otherwise the instance's. A space setting never widens — an entry outside the
19
- * instance's allowlist is dropped, and a larger size is capped.
20
- */
21
- export function resolveAssetLimits(
22
- instance: Pick<StorageConfig, 'allowedMimeTypes' | 'maxFileSize'>,
23
- space: SpaceAssetSettings | null | undefined,
24
- fallbackMaxFileSize = 25 * 1024 * 1024,
25
- ): AssetLimits {
26
- const instanceTypes = instance.allowedMimeTypes ?? [];
27
- const instanceMax = instance.maxFileSize ?? fallbackMaxFileSize;
28
-
29
- const allowedMimeTypes = space?.allowedMimeTypes
30
- ? space.allowedMimeTypes.filter((entry) => withinInstance(entry, instanceTypes))
31
- : instanceTypes;
32
- const maxFileSize =
33
- space?.maxFileSize && space.maxFileSize > 0
34
- ? Math.min(space.maxFileSize, instanceMax)
35
- : instanceMax;
36
-
37
- return { allowedMimeTypes, maxFileSize };
38
- }
39
-
40
- /** Reads the space's asset settings out of its free-form settings, leniently. */
41
- export function readSpaceAssetSettings(
42
- settings: Record<string, unknown> | null | undefined,
43
- ): SpaceAssetSettings | null {
44
- const block = settings?.assets as Partial<SpaceAssetSettings> | undefined;
45
- if (!block || typeof block !== 'object') return null;
46
- const out: SpaceAssetSettings = {};
47
- if (Array.isArray(block.allowedMimeTypes)) {
48
- out.allowedMimeTypes = block.allowedMimeTypes.filter(
49
- (entry): entry is string => typeof entry === 'string',
50
- );
51
- }
52
- if (typeof block.maxFileSize === 'number' && block.maxFileSize > 0) {
53
- out.maxFileSize = block.maxFileSize;
54
- }
55
- return out;
56
- }
package/src/service.ts DELETED
@@ -1,350 +0,0 @@
1
- import { createHash } from 'node:crypto';
2
- import {
3
- type AssetImageEdits,
4
- ManabloxError,
5
- type MediaConfig,
6
- type MediaPreset,
7
- } from '@manablox/core';
8
- import type { AssetRow, Repositories } from '@manablox/db';
9
- import { buildStorageKey, type StorageDriver } from '@manablox/storage';
10
- import sharp, { type Metadata } from 'sharp';
11
- import { coverWindow, readImageEdits, validateImageEdits } from './edits.js';
12
- import {
13
- type AssetLimits,
14
- mimeTypeAllowed,
15
- readSpaceAssetSettings,
16
- resolveAssetLimits,
17
- } from './limits.js';
18
- import { signTransform, transformPath, verifyTransform } from './signing.js';
19
-
20
- export interface UploadInput {
21
- spaceId: string;
22
- filename: string;
23
- mimeType: string;
24
- body: Buffer;
25
- alt?: string;
26
- title?: string;
27
- actorId?: string | null;
28
- }
29
-
30
- export type PresentedAsset = AssetRow & { url: string; thumbnailUrl: string | null };
31
-
32
- export interface MediaServiceOptions {
33
- maxFileSize: number;
34
- allowedMimeTypes: string[];
35
- }
36
-
37
- const IMAGE_FORMATS = new Set(['avif', 'webp', 'jpeg', 'png']);
38
-
39
- /** Upload, probing, and on-demand image derivatives. */
40
- export class MediaService {
41
- constructor(
42
- private readonly repos: Repositories,
43
- private readonly storage: StorageDriver,
44
- private readonly config: MediaConfig & { signingSecret?: string },
45
- private readonly options: MediaServiceOptions,
46
- ) {}
47
-
48
- /**
49
- * The limits an upload into `spaceId` is held to: the space's own settings, narrowed
50
- * to the instance's. The instance's are returned alongside so a settings form can show
51
- * the ceiling it cannot exceed.
52
- */
53
- async limits(spaceId: string): Promise<{ effective: AssetLimits; instance: AssetLimits }> {
54
- const space = await this.repos.spaces.findById(spaceId);
55
- const instance = resolveAssetLimits(this.options, null, this.options.maxFileSize);
56
- const effective = resolveAssetLimits(
57
- this.options,
58
- readSpaceAssetSettings(space?.settings),
59
- this.options.maxFileSize,
60
- );
61
- return { effective, instance };
62
- }
63
-
64
- async upload(input: UploadInput): Promise<AssetRow> {
65
- const { effective: limits } = await this.limits(input.spaceId);
66
- if (input.body.byteLength > limits.maxFileSize) {
67
- throw ManabloxError.badRequest('asset.tooLarge', {
68
- size: input.body.byteLength,
69
- max: limits.maxFileSize,
70
- });
71
- }
72
-
73
- // Trust the bytes, not the client's Content-Type: a caller can label a script as
74
- // `image/png`. The magic-number probe is what decides.
75
- const detected = await detectMimeType(input.body, input.mimeType);
76
- if (!mimeTypeAllowed(detected, limits.allowedMimeTypes)) {
77
- throw ManabloxError.badRequest('asset.mimeType.notAllowed', { mimeType: detected });
78
- }
79
-
80
- const checksum = createHash('sha256').update(input.body).digest('hex');
81
- const existing = await this.repos.assets.findByChecksum(input.spaceId, checksum);
82
- if (existing) return existing;
83
-
84
- const key = buildStorageKey(input.spaceId, input.filename);
85
- await this.storage.put(key, input.body, { contentType: detected });
86
-
87
- const dimensions = await probeImage(input.body, detected);
88
-
89
- const asset = await this.repos.assets.create({
90
- spaceId: input.spaceId,
91
- driver: this.storage.name,
92
- key,
93
- filename: key.split('/').pop() ?? input.filename,
94
- name: input.filename.replace(/\.[^.]+$/, ''),
95
- mimeType: detected,
96
- size: input.body.byteLength,
97
- width: dimensions?.width ?? null,
98
- height: dimensions?.height ?? null,
99
- checksum,
100
- alt: input.alt ?? null,
101
- title: input.title ?? null,
102
- actorId: input.actorId ?? null,
103
- });
104
-
105
- // Eager presets keep the first page view off the transform path.
106
- for (const preset of this.config.eager ?? []) {
107
- if (this.config.presets?.[preset] && dimensions) {
108
- await this.derive(asset, preset, this.config.presets[preset]?.format ?? 'webp').catch(
109
- () => undefined,
110
- );
111
- }
112
- }
113
-
114
- return asset;
115
- }
116
-
117
- /** Returns bytes for a preset, generating and caching the variant on first request. */
118
- async derive(
119
- asset: AssetRow,
120
- presetName: string,
121
- format: string,
122
- ): Promise<{ body: Buffer; contentType: string }> {
123
- const preset = this.config.presets?.[presetName];
124
- if (!preset) throw ManabloxError.notFound('media.preset.notFound', { preset: presetName });
125
- if (!IMAGE_FORMATS.has(format)) {
126
- throw ManabloxError.badRequest('media.format.unsupported', { format });
127
- }
128
- if (!asset.mimeType.startsWith('image/')) {
129
- throw ManabloxError.badRequest('media.notAnImage', { assetId: asset.id });
130
- }
131
-
132
- const variantKey = `${asset.key}.${presetName}.${format}`;
133
- const cached = await this.repos.assets.findVariant(asset.id, presetName, format);
134
- if (cached && (await this.storage.exists(cached.key))) {
135
- return { body: await this.storage.get(cached.key), contentType: `image/${format}` };
136
- }
137
-
138
- const source = await this.storage.get(asset.key);
139
- const body = await transform(source, preset, format, readImageEdits(asset.meta));
140
- const meta = await sharp(body).metadata();
141
-
142
- await this.storage.put(variantKey, body, {
143
- contentType: `image/${format}`,
144
- cacheControl: 'public, max-age=31536000, immutable',
145
- });
146
-
147
- await this.repos.assets.addVariant({
148
- assetId: asset.id,
149
- preset: presetName,
150
- format,
151
- key: variantKey,
152
- width: meta.width ?? null,
153
- height: meta.height ?? null,
154
- size: body.byteLength,
155
- });
156
-
157
- return { body, contentType: `image/${format}` };
158
- }
159
-
160
- /**
161
- * Public URL for a preset — the storage driver's own URL, or a signed transform path.
162
- *
163
- * A variant URL carries the asset's version, so a crop or focal-point change reaches a
164
- * browser or CDN that cached the previous rendering as immutable. The original is
165
- * never edited, so its URL stays put.
166
- */
167
- /**
168
- * An asset as a client receives it: the row plus the URLs it needs to show it. The
169
- * thumbnail is a rendered variant, which only an image has.
170
- */
171
- present(asset: AssetRow): PresentedAsset {
172
- return {
173
- ...asset,
174
- url: this.urlFor(asset),
175
- thumbnailUrl: asset.mimeType.startsWith('image/')
176
- ? this.urlFor(asset, 'thumb', 'webp')
177
- : null,
178
- };
179
- }
180
-
181
- urlFor(asset: AssetRow, presetName?: string, format = 'webp'): string {
182
- if (!presetName) return this.storage.url(asset.key) ?? `/media/${asset.id}/original`;
183
-
184
- const version = `v=${asset.updatedAt.getTime().toString(36)}`;
185
- const secret = this.config.signingSecret;
186
- if (!secret) return `/media/${asset.id}/${presetName}.${format}?${version}`;
187
-
188
- const request = { assetId: asset.id, preset: presetName, format };
189
- return `${transformPath(request, signTransform(secret, request))}&${version}`;
190
- }
191
-
192
- /**
193
- * Sets an image's crop and focal point. Non-destructive: the original stays as
194
- * uploaded, the edits live in `meta`, and every variant derived so far is dropped so
195
- * the next request renders through the new edits.
196
- */
197
- async setImageEdits(assetId: string, edits: AssetImageEdits): Promise<AssetRow> {
198
- const asset = await this.repos.assets.findById(assetId);
199
- if (!asset) throw ManabloxError.notFound('asset.notFound', { id: assetId });
200
- if (!asset.mimeType.startsWith('image/') || !asset.width || !asset.height) {
201
- throw ManabloxError.badRequest('asset.notAnImage', { id: assetId });
202
- }
203
-
204
- const valid = validateImageEdits(edits, { width: asset.width, height: asset.height });
205
- const meta = { ...asset.meta };
206
- if (valid.crop) meta.crop = valid.crop;
207
- else delete meta.crop;
208
- if (valid.focalPoint) meta.focalPoint = valid.focalPoint;
209
- else delete meta.focalPoint;
210
-
211
- const variants = await this.repos.assets.variants([assetId]);
212
- await Promise.all(
213
- variants.map((variant) => this.storage.delete(variant.key).catch(() => undefined)),
214
- );
215
- await this.repos.assets.deleteVariants(assetId);
216
-
217
- return this.repos.assets.update(assetId, { meta });
218
- }
219
-
220
- verify(assetId: string, preset: string, format: string, signature: string | undefined): boolean {
221
- const secret = this.config.signingSecret;
222
- if (!secret) return true;
223
- if (!signature) return false;
224
- return verifyTransform(secret, { assetId, preset, format }, signature);
225
- }
226
-
227
- async delete(assetId: string): Promise<void> {
228
- const asset = await this.repos.assets.findById(assetId);
229
- if (!asset) return;
230
-
231
- const variants = await this.repos.assets.variants([assetId]);
232
- await Promise.all(
233
- variants.map((variant) => this.storage.delete(variant.key).catch(() => undefined)),
234
- );
235
- await this.storage.delete(asset.key).catch(() => undefined);
236
- await this.repos.assets.delete(assetId);
237
- }
238
- }
239
-
240
- /**
241
- * Applies the preset through the asset's edits: the crop first, then — for a preset
242
- * that fixes both sides with `cover` — the window around the focal point, then the
243
- * resize. Both extractions fold into one rectangle, since sharp takes a single one
244
- * ahead of a resize.
245
- */
246
- async function transform(
247
- source: Buffer,
248
- preset: MediaPreset,
249
- format: string,
250
- edits: AssetImageEdits = {},
251
- ): Promise<Buffer> {
252
- const pipeline = sharp(source, { failOn: 'error' }).rotate();
253
-
254
- const meta = await pipeline.metadata();
255
- const oriented = orientedSize(meta);
256
- let region = edits.crop ?? null;
257
- const size = region ? { width: region.width, height: region.height } : oriented;
258
-
259
- if (preset.width && preset.height && (preset.fit ?? 'inside') === 'cover' && size) {
260
- const window = coverWindow(
261
- size,
262
- { width: preset.width, height: preset.height },
263
- edits.focalPoint ?? undefined,
264
- );
265
- region = {
266
- left: (region?.left ?? 0) + window.left,
267
- top: (region?.top ?? 0) + window.top,
268
- width: window.width,
269
- height: window.height,
270
- };
271
- }
272
-
273
- if (region) pipeline.extract(region);
274
-
275
- if (preset.width || preset.height) {
276
- pipeline.resize({
277
- ...(preset.width ? { width: preset.width } : {}),
278
- ...(preset.height ? { height: preset.height } : {}),
279
- fit: preset.fit ?? 'inside',
280
- withoutEnlargement: true,
281
- });
282
- }
283
-
284
- const quality = preset.quality ?? 82;
285
- switch (format) {
286
- case 'avif':
287
- return pipeline.avif({ quality }).toBuffer();
288
- case 'png':
289
- return pipeline.png().toBuffer();
290
- case 'jpeg':
291
- return pipeline.jpeg({ quality, mozjpeg: true }).toBuffer();
292
- default:
293
- return pipeline.webp({ quality }).toBuffer();
294
- }
295
- }
296
-
297
- async function probeImage(body: Buffer, mimeType: string) {
298
- if (!mimeType.startsWith('image/')) return null;
299
- try {
300
- return orientedSize(await sharp(body).metadata());
301
- } catch {
302
- return null;
303
- }
304
- }
305
-
306
- /**
307
- * The size as displayed, not as stored: a phone photo carries an EXIF orientation that
308
- * `rotate()` honours, so a crop drawn on the displayed image must be measured against
309
- * the rotated dimensions or it lands on the wrong part of the picture.
310
- */
311
- function orientedSize(meta: Metadata): { width: number; height: number } | null {
312
- if (!meta.width || !meta.height) return null;
313
- const swapped = (meta.orientation ?? 1) >= 5;
314
- return swapped
315
- ? { width: meta.height, height: meta.width }
316
- : { width: meta.width, height: meta.height };
317
- }
318
-
319
- /** Magic-number sniffing for the formats worth trusting; falls back to the declared type. */
320
- async function detectMimeType(body: Buffer, declared: string): Promise<string> {
321
- const signatures: Array<[string, number[]]> = [
322
- ['image/png', [0x89, 0x50, 0x4e, 0x47]],
323
- ['image/jpeg', [0xff, 0xd8, 0xff]],
324
- ['image/gif', [0x47, 0x49, 0x46, 0x38]],
325
- ['application/pdf', [0x25, 0x50, 0x44, 0x46]],
326
- ];
327
-
328
- for (const [mimeType, bytes] of signatures) {
329
- if (bytes.every((byte, index) => body[index] === byte)) return mimeType;
330
- }
331
-
332
- // RIFF....WEBP
333
- if (
334
- body.subarray(0, 4).toString('ascii') === 'RIFF' &&
335
- body.subarray(8, 12).toString('ascii') === 'WEBP'
336
- ) {
337
- return 'image/webp';
338
- }
339
- // ISO-BMFF brands: AVIF and HEIC share the ftyp box.
340
- if (body.subarray(4, 8).toString('ascii') === 'ftyp') {
341
- const brand = body.subarray(8, 12).toString('ascii');
342
- if (brand.startsWith('avif')) return 'image/avif';
343
- if (brand.startsWith('heic') || brand.startsWith('mif1')) return 'image/heic';
344
- if (brand.startsWith('isom') || brand.startsWith('mp4')) return 'video/mp4';
345
- }
346
-
347
- // Anything unrecognised keeps its declared type but can still be rejected by the
348
- // allowlist — never silently upgraded to something more privileged.
349
- return declared;
350
- }
package/src/signing.ts DELETED
@@ -1,35 +0,0 @@
1
- import { createHmac, timingSafeEqual } from 'node:crypto';
2
-
3
- export interface TransformRequest {
4
- assetId: string;
5
- preset: string;
6
- format: string;
7
- }
8
-
9
- /**
10
- * Signed transform URLs.
11
- *
12
- * Without a signature, an arbitrary `?w=&h=` endpoint is a denial-of-service amplifier:
13
- * anyone can ask for thousands of distinct resizes and force the server to decode the
14
- * source image each time. Signing means only URLs the CMS emitted are honoured.
15
- */
16
- export function signTransform(secret: string, request: TransformRequest): string {
17
- return createHmac('sha256', secret)
18
- .update(`${request.assetId}:${request.preset}:${request.format}`)
19
- .digest('base64url')
20
- .slice(0, 32);
21
- }
22
-
23
- export function verifyTransform(
24
- secret: string,
25
- request: TransformRequest,
26
- signature: string,
27
- ): boolean {
28
- const expected = Buffer.from(signTransform(secret, request));
29
- const actual = Buffer.from(signature);
30
- return expected.length === actual.length && timingSafeEqual(expected, actual);
31
- }
32
-
33
- export function transformPath(request: TransformRequest, signature: string): string {
34
- return `/media/${request.assetId}/${request.preset}.${request.format}?s=${signature}`;
35
- }
package/src/urls.ts DELETED
@@ -1,16 +0,0 @@
1
- /**
2
- * Makes a media URL absolute against the instance's public origin.
3
- *
4
- * `MediaService.urlFor` returns a storage-driver URL when there is one (S3, a CDN) and a
5
- * root-relative path otherwise (`/media/…`). A relative path is fine for a same-origin
6
- * admin and broken for everything the delivery API exists to serve: a frontend on its own
7
- * domain resolves `/media/…` against *itself* and gets a 404. Found by driving
8
- * `apps/example-plain` in a browser against a public instance on another port.
9
- *
10
- * Already-absolute URLs, protocol-relative URLs and data URIs are returned untouched.
11
- */
12
- export function absoluteMediaUrl(url: string, base: string | undefined): string {
13
- if (!base) return url;
14
- if (!url.startsWith('/') || url.startsWith('//')) return url;
15
- return `${base.replace(/\/+$/, '')}${url}`;
16
- }
@@ -1,65 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { coverWindow, readImageEdits, validateImageEdits } from '../src/edits.js';
3
-
4
- describe('coverWindow', () => {
5
- it('keeps the whole width of a wide source for a wider target and centres by default', () => {
6
- // 4000×3000 into 16:9 — the height is what gets trimmed.
7
- const window = coverWindow({ width: 4000, height: 3000 }, { width: 1600, height: 900 });
8
- expect(window).toEqual({ left: 0, top: 375, width: 4000, height: 2250 });
9
- });
10
-
11
- it('follows the focal point', () => {
12
- const source = { width: 4000, height: 3000 };
13
- const target = { width: 1600, height: 900 };
14
- expect(coverWindow(source, target, { x: 0.5, y: 0 })).toMatchObject({ top: 0 });
15
- expect(coverWindow(source, target, { x: 0.5, y: 1 })).toMatchObject({ top: 750 });
16
- expect(coverWindow(source, target, { x: 0.5, y: 0.25 })).toMatchObject({ top: 0 });
17
- });
18
-
19
- it('never lets the window leave the image', () => {
20
- const window = coverWindow(
21
- { width: 1000, height: 1000 },
22
- { width: 100, height: 300 },
23
- { x: 0.02, y: 0.9 },
24
- );
25
- expect(window.left).toBe(0);
26
- expect(window.left + window.width).toBeLessThanOrEqual(1000);
27
- expect(window.top + window.height).toBeLessThanOrEqual(1000);
28
- expect(window).toMatchObject({ width: 333, height: 1000 });
29
- });
30
- });
31
-
32
- describe('validateImageEdits', () => {
33
- const image = { width: 800, height: 600 };
34
-
35
- it('refuses a crop that leaves the image or is not whole pixels', () => {
36
- expect(() =>
37
- validateImageEdits({ crop: { left: 700, top: 0, width: 200, height: 100 } }, image),
38
- ).toThrow(/asset.crop.outOfBounds/);
39
- expect(() =>
40
- validateImageEdits({ crop: { left: 0.5, top: 0, width: 200, height: 100 } }, image),
41
- ).toThrow(/asset.crop.outOfBounds/);
42
- });
43
-
44
- it('treats a crop of the whole image as no crop', () => {
45
- expect(validateImageEdits({ crop: { left: 0, top: 0, ...image } }, image)).toEqual({});
46
- });
47
-
48
- it('keeps a focal point inside the unit square, rounded', () => {
49
- expect(validateImageEdits({ focalPoint: { x: 0.33333, y: 1 } }, image)).toEqual({
50
- focalPoint: { x: 0.333, y: 1 },
51
- });
52
- expect(() => validateImageEdits({ focalPoint: { x: 1.2, y: 0 } }, image)).toThrow(
53
- /asset.focalPoint.outOfBounds/,
54
- );
55
- });
56
- });
57
-
58
- describe('readImageEdits', () => {
59
- it('ignores malformed values rather than failing a render', () => {
60
- expect(readImageEdits({ crop: { left: 'a' }, focalPoint: { x: 0.2, y: 0.4 } })).toEqual({
61
- focalPoint: { x: 0.2, y: 0.4 },
62
- });
63
- expect(readImageEdits(null)).toEqual({});
64
- });
65
- });
@@ -1,43 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { mimeTypeAllowed, resolveAssetLimits, withinInstance } from '../src/limits.js';
3
-
4
- const instance = { allowedMimeTypes: ['image/', 'application/pdf'], maxFileSize: 10_000 };
5
-
6
- describe('resolveAssetLimits', () => {
7
- it('falls back to the instance when the space sets nothing', () => {
8
- expect(resolveAssetLimits(instance, null)).toEqual({
9
- allowedMimeTypes: ['image/', 'application/pdf'],
10
- maxFileSize: 10_000,
11
- });
12
- });
13
-
14
- it('lets a space narrow, and drops what would widen', () => {
15
- const limits = resolveAssetLimits(instance, {
16
- allowedMimeTypes: ['image/png', 'video/'],
17
- maxFileSize: 50_000,
18
- });
19
- expect(limits).toEqual({ allowedMimeTypes: ['image/png'], maxFileSize: 10_000 });
20
- });
21
-
22
- it('reads an instance with no allowlist as everything', () => {
23
- expect(resolveAssetLimits({ maxFileSize: 5 }, { allowedMimeTypes: ['video/'] })).toEqual({
24
- allowedMimeTypes: ['video/'],
25
- maxFileSize: 5,
26
- });
27
- });
28
- });
29
-
30
- describe('withinInstance / mimeTypeAllowed', () => {
31
- it('a family is inside only as a whole family; an exact type through its family', () => {
32
- expect(withinInstance('image/', ['image/'])).toBe(true);
33
- expect(withinInstance('image/', ['image/png'])).toBe(false);
34
- expect(withinInstance('image/png', ['image/'])).toBe(true);
35
- expect(withinInstance('image/png', ['image/jpeg'])).toBe(false);
36
- });
37
-
38
- it('matches uploads by exact type or family', () => {
39
- expect(mimeTypeAllowed('image/webp', ['image/'])).toBe(true);
40
- expect(mimeTypeAllowed('image/webp', ['image/png'])).toBe(false);
41
- expect(mimeTypeAllowed('text/plain', [])).toBe(true);
42
- });
43
- });