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

13 KiB
Raw Permalink Blame History

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 und ../../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.

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.

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:

  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.