Rich content & media
Images and video are first-class; embeds/charts/diagrams are a founding requirement (v1.1 §29-A restores what v1.0 omitted).
Media pipeline (§29.1–29.4, v1.1)
- Storage port
ObjectStorage(put/get/stat/delete); initial adapter stores on/var/lib/eppp/media; future adapters target S3/R2/MinIO without changing content records (content stores media IDs, never absolute paths). - Upload validation (§29.1): size checked before reading the body; content type from magic bytes (client header/extension advisory only); SHA-256 on ingest (duplicate checksum returns the existing record); generated content-addressed keys, never the original filename.
- Image processing (§29.2): every accepted image re-encoded before serving (original bytes never served); metadata stripped, orientation baked in then dropped (GPS/camera serial never reach the public volume); derivative widths 480/800/1200/1600/2400 (never upscaled), AVIF + WebP per width + one universal JPEG fallback; rendered images carry
width/height(zero CLS); lazy-load below the fold; immutable one-year cache headers. - Video posture (§29.3): no transcoding in v1; accept MP4 (H.264 + AAC) only, reject others with a message naming export settings; probe dimensions/duration, generate a poster frame.
Embeds — three tiers (§29-A.1)
| Tier | Mechanism | Survives provider death | Public JS |
|---|---|---|---|
| 1 Native | chart/diagram blocks rendered server-side to SVG | yes | none |
| 2 Snapshot | embed + locally cached static image | degrades to image | none |
| 3 Live | sandboxed iframe from allowlisted host, click-to-load | no | one island |
The editor steers to tier 1 whenever data belongs to the author ("paste data" beside "paste embed URL").
Provider allowlist (§29-A.2)
eppp_embed_providers: exact hostnames only (no wildcards), admin-editable, every change audited. Seed: Observable, Datawrapper, CodePen, youtube-nocookie.com, player.vimeo.com, archive.org, plus a configurable self-hosted Grafana host. Enforced twice: in the renderer and in a CSP frame-src generated from the same table.
Fetch, cache & privacy (§29-A.3)
On save of content containing an embed: resolve provider by exact host (unknown host = author-visible error); fetch oEmbed through the hardened fetch; sanitise to an iframe with allowlisted src + dimensions; download and re-host the thumbnail (never hotlink); refresh on a 30-day cycle. Live iframes are click-to-load; every iframe carries sandbox, referrerpolicy="no-referrer", loading="lazy", a required title. No reader request reaches a third party before explicit interaction.
Hardened outbound fetch (§29-A.4, core)
One shared core service (oEmbed, thumbnail download, future link checker): resolve DNS first and reject private/loopback/link-local/multicast/metadata ranges; re-check after every redirect (max 3), pinning the connection to the pre-resolved address (closes DNS-rebinding); 10 s timeout, 5 MB cap; worker egress additionally restricted at the network layer. No extension ever writes its own outbound fetch (FIT-015).
Native charts & diagrams (§29-A.5)
core.chart (line, bar, area, scatter; CSV/JSON props) and core.diagram (Mermaid), rendered server-side to a single inline <svg>: every chart carries <title>, <desc> and a visually-hidden data table; all colour comes from theme tokens (a literal colour in chart output is a build failure, FIT-016); renderers are pure functions (no headless browser, no client bundle, no network); limits are configuration (2000 points / 12 series default, exceeded at save time).
Ownership: blocks/UX ship as first-party extensions (org.eppp.embeds, org.eppp.charts, or one org.eppp.rich-content); hardened fetch + CSP generation are core (FIT-015–017).
Metadata & syndication (§34-A, v1.1)
Core (every content type needs it): per-page <title>, meta description, canonical URL, Open Graph tags, JSON-LD BlogPosting (headline, datePublished, dateModified, author) on articles; sitemap.xml regenerated on publish; robots.txt excluding /admin and /api; ETag/Last-Modified with conditional requests. RSS remains a v0.2 extension. Open Graph images (when added) are generated server-side (SVG rasterised at publish, cached as a media object), no headless browser.