@manablox/media 0.2.0 → 0.4.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.
@@ -0,0 +1,151 @@
1
+ import { AssetCrop, AssetImageEdits, FocalPoint, MediaConfig, SpaceAssetSettings, StorageConfig, mimeTypeAllowed, withinInstance } from "@manablox/core";
2
+ import { StorageDriver } from "@manablox/storage";
3
+ import { AssetRow, Repositories } from "@manablox/db";
4
+ //#region src/edits.d.ts
5
+ /** The edits kept in an asset's `meta`, read leniently so an odd value is ignored, not fatal. */
6
+ export declare function readImageEdits(meta: Record<string, unknown> | null | undefined): AssetImageEdits;
7
+ /**
8
+ * Checks edits against the image they apply to. A crop must be a whole-pixel rectangle
9
+ * inside the image; a focal point is a fraction of the *cropped* image, so it is always
10
+ * inside the unit square whatever the crop.
11
+ */
12
+ export declare function validateImageEdits(edits: AssetImageEdits, image: {
13
+ width: number;
14
+ height: number;
15
+ }): AssetImageEdits;
16
+ /**
17
+ * The window of a source image that a `cover` resize to `target` keeps, placed so the
18
+ * focal point stays in view — as close to the window's centre as the edges allow.
19
+ *
20
+ * `cover` scales the image until both target sides are covered and trims the rest;
21
+ * which part is trimmed is the whole point of a focal point. Sharp's own `position`
22
+ * only knows edges and centres, so the window is computed here and extracted before
23
+ * the resize.
24
+ */
25
+ export declare function coverWindow(source: {
26
+ width: number;
27
+ height: number;
28
+ }, target: {
29
+ width: number;
30
+ height: number;
31
+ }, focal?: FocalPoint): AssetCrop;
32
+ //#endregion
33
+ //#region src/limits.d.ts
34
+ export interface AssetLimits {
35
+ /** Empty means every type the instance allows. */
36
+ allowedMimeTypes: string[];
37
+ maxFileSize: number;
38
+ }
39
+ /**
40
+ * The limits an upload into a space is held to: the space's own where it has set them,
41
+ * otherwise the instance's. A space setting never widens — an entry outside the
42
+ * instance's allowlist is dropped, and a larger size is capped.
43
+ */
44
+ export declare function resolveAssetLimits(instance: Pick<StorageConfig, 'allowedMimeTypes' | 'maxFileSize'>, space: SpaceAssetSettings | null | undefined, fallbackMaxFileSize?: number): AssetLimits;
45
+ /** Reads the space's asset settings out of its free-form settings, leniently. */
46
+ export declare function readSpaceAssetSettings(settings: Record<string, unknown> | null | undefined): SpaceAssetSettings | null;
47
+ //#endregion
48
+ //#region src/service.d.ts
49
+ export interface UploadInput {
50
+ spaceId: string;
51
+ filename: string;
52
+ mimeType: string;
53
+ body: Buffer;
54
+ alt?: string;
55
+ title?: string;
56
+ actorId?: string | null;
57
+ }
58
+ export type PresentedAsset = AssetRow & {
59
+ url: string;
60
+ thumbnailUrl: string | null;
61
+ };
62
+ export interface MediaServiceOptions {
63
+ maxFileSize: number;
64
+ allowedMimeTypes: string[];
65
+ }
66
+ /** Upload, probing, and on-demand image derivatives. */
67
+ export declare class MediaService {
68
+ private readonly repos;
69
+ private readonly storage;
70
+ private readonly config;
71
+ private readonly options;
72
+ constructor(repos: Repositories, storage: StorageDriver, config: MediaConfig & {
73
+ signingSecret?: string;
74
+ }, options: MediaServiceOptions);
75
+ /**
76
+ * The limits an upload into `spaceId` is held to: the space's own settings, narrowed
77
+ * to the instance's. The instance's are returned alongside so a settings form can show
78
+ * the ceiling it cannot exceed.
79
+ */
80
+ limits(spaceId: string): Promise<{
81
+ effective: AssetLimits;
82
+ instance: AssetLimits;
83
+ }>;
84
+ upload(input: UploadInput): Promise<AssetRow>;
85
+ /** Returns bytes for a preset, generating and caching the variant on first request. */
86
+ derive(asset: AssetRow, presetName: string, format: string): Promise<{
87
+ body: Buffer;
88
+ contentType: string;
89
+ }>;
90
+ /**
91
+ * Public URL for a preset — the storage driver's own URL, or a signed transform path.
92
+ *
93
+ * A variant URL carries the asset's version, so a crop or focal-point change reaches a
94
+ * browser or CDN that cached the previous rendering as immutable. The original is
95
+ * never edited, so its URL stays put.
96
+ */
97
+ /**
98
+ * An asset as a client receives it: the row plus the URLs it needs to show it. The
99
+ * thumbnail is a rendered variant, which only an image has.
100
+ */
101
+ present(asset: AssetRow): PresentedAsset;
102
+ urlFor(asset: AssetRow, presetName?: string, format?: string): string;
103
+ /**
104
+ * Sets an image's crop and focal point. Non-destructive: the original stays as
105
+ * uploaded, the edits live in `meta`, and every variant derived so far is dropped so
106
+ * the next request renders through the new edits.
107
+ */
108
+ setImageEdits(assetId: string, edits: AssetImageEdits): Promise<AssetRow>;
109
+ /** The name, alt text and title; the file itself never changes after upload. */
110
+ update(assetId: string, data: {
111
+ name?: string | undefined;
112
+ alt?: string | null | undefined;
113
+ title?: string | null | undefined;
114
+ }): Promise<AssetRow>;
115
+ verify(assetId: string, preset: string, format: string, signature: string | undefined): boolean;
116
+ delete(assetId: string): Promise<void>;
117
+ private audit;
118
+ }
119
+ //#endregion
120
+ //#region src/signing.d.ts
121
+ export interface TransformRequest {
122
+ assetId: string;
123
+ preset: string;
124
+ format: string;
125
+ }
126
+ /**
127
+ * Signed transform URLs.
128
+ *
129
+ * Without a signature, an arbitrary `?w=&h=` endpoint is a denial-of-service amplifier:
130
+ * anyone can ask for thousands of distinct resizes and force the server to decode the
131
+ * source image each time. Signing means only URLs the CMS emitted are honoured.
132
+ */
133
+ export declare function signTransform(secret: string, request: TransformRequest): string;
134
+ export declare function verifyTransform(secret: string, request: TransformRequest, signature: string): boolean;
135
+ export declare function transformPath(request: TransformRequest, signature: string): string;
136
+ //#endregion
137
+ //#region src/urls.d.ts
138
+ /**
139
+ * Makes a media URL absolute against the instance's public origin.
140
+ *
141
+ * `MediaService.urlFor` returns a storage-driver URL when there is one (S3, a CDN) and a
142
+ * root-relative path otherwise (`/media/…`). A relative path is fine for a same-origin
143
+ * admin and broken for everything the delivery API exists to serve: a frontend on its own
144
+ * domain resolves `/media/…` against *itself* and gets a 404. Found by driving
145
+ * `apps/example-plain` in a browser against a public instance on another port.
146
+ *
147
+ * Already-absolute URLs, protocol-relative URLs and data URIs are returned untouched.
148
+ */
149
+ export declare function absoluteMediaUrl(url: string, base: string | undefined): string;
150
+ //#endregion
151
+ export { mimeTypeAllowed, withinInstance };
package/dist/index.js ADDED
@@ -0,0 +1,472 @@
1
+ import { ManabloxError, diffRecords, mimeTypeAllowed, snapshotChanges, withinInstance } from "@manablox/core";
2
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
3
+ import { buildStorageKey } from "@manablox/storage";
4
+ import sharp from "sharp";
5
+ //#region src/edits.ts
6
+ /** The edits kept in an asset's `meta`, read leniently so an odd value is ignored, not fatal. */
7
+ function readImageEdits(meta) {
8
+ const edits = {};
9
+ const crop = meta?.crop;
10
+ if (crop && [
11
+ crop.left,
12
+ crop.top,
13
+ crop.width,
14
+ crop.height
15
+ ].every(isFiniteNumber)) edits.crop = {
16
+ left: crop.left,
17
+ top: crop.top,
18
+ width: crop.width,
19
+ height: crop.height
20
+ };
21
+ const focal = meta?.focalPoint;
22
+ if (focal && isFiniteNumber(focal.x) && isFiniteNumber(focal.y)) edits.focalPoint = {
23
+ x: focal.x,
24
+ y: focal.y
25
+ };
26
+ return edits;
27
+ }
28
+ /**
29
+ * Checks edits against the image they apply to. A crop must be a whole-pixel rectangle
30
+ * inside the image; a focal point is a fraction of the *cropped* image, so it is always
31
+ * inside the unit square whatever the crop.
32
+ */
33
+ function validateImageEdits(edits, image) {
34
+ const out = {};
35
+ if (edits.crop) {
36
+ const crop = edits.crop;
37
+ const whole = [
38
+ crop.left,
39
+ crop.top,
40
+ crop.width,
41
+ crop.height
42
+ ].every(Number.isInteger);
43
+ const inside = crop.left >= 0 && crop.top >= 0 && crop.width >= 1 && crop.height >= 1 && crop.left + crop.width <= image.width && crop.top + crop.height <= image.height;
44
+ if (!whole || !inside) throw ManabloxError.badRequest("asset.crop.outOfBounds", {
45
+ ...crop,
46
+ ...image
47
+ });
48
+ if (crop.left !== 0 || crop.top !== 0 || crop.width !== image.width || crop.height !== image.height) out.crop = crop;
49
+ }
50
+ if (edits.focalPoint) {
51
+ const { x, y } = edits.focalPoint;
52
+ if (!(x >= 0 && x <= 1 && y >= 0 && y <= 1)) throw ManabloxError.badRequest("asset.focalPoint.outOfBounds", {
53
+ x,
54
+ y
55
+ });
56
+ out.focalPoint = {
57
+ x: round3(x),
58
+ y: round3(y)
59
+ };
60
+ }
61
+ return out;
62
+ }
63
+ /**
64
+ * The window of a source image that a `cover` resize to `target` keeps, placed so the
65
+ * focal point stays in view — as close to the window's centre as the edges allow.
66
+ *
67
+ * `cover` scales the image until both target sides are covered and trims the rest;
68
+ * which part is trimmed is the whole point of a focal point. Sharp's own `position`
69
+ * only knows edges and centres, so the window is computed here and extracted before
70
+ * the resize.
71
+ */
72
+ function coverWindow(source, target, focal = {
73
+ x: .5,
74
+ y: .5
75
+ }) {
76
+ const ratio = target.width / target.height;
77
+ let width = source.width;
78
+ let height = Math.round(source.width / ratio);
79
+ if (height > source.height) {
80
+ height = source.height;
81
+ width = Math.round(source.height * ratio);
82
+ }
83
+ width = Math.max(1, Math.min(width, source.width));
84
+ height = Math.max(1, Math.min(height, source.height));
85
+ return {
86
+ left: clamp(Math.round(focal.x * source.width - width / 2), 0, source.width - width),
87
+ top: clamp(Math.round(focal.y * source.height - height / 2), 0, source.height - height),
88
+ width,
89
+ height
90
+ };
91
+ }
92
+ function clamp(value, min, max) {
93
+ return Math.min(Math.max(value, min), max);
94
+ }
95
+ function round3(value) {
96
+ return Math.round(value * 1e3) / 1e3;
97
+ }
98
+ function isFiniteNumber(value) {
99
+ return typeof value === "number" && Number.isFinite(value);
100
+ }
101
+ //#endregion
102
+ //#region src/limits.ts
103
+ /**
104
+ * The limits an upload into a space is held to: the space's own where it has set them,
105
+ * otherwise the instance's. A space setting never widens — an entry outside the
106
+ * instance's allowlist is dropped, and a larger size is capped.
107
+ */
108
+ function resolveAssetLimits(instance, space, fallbackMaxFileSize = 26214400) {
109
+ const instanceTypes = instance.allowedMimeTypes ?? [];
110
+ const instanceMax = instance.maxFileSize ?? fallbackMaxFileSize;
111
+ return {
112
+ allowedMimeTypes: space?.allowedMimeTypes ? space.allowedMimeTypes.filter((entry) => withinInstance(entry, instanceTypes)) : instanceTypes,
113
+ maxFileSize: space?.maxFileSize && space.maxFileSize > 0 ? Math.min(space.maxFileSize, instanceMax) : instanceMax
114
+ };
115
+ }
116
+ /** Reads the space's asset settings out of its free-form settings, leniently. */
117
+ function readSpaceAssetSettings(settings) {
118
+ const block = settings?.assets;
119
+ if (!block || typeof block !== "object") return null;
120
+ const out = {};
121
+ if (Array.isArray(block.allowedMimeTypes)) out.allowedMimeTypes = block.allowedMimeTypes.filter((entry) => typeof entry === "string");
122
+ if (typeof block.maxFileSize === "number" && block.maxFileSize > 0) out.maxFileSize = block.maxFileSize;
123
+ return out;
124
+ }
125
+ //#endregion
126
+ //#region src/signing.ts
127
+ /**
128
+ * Signed transform URLs.
129
+ *
130
+ * Without a signature, an arbitrary `?w=&h=` endpoint is a denial-of-service amplifier:
131
+ * anyone can ask for thousands of distinct resizes and force the server to decode the
132
+ * source image each time. Signing means only URLs the CMS emitted are honoured.
133
+ */
134
+ function signTransform(secret, request) {
135
+ return createHmac("sha256", secret).update(`${request.assetId}:${request.preset}:${request.format}`).digest("base64url").slice(0, 32);
136
+ }
137
+ function verifyTransform(secret, request, signature) {
138
+ const expected = Buffer.from(signTransform(secret, request));
139
+ const actual = Buffer.from(signature);
140
+ return expected.length === actual.length && timingSafeEqual(expected, actual);
141
+ }
142
+ function transformPath(request, signature) {
143
+ return `/media/${request.assetId}/${request.preset}.${request.format}?s=${signature}`;
144
+ }
145
+ //#endregion
146
+ //#region src/service.ts
147
+ const IMAGE_FORMATS = /* @__PURE__ */ new Set([
148
+ "avif",
149
+ "webp",
150
+ "jpeg",
151
+ "png"
152
+ ]);
153
+ /** The storage key and the checksum are the file, which never changes; the rest is the record. */
154
+ const ASSET_DIFF = {
155
+ expand: ["meta"],
156
+ ignore: [
157
+ "driver",
158
+ "key",
159
+ "checksum",
160
+ "actorId"
161
+ ]
162
+ };
163
+ /** Upload, probing, and on-demand image derivatives. */
164
+ var MediaService = class {
165
+ repos;
166
+ storage;
167
+ config;
168
+ options;
169
+ constructor(repos, storage, config, options) {
170
+ this.repos = repos;
171
+ this.storage = storage;
172
+ this.config = config;
173
+ this.options = options;
174
+ }
175
+ /**
176
+ * The limits an upload into `spaceId` is held to: the space's own settings, narrowed
177
+ * to the instance's. The instance's are returned alongside so a settings form can show
178
+ * the ceiling it cannot exceed.
179
+ */
180
+ async limits(spaceId) {
181
+ const space = await this.repos.spaces.findById(spaceId);
182
+ const instance = resolveAssetLimits(this.options, null, this.options.maxFileSize);
183
+ return {
184
+ effective: resolveAssetLimits(this.options, readSpaceAssetSettings(space?.settings), this.options.maxFileSize),
185
+ instance
186
+ };
187
+ }
188
+ async upload(input) {
189
+ const { effective: limits } = await this.limits(input.spaceId);
190
+ if (input.body.byteLength > limits.maxFileSize) throw ManabloxError.badRequest("asset.tooLarge", {
191
+ size: input.body.byteLength,
192
+ max: limits.maxFileSize
193
+ });
194
+ const detected = await detectMimeType(input.body, input.mimeType);
195
+ if (!mimeTypeAllowed(detected, limits.allowedMimeTypes)) throw ManabloxError.badRequest("asset.mimeType.notAllowed", { mimeType: detected });
196
+ const checksum = createHash("sha256").update(input.body).digest("hex");
197
+ const existing = await this.repos.assets.findByChecksum(input.spaceId, checksum);
198
+ if (existing) return existing;
199
+ const key = buildStorageKey(input.spaceId, input.filename);
200
+ await this.storage.put(key, input.body, { contentType: detected });
201
+ const dimensions = await probeImage(input.body, detected);
202
+ const asset = await this.repos.assets.create({
203
+ spaceId: input.spaceId,
204
+ driver: this.storage.name,
205
+ key,
206
+ filename: key.split("/").pop() ?? input.filename,
207
+ name: input.filename.replace(/\.[^.]+$/, ""),
208
+ mimeType: detected,
209
+ size: input.body.byteLength,
210
+ width: dimensions?.width ?? null,
211
+ height: dimensions?.height ?? null,
212
+ checksum,
213
+ alt: input.alt ?? null,
214
+ title: input.title ?? null,
215
+ actorId: input.actorId ?? null
216
+ });
217
+ await this.audit("asset.upload", asset, snapshotChanges(asset, "created", ASSET_DIFF));
218
+ for (const preset of this.config.eager ?? []) if (this.config.presets?.[preset] && dimensions) await this.derive(asset, preset, this.config.presets[preset]?.format ?? "webp").catch(() => void 0);
219
+ return asset;
220
+ }
221
+ /** Returns bytes for a preset, generating and caching the variant on first request. */
222
+ async derive(asset, presetName, format) {
223
+ const preset = this.config.presets?.[presetName];
224
+ if (!preset) throw ManabloxError.notFound("media.preset.notFound", { preset: presetName });
225
+ if (!IMAGE_FORMATS.has(format)) throw ManabloxError.badRequest("media.format.unsupported", { format });
226
+ if (!asset.mimeType.startsWith("image/")) throw ManabloxError.badRequest("media.notAnImage", { assetId: asset.id });
227
+ const variantKey = `${asset.key}.${presetName}.${format}`;
228
+ const cached = await this.repos.assets.findVariant(asset.id, presetName, format);
229
+ if (cached && await this.storage.exists(cached.key)) return {
230
+ body: await this.storage.get(cached.key),
231
+ contentType: `image/${format}`
232
+ };
233
+ const body = await transform(await this.storage.get(asset.key), preset, format, readImageEdits(asset.meta));
234
+ const meta = await sharp(body).metadata();
235
+ await this.storage.put(variantKey, body, {
236
+ contentType: `image/${format}`,
237
+ cacheControl: "public, max-age=31536000, immutable"
238
+ });
239
+ await this.repos.assets.addVariant({
240
+ assetId: asset.id,
241
+ preset: presetName,
242
+ format,
243
+ key: variantKey,
244
+ width: meta.width ?? null,
245
+ height: meta.height ?? null,
246
+ size: body.byteLength
247
+ });
248
+ return {
249
+ body,
250
+ contentType: `image/${format}`
251
+ };
252
+ }
253
+ /**
254
+ * Public URL for a preset — the storage driver's own URL, or a signed transform path.
255
+ *
256
+ * A variant URL carries the asset's version, so a crop or focal-point change reaches a
257
+ * browser or CDN that cached the previous rendering as immutable. The original is
258
+ * never edited, so its URL stays put.
259
+ */
260
+ /**
261
+ * An asset as a client receives it: the row plus the URLs it needs to show it. The
262
+ * thumbnail is a rendered variant, which only an image has.
263
+ */
264
+ present(asset) {
265
+ return {
266
+ ...asset,
267
+ url: this.urlFor(asset),
268
+ thumbnailUrl: asset.mimeType.startsWith("image/") ? this.urlFor(asset, "thumb", "webp") : null
269
+ };
270
+ }
271
+ urlFor(asset, presetName, format = "webp") {
272
+ if (!presetName) return this.storage.url(asset.key) ?? `/media/${asset.id}/original`;
273
+ const version = `v=${asset.updatedAt.getTime().toString(36)}`;
274
+ const secret = this.config.signingSecret;
275
+ if (!secret) return `/media/${asset.id}/${presetName}.${format}?${version}`;
276
+ const request = {
277
+ assetId: asset.id,
278
+ preset: presetName,
279
+ format
280
+ };
281
+ return `${transformPath(request, signTransform(secret, request))}&${version}`;
282
+ }
283
+ /**
284
+ * Sets an image's crop and focal point. Non-destructive: the original stays as
285
+ * uploaded, the edits live in `meta`, and every variant derived so far is dropped so
286
+ * the next request renders through the new edits.
287
+ */
288
+ async setImageEdits(assetId, edits) {
289
+ const asset = await this.repos.assets.findById(assetId);
290
+ if (!asset) throw ManabloxError.notFound("asset.notFound", { id: assetId });
291
+ if (!asset.mimeType.startsWith("image/") || !asset.width || !asset.height) throw ManabloxError.badRequest("asset.notAnImage", { id: assetId });
292
+ const valid = validateImageEdits(edits, {
293
+ width: asset.width,
294
+ height: asset.height
295
+ });
296
+ const meta = { ...asset.meta };
297
+ if (valid.crop) meta.crop = valid.crop;
298
+ else delete meta.crop;
299
+ if (valid.focalPoint) meta.focalPoint = valid.focalPoint;
300
+ else delete meta.focalPoint;
301
+ const variants = await this.repos.assets.variants([assetId]);
302
+ await Promise.all(variants.map((variant) => this.storage.delete(variant.key).catch(() => void 0)));
303
+ await this.repos.assets.deleteVariants(assetId);
304
+ const saved = await this.repos.assets.update(assetId, { meta });
305
+ await this.audit("asset.setImageEdits", saved, diffRecords(asset, saved, ASSET_DIFF));
306
+ return saved;
307
+ }
308
+ /** The name, alt text and title; the file itself never changes after upload. */
309
+ async update(assetId, data) {
310
+ const asset = await this.repos.assets.findById(assetId);
311
+ if (!asset) throw ManabloxError.notFound("asset.notFound", { id: assetId });
312
+ const saved = await this.repos.assets.update(assetId, data);
313
+ await this.audit("asset.update", saved, diffRecords(asset, saved, ASSET_DIFF));
314
+ return saved;
315
+ }
316
+ verify(assetId, preset, format, signature) {
317
+ const secret = this.config.signingSecret;
318
+ if (!secret) return true;
319
+ if (!signature) return false;
320
+ return verifyTransform(secret, {
321
+ assetId,
322
+ preset,
323
+ format
324
+ }, signature);
325
+ }
326
+ async delete(assetId) {
327
+ const asset = await this.repos.assets.findById(assetId);
328
+ if (!asset) return;
329
+ const variants = await this.repos.assets.variants([assetId]);
330
+ await Promise.all(variants.map((variant) => this.storage.delete(variant.key).catch(() => void 0)));
331
+ await this.storage.delete(asset.key).catch(() => void 0);
332
+ await this.repos.assets.delete(assetId);
333
+ await this.audit("asset.delete", asset, snapshotChanges(asset, "deleted", ASSET_DIFF));
334
+ }
335
+ audit(action, asset, changes) {
336
+ return this.repos.audit.record({
337
+ spaceId: asset.spaceId,
338
+ action,
339
+ targetKind: "asset",
340
+ targetId: asset.id,
341
+ targetLabel: asset.name,
342
+ changes,
343
+ meta: {
344
+ mimeType: asset.mimeType,
345
+ filename: asset.filename
346
+ }
347
+ });
348
+ }
349
+ };
350
+ /**
351
+ * Applies the preset through the asset's edits: the crop first, then — for a preset
352
+ * that fixes both sides with `cover` — the window around the focal point, then the
353
+ * resize. Both extractions fold into one rectangle, since sharp takes a single one
354
+ * ahead of a resize.
355
+ */
356
+ async function transform(source, preset, format, edits = {}) {
357
+ const pipeline = sharp(source, { failOn: "error" }).rotate();
358
+ const oriented = orientedSize(await pipeline.metadata());
359
+ let region = edits.crop ?? null;
360
+ const size = region ? {
361
+ width: region.width,
362
+ height: region.height
363
+ } : oriented;
364
+ if (preset.width && preset.height && (preset.fit ?? "inside") === "cover" && size) {
365
+ const window = coverWindow(size, {
366
+ width: preset.width,
367
+ height: preset.height
368
+ }, edits.focalPoint ?? void 0);
369
+ region = {
370
+ left: (region?.left ?? 0) + window.left,
371
+ top: (region?.top ?? 0) + window.top,
372
+ width: window.width,
373
+ height: window.height
374
+ };
375
+ }
376
+ if (region) pipeline.extract(region);
377
+ if (preset.width || preset.height) pipeline.resize({
378
+ ...preset.width ? { width: preset.width } : {},
379
+ ...preset.height ? { height: preset.height } : {},
380
+ fit: preset.fit ?? "inside",
381
+ withoutEnlargement: true
382
+ });
383
+ const quality = preset.quality ?? 82;
384
+ switch (format) {
385
+ case "avif": return pipeline.avif({ quality }).toBuffer();
386
+ case "png": return pipeline.png().toBuffer();
387
+ case "jpeg": return pipeline.jpeg({
388
+ quality,
389
+ mozjpeg: true
390
+ }).toBuffer();
391
+ default: return pipeline.webp({ quality }).toBuffer();
392
+ }
393
+ }
394
+ async function probeImage(body, mimeType) {
395
+ if (!mimeType.startsWith("image/")) return null;
396
+ try {
397
+ return orientedSize(await sharp(body).metadata());
398
+ } catch {
399
+ return null;
400
+ }
401
+ }
402
+ /**
403
+ * The size as displayed, not as stored: a phone photo carries an EXIF orientation that
404
+ * `rotate()` honours, so a crop drawn on the displayed image must be measured against
405
+ * the rotated dimensions or it lands on the wrong part of the picture.
406
+ */
407
+ function orientedSize(meta) {
408
+ if (!meta.width || !meta.height) return null;
409
+ return (meta.orientation ?? 1) >= 5 ? {
410
+ width: meta.height,
411
+ height: meta.width
412
+ } : {
413
+ width: meta.width,
414
+ height: meta.height
415
+ };
416
+ }
417
+ /** Magic-number sniffing for the formats worth trusting; falls back to the declared type. */
418
+ async function detectMimeType(body, declared) {
419
+ for (const [mimeType, bytes] of [
420
+ ["image/png", [
421
+ 137,
422
+ 80,
423
+ 78,
424
+ 71
425
+ ]],
426
+ ["image/jpeg", [
427
+ 255,
428
+ 216,
429
+ 255
430
+ ]],
431
+ ["image/gif", [
432
+ 71,
433
+ 73,
434
+ 70,
435
+ 56
436
+ ]],
437
+ ["application/pdf", [
438
+ 37,
439
+ 80,
440
+ 68,
441
+ 70
442
+ ]]
443
+ ]) if (bytes.every((byte, index) => body[index] === byte)) return mimeType;
444
+ if (body.subarray(0, 4).toString("ascii") === "RIFF" && body.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp";
445
+ if (body.subarray(4, 8).toString("ascii") === "ftyp") {
446
+ const brand = body.subarray(8, 12).toString("ascii");
447
+ if (brand.startsWith("avif")) return "image/avif";
448
+ if (brand.startsWith("heic") || brand.startsWith("mif1")) return "image/heic";
449
+ if (brand.startsWith("isom") || brand.startsWith("mp4")) return "video/mp4";
450
+ }
451
+ return declared;
452
+ }
453
+ //#endregion
454
+ //#region src/urls.ts
455
+ /**
456
+ * Makes a media URL absolute against the instance's public origin.
457
+ *
458
+ * `MediaService.urlFor` returns a storage-driver URL when there is one (S3, a CDN) and a
459
+ * root-relative path otherwise (`/media/…`). A relative path is fine for a same-origin
460
+ * admin and broken for everything the delivery API exists to serve: a frontend on its own
461
+ * domain resolves `/media/…` against *itself* and gets a 404. Found by driving
462
+ * `apps/example-plain` in a browser against a public instance on another port.
463
+ *
464
+ * Already-absolute URLs, protocol-relative URLs and data URIs are returned untouched.
465
+ */
466
+ function absoluteMediaUrl(url, base) {
467
+ if (!base) return url;
468
+ if (!url.startsWith("/") || url.startsWith("//")) return url;
469
+ return `${base.replace(/\/+$/, "")}${url}`;
470
+ }
471
+ //#endregion
472
+ export { MediaService, absoluteMediaUrl, coverWindow, mimeTypeAllowed, readImageEdits, readSpaceAssetSettings, resolveAssetLimits, signTransform, transformPath, validateImageEdits, verifyTransform, withinInstance };
package/package.json CHANGED
@@ -1,28 +1,35 @@
1
1
  {
2
2
  "name": "@manablox/media",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
7
- "types": "./src/index.ts",
8
- "default": "./src/index.ts"
7
+ "types": "./dist/index.d.ts",
8
+ "default": "./dist/index.js"
9
9
  }
10
10
  },
11
- "main": "./src/index.ts",
12
- "types": "./src/index.ts",
11
+ "main": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
13
  "dependencies": {
14
- "@manablox/core": "0.2.0",
15
- "@manablox/db": "0.2.0",
16
- "@manablox/storage": "0.2.0",
14
+ "@manablox/core": "0.4.0",
15
+ "@manablox/db": "0.4.0",
16
+ "@manablox/storage": "0.4.0",
17
17
  "sharp": "^0.35.4"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@manablox/config-typescript": "0.0.0",
21
21
  "@types/node": "^26.4.1",
22
+ "tsdown": "^0.23.0",
22
23
  "typescript": "^7.0.2",
23
24
  "vitest": "^5.0.0"
24
25
  },
26
+ "files": [
27
+ "dist",
28
+ "!dist/**/*.map",
29
+ "README.md"
30
+ ],
25
31
  "scripts": {
32
+ "build": "tsdown",
26
33
  "typecheck": "tsc --noEmit",
27
34
  "test": "vitest run"
28
35
  }