@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 +19 -0
- package/package.json +4 -4
- package/src/edits.ts +112 -0
- package/src/index.ts +2 -0
- package/src/limits.ts +56 -0
- package/src/service.ts +135 -19
- package/test/edits.test.ts +65 -0
- package/test/limits.test.ts +43 -0
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.
|
|
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.
|
|
15
|
-
"@manablox/db": "0.
|
|
16
|
-
"@manablox/storage": "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
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 {
|
|
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
|
-
|
|
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:
|
|
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 (!
|
|
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
|
-
/**
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|