Skip to content

Commit 5b09448

Browse files
oembed plugin: oEmbed provider for pod resources (link unfurling)
GET /oembed?url=… resolves the resource over loopback with the caller's auth forwarded and never fetches external URLs. image/* becomes a spec-complete photo response — width/height parsed from the actual bytes (hand-rolled PNG/JPEG/GIF header parsers, no deps); text/html is deliberately 'link', never 'rich' (echoing pod-authored HTML would make every pod writer a stored-XSS author on consuming sites). Headline finding: oEmbed discovery wants a per-resource <link> in every HTML head — the sharpest form of the header/content-injection seam yet (third consumer), since even a gated header hook couldn't do in-HTML injection; /oembed/discover renders paste-able tags instead. Also: a write-time api.events metadata cache would replace per-unfurl byte parsing; private pod refusals collapse to oEmbed 404 to avoid leaking which private resources exist.
1 parent 1181215 commit 5b09448

3 files changed

Lines changed: 786 additions & 0 deletions

File tree

oembed/README.md

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# oembed — an oEmbed provider for pod resources
2+
3+
An [oEmbed](https://oembed.com/) endpoint as a #206 loader plugin: third-party
4+
sites (and anything else that speaks oEmbed) can unfurl links to pod resources
5+
into structured previews. One GET, one JSON (or XML) document describing the
6+
resource — resolved over loopback **as the caller**, so real WAC decides what
7+
unfurls, and anonymous consumers (the normal case) see only public resources.
8+
9+
```
10+
plugins: [{
11+
module: 'oembed/plugin.js',
12+
prefix: '/oembed',
13+
config: {
14+
baseUrl: 'http://localhost:3000', // public origin (provider_url + "is this ours?" guard)
15+
loopbackUrl: 'http://127.0.0.1:3000', // where the plugin reaches the host
16+
providerName: 'My Pod', // optional; default "Solid Pod"
17+
},
18+
}]
19+
```
20+
21+
## A curl session
22+
23+
```bash
24+
# unfurl a public image — a photo response with REAL dimensions,
25+
# parsed from the image bytes (PNG IHDR / JPEG SOF / GIF header)
26+
curl -s 'localhost:3000/oembed?url=http://localhost:3000/alice/pics/photo.png'
27+
# → {"version":"1.0","type":"photo",
28+
# "url":"http://localhost:3000/alice/pics/photo.png","width":40,"height":30,
29+
# "title":"photo","provider_name":"Solid Pod","provider_url":"http://localhost:3000"}
30+
31+
# a consumer with a size budget: declared dimensions are scaled to fit
32+
curl -s 'localhost:3000/oembed?url=…/photo.png&maxwidth=20'
33+
# → …"width":20,"height":15… (url still serves the original image)
34+
35+
# an HTML page or a Micropub post unfurls as a link (see Findings on why not "rich")
36+
curl -s 'localhost:3000/oembed?url=http://localhost:3000/alice/posts/note.json'
37+
# → {"version":"1.0","type":"link","title":"Hello oEmbed",…}
38+
39+
# XML if you insist
40+
curl -s 'localhost:3000/oembed?url=…/photo.png&format=xml'
41+
# → <?xml version="1.0" …?><oembed><version>1.0</version><type>photo</type>…
42+
43+
# a private (or nonexistent) resource, anonymously → oEmbed 404
44+
curl -si 'localhost:3000/oembed?url=http://localhost:3000/alice/private.txt' | head -1
45+
# → HTTP/1.1 404 Not Found ({"error":"not found"})
46+
47+
# the discovery tags a pod owner should paste into their page's <head>
48+
curl -s 'localhost:3000/oembed/discover?url=http://localhost:3000/alice/page.html'
49+
# → <link rel="alternate" type="application/json+oembed" href="…/oembed?url=…&amp;format=json" …>
50+
```
51+
52+
## What maps / what doesn't
53+
54+
| oEmbed feature | Status | Notes |
55+
|---|---|---|
56+
| `GET ?url=…` for resources under this server | ✅ done | loopback GET forwarding the caller's `Authorization`; real WAC decides |
57+
| external URLs | ⛔ 400 by design | micropub's local-URL guard; this endpoint **never** fetches off-server |
58+
| `format=json` (default) | ✅ done | `application/json` |
59+
| `format=xml` | ✅ done | hand-rolled, escaped `<oembed>` document, `text/xml` |
60+
| any other `format` | ⛔ 501 | per the spec's allowance |
61+
| `image/*``photo` | ✅ done | width/height parsed from the bytes (PNG/JPEG/GIF, first 64 KiB); unparseable image → `link`, never a spec-violating photo without dimensions |
62+
| `maxwidth` / `maxheight` | ✅ done | declared dimensions scaled proportionally; the `url` still serves the original bytes (no image resizing without deps — see Findings) |
63+
| `text/html``rich` |**by design** | maps to `link` instead — embedding pod-authored HTML is a stored-XSS vector (see Findings) |
64+
| Micropub h-entry `.json``link` | ✅ done | title from `properties.name`, else `properties.content` |
65+
| everything else → `link` | ✅ done | title = filename |
66+
| `video` type | ⛔ out of scope | same dimension problem as photo but needs container parsing; a video resource unfurls as `link` |
67+
| private resources | 404 | pod 401/403/404 all collapse to oEmbed 404 — the consumer falls back to a plain link and existence isn't leaked |
68+
| discovery (`<link rel="alternate" …+oembed>`) | ⚠️ helper only | `GET /oembed/discover?url=…` renders the tags as text for the pod owner to paste — a plugin cannot inject them into resources it doesn't own (see Findings) |
69+
70+
Stateless: no `pluginDir`, nothing returned from `activate`. Error bodies are
71+
always JSON `{"error":…}` regardless of `format`, for simplicity.
72+
73+
## Findings
74+
75+
### 1. Discovery needs content/header injection on core routes (seam #8, third consumer)
76+
77+
oEmbed discovery is defined as a `<link rel="alternate"
78+
type="application/json+oembed" href="…">` in the **HEAD of the resource's own
79+
HTML** (or an equivalent `Link:` header on its response). Those responses are
80+
served by LDP core — routes this plugin does not own and cannot decorate. This
81+
is exactly the response-header/content-injection seam NOTES.md #8 records,
82+
with notifications/ (`Updates-Via`) and micropub/ (`rel=micropub` on the
83+
homepage) as its first two consumers; oEmbed discovery is the **third**, and
84+
the sharpest yet: notifications and micropub each need one header on one
85+
well-known page, but oEmbed wants the tag on *every* HTML resource a pod
86+
serves — per-resource, with a per-resource `href`. Even a
87+
`capabilities: ['hooks']` header grant would only cover the `Link:`-header
88+
variant; the in-HTML variant is content rewriting, which is firmly core's
89+
side of the #564 line. Workaround shipped: `GET /oembed/discover?url=…`
90+
renders the exact tags for the pod owner to paste into their own HTML —
91+
discovery by hand, which is honest about who owns the bytes.
92+
93+
### 2. No `rich` type for pod HTML — a deliberate XSS refusal
94+
95+
The obvious mapping for `text/html` is oEmbed's `rich` type, whose `html`
96+
field consumers inject verbatim into their pages. But pod HTML is
97+
**user-authored content**: echoing it through the oEmbed endpoint would turn
98+
every pod writer into a stored-XSS author on every consuming site that trusts
99+
the `html` field (many do — that's the field's contract). A provider that
100+
manufactures its own sanitized embed markup could offer `rich` safely; a
101+
plugin that would have to *sanitize arbitrary HTML on node builtins alone*
102+
cannot, honestly. So `text/html` maps to `link` and the response never
103+
contains an `html` field. The test suite pins this (a seeded `<script>` must
104+
not appear anywhere in the oEmbed document).
105+
106+
### 3. `config.baseUrl`/`loopbackUrl` again (the `api.serverInfo` finding, Nth consumer)
107+
108+
Needed for `provider_url`, the discovery hrefs, and the "is this URL ours?"
109+
guard — and forcing the same probe-port-then-boot test dance as ~10 siblings.
110+
Nothing new to add beyond one more tally mark on NOTES.md #3; it remains the
111+
cheapest, most broadly wanted seam.
112+
113+
### 4. `photo` requires width/height, and nothing in the api (or HTTP) provides them
114+
115+
oEmbed's photo type REQUIRES `width`/`height`, but a loopback GET only
116+
reveals Content-Type and Content-Length. Omitting the fields would violate
117+
the spec; inventing them would lie. The honest fix was to parse dimensions
118+
from the image bytes themselves — PNG IHDR, JPEG SOF-marker walk, GIF logical
119+
screen descriptor, ~40 lines on node buffers, reading at most the first
120+
64 KiB and cancelling the rest of the stream. It works, but it's per-request
121+
work that a write-time hook (`api.events.onResourceChange`, NOTES.md #2)
122+
would let a plugin do **once per write** into a metadata cache instead of
123+
once per unfurl. Same O(N)-read-time shape as rss/ and sparql/, one level
124+
smaller. Relatedly: `maxwidth`/`maxheight` are honored by scaling the
125+
*declared* dimensions only — actually resizing image bytes needs an image
126+
library, which the import rule (rightly) forbids, so `url` always names the
127+
original resource.
128+
129+
### 5. Private→404 is the right collapse, and loopback makes it free
130+
131+
The oEmbed spec offers 401 for "the URL contains a private resource", but
132+
answering 401-vs-404 truthfully *reveals which private resources exist*.
133+
Because resolution is a loopback GET as the caller, the plugin never learns
134+
more than the caller could learn anyway — collapsing every pod 401/403/404
135+
to an oEmbed 404 costs one `if` and leaks nothing. The forwarded-Authorization
136+
path still works (an authenticated consumer can unfurl its own private
137+
resources), it just isn't the normal oEmbed case.

0 commit comments

Comments
 (0)