@adonis-agora/media 0.12.1 → 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.
@@ -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.