@manablox/media 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +144 -0
- package/dist/index.js +436 -0
- package/package.json +15 -8
- package/src/edits.ts +0 -112
- package/src/index.ts +0 -5
- package/src/limits.ts +0 -56
- package/src/service.ts +0 -350
- package/src/signing.ts +0 -35
- package/src/urls.ts +0 -16
- package/test/edits.test.ts +0 -65
- package/test/limits.test.ts +0 -43
- package/test/signing.test.ts +0 -37
- package/test/urls.test.ts +0 -39
- package/tsconfig.json +0 -1
- package/vitest.config.ts +0 -2
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
verify(assetId: string, preset: string, format: string, signature: string | undefined): boolean;
|
|
110
|
+
delete(assetId: string): Promise<void>;
|
|
111
|
+
}
|
|
112
|
+
//#endregion
|
|
113
|
+
//#region src/signing.d.ts
|
|
114
|
+
export interface TransformRequest {
|
|
115
|
+
assetId: string;
|
|
116
|
+
preset: string;
|
|
117
|
+
format: string;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Signed transform URLs.
|
|
121
|
+
*
|
|
122
|
+
* Without a signature, an arbitrary `?w=&h=` endpoint is a denial-of-service amplifier:
|
|
123
|
+
* anyone can ask for thousands of distinct resizes and force the server to decode the
|
|
124
|
+
* source image each time. Signing means only URLs the CMS emitted are honoured.
|
|
125
|
+
*/
|
|
126
|
+
export declare function signTransform(secret: string, request: TransformRequest): string;
|
|
127
|
+
export declare function verifyTransform(secret: string, request: TransformRequest, signature: string): boolean;
|
|
128
|
+
export declare function transformPath(request: TransformRequest, signature: string): string;
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/urls.d.ts
|
|
131
|
+
/**
|
|
132
|
+
* Makes a media URL absolute against the instance's public origin.
|
|
133
|
+
*
|
|
134
|
+
* `MediaService.urlFor` returns a storage-driver URL when there is one (S3, a CDN) and a
|
|
135
|
+
* root-relative path otherwise (`/media/…`). A relative path is fine for a same-origin
|
|
136
|
+
* admin and broken for everything the delivery API exists to serve: a frontend on its own
|
|
137
|
+
* domain resolves `/media/…` against *itself* and gets a 404. Found by driving
|
|
138
|
+
* `apps/example-plain` in a browser against a public instance on another port.
|
|
139
|
+
*
|
|
140
|
+
* Already-absolute URLs, protocol-relative URLs and data URIs are returned untouched.
|
|
141
|
+
*/
|
|
142
|
+
export declare function absoluteMediaUrl(url: string, base: string | undefined): string;
|
|
143
|
+
//#endregion
|
|
144
|
+
export { mimeTypeAllowed, withinInstance };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import { ManabloxError, mimeTypeAllowed, 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
|
+
/** Upload, probing, and on-demand image derivatives. */
|
|
154
|
+
var MediaService = class {
|
|
155
|
+
repos;
|
|
156
|
+
storage;
|
|
157
|
+
config;
|
|
158
|
+
options;
|
|
159
|
+
constructor(repos, storage, config, options) {
|
|
160
|
+
this.repos = repos;
|
|
161
|
+
this.storage = storage;
|
|
162
|
+
this.config = config;
|
|
163
|
+
this.options = options;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The limits an upload into `spaceId` is held to: the space's own settings, narrowed
|
|
167
|
+
* to the instance's. The instance's are returned alongside so a settings form can show
|
|
168
|
+
* the ceiling it cannot exceed.
|
|
169
|
+
*/
|
|
170
|
+
async limits(spaceId) {
|
|
171
|
+
const space = await this.repos.spaces.findById(spaceId);
|
|
172
|
+
const instance = resolveAssetLimits(this.options, null, this.options.maxFileSize);
|
|
173
|
+
return {
|
|
174
|
+
effective: resolveAssetLimits(this.options, readSpaceAssetSettings(space?.settings), this.options.maxFileSize),
|
|
175
|
+
instance
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
async upload(input) {
|
|
179
|
+
const { effective: limits } = await this.limits(input.spaceId);
|
|
180
|
+
if (input.body.byteLength > limits.maxFileSize) throw ManabloxError.badRequest("asset.tooLarge", {
|
|
181
|
+
size: input.body.byteLength,
|
|
182
|
+
max: limits.maxFileSize
|
|
183
|
+
});
|
|
184
|
+
const detected = await detectMimeType(input.body, input.mimeType);
|
|
185
|
+
if (!mimeTypeAllowed(detected, limits.allowedMimeTypes)) throw ManabloxError.badRequest("asset.mimeType.notAllowed", { mimeType: detected });
|
|
186
|
+
const checksum = createHash("sha256").update(input.body).digest("hex");
|
|
187
|
+
const existing = await this.repos.assets.findByChecksum(input.spaceId, checksum);
|
|
188
|
+
if (existing) return existing;
|
|
189
|
+
const key = buildStorageKey(input.spaceId, input.filename);
|
|
190
|
+
await this.storage.put(key, input.body, { contentType: detected });
|
|
191
|
+
const dimensions = await probeImage(input.body, detected);
|
|
192
|
+
const asset = await this.repos.assets.create({
|
|
193
|
+
spaceId: input.spaceId,
|
|
194
|
+
driver: this.storage.name,
|
|
195
|
+
key,
|
|
196
|
+
filename: key.split("/").pop() ?? input.filename,
|
|
197
|
+
name: input.filename.replace(/\.[^.]+$/, ""),
|
|
198
|
+
mimeType: detected,
|
|
199
|
+
size: input.body.byteLength,
|
|
200
|
+
width: dimensions?.width ?? null,
|
|
201
|
+
height: dimensions?.height ?? null,
|
|
202
|
+
checksum,
|
|
203
|
+
alt: input.alt ?? null,
|
|
204
|
+
title: input.title ?? null,
|
|
205
|
+
actorId: input.actorId ?? null
|
|
206
|
+
});
|
|
207
|
+
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);
|
|
208
|
+
return asset;
|
|
209
|
+
}
|
|
210
|
+
/** Returns bytes for a preset, generating and caching the variant on first request. */
|
|
211
|
+
async derive(asset, presetName, format) {
|
|
212
|
+
const preset = this.config.presets?.[presetName];
|
|
213
|
+
if (!preset) throw ManabloxError.notFound("media.preset.notFound", { preset: presetName });
|
|
214
|
+
if (!IMAGE_FORMATS.has(format)) throw ManabloxError.badRequest("media.format.unsupported", { format });
|
|
215
|
+
if (!asset.mimeType.startsWith("image/")) throw ManabloxError.badRequest("media.notAnImage", { assetId: asset.id });
|
|
216
|
+
const variantKey = `${asset.key}.${presetName}.${format}`;
|
|
217
|
+
const cached = await this.repos.assets.findVariant(asset.id, presetName, format);
|
|
218
|
+
if (cached && await this.storage.exists(cached.key)) return {
|
|
219
|
+
body: await this.storage.get(cached.key),
|
|
220
|
+
contentType: `image/${format}`
|
|
221
|
+
};
|
|
222
|
+
const body = await transform(await this.storage.get(asset.key), preset, format, readImageEdits(asset.meta));
|
|
223
|
+
const meta = await sharp(body).metadata();
|
|
224
|
+
await this.storage.put(variantKey, body, {
|
|
225
|
+
contentType: `image/${format}`,
|
|
226
|
+
cacheControl: "public, max-age=31536000, immutable"
|
|
227
|
+
});
|
|
228
|
+
await this.repos.assets.addVariant({
|
|
229
|
+
assetId: asset.id,
|
|
230
|
+
preset: presetName,
|
|
231
|
+
format,
|
|
232
|
+
key: variantKey,
|
|
233
|
+
width: meta.width ?? null,
|
|
234
|
+
height: meta.height ?? null,
|
|
235
|
+
size: body.byteLength
|
|
236
|
+
});
|
|
237
|
+
return {
|
|
238
|
+
body,
|
|
239
|
+
contentType: `image/${format}`
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Public URL for a preset — the storage driver's own URL, or a signed transform path.
|
|
244
|
+
*
|
|
245
|
+
* A variant URL carries the asset's version, so a crop or focal-point change reaches a
|
|
246
|
+
* browser or CDN that cached the previous rendering as immutable. The original is
|
|
247
|
+
* never edited, so its URL stays put.
|
|
248
|
+
*/
|
|
249
|
+
/**
|
|
250
|
+
* An asset as a client receives it: the row plus the URLs it needs to show it. The
|
|
251
|
+
* thumbnail is a rendered variant, which only an image has.
|
|
252
|
+
*/
|
|
253
|
+
present(asset) {
|
|
254
|
+
return {
|
|
255
|
+
...asset,
|
|
256
|
+
url: this.urlFor(asset),
|
|
257
|
+
thumbnailUrl: asset.mimeType.startsWith("image/") ? this.urlFor(asset, "thumb", "webp") : null
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
urlFor(asset, presetName, format = "webp") {
|
|
261
|
+
if (!presetName) return this.storage.url(asset.key) ?? `/media/${asset.id}/original`;
|
|
262
|
+
const version = `v=${asset.updatedAt.getTime().toString(36)}`;
|
|
263
|
+
const secret = this.config.signingSecret;
|
|
264
|
+
if (!secret) return `/media/${asset.id}/${presetName}.${format}?${version}`;
|
|
265
|
+
const request = {
|
|
266
|
+
assetId: asset.id,
|
|
267
|
+
preset: presetName,
|
|
268
|
+
format
|
|
269
|
+
};
|
|
270
|
+
return `${transformPath(request, signTransform(secret, request))}&${version}`;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Sets an image's crop and focal point. Non-destructive: the original stays as
|
|
274
|
+
* uploaded, the edits live in `meta`, and every variant derived so far is dropped so
|
|
275
|
+
* the next request renders through the new edits.
|
|
276
|
+
*/
|
|
277
|
+
async setImageEdits(assetId, edits) {
|
|
278
|
+
const asset = await this.repos.assets.findById(assetId);
|
|
279
|
+
if (!asset) throw ManabloxError.notFound("asset.notFound", { id: assetId });
|
|
280
|
+
if (!asset.mimeType.startsWith("image/") || !asset.width || !asset.height) throw ManabloxError.badRequest("asset.notAnImage", { id: assetId });
|
|
281
|
+
const valid = validateImageEdits(edits, {
|
|
282
|
+
width: asset.width,
|
|
283
|
+
height: asset.height
|
|
284
|
+
});
|
|
285
|
+
const meta = { ...asset.meta };
|
|
286
|
+
if (valid.crop) meta.crop = valid.crop;
|
|
287
|
+
else delete meta.crop;
|
|
288
|
+
if (valid.focalPoint) meta.focalPoint = valid.focalPoint;
|
|
289
|
+
else delete meta.focalPoint;
|
|
290
|
+
const variants = await this.repos.assets.variants([assetId]);
|
|
291
|
+
await Promise.all(variants.map((variant) => this.storage.delete(variant.key).catch(() => void 0)));
|
|
292
|
+
await this.repos.assets.deleteVariants(assetId);
|
|
293
|
+
return this.repos.assets.update(assetId, { meta });
|
|
294
|
+
}
|
|
295
|
+
verify(assetId, preset, format, signature) {
|
|
296
|
+
const secret = this.config.signingSecret;
|
|
297
|
+
if (!secret) return true;
|
|
298
|
+
if (!signature) return false;
|
|
299
|
+
return verifyTransform(secret, {
|
|
300
|
+
assetId,
|
|
301
|
+
preset,
|
|
302
|
+
format
|
|
303
|
+
}, signature);
|
|
304
|
+
}
|
|
305
|
+
async delete(assetId) {
|
|
306
|
+
const asset = await this.repos.assets.findById(assetId);
|
|
307
|
+
if (!asset) return;
|
|
308
|
+
const variants = await this.repos.assets.variants([assetId]);
|
|
309
|
+
await Promise.all(variants.map((variant) => this.storage.delete(variant.key).catch(() => void 0)));
|
|
310
|
+
await this.storage.delete(asset.key).catch(() => void 0);
|
|
311
|
+
await this.repos.assets.delete(assetId);
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
/**
|
|
315
|
+
* Applies the preset through the asset's edits: the crop first, then — for a preset
|
|
316
|
+
* that fixes both sides with `cover` — the window around the focal point, then the
|
|
317
|
+
* resize. Both extractions fold into one rectangle, since sharp takes a single one
|
|
318
|
+
* ahead of a resize.
|
|
319
|
+
*/
|
|
320
|
+
async function transform(source, preset, format, edits = {}) {
|
|
321
|
+
const pipeline = sharp(source, { failOn: "error" }).rotate();
|
|
322
|
+
const oriented = orientedSize(await pipeline.metadata());
|
|
323
|
+
let region = edits.crop ?? null;
|
|
324
|
+
const size = region ? {
|
|
325
|
+
width: region.width,
|
|
326
|
+
height: region.height
|
|
327
|
+
} : oriented;
|
|
328
|
+
if (preset.width && preset.height && (preset.fit ?? "inside") === "cover" && size) {
|
|
329
|
+
const window = coverWindow(size, {
|
|
330
|
+
width: preset.width,
|
|
331
|
+
height: preset.height
|
|
332
|
+
}, edits.focalPoint ?? void 0);
|
|
333
|
+
region = {
|
|
334
|
+
left: (region?.left ?? 0) + window.left,
|
|
335
|
+
top: (region?.top ?? 0) + window.top,
|
|
336
|
+
width: window.width,
|
|
337
|
+
height: window.height
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
if (region) pipeline.extract(region);
|
|
341
|
+
if (preset.width || preset.height) pipeline.resize({
|
|
342
|
+
...preset.width ? { width: preset.width } : {},
|
|
343
|
+
...preset.height ? { height: preset.height } : {},
|
|
344
|
+
fit: preset.fit ?? "inside",
|
|
345
|
+
withoutEnlargement: true
|
|
346
|
+
});
|
|
347
|
+
const quality = preset.quality ?? 82;
|
|
348
|
+
switch (format) {
|
|
349
|
+
case "avif": return pipeline.avif({ quality }).toBuffer();
|
|
350
|
+
case "png": return pipeline.png().toBuffer();
|
|
351
|
+
case "jpeg": return pipeline.jpeg({
|
|
352
|
+
quality,
|
|
353
|
+
mozjpeg: true
|
|
354
|
+
}).toBuffer();
|
|
355
|
+
default: return pipeline.webp({ quality }).toBuffer();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
async function probeImage(body, mimeType) {
|
|
359
|
+
if (!mimeType.startsWith("image/")) return null;
|
|
360
|
+
try {
|
|
361
|
+
return orientedSize(await sharp(body).metadata());
|
|
362
|
+
} catch {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* The size as displayed, not as stored: a phone photo carries an EXIF orientation that
|
|
368
|
+
* `rotate()` honours, so a crop drawn on the displayed image must be measured against
|
|
369
|
+
* the rotated dimensions or it lands on the wrong part of the picture.
|
|
370
|
+
*/
|
|
371
|
+
function orientedSize(meta) {
|
|
372
|
+
if (!meta.width || !meta.height) return null;
|
|
373
|
+
return (meta.orientation ?? 1) >= 5 ? {
|
|
374
|
+
width: meta.height,
|
|
375
|
+
height: meta.width
|
|
376
|
+
} : {
|
|
377
|
+
width: meta.width,
|
|
378
|
+
height: meta.height
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
/** Magic-number sniffing for the formats worth trusting; falls back to the declared type. */
|
|
382
|
+
async function detectMimeType(body, declared) {
|
|
383
|
+
for (const [mimeType, bytes] of [
|
|
384
|
+
["image/png", [
|
|
385
|
+
137,
|
|
386
|
+
80,
|
|
387
|
+
78,
|
|
388
|
+
71
|
|
389
|
+
]],
|
|
390
|
+
["image/jpeg", [
|
|
391
|
+
255,
|
|
392
|
+
216,
|
|
393
|
+
255
|
|
394
|
+
]],
|
|
395
|
+
["image/gif", [
|
|
396
|
+
71,
|
|
397
|
+
73,
|
|
398
|
+
70,
|
|
399
|
+
56
|
|
400
|
+
]],
|
|
401
|
+
["application/pdf", [
|
|
402
|
+
37,
|
|
403
|
+
80,
|
|
404
|
+
68,
|
|
405
|
+
70
|
|
406
|
+
]]
|
|
407
|
+
]) if (bytes.every((byte, index) => body[index] === byte)) return mimeType;
|
|
408
|
+
if (body.subarray(0, 4).toString("ascii") === "RIFF" && body.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp";
|
|
409
|
+
if (body.subarray(4, 8).toString("ascii") === "ftyp") {
|
|
410
|
+
const brand = body.subarray(8, 12).toString("ascii");
|
|
411
|
+
if (brand.startsWith("avif")) return "image/avif";
|
|
412
|
+
if (brand.startsWith("heic") || brand.startsWith("mif1")) return "image/heic";
|
|
413
|
+
if (brand.startsWith("isom") || brand.startsWith("mp4")) return "video/mp4";
|
|
414
|
+
}
|
|
415
|
+
return declared;
|
|
416
|
+
}
|
|
417
|
+
//#endregion
|
|
418
|
+
//#region src/urls.ts
|
|
419
|
+
/**
|
|
420
|
+
* Makes a media URL absolute against the instance's public origin.
|
|
421
|
+
*
|
|
422
|
+
* `MediaService.urlFor` returns a storage-driver URL when there is one (S3, a CDN) and a
|
|
423
|
+
* root-relative path otherwise (`/media/…`). A relative path is fine for a same-origin
|
|
424
|
+
* admin and broken for everything the delivery API exists to serve: a frontend on its own
|
|
425
|
+
* domain resolves `/media/…` against *itself* and gets a 404. Found by driving
|
|
426
|
+
* `apps/example-plain` in a browser against a public instance on another port.
|
|
427
|
+
*
|
|
428
|
+
* Already-absolute URLs, protocol-relative URLs and data URIs are returned untouched.
|
|
429
|
+
*/
|
|
430
|
+
function absoluteMediaUrl(url, base) {
|
|
431
|
+
if (!base) return url;
|
|
432
|
+
if (!url.startsWith("/") || url.startsWith("//")) return url;
|
|
433
|
+
return `${base.replace(/\/+$/, "")}${url}`;
|
|
434
|
+
}
|
|
435
|
+
//#endregion
|
|
436
|
+
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.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
7
|
-
"types": "./
|
|
8
|
-
"default": "./
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"default": "./dist/index.js"
|
|
9
9
|
}
|
|
10
10
|
},
|
|
11
|
-
"main": "./
|
|
12
|
-
"types": "./
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@manablox/core": "0.
|
|
15
|
-
"@manablox/db": "0.
|
|
16
|
-
"@manablox/storage": "0.
|
|
14
|
+
"@manablox/core": "0.3.0",
|
|
15
|
+
"@manablox/db": "0.3.0",
|
|
16
|
+
"@manablox/storage": "0.3.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
|
}
|