Skip to content

Commit aadcd1d

Browse files
gallery: pod photo/media gallery — first api.mountApp consumer (#583)
The whole plugin is one raw (req,res) handler: upload pipes the un-drained request stream into a loopback PUT with the caller's Authorization forwarded (WAC decides, O(1) plugin memory), and the gallery page is a self-contained HTML grid linking media straight at pod URLs — core's LDP GET honors Range natively (206 + Content-Range), so no plugin Range lane was needed. Also the repo's first plugin with zero required config: origin via api.serverInfo, container defaults to the caller's own pod. Headline findings: the pass-through lane sidesteps the host bodyLimit (core's own 20 MiB buffer cap is what remains — streaming ends at core's front door, the missing half is a streaming LDP write path); getAgent wants a raw-req form; the dot-guard outranks the mount for every method.
1 parent 7c61fe9 commit aadcd1d

3 files changed

Lines changed: 943 additions & 0 deletions

File tree

gallery/README.md

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
# gallery — a pod photo/media gallery (the first `api.mountApp` consumer)
2+
3+
A demo-facing media gallery as a #206 loader plugin, and the first consumer
4+
of **`api.mountApp` (#583, JSS ≥ 0.0.219)**: the whole plugin is ONE raw
5+
node `(req, res)` handler mounted under its prefix — no Fastify routes at
6+
all. Uploads are **streamed**, not buffered: the un-drained request stream
7+
(the thing #583 exists to provide) is piped straight into a loopback `PUT`
8+
carrying the caller's own `Authorization`, so real WAC decides whether the
9+
bytes land and the plugin holds O(1) memory per upload regardless of file
10+
size.
11+
12+
```
13+
plugins: [{ module: 'gallery/plugin.js', prefix: '/gallery' }]
14+
```
15+
16+
That is the **entire** required configuration — the first plugin in this
17+
repo with none: the server origin comes from `api.serverInfo()` (#601) and
18+
the target container defaults to the authenticated caller's own
19+
`<pod>/public/gallery/` (public-read, owner-write under the seeded ACLs —
20+
exactly what a shareable gallery wants). Optional config:
21+
22+
| key | default | what |
23+
|---|---|---|
24+
| `container` | caller's `<pod>/public/gallery/` | fixed pod container (absolute, trailing `/`) |
25+
| `maxItems` | `200` | most-recent cap on the grid (read-time walk, no index — no `api.events`) |
26+
| `baseUrl` | `api.serverInfo().baseUrl` | public origin override for links |
27+
| `loopbackUrl` | live bind from `api.serverInfo()` | where the plugin reaches the host |
28+
29+
Surface:
30+
31+
```
32+
GET /gallery[?container=/alice/public/gallery/] self-contained HTML grid
33+
(inline CSS/JS, dark-mode,
34+
built-in uploader)
35+
POST /gallery/upload/<filename>[?container=…] stream the raw body into
36+
PUT /gallery/upload/<filename>[?container=…] the pod; 201 + Location
37+
```
38+
39+
Container precedence: `?container=``config.container` → the
40+
authenticated caller's own gallery. Playback and download links point
41+
**straight at the pod resources** — core's LDP serves Range natively, so
42+
`<video>` seeking works with no plugin lane in the middle (Finding 3).
43+
44+
## A curl session
45+
46+
```bash
47+
# 1. a pod bearer (WAC will judge every write as this agent)
48+
TOKEN=$(curl -s localhost:3000/idp/credentials \
49+
-H 'content-type: application/json' \
50+
-d '{"username":"alice","password":"…"}' | jq -r .access_token)
51+
52+
# 2. upload a photo — raw body, streamed end-to-end, no multipart
53+
curl -si localhost:3000/gallery/upload/sunset.jpg \
54+
-H "Authorization: Bearer $TOKEN" \
55+
-H 'content-type: image/jpeg' \
56+
--data-binary @sunset.jpg | grep -i location
57+
# → Location: http://localhost:3000/alice/public/gallery/sunset.jpg
58+
59+
# 3. the gallery — a public page anyone can open (or curl)
60+
open 'http://localhost:3000/gallery?container=/alice/public/gallery/'
61+
curl -s 'localhost:3000/gallery?container=/alice/public/gallery/' | grep sunset
62+
63+
# 4. the photo is a plain pod resource; core serves byte ranges itself
64+
curl -si localhost:3000/alice/public/gallery/sunset.jpg \
65+
-H 'Range: bytes=0-1023' | head -4
66+
# → HTTP/1.1 206 Partial Content
67+
# → Content-Range: bytes 0-1023/482113
68+
69+
# 5. a private gallery is just a different container — WAC does the rest
70+
curl -s localhost:3000/gallery/upload/secret.png?container=/alice/private/gallery/ \
71+
-H "Authorization: Bearer $TOKEN" --data-binary @secret.png
72+
curl -s -o /dev/null -w '%{http_code}\n' \
73+
'localhost:3000/gallery?container=/alice/private/gallery/' # → 401
74+
```
75+
76+
The page's built-in uploader drives the same lane: pick files, paste a
77+
bearer for protected containers, and each file goes up as one streamed POST.
78+
79+
## What maps / what doesn't
80+
81+
| feature | status | notes |
82+
|---|---|---|
83+
| streaming raw-body upload (image/audio/video/anything) || piped, O(1) plugin memory; WAC decides via forwarded auth |
84+
| gallery grid with inline `<img>`/`<video>`/`<audio>` || server-rendered, curl-testable; sizes from the listing's `stat:size` |
85+
| byte-range playback / seeking | ✅ via core | LDP GET answers 206 natively — links go straight to the pod |
86+
| per-caller default container || `<pod>/public/gallery/` derived from the WebID (Bearer) |
87+
| multipart upload | ⛔ deliberate | one file = one PUT keeps the stream; see Finding 4 |
88+
| >`bodyLimit` uploads (20 MiB default) | ⛔ core | the mounted lane streams fine; core's LDP buffers the PUT — Finding 2 |
89+
| thumbnails / transcoding | ⛔ out of scope | would need image deps; the browser scales |
90+
| write-time index / cached grid | ⛔ no `api.events` | read-time container walk per request, O(N) |
91+
92+
## Findings
93+
94+
### 1. `api.mountApp` delivers exactly what #583 promised — first-consumer verdict
95+
96+
The seam bundles four chores (the appPaths WAC exemption, a scoped
97+
pass-through content parser, `reply.hijack()`, registration on the prefix
98+
and its subtree) and **all four held**. The load-bearing one is real: the
99+
handler receives the request stream **un-drained**, and a 6 MB body piped
100+
through `Readable.toWeb(req)` into a loopback `PUT` round-trips
101+
byte-identical with no plugin-side buffering. Combined with
102+
`api.serverInfo()` (#601) this is the repo's first plugin with **zero
103+
required config** — no `baseUrl`, no `loopbackUrl`, no container. The
104+
handler bugs are contained as documented (a thrown handler → logged 500,
105+
not a hung client).
106+
107+
### 2. Streaming ends at core's front door — the seam's other half is still open
108+
109+
The mounted lane is now truly streaming — in fact **uncapped**: the
110+
pass-through parser sidesteps the host's body buffering *and therefore its
111+
`bodyLimit`* (a 21 MiB body crosses the lane untouched; test-proven by the
112+
passthrough 413 body). But the loopback `PUT` then hits core's wildcard
113+
`parseAs: 'buffer'` parser, which buffers the whole body in memory and
114+
caps it at `bodyLimit` (20 MiB default). So today the pipeline is
115+
stream → stream → **buffer** → disk. Two consequences, both seams:
116+
117+
- **A streaming LDP write path** is the missing other half of #583: until
118+
core can stream a raw `PUT` body to storage, every media plugin pays one
119+
full-body buffering inside core and inherits its limit. (Raise
120+
`bodyLimit` for bigger media meanwhile.)
121+
- **Flip-side warning for mountApp consumers**: a mounted app that does
122+
*not* forward to core has NO body limit at all unless it imposes its
123+
own. The seam hands you the firehose; core is no longer between you and
124+
it.
125+
126+
### 3. Core LDP GET honors Range natively — playback needs no plugin lane
127+
128+
Probed in the test suite: `GET` on a stored media resource with
129+
`Range: bytes=1048576-1049599` answers **206** with the correct
130+
`Content-Range` and the exact byte slice, and plain GETs advertise
131+
`Accept-Ranges: bytes`. Single ranges only (multi-range falls back to a
132+
full 200 — fine for browsers) and non-RDF resources only. So the gallery
133+
links media **directly at the pod URLs** and `<video>`/`<audio>` seeking
134+
works with zero plugin involvement — no Range reimplementation, contrary
135+
to what this plugin's spec braced for. Plugins only need their own Range
136+
lane if they serve bytes core doesn't store.
137+
138+
### 4. Raw-body upload over multipart, deliberately — micropub's finding stays open
139+
140+
One file = one `PUT` preserves the stream end-to-end with zero parsing and
141+
matches how pods store things anyway (a resource per file). Multipart
142+
would add boundary scanning on the raw stream for no gain *here*. But the
143+
IndieWeb media endpoint (micropub Finding 2) genuinely requires
144+
`multipart/form-data`, and that finding **remains open there** — with a
145+
sharpened shape: #583 now provides the raw stream multipart parsing needs,
146+
so the remaining gap is only a parser (a vendored boundary scanner or a
147+
blessed dep), no longer a missing seam.
148+
149+
### 5. A bare `(req, res)` is the price of the stream
150+
151+
mountApp hands you nothing but node primitives: routing, query parsing,
152+
HEAD handling, error pages, content-length — all hand-rolled here (~40
153+
lines of helpers). That's the right trade for a self-contained app and
154+
precisely why mountApp shouldn't become the default lane; `api.fastify`
155+
remains the ergonomic path for everything that doesn't need the raw
156+
stream. One subtlety: the two lanes can't share a prefix (Fastify would
157+
see duplicate routes), so a plugin is either mounted or route-based under
158+
its prefix, not both — this one went all-in on mounted.
159+
160+
### 6. `getAgent` has an impedance mismatch in the raw lane
161+
162+
`api.auth.getAgent(request)` wants a Fastify request; a raw `req` lacks
163+
`protocol`/`hostname`. Bearer verification (headers-only) works through a
164+
small shim, which is how the per-caller default container is derived —
165+
but DPoP / NIP-98 signatures are verified *over* those fields and can't
166+
succeed from the raw lane. Mild seam: `getAgent` accepting a raw
167+
`IncomingMessage` (deriving protocol/host from the socket + Host header)
168+
would make mounted apps first-class auth citizens.
169+
170+
### 7. The dot-guard outranks appPaths — even inside a mounted prefix
171+
172+
`POST /gallery/upload/.htaccess` never reaches the handler: core 403s any
173+
dotted path segment before the mounted app runs, WAC-exempt prefix or not.
174+
Free traversal protection for every mounted app (this plugin's own
175+
filename guard is a second layer), but also a hard rule: **a mounted app
176+
can never serve a dotted path**, which is the WS-upgrade footgun from
177+
AGENT.md generalized to all methods.
178+
179+
### 8. Content-Type is extension-derived on GET — the upload header is cosmetic
180+
181+
Core stores the raw bytes and re-derives `Content-Type` from the filename
182+
extension when serving. Uploading `photo.png` as `application/octet-stream`
183+
still serves back as `image/png`. No sniffing needed anywhere — but the
184+
extension is **load-bearing**, which is why the upload lane requires a
185+
real filename and the gallery classifies media by extension too.
186+
187+
### 9. Small mercies
188+
189+
- **Deep PUT creates the gallery**: the first upload materializes
190+
`/alice/public/gallery/` with no container dance (same mercy micropub
191+
recorded); the page renders a friendly "first upload creates it" for a
192+
404 container.
193+
- **The container listing is enough for a grid**: `ldp:contains` carries
194+
`stat:size` and `dcterms:modified`, so the page needs ONE loopback GET —
195+
no per-member requests (contrast rss/, which must GET every member for
196+
its content).

0 commit comments

Comments
 (0)