@adonis-agora/media 0.12.0 → 0.12.2
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 +28 -0
- package/package.json +11 -8
- package/skills/media-attachments-single-file/SKILL.md +224 -0
- package/skills/media-library-collections/SKILL.md +334 -0
- package/skills/media-setup/SKILL.md +253 -0
- package/skills/media-stores-delivery/SKILL.md +302 -0
- package/skills/media-uploads-resumable/SKILL.md +347 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: media-uploads-resumable
|
|
3
|
+
description: >-
|
|
4
|
+
Large-file uploads in @adonis-agora/media — the uploads config key (mode auto|proxy|
|
|
5
|
+
direct, partSize, presignTtlSeconds, opt-in routes), media.uploads
|
|
6
|
+
(initiateDirectUpload/completeDirectUpload/proxyUpload, resolveUploadMode), resumable
|
|
7
|
+
TUS sessions via media.resumable and TusUploadHandler (UploadSessionStore, in-memory +
|
|
8
|
+
Lucid, byte-offset resume), direct sessions via media.direct (presigned part URLs,
|
|
9
|
+
part-granular resume, confirmPart/status), the DirectUploadPolicy hooks
|
|
10
|
+
(onInitiate/resolveComplete/onComplete/mapError, server-side keys), and adopting
|
|
11
|
+
finished uploads with completeUploadToLibrary / completeDirectUploadToLibrary /
|
|
12
|
+
attachExisting. Use when uploading big files, resuming interrupted uploads, writing an
|
|
13
|
+
upload policy, or fixing UploadNotSupportedError / UploadPartsIncompleteError /
|
|
14
|
+
ResumableUploadsNotConfiguredError.
|
|
15
|
+
license: MIT
|
|
16
|
+
metadata:
|
|
17
|
+
type: core
|
|
18
|
+
library: "@adonis-agora/media"
|
|
19
|
+
library_version: "0.12.1"
|
|
20
|
+
framework: adonisjs
|
|
21
|
+
sources:
|
|
22
|
+
- "DavideCarvalho/adonis-media:docs/uploads/upload-modes.mdx"
|
|
23
|
+
- "DavideCarvalho/adonis-media:docs/uploads/resumable-tus.mdx"
|
|
24
|
+
- "DavideCarvalho/adonis-media:docs/uploads/direct-sessions.mdx"
|
|
25
|
+
- "DavideCarvalho/adonis-media:docs/uploads/direct-upload-policy.mdx"
|
|
26
|
+
- "DavideCarvalho/adonis-media:packages/adonis/src/upload_mode.ts"
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
# Large-file uploads: proxy, direct-S3, TUS, and policies
|
|
30
|
+
|
|
31
|
+
Beyond buffer-once `attach`, the library ships upload coordinators with three strategies:
|
|
32
|
+
**proxy** (bytes stream through your app — any disk), **direct** (browser PUTs presigned
|
|
33
|
+
multipart parts straight to S3 — multipart-capable disk only), and **resumable TUS**
|
|
34
|
+
(byte-offset resume over the tus 1.0.0 protocol). `media.direct` adds session-backed direct
|
|
35
|
+
uploads whose state survives a reload.
|
|
36
|
+
|
|
37
|
+
## Setup
|
|
38
|
+
|
|
39
|
+
```ts title="config/media.ts"
|
|
40
|
+
import { defineConfig, disks, stores, uploadSessions } from '@adonis-agora/media'
|
|
41
|
+
|
|
42
|
+
export default defineConfig({
|
|
43
|
+
disk: 's3',
|
|
44
|
+
disks: { s3: disks.s3({ bucket: 'my-bucket', region: 'us-east-1' }) },
|
|
45
|
+
store: 'lucid',
|
|
46
|
+
stores: { lucid: stores.lucid() },
|
|
47
|
+
|
|
48
|
+
uploads: {
|
|
49
|
+
mode: 'auto', // auto picks direct on a multipart-capable disk, else proxy
|
|
50
|
+
partSize: 8 * 1024 * 1024, // S3 floor is 5 MiB, cap 10,000 parts
|
|
51
|
+
routes: { enabled: true, prefix: '/media/uploads' }, // opt-in, UNGUARDED routes
|
|
52
|
+
|
|
53
|
+
resumable: { // opt-in — makes media.resumable exist
|
|
54
|
+
store: 'lucid',
|
|
55
|
+
stores: { lucid: uploadSessions.lucid() }, // durable, multi-process sessions
|
|
56
|
+
routes: { enabled: true, prefix: '/media/uploads/tus', maxSize: 5 * 1024 ** 3 },
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
direct: { // opt-in — makes media.direct exist
|
|
60
|
+
store: 'lucid',
|
|
61
|
+
stores: { lucid: uploadSessions.lucid() }, // same session store SPI as TUS
|
|
62
|
+
partSize: 20 * 1024 * 1024,
|
|
63
|
+
routes: {
|
|
64
|
+
enabled: true,
|
|
65
|
+
prefix: '/media/uploads/direct/sessions',
|
|
66
|
+
collection: 'videos', // gate the declared type at initiate
|
|
67
|
+
middleware: [middleware.auth()], // the ONLY gate on these routes
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Naming a session `store` with no matching factory throws
|
|
75
|
+
`UploadSessionStoreNotConfiguredError` — never a silent in-memory fallback.
|
|
76
|
+
|
|
77
|
+
Source: `docs/uploads/upload-modes.mdx`, `docs/uploads/resumable-tus.mdx`, `docs/uploads/direct-sessions.mdx`.
|
|
78
|
+
|
|
79
|
+
## Core patterns
|
|
80
|
+
|
|
81
|
+
### Pattern 1 — resolve the mode yourself
|
|
82
|
+
|
|
83
|
+
`proxy` is always allowed; `direct` is a REQUIREMENT that throws
|
|
84
|
+
`UploadNotSupportedError` on an incapable disk; `auto` is a PREFERENCE that downgrades:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { resolveUploadMode, isMultipartCapable } from '@adonis-agora/media'
|
|
88
|
+
import type { ResolvedUploadMode, UploadModeLevels } from '@adonis-agora/media'
|
|
89
|
+
|
|
90
|
+
const mode: ResolvedUploadMode = resolveUploadMode(
|
|
91
|
+
{ global: 'auto', perCall: request.input('mode') } satisfies UploadModeLevels,
|
|
92
|
+
isMultipartCapable(media.disk('s3')),
|
|
93
|
+
's3',
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Source: `docs/uploads/upload-modes.mdx` (Resolving the mode yourself).
|
|
98
|
+
|
|
99
|
+
### Pattern 2 — resumable TUS sessions
|
|
100
|
+
|
|
101
|
+
The engine is framework-agnostic (`TusUploadHandler`); the provider mounts thin routes.
|
|
102
|
+
Programmatically:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import media from '@adonis-agora/media/services/main'
|
|
106
|
+
|
|
107
|
+
const session = await media.resumable.createUpload({
|
|
108
|
+
disk: 's3', key: `users/1/video.mp4`, size: file.size, contentType: 'video/mp4',
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
let { offset } = await media.resumable.writeChunk(session.id, 0, chunk1)
|
|
112
|
+
;({ offset } = await media.resumable.writeChunk(session.id, offset, chunk2))
|
|
113
|
+
await media.resumable.status(session.id) // { offset, size, expiresAt }
|
|
114
|
+
|
|
115
|
+
const { key, disk, size } = await media.resumable.complete(session.id)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`media.hasResumable` guards access; `media.resumable` throws
|
|
119
|
+
`ResumableUploadsNotConfiguredError` without config. A standard tus client (or
|
|
120
|
+
`@adonis-agora/media-react`) points at `routes.prefix` and works unchanged.
|
|
121
|
+
|
|
122
|
+
Source: `docs/uploads/resumable-tus.mdx` (media.resumable).
|
|
123
|
+
|
|
124
|
+
### Pattern 3 — adopt finished uploads into the library (zero-copy)
|
|
125
|
+
|
|
126
|
+
Do NOT read the bytes back through `attach()` — that buffers and rewrites the object.
|
|
127
|
+
Register the object where it already is:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
// TUS finalize:
|
|
131
|
+
const record = await media.completeUploadToLibrary(session.id, {
|
|
132
|
+
ownerType: 'Patient', ownerId: patient.id, collection: 'exams',
|
|
133
|
+
fileName: 'exam.pdf', mimeType: 'application/pdf',
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
// direct sessions:
|
|
137
|
+
const record2 = await media.completeDirectUploadToLibrary(created.id, {
|
|
138
|
+
ownerType: 'Lesson', ownerId: lesson.id, collection: 'videos',
|
|
139
|
+
fileName: 'original.mp4', mimeType: 'video/mp4',
|
|
140
|
+
}, partsFromClient)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Both chain `library.attachExisting({ ..., key, disk, size })` — the collection's whitelist
|
|
144
|
+
is re-validated against the real bytes (a short head read), `single: true` replace and
|
|
145
|
+
ordering still apply, and nothing streams through the app.
|
|
146
|
+
|
|
147
|
+
Source: `docs/uploads/resumable-tus.mdx` (Turning a finished upload into a media record).
|
|
148
|
+
|
|
149
|
+
### Pattern 4 — a DirectUploadPolicy owns every app decision
|
|
150
|
+
|
|
151
|
+
The handler owns mechanics; the policy owns decisions. With a policy, `keyFor` is never
|
|
152
|
+
called — the key is computed server-side from authenticated context:
|
|
153
|
+
|
|
154
|
+
```ts title="app/media/lesson_video_policy.ts"
|
|
155
|
+
import { cuid } from '@adonisjs/core/helpers'
|
|
156
|
+
import type { HttpContext } from '@adonisjs/core/http'
|
|
157
|
+
import type { DirectUploadPolicy, InitiateDecision, CompleteResolution } from '@adonis-agora/media'
|
|
158
|
+
|
|
159
|
+
export default class LessonVideoPolicy implements DirectUploadPolicy<HttpContext, { uploadRowId: string }> {
|
|
160
|
+
async onInitiate(ctx: HttpContext, input): Promise<InitiateDecision<{ uploadRowId: string }>> {
|
|
161
|
+
const user = ctx.auth.getUserOrFail()
|
|
162
|
+
const lesson = await Lesson.findOrFail(ctx.params.lessonId)
|
|
163
|
+
await ctx.bouncer.authorize('uploadLessonVideo', lesson) // ← authorization lives HERE
|
|
164
|
+
|
|
165
|
+
const key = `tenants/${user.tenantId}/lessons/${lesson.id}/${cuid()}.mp4`
|
|
166
|
+
const row = await LessonUpload.create({ lessonId: lesson.id, key, status: 'pending' })
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
key,
|
|
170
|
+
collection: 'videos',
|
|
171
|
+
context: { uploadRowId: row.id },
|
|
172
|
+
rollback: () => row.delete(), // runs if anything AFTER this decision throws
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async resolveComplete(ctx, input): Promise<CompleteResolution<{ uploadRowId: string }>> {
|
|
177
|
+
const row = await LessonUpload.findByOrFail('uploadSessionId', input.id)
|
|
178
|
+
return {
|
|
179
|
+
sessionId: input.id,
|
|
180
|
+
target: { ownerType: 'Lesson', ownerId: row.lessonId, collection: 'videos',
|
|
181
|
+
fileName: row.fileName, mimeType: 'video/mp4' },
|
|
182
|
+
context: { uploadRowId: row.id },
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async onComplete(_ctx, { record, resolution }) {
|
|
187
|
+
await LessonUpload.query().where('id', resolution.context!.uploadRowId)
|
|
188
|
+
.update({ status: 'stored', mediaId: record.id })
|
|
189
|
+
return { mediaId: record.id, status: 'processing' } // ← IS the response body
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
mapError(_ctx, error) {
|
|
193
|
+
if (error instanceof AuthorizationException) return { status: 403, body: { error: 'forbidden' } }
|
|
194
|
+
return undefined // fall through to the handler's mapping
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Register it as a lazy thunk: `routes: { policy: () => import('#media/lesson_video_policy') }`.
|
|
200
|
+
The module's default export is used — a class is instantiated with no arguments.
|
|
201
|
+
|
|
202
|
+
Source: `docs/uploads/direct-upload-policy.mdx`.
|
|
203
|
+
|
|
204
|
+
## Common mistakes
|
|
205
|
+
|
|
206
|
+
### [CRITICAL] Shipping upload routes without middleware or a policy
|
|
207
|
+
|
|
208
|
+
Wrong:
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
uploads: { direct: { routes: { enabled: true } } } // middleware defaults to []
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Correct:
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
uploads: {
|
|
218
|
+
direct: {
|
|
219
|
+
routes: { enabled: true, middleware: [middleware.auth()],
|
|
220
|
+
policy: () => import('#media/lesson_video_policy') },
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Mechanism: the built-in routes are unguarded by design — `routes.middleware` defaults to
|
|
226
|
+
`[]` and the handlers perform no authorization whatsoever, so the endpoints are open to the
|
|
227
|
+
internet. Use middleware for "are you logged in" and policy hooks for "may you upload to
|
|
228
|
+
THIS resource".
|
|
229
|
+
Source: `docs/uploads/direct-sessions.mdx` (routes.middleware), `docs/uploads/direct-upload-policy.mdx` (warn callout).
|
|
230
|
+
|
|
231
|
+
### [CRITICAL] Re-reading finished upload bytes through library.attach()
|
|
232
|
+
|
|
233
|
+
Wrong:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
const { key, disk } = await media.resumable.complete(session.id)
|
|
237
|
+
const bytes = await media.disk(disk).getBytes(key) // buffers the whole file…
|
|
238
|
+
await media.library.attach({ ..., contents: bytes }) // …and rewrites it to a new key
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Correct:
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
const record = await media.completeUploadToLibrary(session.id, {
|
|
245
|
+
ownerType: 'Patient', ownerId: patient.id, collection: 'exams',
|
|
246
|
+
fileName: 'exam.pdf', mimeType: 'application/pdf',
|
|
247
|
+
})
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Mechanism: `attach` buffers the payload once and writes a NEW object — exactly what a
|
|
251
|
+
resumable upload exists to avoid. `completeUploadToLibrary` /
|
|
252
|
+
`completeDirectUploadToLibrary` chain `attachExisting`, which registers the existing key
|
|
253
|
+
zero-copy.
|
|
254
|
+
Source: `docs/uploads/resumable-tus.mdx` (Turning a finished upload into a media record).
|
|
255
|
+
|
|
256
|
+
### [CRITICAL] Expecting direct uploads to work without bucket CORS exposing ETag
|
|
257
|
+
|
|
258
|
+
Wrong:
|
|
259
|
+
|
|
260
|
+
```json
|
|
261
|
+
// bucket CORS: allowed origins/methods only
|
|
262
|
+
{ "AllowedOrigins": ["https://app.example.com"], "AllowedMethods": ["PUT"] }
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Correct:
|
|
266
|
+
|
|
267
|
+
```json
|
|
268
|
+
{ "AllowedOrigins": ["https://app.example.com"], "AllowedMethods": ["PUT"],
|
|
269
|
+
"ExposeHeaders": ["ETag"] }
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Mechanism: the browser must read the `ETag` response header of each part PUT to confirm it;
|
|
273
|
+
without `ExposeHeaders: ["ETag"]` no part can ever be confirmed — the single most common
|
|
274
|
+
direct-upload misconfiguration (the client fails with an explicit "S3 did not expose an
|
|
275
|
+
ETag" message).
|
|
276
|
+
Source: `docs/uploads/direct-sessions.mdx` (warn callout: Bucket CORS must expose ETag), `docs/react/client.mdx`.
|
|
277
|
+
|
|
278
|
+
### [MEDIUM] Forcing mode:direct on a disk without native multipart
|
|
279
|
+
|
|
280
|
+
Wrong:
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
uploads: { mode: 'direct' } // disk is the local FS driver
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Correct:
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
uploads: { mode: 'auto' } // downgrades to proxy on incapable disks
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Mechanism: `direct` is a requirement, `auto` is a preference — forcing `direct` on a disk
|
|
293
|
+
without native multipart throws `UploadNotSupportedError` (`E_MEDIA_UPLOAD_NOT_SUPPORTED`,
|
|
294
|
+
mapped to 400 by the routes) rather than silently downgrading.
|
|
295
|
+
Source: `docs/uploads/upload-modes.mdx` (Resolving the mode yourself).
|
|
296
|
+
|
|
297
|
+
### [HIGH] Expecting a media record from policy-less direct completion
|
|
298
|
+
|
|
299
|
+
Wrong:
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
await media.direct.complete(id) // returns { key, disk, size } — no MediaRecord created
|
|
303
|
+
await media.library.url('???') // nothing to look up
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Correct:
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
const record = await media.completeDirectUploadToLibrary(id, {
|
|
310
|
+
ownerType: 'Lesson', ownerId: lesson.id, collection: 'videos',
|
|
311
|
+
fileName: 'original.mp4', mimeType: 'video/mp4',
|
|
312
|
+
})
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Mechanism: without a `DirectUploadPolicy`, `complete` returns the raw assembled object and
|
|
316
|
+
creates NO media record — adopting it into the library is the caller's job. With a policy,
|
|
317
|
+
`resolveComplete` → `attachExisting` → `onComplete` runs automatically.
|
|
318
|
+
Source: `docs/uploads/direct-upload-policy.mdx` (Without a policy).
|
|
319
|
+
|
|
320
|
+
### [HIGH] Trusting client-supplied keys or TUS metadata as key segments
|
|
321
|
+
|
|
322
|
+
Wrong:
|
|
323
|
+
|
|
324
|
+
```ts
|
|
325
|
+
// routes take `key` from the request body and use it verbatim
|
|
326
|
+
const key = request.input('key')
|
|
327
|
+
await media.proxyUpload({ key, contents })
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Correct:
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
// resolve the key server-side from authenticated context (or use a policy)
|
|
334
|
+
const user = await auth.getUserOrFail()
|
|
335
|
+
await media.proxyUpload({ key: `users/${user.id}/${cuid()}.mp4`, contents })
|
|
336
|
+
// TUS metadata: parse + validate before use
|
|
337
|
+
const meta = parseTusMetadata(request.header('upload-metadata'))
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Mechanism: the object key is yours to resolve server-side — a client-supplied key lets one
|
|
341
|
+
tenant overwrite another's objects. TUS `Upload-Metadata` is browser input: validate it and
|
|
342
|
+
never use it as a key segment unsanitized.
|
|
343
|
+
Source: `docs/uploads/upload-modes.mdx` (media.uploads), `docs/react/client.mdx` (Custom TUS metadata warn).
|
|
344
|
+
|
|
345
|
+
See also: `media-react-uploads/SKILL.md` — the browser half that speaks these routes;
|
|
346
|
+
`media-library-collections/SKILL.md` — the collection policy that re-validates adopted
|
|
347
|
+
uploads.
|