@adonis-agora/media 0.8.1 → 0.9.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.
Files changed (117) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/README.md +206 -0
  3. package/dist/providers/media_provider.d.ts +3 -13
  4. package/dist/providers/media_provider.d.ts.map +1 -1
  5. package/dist/providers/media_provider.js +151 -4
  6. package/dist/providers/media_provider.js.map +1 -1
  7. package/dist/src/content_type.d.ts +6 -1
  8. package/dist/src/content_type.d.ts.map +1 -1
  9. package/dist/src/content_type.js +102 -7
  10. package/dist/src/content_type.js.map +1 -1
  11. package/dist/src/define_config.d.ts +88 -5
  12. package/dist/src/define_config.d.ts.map +1 -1
  13. package/dist/src/define_config.js +7 -2
  14. package/dist/src/define_config.js.map +1 -1
  15. package/dist/src/direct_upload.d.ts +177 -0
  16. package/dist/src/direct_upload.d.ts.map +1 -0
  17. package/dist/src/direct_upload.js +299 -0
  18. package/dist/src/direct_upload.js.map +1 -0
  19. package/dist/src/direct_upload_handler.d.ts +83 -0
  20. package/dist/src/direct_upload_handler.d.ts.map +1 -0
  21. package/dist/src/direct_upload_handler.js +180 -0
  22. package/dist/src/direct_upload_handler.js.map +1 -0
  23. package/dist/src/disks/factory.d.ts +11 -3
  24. package/dist/src/disks/factory.d.ts.map +1 -1
  25. package/dist/src/disks/factory.js +5 -3
  26. package/dist/src/disks/factory.js.map +1 -1
  27. package/dist/src/disks/s3.d.ts +10 -0
  28. package/dist/src/disks/s3.d.ts.map +1 -1
  29. package/dist/src/disks/s3.js +96 -22
  30. package/dist/src/disks/s3.js.map +1 -1
  31. package/dist/src/disks/sigv4.d.ts +58 -0
  32. package/dist/src/disks/sigv4.d.ts.map +1 -0
  33. package/dist/src/disks/sigv4.js +87 -0
  34. package/dist/src/disks/sigv4.js.map +1 -0
  35. package/dist/src/disks/xml_fallback.d.ts +3 -3
  36. package/dist/src/disks/xml_fallback.js +3 -3
  37. package/dist/src/errors.d.ts +104 -0
  38. package/dist/src/errors.d.ts.map +1 -1
  39. package/dist/src/errors.js +145 -0
  40. package/dist/src/errors.js.map +1 -1
  41. package/dist/src/hls/delivery.d.ts +112 -0
  42. package/dist/src/hls/delivery.d.ts.map +1 -0
  43. package/dist/src/hls/delivery.js +118 -0
  44. package/dist/src/hls/delivery.js.map +1 -0
  45. package/dist/src/hls/playlist.d.ts +67 -0
  46. package/dist/src/hls/playlist.d.ts.map +1 -0
  47. package/dist/src/hls/playlist.js +126 -0
  48. package/dist/src/hls/playlist.js.map +1 -0
  49. package/dist/src/index.d.ts +18 -3
  50. package/dist/src/index.d.ts.map +1 -1
  51. package/dist/src/index.js +13 -2
  52. package/dist/src/index.js.map +1 -1
  53. package/dist/src/media_collection.d.ts +8 -1
  54. package/dist/src/media_collection.d.ts.map +1 -1
  55. package/dist/src/media_collection.js +23 -1
  56. package/dist/src/media_collection.js.map +1 -1
  57. package/dist/src/media_library.d.ts +32 -2
  58. package/dist/src/media_library.d.ts.map +1 -1
  59. package/dist/src/media_library.js +218 -11
  60. package/dist/src/media_library.js.map +1 -1
  61. package/dist/src/media_manager.d.ts +37 -2
  62. package/dist/src/media_manager.d.ts.map +1 -1
  63. package/dist/src/media_manager.js +54 -1
  64. package/dist/src/media_manager.js.map +1 -1
  65. package/dist/src/media_record.d.ts +22 -2
  66. package/dist/src/media_record.d.ts.map +1 -1
  67. package/dist/src/services/booted_app.d.ts +9 -0
  68. package/dist/src/services/booted_app.d.ts.map +1 -0
  69. package/dist/src/services/booted_app.js +33 -0
  70. package/dist/src/services/booted_app.js.map +1 -0
  71. package/dist/src/services/main.d.ts +12 -0
  72. package/dist/src/services/main.d.ts.map +1 -1
  73. package/dist/src/services/main.js +15 -3
  74. package/dist/src/services/main.js.map +1 -1
  75. package/dist/src/single_file_store.d.ts +4 -0
  76. package/dist/src/single_file_store.d.ts.map +1 -1
  77. package/dist/src/single_file_store.js +8 -4
  78. package/dist/src/single_file_store.js.map +1 -1
  79. package/dist/src/testing/fake_transformer.d.ts +32 -0
  80. package/dist/src/testing/fake_transformer.d.ts.map +1 -0
  81. package/dist/src/testing/fake_transformer.js +32 -0
  82. package/dist/src/testing/fake_transformer.js.map +1 -0
  83. package/dist/src/testing/index.d.ts +2 -0
  84. package/dist/src/testing/index.d.ts.map +1 -1
  85. package/dist/src/testing/index.js +1 -0
  86. package/dist/src/testing/index.js.map +1 -1
  87. package/dist/src/transformer.d.ts +111 -0
  88. package/dist/src/transformer.d.ts.map +1 -0
  89. package/dist/src/transformer.js +2 -0
  90. package/dist/src/transformer.js.map +1 -0
  91. package/dist/src/transformers/factory.d.ts +30 -0
  92. package/dist/src/transformers/factory.d.ts.map +1 -0
  93. package/dist/src/transformers/factory.js +34 -0
  94. package/dist/src/transformers/factory.js.map +1 -0
  95. package/dist/src/transformers/hls.d.ts +110 -0
  96. package/dist/src/transformers/hls.d.ts.map +1 -0
  97. package/dist/src/transformers/hls.js +111 -0
  98. package/dist/src/transformers/hls.js.map +1 -0
  99. package/dist/src/transformers/mediabunny.d.ts +5 -0
  100. package/dist/src/transformers/mediabunny.d.ts.map +1 -0
  101. package/dist/src/transformers/mediabunny.js +150 -0
  102. package/dist/src/transformers/mediabunny.js.map +1 -0
  103. package/dist/src/transformers/mediabunny_loader.d.ts +15 -0
  104. package/dist/src/transformers/mediabunny_loader.d.ts.map +1 -0
  105. package/dist/src/transformers/mediabunny_loader.js +21 -0
  106. package/dist/src/transformers/mediabunny_loader.js.map +1 -0
  107. package/dist/src/transformers/probe.d.ts +56 -0
  108. package/dist/src/transformers/probe.d.ts.map +1 -0
  109. package/dist/src/transformers/probe.js +32 -0
  110. package/dist/src/transformers/probe.js.map +1 -0
  111. package/dist/src/transformers/source_file.d.ts +20 -0
  112. package/dist/src/transformers/source_file.d.ts.map +1 -0
  113. package/dist/src/transformers/source_file.js +30 -0
  114. package/dist/src/transformers/source_file.js.map +1 -0
  115. package/dist/stubs/config/media.stub +12 -0
  116. package/package.json +11 -7
  117. package/LICENSE +0 -21
