@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.
- package/CHANGELOG.md +121 -0
- package/README.md +206 -0
- package/dist/providers/media_provider.d.ts +3 -13
- package/dist/providers/media_provider.d.ts.map +1 -1
- package/dist/providers/media_provider.js +151 -4
- package/dist/providers/media_provider.js.map +1 -1
- package/dist/src/content_type.d.ts +6 -1
- package/dist/src/content_type.d.ts.map +1 -1
- package/dist/src/content_type.js +102 -7
- package/dist/src/content_type.js.map +1 -1
- package/dist/src/define_config.d.ts +88 -5
- package/dist/src/define_config.d.ts.map +1 -1
- package/dist/src/define_config.js +7 -2
- package/dist/src/define_config.js.map +1 -1
- package/dist/src/direct_upload.d.ts +177 -0
- package/dist/src/direct_upload.d.ts.map +1 -0
- package/dist/src/direct_upload.js +299 -0
- package/dist/src/direct_upload.js.map +1 -0
- package/dist/src/direct_upload_handler.d.ts +83 -0
- package/dist/src/direct_upload_handler.d.ts.map +1 -0
- package/dist/src/direct_upload_handler.js +180 -0
- package/dist/src/direct_upload_handler.js.map +1 -0
- package/dist/src/disks/factory.d.ts +11 -3
- package/dist/src/disks/factory.d.ts.map +1 -1
- package/dist/src/disks/factory.js +5 -3
- package/dist/src/disks/factory.js.map +1 -1
- package/dist/src/disks/s3.d.ts +10 -0
- package/dist/src/disks/s3.d.ts.map +1 -1
- package/dist/src/disks/s3.js +96 -22
- package/dist/src/disks/s3.js.map +1 -1
- package/dist/src/disks/sigv4.d.ts +58 -0
- package/dist/src/disks/sigv4.d.ts.map +1 -0
- package/dist/src/disks/sigv4.js +87 -0
- package/dist/src/disks/sigv4.js.map +1 -0
- package/dist/src/disks/xml_fallback.d.ts +3 -3
- package/dist/src/disks/xml_fallback.js +3 -3
- package/dist/src/errors.d.ts +104 -0
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +145 -0
- package/dist/src/errors.js.map +1 -1
- package/dist/src/hls/delivery.d.ts +112 -0
- package/dist/src/hls/delivery.d.ts.map +1 -0
- package/dist/src/hls/delivery.js +118 -0
- package/dist/src/hls/delivery.js.map +1 -0
- package/dist/src/hls/playlist.d.ts +67 -0
- package/dist/src/hls/playlist.d.ts.map +1 -0
- package/dist/src/hls/playlist.js +126 -0
- package/dist/src/hls/playlist.js.map +1 -0
- package/dist/src/index.d.ts +18 -3
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +13 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/media_collection.d.ts +8 -1
- package/dist/src/media_collection.d.ts.map +1 -1
- package/dist/src/media_collection.js +23 -1
- package/dist/src/media_collection.js.map +1 -1
- package/dist/src/media_library.d.ts +32 -2
- package/dist/src/media_library.d.ts.map +1 -1
- package/dist/src/media_library.js +218 -11
- package/dist/src/media_library.js.map +1 -1
- package/dist/src/media_manager.d.ts +37 -2
- package/dist/src/media_manager.d.ts.map +1 -1
- package/dist/src/media_manager.js +54 -1
- package/dist/src/media_manager.js.map +1 -1
- package/dist/src/media_record.d.ts +22 -2
- package/dist/src/media_record.d.ts.map +1 -1
- package/dist/src/services/booted_app.d.ts +9 -0
- package/dist/src/services/booted_app.d.ts.map +1 -0
- package/dist/src/services/booted_app.js +33 -0
- package/dist/src/services/booted_app.js.map +1 -0
- package/dist/src/services/main.d.ts +12 -0
- package/dist/src/services/main.d.ts.map +1 -1
- package/dist/src/services/main.js +15 -3
- package/dist/src/services/main.js.map +1 -1
- package/dist/src/single_file_store.d.ts +4 -0
- package/dist/src/single_file_store.d.ts.map +1 -1
- package/dist/src/single_file_store.js +8 -4
- package/dist/src/single_file_store.js.map +1 -1
- package/dist/src/testing/fake_transformer.d.ts +32 -0
- package/dist/src/testing/fake_transformer.d.ts.map +1 -0
- package/dist/src/testing/fake_transformer.js +32 -0
- package/dist/src/testing/fake_transformer.js.map +1 -0
- package/dist/src/testing/index.d.ts +2 -0
- package/dist/src/testing/index.d.ts.map +1 -1
- package/dist/src/testing/index.js +1 -0
- package/dist/src/testing/index.js.map +1 -1
- package/dist/src/transformer.d.ts +111 -0
- package/dist/src/transformer.d.ts.map +1 -0
- package/dist/src/transformer.js +2 -0
- package/dist/src/transformer.js.map +1 -0
- package/dist/src/transformers/factory.d.ts +30 -0
- package/dist/src/transformers/factory.d.ts.map +1 -0
- package/dist/src/transformers/factory.js +34 -0
- package/dist/src/transformers/factory.js.map +1 -0
- package/dist/src/transformers/hls.d.ts +110 -0
- package/dist/src/transformers/hls.d.ts.map +1 -0
- package/dist/src/transformers/hls.js +111 -0
- package/dist/src/transformers/hls.js.map +1 -0
- package/dist/src/transformers/mediabunny.d.ts +5 -0
- package/dist/src/transformers/mediabunny.d.ts.map +1 -0
- package/dist/src/transformers/mediabunny.js +150 -0
- package/dist/src/transformers/mediabunny.js.map +1 -0
- package/dist/src/transformers/mediabunny_loader.d.ts +15 -0
- package/dist/src/transformers/mediabunny_loader.d.ts.map +1 -0
- package/dist/src/transformers/mediabunny_loader.js +21 -0
- package/dist/src/transformers/mediabunny_loader.js.map +1 -0
- package/dist/src/transformers/probe.d.ts +56 -0
- package/dist/src/transformers/probe.d.ts.map +1 -0
- package/dist/src/transformers/probe.js +32 -0
- package/dist/src/transformers/probe.js.map +1 -0
- package/dist/src/transformers/source_file.d.ts +20 -0
- package/dist/src/transformers/source_file.d.ts.map +1 -0
- package/dist/src/transformers/source_file.js +30 -0
- package/dist/src/transformers/source_file.js.map +1 -0
- package/dist/stubs/config/media.stub +12 -0
- package/package.json +11 -7
- 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
|
|
21
|
-
*
|
|
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":"
|
|
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
|
|
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
|
-
|
|
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;
|