@adonis-agora/media 0.12.1 → 0.13.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/CHANGELOG.md +38 -0
- package/dist/providers/dashboard_provider.d.ts.map +1 -1
- package/dist/providers/dashboard_provider.js +25 -2
- package/dist/providers/dashboard_provider.js.map +1 -1
- package/dist/src/dashboard/define_config.d.ts +17 -0
- package/dist/src/dashboard/define_config.d.ts.map +1 -1
- package/dist/src/dashboard/define_config.js.map +1 -1
- package/package.json +9 -6
- 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,302 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: media-stores-delivery
|
|
3
|
+
description: >-
|
|
4
|
+
Pluggable storage seams and delivery in @adonis-agora/media — the MediaStore SPI
|
|
5
|
+
(stores.memory / stores.lucid with the published migration, custom stores via
|
|
6
|
+
StoreFactory, cross-owner keyset list() with encodeMediaCursor/clampMediaListLimit),
|
|
7
|
+
the ImageProcessor SPI (processors.sharp, custom processors), the structural Disk
|
|
8
|
+
contract (putStream, getVisibility), createDriveBackedResolver (module namespace,
|
|
9
|
+
not default export), the bundled disks.s3() with extended operations
|
|
10
|
+
(copy/move/deleteMany/list/size/stat) and declarative visibility, presignS3Url,
|
|
11
|
+
delivery.mode auto|public|signed|proxy, MediaDeliveryHandler and HlsDeliveryHandler
|
|
12
|
+
route mounting, and the @adonis-agora/media/testing doubles. Use when choosing or
|
|
13
|
+
writing a store, wiring S3, serving media, signing URLs, or testing without disks.
|
|
14
|
+
license: MIT
|
|
15
|
+
metadata:
|
|
16
|
+
type: core
|
|
17
|
+
library: "@adonis-agora/media"
|
|
18
|
+
library_version: "0.12.1"
|
|
19
|
+
framework: adonisjs
|
|
20
|
+
sources:
|
|
21
|
+
- "DavideCarvalho/adonis-media:docs/stores-and-processors.mdx"
|
|
22
|
+
- "DavideCarvalho/adonis-media:docs/storage/s3-disk.mdx"
|
|
23
|
+
- "DavideCarvalho/adonis-media:docs/delivery.mdx"
|
|
24
|
+
- "DavideCarvalho/adonis-media:docs/testing.mdx"
|
|
25
|
+
- "DavideCarvalho/adonis-media:packages/adonis/src/disks/drive.ts"
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
# Storage seams & delivery
|
|
29
|
+
|
|
30
|
+
The library delegates everything heavy to three pluggable seams — the `MediaStore`
|
|
31
|
+
(metadata persistence), the `ImageProcessor` (conversions), and a structural `Disk`
|
|
32
|
+
contract that real `@adonisjs/drive` disks satisfy directly. `delivery` is the mirror-image
|
|
33
|
+
strategy for **reads**.
|
|
34
|
+
|
|
35
|
+
## Setup
|
|
36
|
+
|
|
37
|
+
```ts title="config/media.ts"
|
|
38
|
+
import { defineConfig, stores, processors, disks } from '@adonis-agora/media'
|
|
39
|
+
|
|
40
|
+
export default defineConfig({
|
|
41
|
+
disk: 's3',
|
|
42
|
+
disks: { s3: disks.s3({ bucket: 'my-bucket', region: 'us-east-1' }) },
|
|
43
|
+
store: 'lucid',
|
|
44
|
+
stores: { lucid: stores.lucid({ connection: 'pg', table: 'media' }) },
|
|
45
|
+
imageProcessor: processors.sharp(),
|
|
46
|
+
delivery: { mode: 'auto', signedTtlSeconds: 300 },
|
|
47
|
+
})
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
node ace migration:run # publishes the media table for stores.lucid()
|
|
52
|
+
npm i @aws-sdk/client-s3 # optional peer, imported only because s3 is selected
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Source: `docs/configuration.mdx`, `docs/stores-and-processors.mdx`.
|
|
56
|
+
|
|
57
|
+
## Core patterns
|
|
58
|
+
|
|
59
|
+
### Pattern 1 — the cross-owner `MediaStore.list` read
|
|
60
|
+
|
|
61
|
+
`listByOwner` answers "what has this entity got"; `list` answers "everything, paginated,
|
|
62
|
+
newest first" — the read the dashboard's Collections view is built on:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import media from '@adonis-agora/media/services/main'
|
|
66
|
+
|
|
67
|
+
const page = await media.store.list({
|
|
68
|
+
collection: 'gallery',
|
|
69
|
+
ownerType: 'Post',
|
|
70
|
+
prefix: 'Post/42/',
|
|
71
|
+
limit: 50, // default 50, clamped to 200
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
page.items // ordered createdAt desc, then id desc (stable keyset under writes)
|
|
75
|
+
page.nextCursor // opaque string, or null on the last page
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Ordering is a stable keyset so paging stays consistent while rows are written underneath.
|
|
79
|
+
`encodeMediaCursor` / `decodeMediaCursor` / `clampMediaListLimit` and the
|
|
80
|
+
`DEFAULT_MEDIA_LIST_LIMIT` / `MAX_MEDIA_LIST_LIMIT` constants are exported for implementers.
|
|
81
|
+
|
|
82
|
+
Source: `docs/dashboard/collections.mdx`, `docs/stores-and-processors.mdx`.
|
|
83
|
+
|
|
84
|
+
### Pattern 2 — wire a custom store or processor
|
|
85
|
+
|
|
86
|
+
Both seams are tiny interfaces; wire them through a factory thunk so any peer loads lazily:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { defineConfig } from '@adonis-agora/media'
|
|
90
|
+
import type { ImageProcessor, MediaStore, StoreFactory } from '@adonis-agora/media'
|
|
91
|
+
|
|
92
|
+
class RedisMediaStore implements MediaStore { /* save/find/listByOwner/list/delete/nextOrder */ }
|
|
93
|
+
class PassthroughProcessor implements ImageProcessor {
|
|
94
|
+
async convert(input: Buffer, preset) {
|
|
95
|
+
return { data: input, format: preset.format ?? 'webp', contentType: 'image/webp' }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
defineConfig({
|
|
100
|
+
store: 'redis',
|
|
101
|
+
stores: {
|
|
102
|
+
redis: (() => async () => new RedisMediaStore()) as unknown as StoreFactory,
|
|
103
|
+
},
|
|
104
|
+
imageProcessor: new PassthroughProcessor(), // or a factory thunk
|
|
105
|
+
})
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
A `StoreFactory` receives the booted app as `StoreContext`, so a driver can resolve a peer's
|
|
109
|
+
service when it builds.
|
|
110
|
+
|
|
111
|
+
Source: `docs/stores-and-processors.mdx` (Writing your own store / processor).
|
|
112
|
+
|
|
113
|
+
### Pattern 3 — the bundled S3 disk and its extended operations
|
|
114
|
+
|
|
115
|
+
`disks.s3()` implements the base `Disk` contract plus `ExtendedDisk` (detected structurally
|
|
116
|
+
with `isExtendedDisk`) and `MultipartUploadDisk` (`isMultipartCapable`):
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
const s3 = media.disk('s3')
|
|
120
|
+
|
|
121
|
+
if (isExtendedDisk(s3)) {
|
|
122
|
+
await s3.copy('a/photo.jpg', 'b/photo.jpg', { toBucket: 'archive' })
|
|
123
|
+
await s3.deleteMany(['a/1.jpg', 'a/2.jpg'])
|
|
124
|
+
await s3.stat('a/photo.jpg') // { size, contentType?, lastModified? }
|
|
125
|
+
const page = await s3.list('uploads/', { delimiter: '/', limit: 100 })
|
|
126
|
+
page.folders // sub-prefixes from CommonPrefixes
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Key options: `endpoint` + `forcePathStyle` for MinIO/R2/Spaces, `publicEndpoint` for the
|
|
131
|
+
host presigned URLs are signed against (the browser's endpoint), `publicBaseUrl` for stable
|
|
132
|
+
`getUrl()` output, and `visibility: 'public' | 'private'` (default `private`) — declarative,
|
|
133
|
+
read by `delivery.mode: 'auto'`, never applied as an ACL and never probed.
|
|
134
|
+
|
|
135
|
+
Source: `docs/storage/s3-disk.mdx`.
|
|
136
|
+
|
|
137
|
+
### Pattern 4 — serve media through a delivery route you own
|
|
138
|
+
|
|
139
|
+
`delivery.mode` (`auto` default) decides what `deliver()` returns: `public`/`signed` yield
|
|
140
|
+
`{ kind: 'redirect', url }`; `proxy` yields `{ kind: 'stream', stream, mimeType, size,
|
|
141
|
+
fileName }`. `auto` asks the disk's `getVisibility` — public ⇒ public, private ⇒ signed,
|
|
142
|
+
unknown ⇒ signed. `MediaDeliveryHandler` is framework-agnostic: YOU mount the route behind
|
|
143
|
+
YOUR auth:
|
|
144
|
+
|
|
145
|
+
```ts title="start/routes.ts"
|
|
146
|
+
import { MediaDeliveryHandler, MediaManager } from '@adonis-agora/media'
|
|
147
|
+
|
|
148
|
+
const media = await app.container.make(MediaManager)
|
|
149
|
+
const delivery = new MediaDeliveryHandler({ library: media })
|
|
150
|
+
|
|
151
|
+
router.get('/media/:id', async ({ params, request, response, auth }) => {
|
|
152
|
+
await authorizeMediaAccess(auth.user, params.id) // ← your rule, before handle()
|
|
153
|
+
|
|
154
|
+
const result = await delivery.handle({ mediaId: params.id, conversion: request.input('conversion') })
|
|
155
|
+
if (result.kind === 'redirect') return response.redirect(result.url)
|
|
156
|
+
|
|
157
|
+
response.header('content-type', result.mimeType)
|
|
158
|
+
if (result.size !== undefined) response.header('content-length', String(result.size))
|
|
159
|
+
response.header('content-disposition', `inline; filename="${result.fileName}"`)
|
|
160
|
+
return response.stream(result.stream)
|
|
161
|
+
}).use(middleware.auth())
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The handler's own `mode` / `signedTtlSeconds` override the configured ones per call. For
|
|
165
|
+
transformer-generated HLS packages use `HlsDeliveryHandler` — playlists are rewritten per
|
|
166
|
+
request, segment files are validated against the persisted artifact list (no caller input
|
|
167
|
+
ever reaches the disk as a path).
|
|
168
|
+
|
|
169
|
+
Source: `docs/delivery.mdx`, `docs/transformers.mdx` (Serving HLS).
|
|
170
|
+
|
|
171
|
+
## Common mistakes
|
|
172
|
+
|
|
173
|
+
### [CRITICAL] Passing the drive service default export instead of the module namespace
|
|
174
|
+
|
|
175
|
+
Wrong:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
import driveService from '@adonisjs/drive/services/main' // captured default
|
|
179
|
+
const resolve = createDriveBackedResolver({ driveService, configuredDisks: {}, defaultDisk: 's3' })
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Correct:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
import * as driveService from '@adonisjs/drive/services/main' // the NAMESPACE
|
|
186
|
+
const resolve = createDriveBackedResolver({ driveService, configuredDisks: {}, defaultDisk: 's3' })
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Mechanism: Drive's service module assigns its manager inside an `app.booted()` callback, so
|
|
190
|
+
at import time the default export is still `undefined` — a captured value stays `undefined`
|
|
191
|
+
forever (writes fail with `DriveNotReadyError`), while the namespace's ESM live binding
|
|
192
|
+
reflects the later assignment.
|
|
193
|
+
Source: `docs/stores-and-processors.mdx` (createDriveBackedResolver).
|
|
194
|
+
|
|
195
|
+
### [CRITICAL] Exposing a delivery route without authorization
|
|
196
|
+
|
|
197
|
+
Wrong:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
router.get('/media/:id', async ({ params, response }) => {
|
|
201
|
+
const result = await delivery.handle({ mediaId: params.id })
|
|
202
|
+
if (result.kind === 'redirect') return response.redirect(result.url)
|
|
203
|
+
return response.stream(result.stream)
|
|
204
|
+
}) // no middleware — every record is public to anyone who can guess an id
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Correct:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
router.get('/media/:id', async ({ params, request, response, auth }) => {
|
|
211
|
+
await authorizeMediaAccess(auth.user, params.id)
|
|
212
|
+
const result = await delivery.handle({ mediaId: params.id })
|
|
213
|
+
/* ... */
|
|
214
|
+
}).use(middleware.auth())
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Mechanism: `MediaDeliveryHandler` resolves a media id to bytes or a URL and performs NO
|
|
218
|
+
authorization — the same split as `TusUploadHandler`. This is also why the provider mounts
|
|
219
|
+
no delivery route of its own: there is no safe default.
|
|
220
|
+
Source: `docs/delivery.mdx` (error callout: The handler performs NO authorization).
|
|
221
|
+
|
|
222
|
+
### [HIGH] Relying on auto delivery for storage the browser cannot reach
|
|
223
|
+
|
|
224
|
+
Wrong:
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
disks.s3({ bucket: 'media', endpoint: 'http://minio.internal:9000' }) // internal host
|
|
228
|
+
// delivery auto signs URLs against the INTERNAL host → unresolvable in the browser
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Correct:
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
disks.s3({
|
|
235
|
+
bucket: 'media',
|
|
236
|
+
endpoint: 'http://minio.internal:9000', // server-side operations
|
|
237
|
+
publicEndpoint: 'https://files.example.com', // what presigned URLs are signed for
|
|
238
|
+
forcePathStyle: true,
|
|
239
|
+
})
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Mechanism: `auto` resolves to `signed` (never `proxy`) when visibility is unknown, and
|
|
243
|
+
SigV4 bakes the host into the signature — presigned URLs must be signed for the endpoint
|
|
244
|
+
the BROWSER reaches. Proxying is a deployment decision and must be opted into explicitly.
|
|
245
|
+
Source: `docs/delivery.mdx` (auto never picks proxy), `docs/storage/s3-disk.mdx` (publicEndpoint).
|
|
246
|
+
|
|
247
|
+
### [MEDIUM] Implementing a custom MediaStore without a working list()
|
|
248
|
+
|
|
249
|
+
Wrong:
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
class MyStore implements MediaStore {
|
|
253
|
+
async list() { return { items: [], nextCursor: null } } // stubbed "for now"
|
|
254
|
+
// save/find/listByOwner/delete/nextOrder implemented
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Correct:
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
class MyStore implements MediaStore {
|
|
262
|
+
async list(options?: MediaListOptions): Promise<MediaListPage> {
|
|
263
|
+
// keyset page over { createdAt desc, id desc } with cursor + clamped limit
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Mechanism: nothing inside `attach`/`url`/`delete` calls `list()`, so a stubbed
|
|
269
|
+
implementation surfaces only later — the dashboard's Collections view renders empty and
|
|
270
|
+
admin tooling sees nothing. `list` is part of the `MediaStore` interface, not optional.
|
|
271
|
+
Source: `docs/stores-and-processors.mdx` (The MediaStore), `docs/dashboard/collections.mdx`.
|
|
272
|
+
|
|
273
|
+
### [MEDIUM] Setting s3 visibility public and expecting an ACL to be applied
|
|
274
|
+
|
|
275
|
+
Wrong:
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
disks.s3({ bucket: 'media', visibility: 'public' }) // expecting objects to become readable
|
|
279
|
+
// → 403s from the bucket: no bucket policy grants anonymous reads
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Correct:
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
// make the bucket readable via its policy/CDN, THEN declare it:
|
|
286
|
+
disks.s3({ bucket: 'media', visibility: 'public', publicBaseUrl: 'https://cdn.example.com' })
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Mechanism: `visibility` is declarative metadata read by `delivery.mode: 'auto'` — the disk
|
|
290
|
+
never applies an ACL and never probes the bucket, so the declaration must match reality the
|
|
291
|
+
infrastructure already enforces.
|
|
292
|
+
Source: `docs/storage/s3-disk.mdx` (visibility option, getVisibility).
|
|
293
|
+
|
|
294
|
+
## References
|
|
295
|
+
|
|
296
|
+
- Testing doubles for all three seams: use `@adonis-agora/media/testing` —
|
|
297
|
+
`InMemoryMediaStore`, `inMemoryDiskResolver` (with a `DiskVisibility` argument to test
|
|
298
|
+
`auto` delivery's unknown-visibility branch), `FakeImageProcessor`, `InMemoryUploadSessionStore`,
|
|
299
|
+
`FakeTransformer`. See `docs/testing.mdx`.
|
|
300
|
+
|
|
301
|
+
See also: `media-dashboard-console/SKILL.md` — the console browses through these same
|
|
302
|
+
ExtendedDisk operations and `MediaStore.list`.
|
|
@@ -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.
|