seepuls/docs/IMAGES.md
2026-05-19 00:32:51 +02:00

313 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Bilder-Einbau — Plan
**Stand: 2026-05-18.** Cross-Repo-Plan für Web (`seepuls/apps/web`),
API (`seepuls/apps/api`) und Native (`../seepuls-native/`). Namespace
`ι` (Iota) für „Images".
> **SOT:** dieser File ist der Plan. Phasen-Spuren in
> [`../STATUS.md`](../STATUS.md) und [`../../seepuls-native/PLAN.md`](../../seepuls-native/PLAN.md)
> verweisen hier hin.
## Status-Quo (Inventur 2026-05-18)
**Schema:**
- `events.image_url` (text, nullable) — single Hot-Link, befüllt vom
LLM-Extractor in `event-extraction.ts`.
- `venues.logo_url` (text, nullable) — single Hot-Link, befüllt vom
LLM-Extractor in `venue-extraction.ts`.
- Kein `venues.hero_url`, kein `venues.gallery_urls`, kein
`image_probes`-Status.
**Web** (`seepuls/apps/web`):
- `EventCard.astro` hot-linkt `event.imageUrl` mit
`referrerpolicy="no-referrer"`, `alt=""`, ohne `srcset`, ohne
Fallback, ohne `width/height` (→ CLS).
- `event/[slug].astro` und `venue/[slug].astro` analog.
- Kein OG-Image-Fallback, kein `onerror`-Handling, kein
Kategorie-Placeholder.
**Native** (`seepuls-native`):
- Widget (`SeepulsWidgetBundle.swift`) ist komplett bildlos — Text +
Flag-Emoji.
- `CachedEvent.imageUrl` existiert im SwiftData-Model, wird nirgends
gerendert.
- Snapshot-API liefert `imageUrl` bereits mit.
- Kein Thumb-Cache im App-Group-Container.
**AGGREGATOR_POLICY §3** (`../../mana/docs/AGGREGATOR_POLICY.md`):
- Hot-Link ist Default.
- Lokaler Cache in mana-media nur als begründete Ausnahme.
- Eigene Uploads erst nach Claim (Phase β-3).
- Pass-Through-Proxy ist heute *nicht* in der Policy genannt — muss
vor ι-3 ergänzt werden.
## Ziel
Verlässliche Bilder auf Event-Listings, Event-Detail, Venue-Detail
und Widget/Spotlight — ohne hartes Hosting von Drittseiten-Bildern,
mit sauberem Eskalations-Pfad für hot-link-blockierte Sources.
## Phasen-Übersicht
| Phase | Repo | Inhalt | Aufwand | Block |
|---|---|---|---|---|
| ι-0 | web | `SeepulsImage.astro` (onerror→placeholder, alt, CLS-fix, kategorie-Placeholder) | ~1d | |
| ι-1 | api | Schema: `venues.hero_url`, `venues.gallery_urls`, `image_probes`. LLM-Prompt für venue-extraction erweitert. | ~2d | |
| ι-2 | web | Galerie-Render auf `venue/[slug]` (Hero + 3-up Grid) | ~1d | ι-1 |
| ι-3 | api | `GET /api/v1/image-proxy?url=` als Eskalations-Pfad. SSRF-Hardening, HEAD-Probe, `image_probes`-Status, Stream-Passthrough. | ~2-3d | ι-1, **mana-compliance-Veto** |
| ι-3.5 | api | Optional: nightly Cache-Job für persistierend blocked URLs → mana-media. 30d Expiry, Hash-Refresh. | ~2d | ι-3 + 2 Wochen Beobachtung |
| ι-4 | native | `SnapshotSync` lädt 300×300-Thumb in App-Group. Widget rendert mit SF-Fallback. Cap 50 Thumbs. | ~2d | parallel zu ι-0..3 |
| ι-5 | native | Spotlight-Indexer (σ-4) nutzt denselben Thumb-Cache. | bündelt σ-4 | ι-4 |
## Architektur-Entscheidungen
Beschlossen. Trade-offs explizit.
### 1. `venues.gallery_urls` als jsonb-Array, nicht Join-Tabelle
Einfacher, ein JOIN weniger, MVP-Bedarf ist 13 Bilder pro Venue.
Migration auf Join-Tabelle möglich, wenn Galerien später wachsen.
Validierung: zod-Schema `array(url).max(3)` im Service-Layer.
### 2. `events.image_url` bleibt single-Field
Kein Galerie-Bedarf für Events. Crawl-Pipeline liefert pro Event
selten mehr als ein Hero-Bild.
### 3. Hot-Link bleibt Default — Proxy ist Eskalations-Pfad
Frontend ruft Proxy *nur* via `<img onerror>` auf, kein blindes
Routing aller Bilder durch den Proxy.
Beweis-Trail in `image_probes.status` für mana-compliance.
Konsequenz: SeepulsImage rendert zwei Stages —
`originalUrl` → on-error → `/api/v1/image-proxy?url=<originalUrl>`
→ on-error → kategorie-Placeholder.
### 4. `image_probes` ist URL-zentrisch, nicht Event/Venue-zentrisch
`url` als PK. Eine Probe-Row pro distinkter Source-URL. Kein
Doppel-Probe wenn zwei Venues dieselbe URL hot-linken.
### 5. Native cached eigene Thumbs, nicht über Proxy
WidgetKit-Snapshots brauchen lokale Daten — Widget-Process darf nicht
selbst HTTP machen (unzuverlässig, ATS, Snapshot-Frieren).
Thumb-Download passiert im App-Process beim SnapshotSync, Widget
liest aus App-Group-File.
### 6. Cloudflare-Cache vor Proxy-Route
Wenn ι-3 live geht: Cache-Control `public, max-age=86400` plus ETag
plus Cloudflare-Edge-Cache. Hono-Mem-Cache nur 5 min als Fallback.
## Datenmodell-Erweiterung (ι-1)
```sql
-- Neue Spalten auf venues
ALTER TABLE seepuls.venues
ADD COLUMN hero_url text,
ADD COLUMN gallery_urls jsonb; -- array of URLs, max 3
-- Neue Tabelle: URL-zentrische Probe-Status
CREATE TABLE seepuls.image_probes (
url text PRIMARY KEY,
status text NOT NULL, -- 'ok' | 'blocked' | '404' | 'gone' | 'non-image' | 'pending'
last_probe_at timestamptz NOT NULL DEFAULT now(),
http_status integer,
blocked_reason text,
cached_mana_media_id text, -- gesetzt ab ι-3.5
cache_expires_at timestamptz, -- 30d ab cached_at
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX image_probes_status_idx ON seepuls.image_probes(status);
CREATE INDEX image_probes_expires_idx ON seepuls.image_probes(cache_expires_at)
WHERE cache_expires_at IS NOT NULL;
```
`events.image_url` und `venues.logo_url` bleiben unverändert.
## API-Erweiterung (ι-3)
```
GET /api/v1/image-proxy?url=<urlencoded-original>
```
**Pflicht-Checks vor Pass-Through:**
1. URL parsed + http(s)-only.
2. Host nicht in Private-IP-Range (`10.*`, `172.16-31.*`, `192.168.*`,
`127.*`, `::1`, `fc00::/7`). DNS-Lookup *vor* Request.
3. Host in Whitelist:
- Set aus `venues.source_domain` (alle bekannten Aggregator-Quellen)
- + Set aus bekannten Image-CDN-Hosts (initial leer, wächst über
`image_probes` mit hand-verifizierten Einträgen)
4. HEAD-Request mit 8s-Timeout. `Content-Type` muss `image/*`.
`Content-Length` ≤ 5 MB.
5. GET nur wenn HEAD bestanden. Stream-Passthrough, kein lokales
Buffering.
**Response-Header:**
- `Cache-Control: public, max-age=86400`
- `ETag: <sha256(url + last_probe_at)[:16]>`
- `X-Seepuls-Proxy: passthrough` (Debug, kein Personenbezug)
**Status-Codes:**
- 200 — passthrough ok
- 400 — invalid URL / not http(s)
- 403 — host blocked (private IP / off-whitelist)
- 415 — non-image content-type
- 502 — upstream non-2xx
- 504 — upstream timeout
Jeder Aufruf updated `image_probes`: status + http_status +
last_probe_at.
## mana-media-Cache (ι-3.5, optional)
Nightly cron in `apps/api/src/jobs/cache-blocked-images.ts`:
1. Query: `image_probes WHERE status='blocked' AND cached_mana_media_id IS NULL`
2. Pro URL: Proxy-Stream einmal komplett laden, an mana-media POSTen
(existing `POST /api/v1/upload` mit `source_url` als metadata).
3. `image_probes.cached_mana_media_id` + `cache_expires_at = now() + 30d`.
Render-Flow im Frontend bleibt: bei `onerror` Proxy-URL. Proxy-Route
checkt `image_probes.cached_mana_media_id` — falls gesetzt + nicht
expired, 302-Redirect auf `media.mana.how/file/...`. Sonst
Passthrough.
Refresh-Job alle 30 Tage: re-fetch Original, sha256-Compare, bei
Unterschied neu uploaden, bei 404/gone aus mana-media löschen.
## Native-Thumb-Cache (ι-4)
**Storage:** `<AppGroupContainer>/Library/seepuls-thumbs/<event-slug>.jpg`,
300×300, JPEG-Quality 0.7. Cap 50 Files (= Snapshot-Window).
**SnapshotSync.swift-Erweiterung:**
```
Nach erfolgreichem /snapshot/next-7d-Pull:
for event in snapshot.events where event.imageUrl != nil:
if not exists(thumbsDir/<slug>.jpg) or older than 24h:
download event.imageUrl
resize to 300×300 (aspect-fill, centered)
JPEG 0.7
write to thumbsDir/<slug>.jpg
cleanup: rm Thumbs für slugs nicht in aktueller Snapshot-Liste
```
**CachedEvent**-Erweiterung: `thumbnailFilename: String?` (nur Filename,
Container-URL wird zur Read-Zeit rekonstruiert — gleicher Trick wie
bei `SnapshotContainer.defaultStoreURL()`).
**Widget-Read:** `UIImage(contentsOfFile: ...)` /
`NSImage(contentsOfFile: ...)`. Fallback bei null: bisheriges
SF-Symbol-Layout.
**Backup-Exclude:** Thumbs als rebuildbar markieren via
`URLResourceKey.isExcludedFromBackupKey`.
## Backfill der 5 manuellen Bodensee-Venues
Die existierenden manuell geseedeten Venues (Museum Rosenegg,
Seemuseum + Planetarium, Rosgartenmuseum, Archäologisches LM Konstanz)
haben `crawl_status='manual-entry'`. Sie werden vom Auto-Crawl
ausgenommen.
Option A (gewählt): nach ι-1 die Felder `logo_url`, `hero_url`,
`gallery_urls` manuell pflegen — 5 Venues, kontrolliert, schnell.
Option B (verworfen): Force-Recrawl. Riskiert Override der manuell
gepflegten Geokoordinaten + Adresse.
## Pre-Live-Gates
| Phase | Gate | Stand 2026-05-18 |
|---|---|---|
| ι-3 | mana-compliance-Subagent grün (Pass-Through ist re-publication-frei) | 🟡 **Bedingt grün** mit 4 Auflagen |
| ι-3 | AGGREGATOR_POLICY §3 ergänzt um „Pass-Through-Proxy als dritte Option" + Verbots-Satz + Take-Down-Kopplung | ✅ |
| ι-3 | Verbots-Satz „Verbot der Default-Proxy-Route" in §3 | ✅ |
| ι-3 | Take-Down-Endpoint löscht image_probes-Rows mit (Vitest-test-pflichtig) | ✅ Code + 11 Tests |
| ι-3 | Whitelist-Pflege braucht `dsgvo_note`-Spalte als Pflichtfeld bei Admin-Erweiterung | ✅ Schema + `/api/v1/admin/image-probes/{,/whitelist,/scrub}` |
| ι-3 | Anwaltliche Pre-Live-Review (§5 AGGREGATOR_POLICY) | ⏳ menschlich, weiter offen |
| ι-3 + ι-3.5 | SSRF-Pen-Test grün (Private-IP, DNS-Rebinding, Redirect-Chains) | ⏳ |
| ι-3.5 | mana-compliance-Subagent grün (Caching ist explizit policy-konform §3 Zeile 2) | ⏳ |
| ι-3.5 | DSGVO-Log-Check: image_probes loggt URLs, keine User-Daten | ✅ (heute kein Personenbezug) |
## Beobachtungen aus Live-Daten
- **Externe Bild-CDNs nicht in Whitelist** (2026-05-19, im /venue-Liste-
Smoke aufgefallen): mindestens eine Venue hot-linkt von
`static.wixstatic.com`. Dieser Host ist nicht `venues.source_domain`
und nicht über das `cdn./images./static./media.`-Subdomain-Pattern
abgedeckt. Hot-Link selbst funktioniert (Browser-direkt), aber bei
blockiertem Hot-Link würde der Proxy 403 (off-whitelist) zurückgeben.
Genau der Use-Case für den `dsgvo_note`-Admin-Endpoint: wenn
`image_probes` einen wixstatic-Host mit status=blocked sammelt, kann
Reviewer ihn mit Pflicht-Begründung whitelisten. Watch für die nächsten
2 Wochen ι-3-Beobachtung.
- **WebFetch-Limit bei JS-rendered Bildgalerien** (2026-05-18/19 Backfill-
Versuche): Theater Konstanz, K9 Kulturzentrum, Kunstraum Kreuzlingen,
Bodensee-Planetarium liefern via WebFetch keine direkten Bild-URLs —
entweder cache-/resize-System mit dynamischen Parametern oder reines
JavaScript-Rendering. Für diese Venues braucht der Backfill einen
echten Headless-Browser-Crawl oder manuelle DevTools-Inspektion. Der
LLM-Crawl-Pfad (ι-1) hat das gleiche Problem, deswegen sind die Bilder
dort heute auch nicht gefüllt.
- **DNS-Failures von außerhalb** (2026-05-19): `museum-rosenegg.de` und
`archaeologie.landesmuseum.de` antworten nicht (curl `http=000` aus
meinem Netz, beide auch mit/ohne `www.`). Site-spezifisch — eventuell
reine Aussetzer, müsste in einer ruhigen Folge-Sitzung re-probed
werden. Bis dahin: Kategorie-Placeholder + Logo wo schon manuell
gepflegt.
- **Backfill-Stand 2026-05-19**: 3/35 Venues mit voller Galerie (Apollo,
Kult-X, Seemuseum) + 8 weitere mit echtem Logo aus der Ursprungs-
Crawl-Pipeline. Rest zeigt Kategorie-Placeholder via `SeepulsImage`.
## Offene Punkte
- **AGGREGATOR_POLICY §3 erweitern.** Stream-Passthrough-Proxy heute
nicht erwähnt. Patch-Vorschlag: vierte Tabellen-Zeile „Pass-Through-
Proxy" mit Begründung „keine Re-Publikation, nur Referrer-Strip +
CDN-Hop". Mana-Compliance kann sonst zu Recht ι-3 blocken.
- **SSRF-Whitelist-Pflege.** `venues.source_domain` als Default ist
zu eng — viele Sources hosten Bilder auf einem CDN-Subdomain
(`cdn.<source>` oder `images.<source>`). Pragmatik: Whitelist
initial nur `source_domain`, neue Hosts nach manueller Sichtung in
`image_probes` ergänzen (admin-Endpoint).
- **Bandbreiten-Monitoring.** Proxy kann teuer werden. Cloudflare-
Edge-Cache mitigiert, aber Threshold-Alarm (z.B. `>50k req/Tag`)
wäre sinnvoll. Heute kein Monitoring-Plattform-Service — Memory
`project_compose_project_collision.md` zeigt: Outage-Recovery noch
manuell.
- **Thumb-Storage iCloud.** Default-App-Group-Container ist
iCloud-backupable. `excluded-from-backup` setzen, damit User nicht
ihre iCloud mit Cache-Garbage fluten.
- **Galerie-Sortierung.** `gallery_urls` ist sortiert (Array-Order).
LLM-Prompt soll Hero zuerst, Innenraum/Außenraum nach Wertigkeit.
Reihenfolge instabil bei Re-Crawl — okay für MVP.
- **AT/LI-Sprache.** LLM-Prompt für Bilder-Extraktion ist sprach-
unabhängig (visuell), funktioniert direkt.
- **Lightbox-Galerie.** Nicht im MVP. Klick öffnet einfach das
Original im neuen Tab. Lightbox kann später als Astro-Island
ergänzt werden.
## Reihenfolge
1. **ι-0** sofort (kein Schema-Change, isoliert, riskolos).
2. **ι-4** parallel (entkoppelt vom Server-Schema).
3. **ι-1 → ι-2** seriell (Schema + Render).
4. **AGGREGATOR_POLICY §3 patchen** parallel zu ι-1.
5. **ι-3** nach Policy-Patch + mana-compliance-Veto-Check.
6. **ι-3.5** nach 2 Wochen Beobachtung von ι-3 in Live-Daten.
7. **ι-5** mit σ-4 (Spotlight + ShareExt) zusammen.