@manablox/media 0.1.0 → 0.2.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/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # `@manablox/media`
2
+
3
+ Uploads and image transforms: validating a file, storing it through the storage driver, recording the asset, deriving presets with sharp, and signing transform URLs so a public consumer cannot mint a resize.
4
+
5
+ ## Exports
6
+
7
+ - `MediaService`
8
+ - `signVariant`, `verifyVariant`
9
+ - `absoluteMediaUrl`
10
+
11
+ ## Depends on
12
+
13
+ @manablox/db, @manablox/storage, sharp
14
+
15
+ ## Test
16
+
17
+ ```sh
18
+ pnpm --filter @manablox/media test
19
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manablox/media",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -11,9 +11,9 @@
11
11
  "main": "./src/index.ts",
12
12
  "types": "./src/index.ts",
13
13
  "dependencies": {
14
- "@manablox/core": "0.1.0",
15
- "@manablox/db": "0.1.0",
16
- "@manablox/storage": "0.1.0",
14
+ "@manablox/core": "0.2.0",
15
+ "@manablox/db": "0.2.0",
16
+ "@manablox/storage": "0.2.0",
17
17
  "sharp": "^0.35.4"
18
18
  },
19
19
  "devDependencies": {
package/src/edits.ts ADDED
@@ -0,0 +1,112 @@
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 CHANGED
@@ -1,3 +1,5 @@
1
+ export * from './edits.js';
2
+ export * from './limits.js';
1
3
  export * from './service.js';
2
4
  export * from './signing.js';
3
5
  export * from './urls.js';
package/src/limits.ts ADDED
@@ -0,0 +1,56 @@
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 CHANGED
@@ -1,8 +1,20 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { ManabloxError, type MediaConfig, type MediaPreset } from '@manablox/core';
2
+ import {
3
+ type AssetImageEdits,
4
+ ManabloxError,
5
+ type MediaConfig,
6
+ type MediaPreset,
7
+ } from '@manablox/core';
3
8
  import type { AssetRow, Repositories } from '@manablox/db';
4
9
  import { buildStorageKey, type StorageDriver } from '@manablox/storage';
5
- import sharp from 'sharp';
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';
6
18
  import { signTransform, transformPath, verifyTransform } from './signing.js';
7
19
 
8
20
  export interface UploadInput {
@@ -15,6 +27,8 @@ export interface UploadInput {
15
27
  actorId?: string | null;
16
28
  }
17
29
 
30
+ export type PresentedAsset = AssetRow & { url: string; thumbnailUrl: string | null };
31
+
18
32
  export interface MediaServiceOptions {
19
33
  maxFileSize: number;
20
34
  allowedMimeTypes: string[];
@@ -31,18 +45,35 @@ export class MediaService {
31
45
  private readonly options: MediaServiceOptions,
32
46
  ) {}
33
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
+
34
64
  async upload(input: UploadInput): Promise<AssetRow> {
35
- if (input.body.byteLength > this.options.maxFileSize) {
65
+ const { effective: limits } = await this.limits(input.spaceId);
66
+ if (input.body.byteLength > limits.maxFileSize) {
36
67
  throw ManabloxError.badRequest('asset.tooLarge', {
37
68
  size: input.body.byteLength,
38
- max: this.options.maxFileSize,
69
+ max: limits.maxFileSize,
39
70
  });
40
71
  }
41
72
 
42
73
  // Trust the bytes, not the client's Content-Type: a caller can label a script as
43
74
  // `image/png`. The magic-number probe is what decides.
44
75
  const detected = await detectMimeType(input.body, input.mimeType);
45
- if (!this.isAllowed(detected)) {
76
+ if (!mimeTypeAllowed(detected, limits.allowedMimeTypes)) {
46
77
  throw ManabloxError.badRequest('asset.mimeType.notAllowed', { mimeType: detected });
47
78
  }
48
79
 
@@ -105,7 +136,7 @@ export class MediaService {
105
136
  }
106
137
 
107
138
  const source = await this.storage.get(asset.key);
108
- const body = await transform(source, preset, format);
139
+ const body = await transform(source, preset, format, readImageEdits(asset.meta));
109
140
  const meta = await sharp(body).metadata();
110
141
 
111
142
  await this.storage.put(variantKey, body, {
@@ -126,15 +157,64 @@ export class MediaService {
126
157
  return { body, contentType: `image/${format}` };
127
158
  }
128
159
 
129
- /** Public URL for a preset — the storage driver's own URL, or a signed transform path. */
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
+
130
181
  urlFor(asset: AssetRow, presetName?: string, format = 'webp'): string {
131
182
  if (!presetName) return this.storage.url(asset.key) ?? `/media/${asset.id}/original`;
132
183
 
184
+ const version = `v=${asset.updatedAt.getTime().toString(36)}`;
133
185
  const secret = this.config.signingSecret;
134
- if (!secret) return `/media/${asset.id}/${presetName}.${format}`;
186
+ if (!secret) return `/media/${asset.id}/${presetName}.${format}?${version}`;
135
187
 
136
188
  const request = { assetId: asset.id, preset: presetName, format };
137
- return transformPath(request, signTransform(secret, request));
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 });
138
218
  }
139
219
 
140
220
  verify(assetId: string, preset: string, format: string, signature: string | undefined): boolean {
@@ -155,18 +235,42 @@ export class MediaService {
155
235
  await this.storage.delete(asset.key).catch(() => undefined);
156
236
  await this.repos.assets.delete(assetId);
157
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();
158
253
 
159
- private isAllowed(mimeType: string): boolean {
160
- const allowed = this.options.allowedMimeTypes;
161
- if (allowed.length === 0) return true;
162
- return allowed.some((entry) =>
163
- entry.endsWith('/') ? mimeType.startsWith(entry) : mimeType === entry,
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,
164
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
+ };
165
271
  }
166
- }
167
272
 
168
- async function transform(source: Buffer, preset: MediaPreset, format: string): Promise<Buffer> {
169
- const pipeline = sharp(source, { failOn: 'error' }).rotate();
273
+ if (region) pipeline.extract(region);
170
274
 
171
275
  if (preset.width || preset.height) {
172
276
  pipeline.resize({
@@ -193,13 +297,25 @@ async function transform(source: Buffer, preset: MediaPreset, format: string): P
193
297
  async function probeImage(body: Buffer, mimeType: string) {
194
298
  if (!mimeType.startsWith('image/')) return null;
195
299
  try {
196
- const meta = await sharp(body).metadata();
197
- return meta.width && meta.height ? { width: meta.width, height: meta.height } : null;
300
+ return orientedSize(await sharp(body).metadata());
198
301
  } catch {
199
302
  return null;
200
303
  }
201
304
  }
202
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
+
203
319
  /** Magic-number sniffing for the formats worth trusting; falls back to the declared type. */
204
320
  async function detectMimeType(body: Buffer, declared: string): Promise<string> {
205
321
  const signatures: Array<[string, number[]]> = [
@@ -0,0 +1,65 @@
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
+ });
@@ -0,0 +1,43 @@
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
+ });