package/CHANGELOG.md CHANGED
@@ -1,5 +1,126 @@
1
1
  # @adonis-agora/media
2
2
 
3
+ ## 0.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#20](https://github.com/DavideCarvalho/adonis-media/pull/20) [`a44014e`](https://github.com/DavideCarvalho/adonis-media/commit/a44014eb05c1aea460c163b1dd68c98bee01c284) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Session-backed direct uploads (`media.direct`) and a hand-rolled SigV4 presigner with split internal/public endpoints
8
+
9
+ **Why a third upload flow.** For video-sized files the deciding cost is bandwidth _through the app_. The TUS path resumes beautifully but streams every chunk client→app→S3 — 2× the bytes, and the app pays for both directions. The raw direct primitives (`media.uploads.initiateDirect` & friends) send bytes straight to S3, but they are stateless: `uploadId`, the part size and every collected `ETag` live only in the client's memory, so a page reload orphans the upload — and every consuming app was growing its own tracking table to compensate. `DirectUploadManager` (`media.direct`, config `uploads.direct`) combines the halves: the browser `PUT`s multipart parts straight to S3 through presigned URLs (the app moves **no** payload), while the session — `uploadId`, the agreed `partSize`, every confirmed part ETag — persists in the existing `UploadSessionStore` SPI. Same SPI, and with `uploadSessions.lucid()` the same tables, as TUS: **zero new migrations**; a part-size marker in the session metadata keeps the two flows from ever misreading each other's sessions in a shared store. TUS remains the right tool when the disk isn't S3 or bytes must flow through the app; direct sessions when they mustn't.
10
+
11
+ The lifecycle is deliberately front-loaded and resumable at every step:
12
+
13
+ - **`initiate`** validates part-size geometry (S3's 5 MiB floor and 10,000-part cap — caught here as the new `UploadPartSizeError`, instead of as S3's `EntityTooSmall` _after_ the whole file crossed the wire) and, given a `collection`, the declared `contentType` against that collection's `acceptsMimeTypes` — read from the same registry `attach` uses, so the gate and the barrier cannot drift — before the multipart upload even opens. Then it persists the session and presigns every part URL in one local batch.
14
+ - **`confirmPart`** records each uploaded part's ETag server-side — the resume currency. Everything else is disposable client state.
15
+ - **`status`** is the resume answer: confirmed ETags plus **fresh** presigned URLs for the parts still missing — which is also why the presign TTL no longer has to be sized to "the whole upload on a slow connection"; an expired URL costs one status round-trip, not the upload.
16
+ - **`complete(id, parts?)`** merges caller-supplied and confirmed parts and fails fast naming the exact missing part numbers (`UploadPartsIncompleteError`) instead of letting S3 return an opaque `InvalidPart`; **`abort`** frees the parts S3 is storing (best-effort — a lifecycle-reaped upload must still be deletable locally); expired sessions are reaped on access exactly like TUS.
17
+
18
+ `DirectUploadHandler` is the framework-neutral HTTP half in the `TusUploadHandler` mold — the provider mounts it under `uploads.direct.routes` (default `/media/uploads/direct/sessions`), it performs **no authorization** (the app guards the route), and the client sends a `fileName`, never a key: the key is resolved server-side via `keyFor`. `MediaManager.completeDirectUploadToLibrary(sessionId, input, parts?)` mirrors `completeUploadToLibrary`: complete + `attachExisting` in one step, zero-copy, with the collection whitelist re-validated against the **real** bytes (`verifyContentAgainstWhitelist` on a 16-byte head read) — the initiate gate checked what the client _declared_; this remains the barrier that checks what it _sent_.
19
+
20
+ **Presigned URLs are now hand-rolled SigV4 (query auth, `UNSIGNED-PAYLOAD`) — the `@aws-sdk/s3-request-presigner` peer is gone.** Presigning is pure local crypto (an HMAC chain over a canonical request; no round-trip to S3), so the SDK package bought nothing except a hard coupling of the signature to the client's configured endpoint. That coupling is precisely what breaks browser-consumed URLs behind a private network: SigV4 bakes the `Host` into the signature, and a URL signed for the internal MinIO FQDN is invalid on the public one — with the SDK the only fix is constructing a second client per endpoint. Owning the signer makes it a parameter: the new `publicEndpoint` option on `disks.s3()` signs **browser-facing** URLs (direct-upload part `PUT`s, signed `GET`s) for the public host while server-side operations — including the ListObjectsV2 XML-fallback fetch — stay on the internal `endpoint`. The signer (`presignS3Url`, exported) is verified byte-for-byte against AWS's published `examplebucket` presigned-GET test vector, plus an independently recomputed `UploadPart` vector locked as a regression pin; strict RFC 3986 encoding (including the `!'()*` set `encodeURIComponent` skips) and STS session tokens are covered. Credentials and region still resolve from the SDK client's own config, so every credential source (static, env, shared config, IAM role) keeps working.
21
+
22
+ Also new: typed `UploadPartOutOfRangeError` (a confirmed part number outside the agreed slicing) and `DirectUploadsNotConfiguredError` (accessing `media.direct` without `uploads.direct`), both alongside the errors above; `direct.partSize`/`direct.presignTtlSeconds` fall back to the shared `uploads.*` knobs, with a 20 MiB default part size (direct parts occupy no app memory, so a bigger part only enlarges the retry unit). Requires bucket CORS that allows `PUT` from the app's origin and exposes the `ETag` response header — the browser must read it to confirm parts.
23
+
24
+ - [#21](https://github.com/DavideCarvalho/adonis-media/pull/21) [`75649a9`](https://github.com/DavideCarvalho/adonis-media/commit/75649a9c6793f9f8fc83da07ee2418ab9ff8432b) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Transformers — transformações de conteúdo plugáveis persistidas como conversions — com HLS de vídeo como implementação de referência
25
+
26
+ **O contrato `Transformer`.** Conversions de imagem respondem "mesma imagem, outro tamanho/formato". Tudo o mais que deriva de uma mídia — um pacote HLS, uma faixa de áudio extraída, metadados sondados, um blurhash — agora passa por um contrato único: um `Transformer` recebe um `TransformerContext` (o record, leitura do original via `getBytes`/`getStream`, o `storage` para assets auxiliares em outros disks) e produz artefatos + metadados persistidos como uma conversion nomeada no `MediaRecord`. O contrato foi validado contra os shapes possíveis antes de nascer: N artefatos com entry (HLS), 1 artefato (otimizar imagem, extrair áudio), **zero artefato** (probe/placeholder — só `meta`), input auxiliar (watermark via `context.storage`) e requisito de runtime declarável (`TransformerRuntimeMissingError` tipado para WebCodecs/peer ausente).
27
+
28
+ O sandbox é do context, não do transformer: todo `write(relativePath, contents)` cai sob o prefixo `…/conversions/<name>/`, é validado (sem `..`/caminho absoluto — `TransformerOutputError`) e **registrado** — a lista `files` persistida vem do que foi de fato escrito, nunca da contabilidade do transformer. É isso que torna um pacote de centenas de segmentos deletável (batch via `deleteMany` em disks capazes) e servível arquivo a arquivo sem aritmética de path sobre input do caller. `MediaConversion` ganhou o shape para isso: `path`/`disk` agora opcionais (transform metadata-only não tem artefato), mais `prefix`, `files` e `meta` — as conversions de imagem existentes seguem `{ path, disk }` sem migração (colunas JSON).
29
+
30
+ **Registro e disparo.** Transformers declaram-se por collection (`transformers: [transformers.hls()]`), no mesmo namespace de nomes das presets — colisão falha no boot com `TransformerConflictError`. Vídeo é pesado e assíncrono por natureza, então nada roda no attach por padrão: o app dispara `media.library.transform(id, 'hls')` do job dele (a lib não assume sistema de filas). A chamada é **idempotente** — conversion existente ⇒ skip — e falha no meio varre os artefatos parciais e nunca persiste entry, então retry recomeça limpo. `eager: true` (para transforms baratos) roda dentro de `attach` **e** `attachExisting` — o caminho do finalize TUS via `completeUploadToLibrary`, fechando o fluxo "TUS larga os bytes, transformers derivam depois" sem fiação extra — com o mesmo rollback das conversions eager (mídia nova desfeita inteira, a anterior de uma collection `single` fica de pé). Leitura nunca gera transform: `url()`/`deliver()` numa conversion de transformer ausente lançam `TransformNotReadyError` em vez de prender um request atrás de um remux (presets de imagem continuam lazy).
31
+
32
+ **`transformers.hls()` — o transformer de vídeo.** Remuxa o vídeo para HLS (segmentos MPEG-TS + playlists de mídia atrás de um master) com [mediabunny](https://mediabunny.dev) — TypeScript puro, **sem binário ffmpeg**, peer OPCIONAL importado só no primeiro `transform()` (padrão `processors.sharp()`/`disks.s3()`; ausente ⇒ `TransformerRuntimeMissingError` com hint de install, não `ERR_MODULE_NOT_FOUND` cinco imports abaixo). Incorpora os gotchas provados em produção no protótipo do eduliberta:
33
+
34
+ - **Remux-only por padrão, com o porquê documentado**: encodar exige WebCodecs, que o Node não expõe. h264/aac copia sem re-encode (sem perda, rápido); codec que o MPEG-TS não carrega (vp9/av1) falha com `HlsSourceUnsupportedError` listando codec e razão por track — definitivo para o arquivo, não retryável.
35
+ - **O trim do AAC priming**: áudio AAC com edit list começa em timestamp NEGATIVO; o default do mediabunny corta em t=0, o que tiraria a track do fast path de cópia. A conversão corta no `getFirstTimestamp()` real — todas as tracks deslocam igual, sync intacto, tudo continua stream-copy.
36
+ - **WebCodecs injetável**: `transformers.hls({ webcodecs: () => import('@napi-rs/webcodecs') })` instala a implementação em `globalThis` (só o que falta; nativo nunca é sobrescrito) e destrava o fallback de re-encode do mediabunny para fontes incompatíveis. `resolveWebCodecsSupport()` reporta `native`/`injected`/`absent`. Multi-qualidade (ladder 360p/480p/720p via fan-out) fica como evolução documentada no roadmap sobre esse mesmo seam — não é fingida hoje.
37
+
38
+ Meta persistida: duração, resolução, codecs, contagem de segmentos/playlists, targetDuration. O engine é injetável (`HlsRemuxEngine`), então a orquestração inteira (temp files, upload streaming com contentLength, content types) é testada sem mediabunny — e um teste de integração roda o mediabunny REAL contra uma fixture h264/aac de 12 KB no CI (JS puro, funciona no ubuntu-latest), provando o caminho de produção inclusive o trim.
39
+
40
+ **`transformers.probe()` — o transformer metadata-only.** Sonda duração/resolução/codecs/sample rate e persiste só `meta`, zero artefato — a prova real do shape "sem artefato" do contrato, e o substituto do serviço manual de "baixa o arquivo e lê a duração" que todo app de mídia acaba criando.
41
+
42
+ **Delivery HLS-aware.** Playlists são armazenadas com referências relativas ao layout do storage e precisam de rewrite a cada serve. `rewriteHlsPlaylist(content, rewrite)` cobre todos os lugares onde o RFC 8216 põe uma URI — linhas de URI, atributos `URI="…"` de `#EXT-X-MEDIA` (renditions de áudio), `#EXT-X-MAP`, `#EXT-X-I-FRAME-STREAM-INF`, `#EXT-X-KEY` — reescreve só referências **relativas** (absolutas/rooted ficam como estão), preserva o resto byte a byte (CRLF incluso) e aceita rewriter async. Em cima dela, `HlsDeliveryHandler` é a rota-metade framework-agnóstica no padrão `MediaDeliveryHandler`/`TusUploadHandler`: o app monta UMA rota `(:mediaId, :file?)` com o middleware dele — **a lib não decide autorização** — e o handler serve o pacote inteiro: playlist pedida ⇒ conteúdo reescrito (sub-playlists via `urlForPlaylist` do app, para cada hop voltar pela auth; segmentos via `urlForSegment` ou, por padrão, **presigned URL direto do disk** com TTL — o caso comum pronto); segmento pedido ⇒ `redirect` presigned ou `stream` proxied (`segmentDelivery`). Arquivo pedido é validado por pertencimento à lista `files` persistida — traversal estruturalmente impossível.
43
+
44
+ **Tipos inferidos da config.** `defineConfig` agora é `<const T>` e preserva nomes literais; `InferTransformers<typeof config>` / `InferConversions<typeof config>` extraem as unions de nomes (presets + transformers) para tipar payloads de job e rotas — o padrão `InferConverters` do @jrmc/adonis-attachment, sem `as const`.
45
+
46
+ **Testing kit**: `FakeTransformer` (registra contexts, escreve artefatos configurados, `behavior` custom) ao lado do `FakeImageProcessor`.
47
+
48
+ **Cortes explícitos** (registrados no roadmap, não neste PR): multi-qualidade/ladder (exige o WebCodecs injetado; seam pronto), PosterFrame (exige decoder — idem), watermark/audio-extract/image-optimize/placeholder/transcription (o contrato acomoda os seis shapes; nada de stubs mortos), DRM/EXT-X-KEY com criptografia, HLS live/low-latency (só VOD), decorator Lucid `@media` no model do app (API desenhada no roadmap; o shape novo de `MediaConversion` já a suporta sem refactor) e a rota de delivery nomeada com inferência Tuyau + helpers Edge (idem).
49
+
50
+ Sem breaking para consumidores existentes além do type de `MediaConversion` (`path`/`disk` opcionais — 0.x, sem shim): stores existentes leem os records antigos como sempre.
51
+
52
+ - [#19](https://github.com/DavideCarvalho/adonis-media/pull/19) [`f81478e`](https://github.com/DavideCarvalho/adonis-media/commit/f81478e7a683347fa4ba56c9fb8d5614c2e95477) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Magic-byte signatures for the common video containers — and with them, video collections finally get a real whitelist
53
+
54
+ The signature table covered PNG/JPEG/GIF/WEBP/PDF and **no video format**. That had two concrete consequences for any collection gating video:
55
+
56
+ - No video was ever _detectable_, so a whitelist like `['video/mp4']` could never be **closed** (see 0.8.0): the closed-whitelist rejection for unrecognisable content simply never activated, and anything without a known signature — a `.txt`, an executable, random bytes — sailed through `attach`/`attachExisting`/TUS as long as the caller declared `video/mp4`.
57
+ - `attachExisting` could not catch content disguised as a video: a PNG uploaded as `clip.mp4` produced no `ContentTypeMismatchError`, because the detector had no idea what an MP4 looks like.
58
+
59
+ The table now recognises:
60
+
61
+ - **`video/mp4`** — ISO-BMFF `ftyp` box (bytes 4–7; the first 4 bytes are the box _size_ and vary per file, so they are skipped — the existing `[offset, hex]` parts mechanism already expresses this) with a major brand from the recognised set: `isom`, `iso2`–`iso6`, `mp41`, `mp42`, `avc1`, `av01`, `dash`. One table entry per brand, the GIF87a/GIF89a pattern.
62
+ - **`video/quicktime`** — the same `ftyp` box with major brand `qt `. Same container, different type: a `.mov` relabeled `video/mp4` is now a positive **mismatch**, not a pass.
63
+ - **`video/webm`** / **`video/x-matroska`** — EBML magic `1A45DFA3` at offset 0, discriminated by the `DocType` element (`webm` vs `matroska`). DocType sits inside a variable-length EBML header, so its offset depends on the muxer — the one thing fixed offsets cannot express. The signature mechanism gained a minimal `scan` field: a byte run (element id + size + doc type string, so it cannot fire on stray text) that must appear _anywhere_ in the head, checked only **after** the fixed parts already identified the container. No EBML parser, no new dependency.
64
+ - **`video/x-msvideo`** — `RIFF` + `AVI ` form type (bytes 4–7 are the chunk size and skipped, exactly like WEBP).
65
+ - **`video/mp2t`** — MPEG transport stream. There is no magic number, only the `0x47` sync byte opening every 188-byte packet; one byte would collide with anything starting with `G`, so the sync byte is required at the start of the first **two** packets (offsets 0 and 188). It is the weakest signature and sits last in the table, so it can never shadow a stronger match.
66
+
67
+ `SIGNATURE_HEAD_BYTES` grows from 16 to **189** (the second TS sync byte). All the sniffing paths already read exactly this constant and handle short payloads: `attach` still peeks and replays the head without buffering the payload, `attachExisting` still tears the disk stream down after one short read, and a head shorter than a signature's deepest offset degrades to _unrecognised_, never to a false positive.
68
+
69
+ Deliberate non-guesses, so a legitimate file never turns into a false mismatch: an `ftyp` brand outside the set above (3GPP's `3gp*`, Apple's `M4A `/`M4V `) and an EBML DocType that is neither `webm` nor `matroska` stay **unrecognised** — under an open whitelist they fall back to the declared type exactly as before.
70
+
71
+ **Behaviour change to review if you gate video:** a whitelist made up entirely of the types above — `['video/mp4']` being the canonical case — was open and is now **closed**. Three things follow:
72
+
73
+ 1. Content whose signature contradicts the declared type is rejected with `ContentTypeMismatchError` (`E_MEDIA_CONTENT_TYPE_MISMATCH`) — the PNG-disguised-as-MP4 case, previously accepted.
74
+ 2. Content matching _no_ signature is rejected with `ContentSignatureUnrecognizedError` (`E_MEDIA_CONTENT_SIGNATURE_UNRECOGNIZED`), per the 0.8.0 closed-whitelist rule that never activated for video before.
75
+ 3. Legitimate videos whose real container differs from the declared type — `.mov`/`.mkv`/`.avi` files uploaded under a blanket `video/mp4` — now fail instead of slipping through. If your collection genuinely accepts them, list what you accept: `acceptsMimeTypes: ['video/mp4', 'video/quicktime', 'video/webm']`.
76
+
77
+ Applies uniformly to `attach`, `attachExisting` and the TUS first-chunk check, which all share `verifyContentAgainstWhitelist`.
78
+
79
+ ## 0.8.2
80
+
81
+ ### Patch Changes
82
+
83
+ - [#17](https://github.com/DavideCarvalho/adonis-media/pull/17) [`e911880`](https://github.com/DavideCarvalho/adonis-media/commit/e911880969a28dd6bdbc0c56181462628eec1a4d) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Corrige dois pontos onde este pacote ainda capturava um singleton do `@adonisjs/core` via `import`
84
+ de módulo em vez de recebê-lo do provider — o mesmo formato de dual-package hazard que já havia
85
+ derrubado o `Database` do Lucid em produção (ver o changeset `lucid-string-token`), só que agora nos
86
+ singletons `app` e `router` do próprio core.
87
+
88
+ **1 — `app` capturado eager em `services/main.ts` e `single_file_store.ts`.** Ambos faziam
89
+ `import app from '@adonisjs/core/services/app'` no topo do módulo. Esse binding só é preenchido via
90
+ `setApp()` chamado pelo Ignitor da app hospedeira; se a árvore de dependências contiver DUAS cópias
91
+ físicas de `@adonisjs/core` (pnpm workspace hoisting, pins divergentes, ou até a mesma versão
92
+ resolvida sob peer-sets diferentes), a cópia que este pacote importa nunca recebe esse `setApp()` — o
93
+ `app` fica `undefined` e qualquer uso quebra com um `Cannot read properties of undefined` opaco.
94
+
95
+ A correção segue o padrão já usado pelo `@adonis-agora/authz` (`services/booted_app.ts`): o
96
+ `MediaProvider.register()` agora captura a instância de `ApplicationService` que a própria aplicação
97
+ lhe entrega — que é sempre a cópia correta, seja qual for a duplicação na árvore — e a expõe via um
98
+ módulo interno (`src/services/booted_app.ts`) para `services/main.ts` e `single_file_store.ts`
99
+ lerem. Nenhuma mudança de API: `media.library.attach(...)` e as funções `storeSingleFile` /
100
+ `removeSingleFile` / `isSingleFileStoreAvailable` continuam idênticas por fora.
101
+
102
+ **2 — `router` resolvido eager e usado síncrono em `providers/media_provider.ts`.** O provider
103
+ importava `router` de `@adonisjs/core/services/router` no topo do módulo e chamava `router.get(...)`
104
+ / `router.post(...)` direto dentro de `boot()`. Esse serviço específico só é atribuído dentro de um
105
+ hook `app.booted(...)` no próprio módulo `services/router.ts` do core — que dispara estritamente
106
+ DEPOIS do `boot()` de todos os providers. Ou seja, mesmo numa árvore com uma única cópia do core,
107
+ usar esse import de forma síncrona em `boot()` já é uma corrida contra o próprio mecanismo que o
108
+ preenche; numa árvore duplicada o problema se soma ao caso 1.
109
+
110
+ A montagem das rotas (`/media/uploads/direct/*`, `/media/uploads/proxy`, `/media/uploads/tus/*`)
111
+ agora é adiada para dentro de `app.booted(...)` — o mesmo padrão já documentado e em produção no
112
+ `DashboardProvider` do `@adonis-agora/durable`. Verificado que isso é seguro para o ciclo de vida do
113
+ Adonis: os hooks `booted` de TODOS os providers disparam antes do `Server#boot()` do
114
+ `@adonisjs/http-server` (que roda dentro de `app.start()`) chamar `router.commit()` — o último ponto
115
+ em que rotas ainda podem ser adicionadas. Dentro do hook, o `router` é resolvido pelo container
116
+ (`app.container.make('router')`) a partir do `this.app` do provider, imune à duplicação da mesma
117
+ forma que o caso 1.
118
+
119
+ Nenhuma mudança de comportamento observável: as mesmas rotas são montadas, só que um instante mais
120
+ tarde no boot (antes do servidor HTTP subir, nunca depois).
121
+
122
+ Também adicionado `prepack` ao `package.json` do pacote publicável, espelhando o `build`.
123
+
3
124
  ## 0.8.1
4
125
 
5
126
  ### Patch Changes
package/README.md ADDED
@@ -0,0 +1,206 @@
1
+ # @adonis-agora/media
2
+
3
+ Media-library for **AdonisJS**, part of the [Agora](https://github.com/DavideCarvalho) ecosystem — the
4
+ spatie/laravel-media-library feel, on top of [`@adonisjs/drive`](https://github.com/adonisjs/drive).
5
+
6
+ Attach files to entities, organize them into collections (MIME whitelist, single-file replace,
7
+ ordering), and generate image conversions (eager or lazy). Storage is delegated entirely to Drive, so
8
+ you reuse your existing `local` / `s3` / `gcs` disks — this package never reimplements disk drivers.
9
+
10
+ This is the **core library**: `MediaLibrary`, `AttachmentManager`, the `MediaStore` SPI (in-memory +
11
+ Lucid), the `ImageProcessor` SPI (sharp), the bundled `disks.s3()` driver, proxy/direct/resumable
12
+ uploads, configurable delivery, the provider + `defineConfig`, and a testing kit. Pair it with
13
+ [`@adonis-agora/media-react`](https://github.com/DavideCarvalho/adonis-media/tree/master/packages/react)
14
+ for a browser upload client, or
15
+ [`@adonis-agora/media-dashboard`](https://github.com/DavideCarvalho/adonis-media/tree/master/packages/dashboard)
16
+ for a management console.
17
+
18
+ ## Subpath exports
19
+
20
+ The package uses **subpath exports** (the Agora idiom), so heavy backends stay optional:
21
+
22
+ | Subpath | What |
23
+ |---|---|
24
+ | `@adonis-agora/media` | barrel — `defineConfig`, `stores`, `processors`, `disks`, `uploadSessions`, `MediaManager`, `MediaLibrary`, `AttachmentManager`, `UploadManager`, `ResumableUploadManager`, `MediaDeliveryHandler`, SPIs, errors |
25
+ | `@adonis-agora/media/media_provider` | the service provider (binds `MediaManager`, mounts optional upload/TUS routes) |
26
+ | `@adonis-agora/media/configure` | `node ace configure` hook |
27
+ | `@adonis-agora/media/single-file` | `storeSingleFile` / `removeSingleFile` / `isSingleFileStoreAvailable` — lets other packages delegate single-file uploads (e.g. avatars) to media without a hard dependency |
28
+ | `@adonis-agora/media/stores/lucid` | the Lucid `MediaStore` (`@adonisjs/lucid` peer) |
29
+ | `@adonis-agora/media/upload_sessions/lucid` | the Lucid resumable `UploadSessionStore` (`@adonisjs/lucid` peer) |
30
+ | `@adonis-agora/media/disks/s3` | the bundled `S3Disk` (`@aws-sdk/client-s3` peer; presigning is hand-rolled SigV4) |
31
+ | `@adonis-agora/media/processors/sharp` | the sharp `ImageProcessor` (`sharp` peer) |
32
+ | `@adonis-agora/media/telescope` | `mediaTelescopeExtension` (`@adonis-agora/telescope` optional peer) |
33
+ | `@adonis-agora/media/testing` | `InMemoryMediaStore`, `InMemoryDisk`, `inMemoryDiskResolver`, `FakeImageProcessor`, `InMemoryUploadSessionStore` |
34
+ | `@adonis-agora/media/types` | the structural `Disk` / `DiskResolver` / `ExtendedDisk` / `MultipartUploadDisk` contracts |
35
+
36
+ ## Install
37
+
38
+ ```sh
39
+ node ace add @adonisjs/drive # storage backend (required)
40
+ npm i @adonis-agora/media
41
+ node ace configure @adonis-agora/media
42
+ ```
43
+
44
+ `configure` registers the provider, publishes `config/media.ts`, and publishes the `media` table
45
+ migration (delete it if you only use the in-memory store; otherwise `node ace migration:run`).
46
+
47
+ Optional peers, loaded lazily only when selected:
48
+
49
+ - `@adonisjs/lucid` (`^22.4.0`) — the `lucid` `MediaStore` / `UploadSessionStore`
50
+ - `sharp` (`^0.33.0`) — image conversions
51
+ - `@aws-sdk/client-s3` (`^3.0.0`) — the bundled `disks.s3()` driver (presigned URLs are hand-rolled SigV4, no presigner package)
52
+ - `@adonis-agora/telescope` (`^0.4.0`) — the Telescope watcher extension
53
+
54
+ ## Configure
55
+
56
+ ```ts
57
+ // config/media.ts
58
+ import { defineConfig, stores, processors } from '@adonis-agora/media'
59
+
60
+ export default defineConfig({
61
+ // disk: 's3', // omit to use Drive's default disk
62
+ store: 'lucid',
63
+ stores: {
64
+ memory: stores.memory(),
65
+ lucid: stores.lucid({ connection: 'pg' }),
66
+ },
67
+ imageProcessor: processors.sharp(),
68
+ collections: [
69
+ { name: 'avatar', single: true, acceptsMimeTypes: ['image/png', 'image/jpeg'] },
70
+ {
71
+ name: 'gallery',
72
+ conversions: [
73
+ { name: 'thumb', width: 200, height: 200 },
74
+ { name: 'og', width: 1200, eager: true },
75
+ ],
76
+ },
77
+ ],
78
+ })
79
+ ```
80
+
81
+ ## Use
82
+
83
+ The provider binds a singleton `MediaManager`; reach it with the service import (or `@inject()` it).
84
+ A real upload controller loads the owner, validates the file, and streams its tmp bytes into a
85
+ collection:
86
+
87
+ ```ts
88
+ // app/controllers/post_images_controller.ts
89
+ import { createReadStream } from 'node:fs'
90
+ import Post from '#models/post'
91
+ import media from '@adonis-agora/media/services/main'
92
+ import type { HttpContext } from '@adonisjs/core/http'
93
+
94
+ export default class PostImagesController {
95
+ async store({ request, response, params }: HttpContext) {
96
+ const post = await Post.findOrFail(params.id)
97
+
98
+ const image = request.file('image', { size: '5mb', extnames: ['jpg', 'png', 'webp'] })
99
+ if (!image) return response.badRequest({ error: 'An "image" file is required' })
100
+
101
+ const record = await media.library.attach({
102
+ ownerType: 'Post',
103
+ ownerId: post.id, // string | number — coerced for you
104
+ collection: 'gallery',
105
+ fileName: image.clientName,
106
+ mimeType: image.type!,
107
+ contents: createReadStream(image.tmpPath!), // Buffer | Readable
108
+ })
109
+
110
+ return response.created({ id: record.id, url: await media.library.url(record.id) })
111
+ }
112
+ }
113
+ ```
114
+
115
+ Reading, listing and deleting go through the same `media` singleton:
116
+
117
+ ```ts
118
+ await media.library.url(record.id) // public url of the original
119
+ await media.library.url(record.id, 'thumb') // generated lazily on first call, then cached
120
+ await media.library.list('Post', post.id, 'gallery')
121
+ await media.library.delete(record.id)
122
+
123
+ // Or bind an owner once for a bulk upload
124
+ const gallery = media.library.for('Post', post.id)
125
+ await gallery.attach({ collection: 'gallery', fileName: image.clientName, mimeType: image.type!, contents })
126
+
127
+ // Column attachments (adonis-attachment style) — store the value object on a JSON column
128
+ const att = await media.attachments.createFromFile(
129
+ { fileName: image.clientName, mimeType: image.type!, contents },
130
+ { variants: [{ name: 'thumb', width: 100 }] },
131
+ )
132
+ user.avatarData = att.toJSON()
133
+ await media.attachments.url(att, 'thumb')
134
+ ```
135
+
136
+ A `ConversionPreset` is `{ name, width?, height?, fit?, format?, quality?, eager? }`. Eager presets are
137
+ generated synchronously on `attach`; the rest are generated lazily on the first `url(id, name)` and
138
+ cached on the record. `fit` and `format` map straight onto sharp.
139
+
140
+ ## Uploads, S3 and delivery
141
+
142
+ Large-file uploads ship in three flavours — **proxy** (bytes stream through your app), **direct-S3
143
+ multipart** (the browser uploads straight to the bucket via presigned part URLs), and **resumable
144
+ [tus](https://tus.io)** sessions (pluggable in-memory + Lucid session stores). Opt in under `uploads`
145
+ in `config/media.ts`; the provider mounts the routes under `/media/uploads` and `/media/uploads/tus`.
146
+
147
+ ```ts
148
+ export default defineConfig({
149
+ // ...
150
+ uploads: {
151
+ mode: 'auto', // 'direct' | 'proxy' | 'auto'
152
+ routes: { enabled: true },
153
+ resumable: { routes: { enabled: true } },
154
+ },
155
+ delivery: { mode: 'auto' }, // 'public' | 'signed' | 'proxy' | 'auto'
156
+ })
157
+ ```
158
+
159
+ `delivery` is the read-side counterpart to `uploads.mode`: `MediaLibrary.deliver(id, options?)`
160
+ resolves to a discriminated union — `{ kind: 'redirect', url }` for `public`/`signed`, or
161
+ `{ kind: 'stream', stream, mimeType, size, fileName }` for `proxy` — so a private bucket doesn't need
162
+ a hand-rolled streaming route. `MediaDeliveryHandler` and `TusUploadHandler` are the framework-agnostic
163
+ route halves your app mounts with its own middleware; **neither performs authorization** — guard the
164
+ route before calling `handle`.
165
+
166
+ The bundled `disks.s3()` driver adds extended operations (copy/move/deleteMany/list/size/stat) and
167
+ native multipart over the optional AWS SDK peer. Drive it from the browser with
168
+ [`@adonis-agora/media-react`](https://github.com/DavideCarvalho/adonis-media/tree/master/packages/react).
169
+
170
+ ## Diagnostics
171
+
172
+ Lifecycle events (`attach`, `delete`, `conversion`, `attachment.create`, `attachment.delete`) are
173
+ emitted on the `agora:media:*` channel via the `@agora/diagnostics:emit` global slot — read
174
+ structurally, with **no hard dependency** on `@adonis-agora/diagnostics`. When that package isn't
175
+ installed, emitting is an inert no-op; when it is, the Telescope generic watcher (or
176
+ `@adonis-agora/media/telescope`) captures every media event automatically.
177
+
178
+ ## Testing
179
+
180
+ `@adonis-agora/media/testing` ships in-memory doubles for the SPIs so you can exercise the library
181
+ without a disk, database, or sharp:
182
+
183
+ ```ts
184
+ import { MediaLibrary, StorageManager } from '@adonis-agora/media'
185
+ import { InMemoryMediaStore, inMemoryDiskResolver, FakeImageProcessor } from '@adonis-agora/media/testing'
186
+
187
+ const { resolve, disks } = inMemoryDiskResolver(['fs'])
188
+ const library = new MediaLibrary({
189
+ storage: new StorageManager({ default: 'fs', resolve }),
190
+ store: new InMemoryMediaStore(),
191
+ imageProcessor: new FakeImageProcessor(),
192
+ })
193
+ ```
194
+
195
+ In a real app you can also `drive.fake()` from `@adonisjs/drive` to back the disks with an in-memory
196
+ fake while keeping the rest of the wiring intact.
197
+
198
+ ## Links
199
+
200
+ - Repo: https://github.com/DavideCarvalho/adonis-media
201
+ - Changelog: https://github.com/DavideCarvalho/adonis-media/blob/master/packages/adonis/CHANGELOG.md
202
+ - Sibling packages: [`@adonis-agora/media-react`](https://github.com/DavideCarvalho/adonis-media/tree/master/packages/react), [`@adonis-agora/media-dashboard`](https://github.com/DavideCarvalho/adonis-media/tree/master/packages/dashboard)
203
+
204
+ ## License
205
+
206
+ MIT
@@ -17,19 +17,9 @@ export default class MediaProvider {
17
17
  constructor(app: ApplicationService);
18
18
  register(): void;
19
19
  /**
20
- * Mount the optional direct-S3 upload routes when `uploads.routes.enabled` is set. These are plain
21
- * AdonisJS routes (NOT controllers): each resolves the {@link MediaManager} singleton lazily and
22
- * delegates to its upload methods. The object key is taken from the request body/query — resolve
23
- * it server-side (per user/tenant) in a real app so a client can't point an upload at another
24
- * object.
25
- *
26
- * Routes (relative to `uploads.routes.prefix`, default `/media/uploads`):
27
- * - `POST /direct/initiate` → presign multipart parts
28
- * - `POST /direct/:uploadId/parts/:partNumber` → presign one (retried) part
29
- * - `POST /direct/:uploadId/complete` → assemble the parts
30
- * - `DELETE /direct/:uploadId` → abort the upload
31
- * - `PUT /proxy` → stream bytes through the app (proxy mode)
20
+ * Mount the media routes once the app has booted (see the comment inside for why). No-ops when
21
+ * neither the TUS resumable routes nor the direct-upload routes are enabled in config.
32
22
  */
33
- boot(): void;
23
+ boot(): Promise<void>;
34
24
  }
35
25
  //# sourceMappingURL=media_provider.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"media_provider.d.ts","sourceRoot":"","sources":["../../providers/media_provider.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAe/D;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,OAAO,OAAO,aAAa;;IACpB,SAAS,CAAC,GAAG,EAAE,kBAAkB;gBAAvB,GAAG,EAAE,kBAAkB;IAE7C,QAAQ;IA4ER;;;;;;;;;;;;;OAaG;IACH,IAAI;CA0ML"}
1
+ {"version":3,"file":"media_provider.d.ts","sourceRoot":"","sources":["../../providers/media_provider.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAqB,MAAM,sBAAsB,CAAC;AAkBlF;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,OAAO,OAAO,aAAa;;IACpB,SAAS,CAAC,GAAG,EAAE,kBAAkB;gBAAvB,GAAG,EAAE,kBAAkB;IAE7C,QAAQ;IA+GR;;;OAGG;IACG,IAAI;CAqWX"}
@@ -1,7 +1,8 @@
1
- import router from '@adonisjs/core/services/router';
1
+ import { DirectUploadHandler } from '../src/direct_upload_handler.js';
2
2
  import { createDriveBackedResolver } from '../src/disks/drive.js';
3
3
  import { resolveConfiguredDisks } from '../src/disks/factory.js';
4
4
  import { MediaManager } from '../src/media_manager.js';
5
+ import { setBootedApp } from '../src/services/booted_app.js';
5
6
  import { resolveStore } from '../src/stores/factory.js';
6
7
  import { TusUploadHandler } from '../src/tus.js';
7
8
  import { resolveUploadSessionStore } from '../src/upload_sessions/factory.js';
@@ -23,6 +24,11 @@ export default class MediaProvider {
23
24
  this.app = app;
24
25
  }
25
26
  register() {
27
+ // Hand the booted app to the internal singleton so it never has to `import` an
28
+ // `@adonisjs/core` copy that may not be the one `bin/server` booted (see
29
+ // `../src/services/booted_app.js`) — mirrors the fix already applied to the `@adonisjs/lucid`
30
+ // `Database` token (`'lucid.db'` string alias) for the same class of dual-package hazard.
31
+ setBootedApp(this.app);
26
32
  this.app.container.singleton(MediaManager, async () => {
27
33
  const config = this.app.config.get('media', {});
28
34
  // Validate + build the store from config first, so a misconfigured `store` fails fast (before
@@ -39,6 +45,16 @@ export default class MediaProvider {
39
45
  ...(resumable.stores !== undefined ? { stores: resumable.stores } : {}),
40
46
  }, { app: this.app })
41
47
  : undefined;
48
+ // Same lazy, opt-in construction for the session-backed direct upload store. It may share
49
+ // the resumable store's Lucid tables, but each subsystem owns its store INSTANCE so enabling
50
+ // one never implies the other.
51
+ const direct = config.uploads?.direct;
52
+ const directUploadSessions = direct
53
+ ? await resolveUploadSessionStore({
54
+ ...(direct.store !== undefined ? { store: direct.store } : {}),
55
+ ...(direct.stores !== undefined ? { stores: direct.stores } : {}),
56
+ }, { app: this.app })
57
+ : undefined;
42
58
  // Build any disks declared in `config.disks` (e.g. the bundled `disks.s3()` driver). Each
43
59
  // factory lazily loads its peer (the AWS SDK), so nothing is imported unless an S3 disk is
44
60
  // actually configured.
@@ -77,6 +93,21 @@ export default class MediaProvider {
77
93
  ...(resumable?.sessionTtlSeconds !== undefined
78
94
  ? { resumableSessionTtlSeconds: resumable.sessionTtlSeconds }
79
95
  : {}),
96
+ ...(directUploadSessions !== undefined ? { directUploadSessions } : {}),
97
+ // Direct settings fall back to the shared `uploads.*` knobs, so one part size / TTL can
98
+ // govern both the raw primitives and the session flow.
99
+ ...((direct?.partSize ?? config.uploads?.partSize)
100
+ ? { directPartSize: (direct?.partSize ?? config.uploads?.partSize) }
101
+ : {}),
102
+ ...((direct?.presignTtlSeconds ?? config.uploads?.presignTtlSeconds)
103
+ ? {
104
+ directPresignTtlSeconds: (direct?.presignTtlSeconds ??
105
+ config.uploads?.presignTtlSeconds),
106
+ }
107
+ : {}),
108
+ ...(direct?.sessionTtlSeconds !== undefined
109
+ ? { directSessionTtlSeconds: direct.sessionTtlSeconds }
110
+ : {}),
80
111
  // Delivery is config only — no route is mounted for it, deliberately: serving a record is
81
112
  // an authorization decision, so the app owns the route (see `MediaDeliveryHandler`).
82
113
  ...(config.delivery?.mode !== undefined ? { deliveryMode: config.delivery.mode } : {}),
@@ -86,6 +117,35 @@ export default class MediaProvider {
86
117
  });
87
118
  });
88
119
  }
120
+ /**
121
+ * Mount the media routes once the app has booted (see the comment inside for why). No-ops when
122
+ * neither the TUS resumable routes nor the direct-upload routes are enabled in config.
123
+ */
124
+ async boot() {
125
+ // Route registration can't happen synchronously in `boot()`: at this point in the AdonisJS
126
+ // lifecycle the HTTP server/router binding may not be resolvable yet (bindings from other
127
+ // providers' `boot()` methods can still be pending), and — critically — the *documented*
128
+ // `@adonisjs/core/services/router` singleton is only assigned once the app's "booted" hooks run
129
+ // (`await app.booted(async () => { router = ... })` inside that service module itself), which
130
+ // fire strictly AFTER every provider's own `boot()`. A provider that imports that singleton and
131
+ // calls `router.get(...)` directly inside `boot()` crashes every entrypoint (serve/ace/tests)
132
+ // that registers it, because `router` is still `undefined` at that point — the same hazard this
133
+ // provider now avoids for `MediaManager` itself (see `../src/services/booted_app.js`).
134
+ //
135
+ // Deferring to `app.booted(...)` runs our route registration as another "booted" hook — the
136
+ // same mechanism the router service uses to become available in the first place — which is
137
+ // guaranteed to fire BEFORE the HTTP server's own `boot()` commits the router (the last point at
138
+ // which routes can still be added; see `Server#boot()` in `@adonisjs/http-server`, which runs
139
+ // inside `app.start()`, strictly after all "booted" hooks). Resolving `router` fresh from the
140
+ // container here (rather than depending on that service singleton) is also the same pattern
141
+ // `@adonisjs/core`'s own `AppServiceProvider` and `@adonis-agora/durable`'s `DashboardProvider`
142
+ // use internally, and is immune to the dual-package hazard for the same reason `booted_app.ts`
143
+ // is: it comes from `this.app` (captured at registration), never from a module-level import.
144
+ await this.app.booted(async () => {
145
+ const router = await this.app.container.make('router');
146
+ this.#registerRoutes(router);
147
+ });
148
+ }
89
149
  /**
90
150
  * Mount the optional direct-S3 upload routes when `uploads.routes.enabled` is set. These are plain
91
151
  * AdonisJS routes (NOT controllers): each resolves the {@link MediaManager} singleton lazily and
@@ -100,9 +160,10 @@ export default class MediaProvider {
100
160
  * - `DELETE /direct/:uploadId` → abort the upload
101
161
  * - `PUT /proxy` → stream bytes through the app (proxy mode)
102
162
  */
103
- boot() {
163
+ #registerRoutes(router) {
104
164
  const config = this.app.config.get('media', {});
105
- this.#mountTusRoutes(config);
165
+ this.#mountTusRoutes(router, config);
166
+ this.#mountDirectSessionRoutes(router, config);
106
167
  const routes = config.uploads?.routes;
107
168
  if (!routes?.enabled)
108
169
  return;
@@ -202,7 +263,7 @@ export default class MediaProvider {
202
263
  * Set `uploads.resumable.routes.collection` to have the handler enforce that collection's
203
264
  * `acceptsMimeTypes` at create time and on the first chunk (`415`), instead of only at attach.
204
265
  */
205
- #mountTusRoutes(config) {
266
+ #mountTusRoutes(router, config) {
206
267
  const resumable = config.uploads?.resumable;
207
268
  if (!resumable?.routes?.enabled)
208
269
  return;
@@ -254,6 +315,92 @@ export default class MediaProvider {
254
315
  router.patch(`${prefix}/:id`, run('PATCH', true)).as('media.uploads.tus.patch');
255
316
  router.delete(`${prefix}/:id`, run('DELETE')).as('media.uploads.tus.delete');
256
317
  }
318
+ /**
319
+ * Mount the optional session-backed direct upload routes when `uploads.direct.routes.enabled`.
320
+ * Plain AdonisJS routes (NOT controllers), each driving a framework-agnostic
321
+ * {@link DirectUploadHandler} over `media.direct` — mirroring the TUS mount. The client sends a
322
+ * `fileName`, never a key: the key is resolved server-side by the handler's `keyFor`.
323
+ *
324
+ * Routes (relative to `uploads.direct.routes.prefix`, default `/media/uploads/direct/sessions`):
325
+ * - `POST /` → initiate: persist a session, presign every part URL
326
+ * - `GET /:id` → status: confirmed ETags + fresh URLs for pending parts
327
+ * - `POST /:id/parts/:partNumber` → confirm one uploaded part's ETag
328
+ * - `POST /:id/complete` → assemble the parts into the final object
329
+ * - `DELETE /:id` → abort
330
+ *
331
+ * Like every built-in route these are UNGUARDED — add your own auth middleware, or leave them off
332
+ * and mount your own routes over the handler/manager.
333
+ */
334
+ #mountDirectSessionRoutes(router, config) {
335
+ const direct = config.uploads?.direct;
336
+ if (!direct?.routes?.enabled)
337
+ return;
338
+ const prefix = (direct.routes.prefix ?? '/media/uploads/direct/sessions').replace(/\/+$/, '');
339
+ const routeDisk = direct.routes.disk;
340
+ const maxSize = direct.routes.maxSize;
341
+ const collection = direct.routes.collection;
342
+ // Build the handler once, on first request, from the resolved MediaManager singleton.
343
+ let handler;
344
+ const getHandler = async () => {
345
+ if (!handler) {
346
+ const media = await this.app.container.make(MediaManager);
347
+ handler = new DirectUploadHandler({
348
+ manager: media.direct,
349
+ ...(routeDisk !== undefined ? { disk: routeDisk } : {}),
350
+ ...(collection !== undefined ? { collection } : {}),
351
+ ...(maxSize !== undefined ? { maxSize } : {}),
352
+ });
353
+ }
354
+ return handler;
355
+ };
356
+ const run = (toRequest) => {
357
+ return async (ctx) => {
358
+ const direct_ = await getHandler();
359
+ const res = await direct_.handle(toRequest(ctx));
360
+ ctx.response.status(res.status);
361
+ return res.body ?? '';
362
+ };
363
+ };
364
+ router
365
+ .post(prefix, run((ctx) => {
366
+ const body = ctx.request.body();
367
+ return {
368
+ action: 'initiate',
369
+ fileName: body.fileName,
370
+ size: body.size,
371
+ ...(body.contentType !== undefined ? { contentType: body.contentType } : {}),
372
+ ...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
373
+ };
374
+ }))
375
+ .as('media.uploads.direct.sessions.initiate');
376
+ router
377
+ .get(`${prefix}/:id`, run((ctx) => ({ action: 'status', uploadId: String(ctx.params.id) })))
378
+ .as('media.uploads.direct.sessions.status');
379
+ router
380
+ .post(`${prefix}/:id/parts/:partNumber`, run((ctx) => {
381
+ const body = ctx.request.body();
382
+ return {
383
+ action: 'confirm-part',
384
+ uploadId: String(ctx.params.id),
385
+ partNumber: Number(ctx.params.partNumber),
386
+ etag: body.etag,
387
+ };
388
+ }))
389
+ .as('media.uploads.direct.sessions.confirmPart');
390
+ router
391
+ .post(`${prefix}/:id/complete`, run((ctx) => {
392
+ const body = ctx.request.body();
393
+ return {
394
+ action: 'complete',
395
+ uploadId: String(ctx.params.id),
396
+ ...(body.parts !== undefined ? { parts: body.parts } : {}),
397
+ };
398
+ }))
399
+ .as('media.uploads.direct.sessions.complete');
400
+ router
401
+ .delete(`${prefix}/:id`, run((ctx) => ({ action: 'abort', uploadId: String(ctx.params.id) })))
402
+ .as('media.uploads.direct.sessions.abort');
403
+ }
257
404
  /** A config `imageProcessor` may be a ready instance or a lazy factory thunk. */
258
405
  async #resolveImageProcessor(config) {
259
406
  const ip = config.imageProcessor;