313 lines
13 KiB
Markdown
313 lines
13 KiB
Markdown
# 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 1–3 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.
|