@spinekit/media 0.1.1
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/CHANGELOG.md +44 -0
- package/LICENSE +75 -0
- package/README.md +82 -0
- package/dist/attachments.d.mts +66 -0
- package/dist/attachments.mjs +118 -0
- package/dist/config/media.config.d.mts +96 -0
- package/dist/config/media.config.mjs +82 -0
- package/dist/config/media.defaults.d.mts +131 -0
- package/dist/config/media.defaults.mjs +145 -0
- package/dist/index.d.mts +243 -0
- package/dist/index.mjs +628 -0
- package/dist/providers/media-provider.config.d.mts +66 -0
- package/dist/providers/media-provider.config.mjs +97 -0
- package/dist/providers/memory.provider.d.mts +5 -0
- package/dist/providers/memory.provider.mjs +68 -0
- package/dist/resources/media/media.buffered-upload.d.mts +47 -0
- package/dist/resources/media/media.buffered-upload.mjs +76 -0
- package/dist/resources/media/media.routes.d.mts +28 -0
- package/dist/resources/media/media.routes.mjs +203 -0
- package/dist/resources/media/media.schema.d.mts +62 -0
- package/dist/resources/media/media.schema.mjs +54 -0
- package/package.json +119 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import { FOLDER_CONTENT_TYPE_MAP, IMAGE_SETTINGS, SIZE_VARIANTS } from "./config/media.defaults.mjs";
|
|
2
|
+
import { defineResource, mergeResourceConfig } from "@classytic/arc";
|
|
3
|
+
import { createEngineSlot } from "@spinekit/kit/engine-slot";
|
|
4
|
+
import { resolveSpineTenant } from "@spinekit/kit/tenant";
|
|
5
|
+
import { scopeFirstCtx } from "@classytic/arc/scope";
|
|
6
|
+
import { QueryParser } from "@classytic/mongokit";
|
|
7
|
+
import { createMongooseAdapter } from "@classytic/mongokit/adapter";
|
|
8
|
+
import { allowPublic, requireOrgRole } from "@classytic/arc/permissions";
|
|
9
|
+
import { defineEngineModule } from "@spinekit/kit/engine-module";
|
|
10
|
+
import { MEDIA_TRANSFORM_POLICY_VERSION } from "@classytic/media-transform/policy";
|
|
11
|
+
import { confirmUploadSchema, startWriteSchema } from "@classytic/media-kit/schemas";
|
|
12
|
+
import { createAssetTransform } from "@classytic/media-kit/transforms";
|
|
13
|
+
//#region src/config/transform-policy.ts
|
|
14
|
+
/** Longest edge a size variant occupies, from its width/height box. */
|
|
15
|
+
function edgeOf(variant) {
|
|
16
|
+
return Math.max(variant.width ?? 0, variant.height ?? 0);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Build the policy document served to clients.
|
|
20
|
+
*
|
|
21
|
+
* Ordering matters and is deliberate: untouched folders are matched FIRST, so a
|
|
22
|
+
* deployment can never accidentally shadow them with a broader imagery rule.
|
|
23
|
+
*/
|
|
24
|
+
function buildTransformPolicy(input = {}) {
|
|
25
|
+
const derivatives = SIZE_VARIANTS.map((v) => ({
|
|
26
|
+
name: v.name,
|
|
27
|
+
maxEdge: edgeOf(v),
|
|
28
|
+
quality: v.quality !== void 0 ? v.quality / 100 : void 0
|
|
29
|
+
})).filter((d) => d.maxEdge > 0);
|
|
30
|
+
const rules = [];
|
|
31
|
+
if (input.untouchedFolders?.length) rules.push({
|
|
32
|
+
id: "originals-untouched",
|
|
33
|
+
when: { folder: [...input.untouchedFolders] },
|
|
34
|
+
use: false
|
|
35
|
+
});
|
|
36
|
+
const avatarFolders = FOLDER_CONTENT_TYPE_MAP.avatar;
|
|
37
|
+
if (avatarFolders.length > 0) rules.push({
|
|
38
|
+
id: "avatars",
|
|
39
|
+
when: { folder: [...avatarFolders] },
|
|
40
|
+
use: { preset: "avatar" }
|
|
41
|
+
});
|
|
42
|
+
return {
|
|
43
|
+
version: MEDIA_TRANSFORM_POLICY_VERSION,
|
|
44
|
+
rules,
|
|
45
|
+
/**
|
|
46
|
+
* Ordinary imagery — product, category, banner, brand and anything a
|
|
47
|
+
* deployment adds later. `ecom` keeps zoomable detail; the derivatives are
|
|
48
|
+
* the server's own declared variants, now produced on the device that
|
|
49
|
+
* already holds the decoded bitmap.
|
|
50
|
+
*
|
|
51
|
+
* A fallback is REQUIRED by the contract, so "no rule matched" can never
|
|
52
|
+
* mean "client decides".
|
|
53
|
+
*/
|
|
54
|
+
fallback: {
|
|
55
|
+
preset: "ecom",
|
|
56
|
+
maxEdge: input.maxEdge ?? IMAGE_SETTINGS.defaultMaxWidth,
|
|
57
|
+
derivatives
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
//#endregion
|
|
62
|
+
//#region src/cleanup.ts
|
|
63
|
+
/** Count candidates through the repository so tenant scoping still applies. */
|
|
64
|
+
async function countCandidates(repo, filters, options) {
|
|
65
|
+
const found = await repo.getAll({ filters }, {
|
|
66
|
+
lean: true,
|
|
67
|
+
...options
|
|
68
|
+
});
|
|
69
|
+
return Array.isArray(found) ? found.length : 0;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The three media purge steps, in the order a full cleanup should run them:
|
|
73
|
+
* stale uploads first (cheapest, unblocks nothing), then expired assets, then
|
|
74
|
+
* the soft-delete backlog.
|
|
75
|
+
*/
|
|
76
|
+
function mediaCleanupSteps(engine, options = {}) {
|
|
77
|
+
const repo = engine.repositories.media;
|
|
78
|
+
const ctx = options.ctx;
|
|
79
|
+
const tenantOpts = () => repo._tenantOpts?.(ctx) ?? {};
|
|
80
|
+
return [
|
|
81
|
+
{
|
|
82
|
+
id: "media.stale-pending",
|
|
83
|
+
resource: "abandoned media uploads",
|
|
84
|
+
destructive: true,
|
|
85
|
+
async estimate(stepCtx) {
|
|
86
|
+
const cutoff = options.stalePendingOlderThan ?? stepCtx.now;
|
|
87
|
+
const estimated = await countCandidates(repo, {
|
|
88
|
+
status: { $in: ["pending", "deleting"] },
|
|
89
|
+
createdAt: { $lt: cutoff }
|
|
90
|
+
}, tenantOpts());
|
|
91
|
+
return {
|
|
92
|
+
resource: this.resource,
|
|
93
|
+
estimated,
|
|
94
|
+
retained: "completed uploads untouched",
|
|
95
|
+
warnings: ["Removes the storage object as well as the row — irreversible.", "Not chunked: the kernel loads all candidates before deleting, so a cancel lands after this step, not mid-sweep."]
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
async execute(stepCtx) {
|
|
99
|
+
await stepCtx.throwIfCancelled?.();
|
|
100
|
+
try {
|
|
101
|
+
const processed = await repo.purgeStalePending(options.stalePendingOlderThan, ctx);
|
|
102
|
+
await stepCtx.onProgress?.({
|
|
103
|
+
resource: this.resource,
|
|
104
|
+
processed
|
|
105
|
+
});
|
|
106
|
+
return {
|
|
107
|
+
resource: this.resource,
|
|
108
|
+
processed,
|
|
109
|
+
ok: true
|
|
110
|
+
};
|
|
111
|
+
} catch (error) {
|
|
112
|
+
return {
|
|
113
|
+
resource: this.resource,
|
|
114
|
+
processed: 0,
|
|
115
|
+
ok: false,
|
|
116
|
+
error: error instanceof Error ? error.message : String(error)
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
async verify(stepCtx) {
|
|
121
|
+
const cutoff = options.stalePendingOlderThan ?? stepCtx.now;
|
|
122
|
+
const remaining = await countCandidates(repo, {
|
|
123
|
+
status: { $in: ["pending", "deleting"] },
|
|
124
|
+
createdAt: { $lt: cutoff }
|
|
125
|
+
}, tenantOpts());
|
|
126
|
+
return [{
|
|
127
|
+
name: "media.stale-pending.drained",
|
|
128
|
+
ok: remaining === 0,
|
|
129
|
+
detail: remaining === 0 ? "no stale uploads remain" : `${remaining} still present`
|
|
130
|
+
}];
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: "media.expired",
|
|
135
|
+
resource: "expired media assets",
|
|
136
|
+
destructive: true,
|
|
137
|
+
async estimate(stepCtx) {
|
|
138
|
+
const cutoff = options.expiredBefore ?? stepCtx.now;
|
|
139
|
+
const estimated = await countCandidates(repo, { expiresAt: {
|
|
140
|
+
$ne: null,
|
|
141
|
+
$lte: cutoff
|
|
142
|
+
} }, tenantOpts());
|
|
143
|
+
return {
|
|
144
|
+
resource: this.resource,
|
|
145
|
+
estimated,
|
|
146
|
+
retained: "assets without an expiry, and those not yet expired",
|
|
147
|
+
warnings: ["Removes the storage object as well as the row — irreversible."]
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
async execute(stepCtx) {
|
|
151
|
+
await stepCtx.throwIfCancelled?.();
|
|
152
|
+
try {
|
|
153
|
+
const result = await repo.purgeExpired(options.expiredBefore, ctx);
|
|
154
|
+
const processed = result.success.length;
|
|
155
|
+
await stepCtx.onProgress?.({
|
|
156
|
+
resource: this.resource,
|
|
157
|
+
processed
|
|
158
|
+
});
|
|
159
|
+
return {
|
|
160
|
+
resource: this.resource,
|
|
161
|
+
processed,
|
|
162
|
+
ok: result.failed.length === 0,
|
|
163
|
+
...result.failed.length > 0 ? { error: `${result.failed.length} asset(s) failed to purge` } : {}
|
|
164
|
+
};
|
|
165
|
+
} catch (error) {
|
|
166
|
+
return {
|
|
167
|
+
resource: this.resource,
|
|
168
|
+
processed: 0,
|
|
169
|
+
ok: false,
|
|
170
|
+
error: error instanceof Error ? error.message : String(error)
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
async verify(stepCtx) {
|
|
175
|
+
const cutoff = options.expiredBefore ?? stepCtx.now;
|
|
176
|
+
const remaining = await countCandidates(repo, { expiresAt: {
|
|
177
|
+
$ne: null,
|
|
178
|
+
$lte: cutoff
|
|
179
|
+
} }, tenantOpts());
|
|
180
|
+
return [{
|
|
181
|
+
name: "media.expired.drained",
|
|
182
|
+
ok: remaining === 0,
|
|
183
|
+
detail: remaining === 0 ? "no expired assets remain" : `${remaining} still present`
|
|
184
|
+
}];
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
id: "media.soft-deleted",
|
|
189
|
+
resource: "soft-deleted media",
|
|
190
|
+
destructive: true,
|
|
191
|
+
async estimate(_stepCtx) {
|
|
192
|
+
const estimated = options.deletedOlderThan ? await countCandidates(repo, { deletedAt: {
|
|
193
|
+
$ne: null,
|
|
194
|
+
$lt: options.deletedOlderThan
|
|
195
|
+
} }, {
|
|
196
|
+
includeDeleted: true,
|
|
197
|
+
...tenantOpts()
|
|
198
|
+
}) : await countCandidates(repo, { deletedAt: { $ne: null } }, {
|
|
199
|
+
includeDeleted: true,
|
|
200
|
+
...tenantOpts()
|
|
201
|
+
});
|
|
202
|
+
return {
|
|
203
|
+
resource: this.resource,
|
|
204
|
+
estimated,
|
|
205
|
+
retained: "live media untouched",
|
|
206
|
+
warnings: [
|
|
207
|
+
"Removes the storage object as well as the row — irreversible.",
|
|
208
|
+
...options.deletedOlderThan ? [] : ["No explicit cutoff: the kernel default (softDelete.ttlDays, 30d) applies, so this estimate counts the FULL soft-deleted set and may overstate what is purged. Pass `deletedOlderThan` for an exact preview."],
|
|
209
|
+
"Not chunked: the kernel loads all candidates before deleting."
|
|
210
|
+
]
|
|
211
|
+
};
|
|
212
|
+
},
|
|
213
|
+
async execute(stepCtx) {
|
|
214
|
+
await stepCtx.throwIfCancelled?.();
|
|
215
|
+
try {
|
|
216
|
+
const processed = await repo.purgeDeleted(options.deletedOlderThan, ctx);
|
|
217
|
+
await stepCtx.onProgress?.({
|
|
218
|
+
resource: this.resource,
|
|
219
|
+
processed
|
|
220
|
+
});
|
|
221
|
+
return {
|
|
222
|
+
resource: this.resource,
|
|
223
|
+
processed,
|
|
224
|
+
ok: true
|
|
225
|
+
};
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return {
|
|
228
|
+
resource: this.resource,
|
|
229
|
+
processed: 0,
|
|
230
|
+
ok: false,
|
|
231
|
+
error: error instanceof Error ? error.message : String(error)
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
async verify(_stepCtx) {
|
|
236
|
+
if (!options.deletedOlderThan) return [{
|
|
237
|
+
name: "media.soft-deleted.swept",
|
|
238
|
+
ok: true,
|
|
239
|
+
detail: `${await countCandidates(repo, { deletedAt: { $ne: null } }, {
|
|
240
|
+
includeDeleted: true,
|
|
241
|
+
...tenantOpts()
|
|
242
|
+
})} soft-deleted row(s) remain inside the kernel retention window`
|
|
243
|
+
}];
|
|
244
|
+
const remaining = await countCandidates(repo, { deletedAt: {
|
|
245
|
+
$ne: null,
|
|
246
|
+
$lt: options.deletedOlderThan
|
|
247
|
+
} }, {
|
|
248
|
+
includeDeleted: true,
|
|
249
|
+
...tenantOpts()
|
|
250
|
+
});
|
|
251
|
+
return [{
|
|
252
|
+
name: "media.soft-deleted.drained",
|
|
253
|
+
ok: remaining === 0,
|
|
254
|
+
detail: remaining === 0 ? "no purgeable soft-deleted media remain" : `${remaining} still present`
|
|
255
|
+
}];
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
];
|
|
259
|
+
}
|
|
260
|
+
//#endregion
|
|
261
|
+
//#region src/index.ts
|
|
262
|
+
function createMediaRolePermissions(input) {
|
|
263
|
+
if (input.view.length === 0 || input.upload.length === 0 || input.manage.length === 0) throw new Error("createMediaRolePermissions: role lists must not be empty");
|
|
264
|
+
return {
|
|
265
|
+
view: requireOrgRole(...input.view),
|
|
266
|
+
upload: requireOrgRole(...input.upload),
|
|
267
|
+
manage: requireOrgRole(...input.manage)
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
function createMediaEngineSlot() {
|
|
271
|
+
return createEngineSlot("media");
|
|
272
|
+
}
|
|
273
|
+
/** Actor context from the arc request — the actor is ALWAYS the authenticated user. */
|
|
274
|
+
function ctxOf(req) {
|
|
275
|
+
const { actorId: userId, organizationId } = scopeFirstCtx(req, { orgHeader: false });
|
|
276
|
+
return {
|
|
277
|
+
...userId !== void 0 ? { userId } : {},
|
|
278
|
+
...organizationId !== void 0 ? { organizationId } : {}
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Parse a command body against a STRICT schema, or 400 with the reason.
|
|
283
|
+
*
|
|
284
|
+
* Replaces per-route `typeof` checks followed by a cast. Those validated the
|
|
285
|
+
* two or three fields someone remembered and passed everything else through
|
|
286
|
+
* untouched — so an invalid `hashStrategy`, a negative dimension, a malformed
|
|
287
|
+
* hash or an unknown key reached kernel code, and Zod's default STRIP would
|
|
288
|
+
* have quietly dropped the unknown one rather than refusing it. For an upload
|
|
289
|
+
* command, dropping a key does not fail; it executes a different instruction
|
|
290
|
+
* than the caller wrote.
|
|
291
|
+
*/
|
|
292
|
+
function parseBody(reply, schema, body) {
|
|
293
|
+
const result = schema.safeParse(body ?? {});
|
|
294
|
+
if (!result.success) {
|
|
295
|
+
badRequest(reply, (result.error?.issues ?? []).slice(0, 4).map((i) => `${i.path.join(".") || "(body)"}: ${i.message}`).join("; ") || "invalid request body");
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
return result.data;
|
|
299
|
+
}
|
|
300
|
+
function badRequest(reply, message) {
|
|
301
|
+
return reply.code(400).send({
|
|
302
|
+
code: "media.invalid_input",
|
|
303
|
+
message,
|
|
304
|
+
status: 400
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
const DEFAULT_MULTIPART_THRESHOLD = 104857600;
|
|
308
|
+
function createMediaResource(deps) {
|
|
309
|
+
const { permissions } = deps;
|
|
310
|
+
const media = deps.engine.repositories.media;
|
|
311
|
+
const threshold = deps.multipartThresholdBytes ?? DEFAULT_MULTIPART_THRESHOLD;
|
|
312
|
+
const transformPolicy = deps.transformPolicy ?? buildTransformPolicy();
|
|
313
|
+
const assetTransform = createAssetTransform({ media: deps.engine });
|
|
314
|
+
const serveAsset = async (req, reply) => {
|
|
315
|
+
const params = req.params;
|
|
316
|
+
const query = Object.fromEntries(Object.entries(req.query).filter((entry) => typeof entry[1] === "string"));
|
|
317
|
+
const result = await assetTransform.handle({
|
|
318
|
+
fileId: params.id,
|
|
319
|
+
...params.variant ? { variant: params.variant } : {},
|
|
320
|
+
params: req.query,
|
|
321
|
+
query,
|
|
322
|
+
...typeof req.headers.accept === "string" ? { accept: req.headers.accept } : {},
|
|
323
|
+
...typeof req.headers.range === "string" ? { range: req.headers.range } : {},
|
|
324
|
+
principal: req.user ?? req.scope
|
|
325
|
+
});
|
|
326
|
+
reply.code(result.status).headers(result.headers);
|
|
327
|
+
return reply.send(result.stream);
|
|
328
|
+
};
|
|
329
|
+
return defineResource(mergeResourceConfig({
|
|
330
|
+
name: "media",
|
|
331
|
+
displayName: "Media",
|
|
332
|
+
tag: "Media",
|
|
333
|
+
prefix: deps.prefix ?? "/media",
|
|
334
|
+
audit: true,
|
|
335
|
+
tenantField: resolveSpineTenant(deps.engine.config.tenant).resourceTenantField,
|
|
336
|
+
adapter: createMongooseAdapter({
|
|
337
|
+
model: deps.engine.models.Media,
|
|
338
|
+
repository: media
|
|
339
|
+
}),
|
|
340
|
+
queryParser: new QueryParser({
|
|
341
|
+
maxLimit: 100,
|
|
342
|
+
searchMode: "regex",
|
|
343
|
+
searchFields: [
|
|
344
|
+
"filename",
|
|
345
|
+
"title",
|
|
346
|
+
"tags"
|
|
347
|
+
],
|
|
348
|
+
allowedFilterFields: [
|
|
349
|
+
"status",
|
|
350
|
+
"mimeType",
|
|
351
|
+
"folder",
|
|
352
|
+
"visibility",
|
|
353
|
+
"hash",
|
|
354
|
+
"filename",
|
|
355
|
+
"tags"
|
|
356
|
+
],
|
|
357
|
+
allowedSortFields: [
|
|
358
|
+
"createdAt",
|
|
359
|
+
"size",
|
|
360
|
+
"filename"
|
|
361
|
+
]
|
|
362
|
+
}),
|
|
363
|
+
disabledRoutes: ["create", "update"],
|
|
364
|
+
permissions: {
|
|
365
|
+
list: permissions.view,
|
|
366
|
+
get: permissions.view,
|
|
367
|
+
delete: permissions.manage
|
|
368
|
+
},
|
|
369
|
+
actions: { "signed-url": {
|
|
370
|
+
handler: async (id, data, req) => {
|
|
371
|
+
const opts = data ?? {};
|
|
372
|
+
return { url: await media.getSignedAssetUrl(id, {
|
|
373
|
+
...opts.variant !== void 0 ? { variant: opts.variant } : {},
|
|
374
|
+
...opts.expiresIn !== void 0 ? { expiresIn: opts.expiresIn } : {}
|
|
375
|
+
}, ctxOf(req)) };
|
|
376
|
+
},
|
|
377
|
+
permissions: permissions.view
|
|
378
|
+
} },
|
|
379
|
+
routes: [
|
|
380
|
+
{
|
|
381
|
+
method: "GET",
|
|
382
|
+
path: "/content/:id",
|
|
383
|
+
summary: "Serve public media or private media bearing a valid signed URL",
|
|
384
|
+
permissions: allowPublic(),
|
|
385
|
+
rawHandler: serveAsset
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
method: "GET",
|
|
389
|
+
path: "/content/:id/:variant",
|
|
390
|
+
summary: "Serve a named media variant bearing a valid signed URL",
|
|
391
|
+
permissions: allowPublic(),
|
|
392
|
+
rawHandler: serveAsset
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
method: "POST",
|
|
396
|
+
path: "/start-write",
|
|
397
|
+
summary: "Begin a direct-to-storage upload: dedup short-circuit, presigned PUT, or multipart/resumable session",
|
|
398
|
+
permissions: permissions.upload,
|
|
399
|
+
rawHandler: async (req, reply) => {
|
|
400
|
+
const body = parseBody(reply, startWriteSchema, req.body);
|
|
401
|
+
if (!body) return;
|
|
402
|
+
const ctx = ctxOf(req);
|
|
403
|
+
if (typeof body.sha256 === "string" && body.sha256.length > 0) {
|
|
404
|
+
const hit = await media.existsByHash(body.sha256, ctx, { ...typeof body.size === "number" ? { expectedSize: body.size } : {} });
|
|
405
|
+
if (hit.exists && hit.media) {
|
|
406
|
+
const result = {
|
|
407
|
+
kind: "dedup",
|
|
408
|
+
media: hit.media
|
|
409
|
+
};
|
|
410
|
+
return reply.code(200).send(result);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (body.multipart === true || body.partCount !== void 0 || typeof body.size === "number" && body.size >= threshold) {
|
|
414
|
+
const input = {
|
|
415
|
+
filename: body.filename,
|
|
416
|
+
contentType: body.contentType,
|
|
417
|
+
...body.folder !== void 0 ? { folder: body.folder } : {},
|
|
418
|
+
...body.partCount !== void 0 ? { partCount: body.partCount } : {},
|
|
419
|
+
...body.expiresIn !== void 0 ? { expiresIn: body.expiresIn } : {}
|
|
420
|
+
};
|
|
421
|
+
const session = await media.initiateMultipartUpload(input, ctx);
|
|
422
|
+
const result = {
|
|
423
|
+
kind: session.type,
|
|
424
|
+
session
|
|
425
|
+
};
|
|
426
|
+
return reply.code(201).send(result);
|
|
427
|
+
}
|
|
428
|
+
const upload = await media.getSignedUploadUrl(body.filename, body.contentType, {
|
|
429
|
+
...body.folder !== void 0 ? { folder: body.folder } : {},
|
|
430
|
+
...body.size !== void 0 ? { size: body.size } : {},
|
|
431
|
+
...body.expiresIn !== void 0 ? { expiresIn: body.expiresIn } : {}
|
|
432
|
+
}, ctx);
|
|
433
|
+
/**
|
|
434
|
+
* Presign the sizes the client made from the SAME decode.
|
|
435
|
+
*
|
|
436
|
+
* Requested in this call rather than a second round trip: the
|
|
437
|
+
* transform policy already told the client which sizes to make,
|
|
438
|
+
* and it made them before it had anywhere to put them.
|
|
439
|
+
*
|
|
440
|
+
* A failure here FAILS the whole start — not a partial success.
|
|
441
|
+
* Handing back a primary URL with no derivative URLs would look
|
|
442
|
+
* identical to "this policy asks for none", and the client would
|
|
443
|
+
* upload one file believing it had done the job.
|
|
444
|
+
*/
|
|
445
|
+
const derivativeUploads = body.derivatives && body.derivatives.length > 0 ? await media.signDerivativeUploads({
|
|
446
|
+
primaryKey: upload.key,
|
|
447
|
+
derivatives: body.derivatives,
|
|
448
|
+
...body.expiresIn !== void 0 ? { expiresIn: body.expiresIn } : {}
|
|
449
|
+
}, ctx) : void 0;
|
|
450
|
+
const result = {
|
|
451
|
+
kind: "presigned",
|
|
452
|
+
upload,
|
|
453
|
+
...derivativeUploads?.length ? { derivativeUploads } : {}
|
|
454
|
+
};
|
|
455
|
+
return reply.code(201).send(result);
|
|
456
|
+
}
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
method: "GET",
|
|
460
|
+
path: "/transform-policy",
|
|
461
|
+
summary: "What a client should do to an upload before sending it (host-owned)",
|
|
462
|
+
/**
|
|
463
|
+
* Readable by anyone who can VIEW media, not just uploaders: the
|
|
464
|
+
* policy is a description of the deployment's own rules, and a
|
|
465
|
+
* client resolves it before it knows whether it will upload.
|
|
466
|
+
*/
|
|
467
|
+
permissions: permissions.view,
|
|
468
|
+
rawHandler: async (_req, reply) => {
|
|
469
|
+
/**
|
|
470
|
+
* Cacheable, and deliberately so — this document changes on
|
|
471
|
+
* deploy, not per request, and every upload consults it. It is
|
|
472
|
+
* versioned, so a client that does not understand the version
|
|
473
|
+
* declines to transform rather than guessing.
|
|
474
|
+
*/
|
|
475
|
+
reply.header("cache-control", "public, max-age=300");
|
|
476
|
+
return reply.code(200).send(transformPolicy);
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
method: "POST",
|
|
481
|
+
path: "/complete-write",
|
|
482
|
+
summary: "Confirm a presigned upload — storage-verified (exists/stat/MIME), stores client display hints",
|
|
483
|
+
permissions: permissions.upload,
|
|
484
|
+
rawHandler: async (req, reply) => {
|
|
485
|
+
const body = parseBody(reply, confirmUploadSchema, req.body);
|
|
486
|
+
if (!body) return;
|
|
487
|
+
const doc = await media.confirmUpload(body, ctxOf(req));
|
|
488
|
+
return reply.code(201).send(doc);
|
|
489
|
+
}
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
method: "POST",
|
|
493
|
+
path: "/sign-parts",
|
|
494
|
+
summary: "Sign multipart upload part URLs on demand",
|
|
495
|
+
permissions: permissions.upload,
|
|
496
|
+
rawHandler: async (req, reply) => {
|
|
497
|
+
const body = req.body ?? {};
|
|
498
|
+
if (typeof body.key !== "string" || typeof body.uploadId !== "string") return badRequest(reply, "`key` and `uploadId` are required");
|
|
499
|
+
if (!Array.isArray(body.partNumbers) || body.partNumbers.length === 0) return badRequest(reply, "`partNumbers` must be a non-empty array");
|
|
500
|
+
const parts = await media.signUploadParts(body.key, body.uploadId, body.partNumbers, body.expiresIn);
|
|
501
|
+
return reply.code(200).send({ parts });
|
|
502
|
+
}
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
method: "POST",
|
|
506
|
+
path: "/complete-multipart",
|
|
507
|
+
summary: "Assemble uploaded parts and register the asset (storage-verified)",
|
|
508
|
+
permissions: permissions.upload,
|
|
509
|
+
rawHandler: async (req, reply) => {
|
|
510
|
+
const body = req.body ?? {};
|
|
511
|
+
if (typeof body.key !== "string" || typeof body.uploadId !== "string") return badRequest(reply, "`key` and `uploadId` are required");
|
|
512
|
+
if (!Array.isArray(body.parts) || body.parts.length === 0) return badRequest(reply, "`parts` must be a non-empty array");
|
|
513
|
+
if (typeof body.filename !== "string" || typeof body.mimeType !== "string") return badRequest(reply, "`filename` and `mimeType` are required");
|
|
514
|
+
const doc = await media.completeMultipartUpload(body, ctxOf(req));
|
|
515
|
+
return reply.code(201).send(doc);
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
method: "POST",
|
|
520
|
+
path: "/abort-multipart",
|
|
521
|
+
summary: "Abort an in-flight multipart upload (tenant-guarded)",
|
|
522
|
+
permissions: permissions.upload,
|
|
523
|
+
rawHandler: async (req, reply) => {
|
|
524
|
+
const body = req.body ?? {};
|
|
525
|
+
if (typeof body.key !== "string" || typeof body.uploadId !== "string") return badRequest(reply, "`key` and `uploadId` are required");
|
|
526
|
+
await media.abortMultipartUpload(body.key, body.uploadId, ctxOf(req));
|
|
527
|
+
return reply.code(204).send();
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
]
|
|
531
|
+
}, deps.seams));
|
|
532
|
+
}
|
|
533
|
+
function createMediaModule(deps) {
|
|
534
|
+
const supplied = deps.source?.kind === "external" ? deps.source.engine : deps.engine;
|
|
535
|
+
const maintenance = deps.maintenance;
|
|
536
|
+
const { module } = defineEngineModule({
|
|
537
|
+
name: "media",
|
|
538
|
+
...deps.dependsOn ? { dependsOn: [...deps.dependsOn] } : {},
|
|
539
|
+
...supplied ? { supplied } : {},
|
|
540
|
+
...deps.slot ? { slot: deps.slot } : {},
|
|
541
|
+
boot: async () => {
|
|
542
|
+
if (deps.source?.kind === "blueprint") {
|
|
543
|
+
const rt = typeof deps.source.runtime === "function" ? await deps.source.runtime() : deps.source.runtime;
|
|
544
|
+
return await deps.source.blueprint.bind(deps.source.connection, rt);
|
|
545
|
+
}
|
|
546
|
+
throw new Error("createMediaModule: supply a blueprint source or a live engine");
|
|
547
|
+
},
|
|
548
|
+
close: (engine) => engine.close(),
|
|
549
|
+
resources: (engine) => {
|
|
550
|
+
const { seams: seamsInput, source: _source, slot: _slot, ...resourceDeps } = deps;
|
|
551
|
+
const seams = typeof seamsInput === "function" ? seamsInput(engine) : seamsInput;
|
|
552
|
+
return [createMediaResource({
|
|
553
|
+
...resourceDeps,
|
|
554
|
+
engine,
|
|
555
|
+
...seams ? { seams } : {}
|
|
556
|
+
})];
|
|
557
|
+
},
|
|
558
|
+
...deps.afterResources ? { afterResources: deps.afterResources } : {},
|
|
559
|
+
extend: (get) => maintenance === false ? {} : { scheduledJobs: () => mediaMaintenanceSchedules(get(), maintenance) }
|
|
560
|
+
});
|
|
561
|
+
return module;
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Maintenance schedule definitions for arc's `schedulesPlugin` — drop into
|
|
565
|
+
* the host's schedule table (with a `lock` for multi-replica leader safety):
|
|
566
|
+
*
|
|
567
|
+
* ```ts
|
|
568
|
+
* await app.register(schedulesPlugin, {
|
|
569
|
+
* lock: createMongoLockAdapter({ connection }),
|
|
570
|
+
* schedules: [...mediaMaintenanceSchedules(engine), ...otherJobs],
|
|
571
|
+
* });
|
|
572
|
+
* ```
|
|
573
|
+
*
|
|
574
|
+
* ## What these sweeps do NOT cover — read before relying on them
|
|
575
|
+
*
|
|
576
|
+
* An abandoned TWO-PHASE upload leaves **no database row at all**. `start-write`
|
|
577
|
+
* only signs a URL; the record is created by `complete-write`. So a client that
|
|
578
|
+
* presigns and then never confirms leaves an object in the bucket that nothing
|
|
579
|
+
* here can see, let alone reclaim.
|
|
580
|
+
*
|
|
581
|
+
* This docblock previously said those uploads "leave `pending` docs that these
|
|
582
|
+
* sweeps reclaim", and treated bucket lifecycle as belt-and-braces. That was
|
|
583
|
+
* backwards, and it is the dangerous direction to be wrong in: a deployment
|
|
584
|
+
* reading it would skip the lifecycle rule and accumulate unreferenced objects
|
|
585
|
+
* it is still paying to store, with a maintenance job reporting success the
|
|
586
|
+
* whole time.
|
|
587
|
+
*
|
|
588
|
+
* **A bucket lifecycle expiry on incomplete/unreferenced keys is REQUIRED, not
|
|
589
|
+
* optional.** See media-kit's README ("Orphaned storage objects").
|
|
590
|
+
*
|
|
591
|
+
* What these sweeps genuinely reclaim: rows that DO exist and are stuck —
|
|
592
|
+
* `pending` records from the buffered upload path, soft-deleted docs past their
|
|
593
|
+
* retention window, and expired assets.
|
|
594
|
+
*/
|
|
595
|
+
function mediaMaintenanceSchedules(engine, options = {}) {
|
|
596
|
+
const media = engine.repositories.media;
|
|
597
|
+
return [
|
|
598
|
+
{
|
|
599
|
+
name: "media.purge.stale-pending",
|
|
600
|
+
every: options.stalePendingEvery ?? 36e5,
|
|
601
|
+
leaseMs: Math.floor((options.stalePendingEvery ?? 36e5) * .9),
|
|
602
|
+
jitterMs: 6e4,
|
|
603
|
+
handler: async () => {
|
|
604
|
+
await media.purgeStalePending();
|
|
605
|
+
}
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
name: "media.purge.deleted",
|
|
609
|
+
every: options.purgeDeletedEvery ?? 864e5,
|
|
610
|
+
leaseMs: Math.floor((options.purgeDeletedEvery ?? 864e5) * .9),
|
|
611
|
+
jitterMs: 3e5,
|
|
612
|
+
handler: async () => {
|
|
613
|
+
await media.purgeDeleted();
|
|
614
|
+
}
|
|
615
|
+
},
|
|
616
|
+
{
|
|
617
|
+
name: "media.purge.expired",
|
|
618
|
+
every: options.purgeExpiredEvery ?? 36e5,
|
|
619
|
+
leaseMs: Math.floor((options.purgeExpiredEvery ?? 36e5) * .9),
|
|
620
|
+
jitterMs: 6e4,
|
|
621
|
+
handler: async () => {
|
|
622
|
+
await media.purgeExpired();
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
];
|
|
626
|
+
}
|
|
627
|
+
//#endregion
|
|
628
|
+
export { createMediaEngineSlot, createMediaModule, createMediaResource, createMediaRolePermissions, mediaCleanupSteps, mediaMaintenanceSchedules };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { StorageDriver } from "@classytic/media-kit";
|
|
2
|
+
//#region src/providers/media-provider.config.d.ts
|
|
3
|
+
type StorageDriverLike = import('@classytic/media-kit').StorageDriver;
|
|
4
|
+
interface S3ProviderConfig {
|
|
5
|
+
kind: 's3';
|
|
6
|
+
bucket: string;
|
|
7
|
+
region: string;
|
|
8
|
+
accessKeyId: string;
|
|
9
|
+
secretAccessKey: string;
|
|
10
|
+
publicUrl?: string;
|
|
11
|
+
acl?: string;
|
|
12
|
+
}
|
|
13
|
+
interface GcsProviderConfig {
|
|
14
|
+
kind: 'gcs';
|
|
15
|
+
bucket: string;
|
|
16
|
+
projectId?: string;
|
|
17
|
+
keyFilename?: string;
|
|
18
|
+
credentials?: Record<string, unknown>;
|
|
19
|
+
publicUrl?: string;
|
|
20
|
+
}
|
|
21
|
+
interface CloudflareImagesProviderConfig {
|
|
22
|
+
kind: 'cloudflare-images';
|
|
23
|
+
accountId: string;
|
|
24
|
+
apiToken: string;
|
|
25
|
+
deliveryUrl?: string;
|
|
26
|
+
}
|
|
27
|
+
interface CloudinaryProviderConfig {
|
|
28
|
+
kind: 'cloudinary';
|
|
29
|
+
cloudName: string;
|
|
30
|
+
apiKey: string;
|
|
31
|
+
apiSecret: string;
|
|
32
|
+
}
|
|
33
|
+
interface ImageKitProviderConfig {
|
|
34
|
+
kind: 'imagekit';
|
|
35
|
+
publicKey: string;
|
|
36
|
+
privateKey: string;
|
|
37
|
+
urlEndpoint: string;
|
|
38
|
+
}
|
|
39
|
+
interface ImgbbProviderConfig {
|
|
40
|
+
kind: 'imgbb';
|
|
41
|
+
apiKey: string;
|
|
42
|
+
}
|
|
43
|
+
interface LocalProviderConfig {
|
|
44
|
+
kind: 'local';
|
|
45
|
+
basePath: string;
|
|
46
|
+
publicUrl?: string;
|
|
47
|
+
}
|
|
48
|
+
/** No credentials, nothing persisted. Dev and tests only — never a production fallback. */
|
|
49
|
+
interface MemoryProviderConfig {
|
|
50
|
+
kind: 'memory';
|
|
51
|
+
}
|
|
52
|
+
type MediaProviderConfig = S3ProviderConfig | GcsProviderConfig | CloudflareImagesProviderConfig | CloudinaryProviderConfig | ImageKitProviderConfig | ImgbbProviderConfig | LocalProviderConfig | MemoryProviderConfig;
|
|
53
|
+
interface ResolveMediaProviderOptions {
|
|
54
|
+
/**
|
|
55
|
+
* Driver to use when `config` is absent or incomplete.
|
|
56
|
+
*
|
|
57
|
+
* EXPLICIT opt-in. Without it an incomplete config throws, because a silent fallback
|
|
58
|
+
* means uploads that appear to succeed and vanish.
|
|
59
|
+
*/
|
|
60
|
+
fallback?: () => Promise<StorageDriverLike> | StorageDriverLike;
|
|
61
|
+
}
|
|
62
|
+
/** Which required fields are missing. Exported so a host can pre-flight its own config. */
|
|
63
|
+
declare function missingProviderFields(config: MediaProviderConfig): string[];
|
|
64
|
+
declare function resolveMediaProvider(config: MediaProviderConfig | undefined, options?: ResolveMediaProviderOptions): Promise<StorageDriverLike>;
|
|
65
|
+
//#endregion
|
|
66
|
+
export { CloudflareImagesProviderConfig, CloudinaryProviderConfig, GcsProviderConfig, ImageKitProviderConfig, ImgbbProviderConfig, LocalProviderConfig, MediaProviderConfig, MemoryProviderConfig, ResolveMediaProviderOptions, S3ProviderConfig, type StorageDriver, missingProviderFields, resolveMediaProvider };
|