13 KiB
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.mdund../../seepuls-native/PLAN.mdverweisen hier hin.
Status-Quo (Inventur 2026-05-18)
Schema:
events.image_url(text, nullable) — single Hot-Link, befüllt vom LLM-Extractor inevent-extraction.ts.venues.logo_url(text, nullable) — single Hot-Link, befüllt vom LLM-Extractor invenue-extraction.ts.- Kein
venues.hero_url, keinvenues.gallery_urls, keinimage_probes-Status.
Web (seepuls/apps/web):
EventCard.astrohot-linktevent.imageUrlmitreferrerpolicy="no-referrer",alt="", ohnesrcset, ohne Fallback, ohnewidth/height(→ CLS).event/[slug].astroundvenue/[slug].astroanalog.- Kein OG-Image-Fallback, kein
onerror-Handling, kein Kategorie-Placeholder.
Native (seepuls-native):
- Widget (
SeepulsWidgetBundle.swift) ist komplett bildlos — Text + Flag-Emoji. CachedEvent.imageUrlexistiert im SwiftData-Model, wird nirgends gerendert.- Snapshot-API liefert
imageUrlbereits 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)
-- 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:
- URL parsed + http(s)-only.
- Host nicht in Private-IP-Range (
10.*,172.16-31.*,192.168.*,127.*,::1,fc00::/7). DNS-Lookup vor Request. - Host in Whitelist:
- Set aus
venues.source_domain(alle bekannten Aggregator-Quellen) -
- Set aus bekannten Image-CDN-Hosts (initial leer, wächst über
image_probesmit hand-verifizierten Einträgen)
- Set aus bekannten Image-CDN-Hosts (initial leer, wächst über
- Set aus
- HEAD-Request mit 8s-Timeout.
Content-Typemussimage/*.Content-Length≤ 5 MB. - GET nur wenn HEAD bestanden. Stream-Passthrough, kein lokales Buffering.
Response-Header:
Cache-Control: public, max-age=86400ETag: <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:
- Query:
image_probes WHERE status='blocked' AND cached_mana_media_id IS NULL - Pro URL: Proxy-Stream einmal komplett laden, an mana-media POSTen
(existing
POST /api/v1/uploadmitsource_urlals metadata). 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 nichtvenues.source_domainund nicht über dascdn./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 dendsgvo_note-Admin-Endpoint: wennimage_probeseinen 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.deundarchaeologie.landesmuseum.deantworten nicht (curlhttp=000aus meinem Netz, beide auch mit/ohnewww.). 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_domainals Default ist zu eng — viele Sources hosten Bilder auf einem CDN-Subdomain (cdn.<source>oderimages.<source>). Pragmatik: Whitelist initial nursource_domain, neue Hosts nach manueller Sichtung inimage_probesergä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 — Memoryproject_compose_project_collision.mdzeigt: Outage-Recovery noch manuell. - Thumb-Storage iCloud. Default-App-Group-Container ist
iCloud-backupable.
excluded-from-backupsetzen, damit User nicht ihre iCloud mit Cache-Garbage fluten. - Galerie-Sortierung.
gallery_urlsist 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
- ι-0 sofort (kein Schema-Change, isoliert, riskolos).
- ι-4 parallel (entkoppelt vom Server-Schema).
- ι-1 → ι-2 seriell (Schema + Render).
- AGGREGATOR_POLICY §3 patchen parallel zu ι-1.
- ι-3 nach Policy-Patch + mana-compliance-Veto-Check.
- ι-3.5 nach 2 Wochen Beobachtung von ι-3 in Live-Daten.
- ι-5 mit σ-4 (Spotlight + ShareExt) zusammen.