@manablox/media 0.1.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 +29 -0
- package/src/index.ts +3 -0
- package/src/service.ts +234 -0
- package/src/signing.ts +35 -0
- package/src/urls.ts +16 -0
- package/test/signing.test.ts +37 -0
- package/test/urls.test.ts +39 -0
- package/tsconfig.json +1 -0
- package/vitest.config.ts +2 -0
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@manablox/media",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"default": "./src/index.ts"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"main": "./src/index.ts",
|
|
12
|
+
"types": "./src/index.ts",
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@manablox/core": "0.1.0",
|
|
15
|
+
"@manablox/db": "0.1.0",
|
|
16
|
+
"@manablox/storage": "0.1.0",
|
|
17
|
+
"sharp": "^0.35.4"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@manablox/config-typescript": "0.0.0",
|
|
21
|
+
"@types/node": "^26.4.1",
|
|
22
|
+
"typescript": "^7.0.2",
|
|
23
|
+
"vitest": "^5.0.0"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"test": "vitest run"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/index.ts
ADDED
package/src/service.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { ManabloxError, type MediaConfig, type MediaPreset } from '@manablox/core';
|
|
3
|
+
import type { AssetRow, Repositories } from '@manablox/db';
|
|
4
|
+
import { buildStorageKey, type StorageDriver } from '@manablox/storage';
|
|
5
|
+
import sharp from 'sharp';
|
|
6
|
+
import { signTransform, transformPath, verifyTransform } from './signing.js';
|
|
7
|
+
|
|
8
|
+
export interface UploadInput {
|
|
9
|
+
spaceId: string;
|
|
10
|
+
filename: string;
|
|
11
|
+
mimeType: string;
|
|
12
|
+
body: Buffer;
|
|
13
|
+
alt?: string;
|
|
14
|
+
title?: string;
|
|
15
|
+
actorId?: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MediaServiceOptions {
|
|
19
|
+
maxFileSize: number;
|
|
20
|
+
allowedMimeTypes: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const IMAGE_FORMATS = new Set(['avif', 'webp', 'jpeg', 'png']);
|
|
24
|
+
|
|
25
|
+
/** Upload, probing, and on-demand image derivatives. */
|
|
26
|
+
export class MediaService {
|
|
27
|
+
constructor(
|
|
28
|
+
private readonly repos: Repositories,
|
|
29
|
+
private readonly storage: StorageDriver,
|
|
30
|
+
private readonly config: MediaConfig & { signingSecret?: string },
|
|
31
|
+
private readonly options: MediaServiceOptions,
|
|
32
|
+
) {}
|
|
33
|
+
|
|
34
|
+
async upload(input: UploadInput): Promise<AssetRow> {
|
|
35
|
+
if (input.body.byteLength > this.options.maxFileSize) {
|
|
36
|
+
throw ManabloxError.badRequest('asset.tooLarge', {
|
|
37
|
+
size: input.body.byteLength,
|
|
38
|
+
max: this.options.maxFileSize,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Trust the bytes, not the client's Content-Type: a caller can label a script as
|
|
43
|
+
// `image/png`. The magic-number probe is what decides.
|
|
44
|
+
const detected = await detectMimeType(input.body, input.mimeType);
|
|
45
|
+
if (!this.isAllowed(detected)) {
|
|
46
|
+
throw ManabloxError.badRequest('asset.mimeType.notAllowed', { mimeType: detected });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const checksum = createHash('sha256').update(input.body).digest('hex');
|
|
50
|
+
const existing = await this.repos.assets.findByChecksum(input.spaceId, checksum);
|
|
51
|
+
if (existing) return existing;
|
|
52
|
+
|
|
53
|
+
const key = buildStorageKey(input.spaceId, input.filename);
|
|
54
|
+
await this.storage.put(key, input.body, { contentType: detected });
|
|
55
|
+
|
|
56
|
+
const dimensions = await probeImage(input.body, detected);
|
|
57
|
+
|
|
58
|
+
const asset = await this.repos.assets.create({
|
|
59
|
+
spaceId: input.spaceId,
|
|
60
|
+
driver: this.storage.name,
|
|
61
|
+
key,
|
|
62
|
+
filename: key.split('/').pop() ?? input.filename,
|
|
63
|
+
name: input.filename.replace(/\.[^.]+$/, ''),
|
|
64
|
+
mimeType: detected,
|
|
65
|
+
size: input.body.byteLength,
|
|
66
|
+
width: dimensions?.width ?? null,
|
|
67
|
+
height: dimensions?.height ?? null,
|
|
68
|
+
checksum,
|
|
69
|
+
alt: input.alt ?? null,
|
|
70
|
+
title: input.title ?? null,
|
|
71
|
+
actorId: input.actorId ?? null,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Eager presets keep the first page view off the transform path.
|
|
75
|
+
for (const preset of this.config.eager ?? []) {
|
|
76
|
+
if (this.config.presets?.[preset] && dimensions) {
|
|
77
|
+
await this.derive(asset, preset, this.config.presets[preset]?.format ?? 'webp').catch(
|
|
78
|
+
() => undefined,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return asset;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Returns bytes for a preset, generating and caching the variant on first request. */
|
|
87
|
+
async derive(
|
|
88
|
+
asset: AssetRow,
|
|
89
|
+
presetName: string,
|
|
90
|
+
format: string,
|
|
91
|
+
): Promise<{ body: Buffer; contentType: string }> {
|
|
92
|
+
const preset = this.config.presets?.[presetName];
|
|
93
|
+
if (!preset) throw ManabloxError.notFound('media.preset.notFound', { preset: presetName });
|
|
94
|
+
if (!IMAGE_FORMATS.has(format)) {
|
|
95
|
+
throw ManabloxError.badRequest('media.format.unsupported', { format });
|
|
96
|
+
}
|
|
97
|
+
if (!asset.mimeType.startsWith('image/')) {
|
|
98
|
+
throw ManabloxError.badRequest('media.notAnImage', { assetId: asset.id });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const variantKey = `${asset.key}.${presetName}.${format}`;
|
|
102
|
+
const cached = await this.repos.assets.findVariant(asset.id, presetName, format);
|
|
103
|
+
if (cached && (await this.storage.exists(cached.key))) {
|
|
104
|
+
return { body: await this.storage.get(cached.key), contentType: `image/${format}` };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const source = await this.storage.get(asset.key);
|
|
108
|
+
const body = await transform(source, preset, format);
|
|
109
|
+
const meta = await sharp(body).metadata();
|
|
110
|
+
|
|
111
|
+
await this.storage.put(variantKey, body, {
|
|
112
|
+
contentType: `image/${format}`,
|
|
113
|
+
cacheControl: 'public, max-age=31536000, immutable',
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await this.repos.assets.addVariant({
|
|
117
|
+
assetId: asset.id,
|
|
118
|
+
preset: presetName,
|
|
119
|
+
format,
|
|
120
|
+
key: variantKey,
|
|
121
|
+
width: meta.width ?? null,
|
|
122
|
+
height: meta.height ?? null,
|
|
123
|
+
size: body.byteLength,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return { body, contentType: `image/${format}` };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Public URL for a preset — the storage driver's own URL, or a signed transform path. */
|
|
130
|
+
urlFor(asset: AssetRow, presetName?: string, format = 'webp'): string {
|
|
131
|
+
if (!presetName) return this.storage.url(asset.key) ?? `/media/${asset.id}/original`;
|
|
132
|
+
|
|
133
|
+
const secret = this.config.signingSecret;
|
|
134
|
+
if (!secret) return `/media/${asset.id}/${presetName}.${format}`;
|
|
135
|
+
|
|
136
|
+
const request = { assetId: asset.id, preset: presetName, format };
|
|
137
|
+
return transformPath(request, signTransform(secret, request));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
verify(assetId: string, preset: string, format: string, signature: string | undefined): boolean {
|
|
141
|
+
const secret = this.config.signingSecret;
|
|
142
|
+
if (!secret) return true;
|
|
143
|
+
if (!signature) return false;
|
|
144
|
+
return verifyTransform(secret, { assetId, preset, format }, signature);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async delete(assetId: string): Promise<void> {
|
|
148
|
+
const asset = await this.repos.assets.findById(assetId);
|
|
149
|
+
if (!asset) return;
|
|
150
|
+
|
|
151
|
+
const variants = await this.repos.assets.variants([assetId]);
|
|
152
|
+
await Promise.all(
|
|
153
|
+
variants.map((variant) => this.storage.delete(variant.key).catch(() => undefined)),
|
|
154
|
+
);
|
|
155
|
+
await this.storage.delete(asset.key).catch(() => undefined);
|
|
156
|
+
await this.repos.assets.delete(assetId);
|
|
157
|
+
}
|
|
158
|
+
|
|
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,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function transform(source: Buffer, preset: MediaPreset, format: string): Promise<Buffer> {
|
|
169
|
+
const pipeline = sharp(source, { failOn: 'error' }).rotate();
|
|
170
|
+
|
|
171
|
+
if (preset.width || preset.height) {
|
|
172
|
+
pipeline.resize({
|
|
173
|
+
...(preset.width ? { width: preset.width } : {}),
|
|
174
|
+
...(preset.height ? { height: preset.height } : {}),
|
|
175
|
+
fit: preset.fit ?? 'inside',
|
|
176
|
+
withoutEnlargement: true,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const quality = preset.quality ?? 82;
|
|
181
|
+
switch (format) {
|
|
182
|
+
case 'avif':
|
|
183
|
+
return pipeline.avif({ quality }).toBuffer();
|
|
184
|
+
case 'png':
|
|
185
|
+
return pipeline.png().toBuffer();
|
|
186
|
+
case 'jpeg':
|
|
187
|
+
return pipeline.jpeg({ quality, mozjpeg: true }).toBuffer();
|
|
188
|
+
default:
|
|
189
|
+
return pipeline.webp({ quality }).toBuffer();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function probeImage(body: Buffer, mimeType: string) {
|
|
194
|
+
if (!mimeType.startsWith('image/')) return null;
|
|
195
|
+
try {
|
|
196
|
+
const meta = await sharp(body).metadata();
|
|
197
|
+
return meta.width && meta.height ? { width: meta.width, height: meta.height } : null;
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Magic-number sniffing for the formats worth trusting; falls back to the declared type. */
|
|
204
|
+
async function detectMimeType(body: Buffer, declared: string): Promise<string> {
|
|
205
|
+
const signatures: Array<[string, number[]]> = [
|
|
206
|
+
['image/png', [0x89, 0x50, 0x4e, 0x47]],
|
|
207
|
+
['image/jpeg', [0xff, 0xd8, 0xff]],
|
|
208
|
+
['image/gif', [0x47, 0x49, 0x46, 0x38]],
|
|
209
|
+
['application/pdf', [0x25, 0x50, 0x44, 0x46]],
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
for (const [mimeType, bytes] of signatures) {
|
|
213
|
+
if (bytes.every((byte, index) => body[index] === byte)) return mimeType;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// RIFF....WEBP
|
|
217
|
+
if (
|
|
218
|
+
body.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
|
219
|
+
body.subarray(8, 12).toString('ascii') === 'WEBP'
|
|
220
|
+
) {
|
|
221
|
+
return 'image/webp';
|
|
222
|
+
}
|
|
223
|
+
// ISO-BMFF brands: AVIF and HEIC share the ftyp box.
|
|
224
|
+
if (body.subarray(4, 8).toString('ascii') === 'ftyp') {
|
|
225
|
+
const brand = body.subarray(8, 12).toString('ascii');
|
|
226
|
+
if (brand.startsWith('avif')) return 'image/avif';
|
|
227
|
+
if (brand.startsWith('heic') || brand.startsWith('mif1')) return 'image/heic';
|
|
228
|
+
if (brand.startsWith('isom') || brand.startsWith('mp4')) return 'video/mp4';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Anything unrecognised keeps its declared type but can still be rejected by the
|
|
232
|
+
// allowlist — never silently upgraded to something more privileged.
|
|
233
|
+
return declared;
|
|
234
|
+
}
|
package/src/signing.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { signTransform, transformPath, verifyTransform } from '../src/signing.js';
|
|
3
|
+
|
|
4
|
+
const SECRET = 'a-signing-secret';
|
|
5
|
+
const request = { assetId: 'asset-1', preset: 'thumb', format: 'webp' };
|
|
6
|
+
|
|
7
|
+
describe('transform signing', () => {
|
|
8
|
+
it('verifies a signature it produced', () => {
|
|
9
|
+
expect(verifyTransform(SECRET, request, signTransform(SECRET, request))).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Without a signature, `/media/:id/:preset` is a resize amplifier: a caller can force
|
|
14
|
+
* an unbounded number of distinct decodes. Each component is bound into the MAC.
|
|
15
|
+
*/
|
|
16
|
+
it('rejects a signature bound to a different asset, preset or format', () => {
|
|
17
|
+
const signature = signTransform(SECRET, request);
|
|
18
|
+
expect(verifyTransform(SECRET, { ...request, assetId: 'asset-2' }, signature)).toBe(false);
|
|
19
|
+
expect(verifyTransform(SECRET, { ...request, preset: 'hero' }, signature)).toBe(false);
|
|
20
|
+
expect(verifyTransform(SECRET, { ...request, format: 'avif' }, signature)).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('rejects a signature made with another secret', () => {
|
|
24
|
+
expect(verifyTransform(SECRET, request, signTransform('other', request))).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('rejects malformed signatures without throwing', () => {
|
|
28
|
+
for (const value of ['', 'x', 'x'.repeat(200)]) {
|
|
29
|
+
expect(verifyTransform(SECRET, request, value)).toBe(false);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('builds a path carrying the signature', () => {
|
|
34
|
+
const signature = signTransform(SECRET, request);
|
|
35
|
+
expect(transformPath(request, signature)).toBe(`/media/asset-1/thumb.webp?s=${signature}`);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { absoluteMediaUrl } from '../src/urls.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A relative `/media/…` resolves against whatever origin the *browser* is on, which for
|
|
6
|
+
* a delivery consumer is the frontend, not the API. Found by driving `example-plain`
|
|
7
|
+
* against a public instance on another port: every `<img>` 404ed.
|
|
8
|
+
*/
|
|
9
|
+
describe('absoluteMediaUrl', () => {
|
|
10
|
+
it('prefixes a root-relative path with the public origin', () => {
|
|
11
|
+
expect(absoluteMediaUrl('/media/a1/original', 'https://cms.example.com')).toBe(
|
|
12
|
+
'https://cms.example.com/media/a1/original',
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('does not double a trailing slash on the base', () => {
|
|
17
|
+
expect(absoluteMediaUrl('/media/a1/original', 'https://cms.example.com/')).toBe(
|
|
18
|
+
'https://cms.example.com/media/a1/original',
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('leaves an absolute URL alone, so an S3 or CDN URL survives', () => {
|
|
23
|
+
const cdn = 'https://cdn.example.com/uploads/a1.jpg';
|
|
24
|
+
expect(absoluteMediaUrl(cdn, 'https://cms.example.com')).toBe(cdn);
|
|
25
|
+
expect(absoluteMediaUrl('//cdn.example.com/a1.jpg', 'https://cms.example.com')).toBe(
|
|
26
|
+
'//cdn.example.com/a1.jpg',
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('leaves the URL alone when no public origin is configured', () => {
|
|
31
|
+
expect(absoluteMediaUrl('/media/a1/original', undefined)).toBe('/media/a1/original');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('preserves a signed transform query', () => {
|
|
35
|
+
expect(absoluteMediaUrl('/media/a1/card.webp?s=abc', 'https://cms.example.com')).toBe(
|
|
36
|
+
'https://cms.example.com/media/a1/card.webp?s=abc',
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "extends": "@manablox/config-typescript/library.json", "include": ["src", "test"] }
|
package/vitest.config.ts
ADDED