- Extract: button on the bullet (desktop) or /extract (/notiz). The bullet becomes the title (hashtags stay at the link), its children the content of a new note in the same folder with the source's shares (a foreign source owner gets write access); the branch becomes [[title]] through the operation log, so open editors follow. - Merge: drop a note onto another in the sidebar or use "Mit anderer Notiz zusammenführen…" in the note header. The own source is appended as an h2 section with its bullets below and goes to the trash; the toast offers a complete undo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
23 KiB
Architektur
Überblick
Browser (React SPA, PWA-Manifest)
│ REST /api/v1/* Authorization: Bearer <JWT | mindsift_sec_…>
│ WebSocket /api/v1/ws Fallback: SSE /api/v1/events
▼
Go-Server (chi) ── internal/api Handler, Middleware, Alarm-Ticker
│ internal/ws Hub (Räume, Benutzer-Kanäle, SSE)
│ internal/auth JWT, bcrypt, API-Key-Hash
▼ internal/config
SQLite (modernc.org/sqlite, WAL) ── internal/storage
Das Frontend liegt per go:embed im Binary (cmd/server/main.go). Unbekannte Pfade liefern index.html (SPA-Routing); index.html, sw.js und manifest.json werden mit no-store ausgeliefert.
Stack
| Schicht | Technik |
|---|---|
| Backend | Go 1.23, go-chi/chi v5, go-chi/cors, gorilla/websocket, golang-jwt/jwt v5, x/crypto/bcrypt |
| Datenbank | SQLite über modernc.org/sqlite (reines Go, kein CGO), WAL, foreign_keys=ON |
| Frontend | React 18, TypeScript, Vite 5, Tailwind 3, framer-motion (Triage-Karten), lucide-react, prismjs (Code-Blöcke) |
| Auslieferung | Mehrstufiges Dockerfile (Node 22 → Go 1.23 → Alpine 3.21), Image git.dsit.me/sandrodach/mindsift |
Verzeichnisse
| Pfad | Inhalt |
|---|---|
cmd/server/ |
main.go, eingebettetes dist/ |
internal/api/ |
server.go (Server, Router mit allen Routen, Middleware, JSON-Helfer); Handler nach Bereich in handlers_*.go (auth, account, thoughts, documents, folders, lists, tasklists, teams, admin, integrations), realtime.go (WebSocket/SSE), alarms.go (Ticker und Versand), docsync.go, mail.go, netguard.go, password_reset.go |
internal/storage/ |
sqlite.go (Store, Schema, Migrationen); Datenlogik nach Bereich: users.go, apikeys.go, thoughts.go (inkl. Alarme), tasklists.go, documents.go, folders.go, tags.go, lists.go, teams.go, system.go (Einstellungen, Statistik, Export), dazu visibility.go, docsync.go, search.go, trash.go, backlinks.go, recurrence.go u. a. |
internal/domain/ |
Strukturen (models.go), ID-Erzeugung, Tag-Extraktion |
internal/ws/ |
hub.go |
internal/collab/ |
Kern der Notiz-Synchronisation ohne Datenbank: Blockbaum, Operationen und Konfliktregeln, Sortierschlüssel, Leases, Long-Poll-Benachrichtigung |
internal/auth/ |
Tokens und Passwörter |
internal/config/ |
Umgebungsvariablen |
frontend/src/features/ |
triage, workspace, lists, teams, settings, capture, auth, help |
frontend/src/lib/ |
api.ts (REST-Client, Transport-Umschaltung WS/SSE), types.ts, markdown.tsx, theme.ts |
frontend/src/lib/docSync/ |
Notiz-Synchronisation im Browser: Portierung von internal/collab (model.ts, orderKey.ts), Vergleich Editor-Liste → Operationen (diff.ts), engine.ts, IndexedDB (persist.ts), HTTP (transport.ts) |
e2e/ |
Browser-Tests mit Playwright gegen einen echten Server (e2e/run.sh) |
frontend/src/themes/ |
CSS pro Theme |
Start und Laufzeit
config.Load()liest die Umgebung (siehe unten).storage.NewStore()öffnet SQLite (busy_timeout, WAL, max. 8 Verbindungen) und führtmigrate()aus.- SMTP-Werte aus der Umgebung werden in
system_settingsübernommen, sofern dort noch nichts steht. - Mit Demo-Flag legt
EnsureDemoAccountsdie Kontenadmin/adminunduser/useran. api.NewServerstartet den Alarm-Ticker (jede Minute), die Verdichtung des Notiz-Logs (beim Start, dann alle 6 Stunden) und baut den Router. Server-Timeouts: Lesen 15 s, Schreiben 30 s, Leerlauf 60 s. Beim Beenden wird sauber heruntergefahren.
Konfiguration
| Variable | Standard | Bedeutung |
|---|---|---|
PORT |
8080 |
HTTP-Port |
DATA_DIR |
./data (Container: /data) |
Verzeichnis der Datenbank |
DATABASE_URL |
leer | sqlite:///pfad/datei.db oder Pfad; ohne Angabe DATA_DIR/mindsift.db. Postgres-URLs werden nicht unterstützt (siehe Hinweis) |
JWT_SECRET |
zufällig erzeugt, gespeichert in $DATA_DIR/jwt.secret |
Signaturschlüssel der Tokens; bekannte Platzhalter werden ignoriert |
TRUSTED_PROXIES |
private |
Proxys, deren X-Forwarded-For/X-Real-IP geglaubt wird (private, all, none, CIDR-Liste); internal/api/clientip.go |
LOGIN_PROTECTION |
true |
Login-Sperre nach Fehlversuchen und Limits für Registrierung und „Passwort vergessen“ (internal/api/loginguard.go), false = aus |
BASE_URL |
http://localhost:<PORT> |
Basis-URL der Instanz; explizit gesetzt Voraussetzung für das Passwort-Zurücksetzen per Mail |
TZ |
Europe/Berlin |
Zeitzone für Fälligkeiten und Alarme; die Zeitzonendaten sind ins Binary eingebettet (time/tzdata) |
ALLOW_REGISTRATION |
false |
Startwert; die Einstellung im Admin-UI (system_settings) hat Vorrang |
LOG_LEVEL / DEBUG |
info / false |
debug, info, warn, error; DEBUG=true erzwingt debug |
MINDA_DEMO (auch MINDSIFT_DEMO, DEMO_MODE) |
false |
Demo-Konten anlegen. Die Demo verschickt nichts (keine Alarme, Test- oder Hinweismails, Webhooks), Server-Einstellungen sind schreibgeschützt, Passwort, E-Mail und Konten der gemeinsam genutzten Demo-Konten lassen sich nicht ändern (notInDemo in internal/api/demo.go) |
DEMO_RESET_MINUTES |
20 |
Demo-Modus: periodischer Reset auf die Beispieldaten (0 = aus). Feste IDs der Demo-Konten (usr_demo_admin, usr_demo_user) halten Sitzungen gültig; laufende Anfragen sperren den Reset (internal/api/demo.go, Streams ausgenommen); danach DEMO_RESET an alle Verbindungen, /auth/status liefert demoNextReset/demoResetInSeconds. |
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM, SMTP_TLS |
leer / 587 / true |
Mailversand, auch im Admin-UI einstellbar |
SMTP_FROM_NAME |
MindSift |
Anzeigename des Absenders, wenn SMTP_FROM nur eine Adresse ist (From: "MindSift" <adresse>, der SMTP-Umschlag bleibt die reine Adresse); ein Name in SMTP_FROM hat Vorrang |
SMTP_SKIP_VERIFY |
false |
Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) |
STT_URL |
leer | Eigene Spracherkennung: Basis-URL eines OpenAI-kompatiblen Whisper-Servers (z. B. http://whisper:8000, speaches). Gesetzt = Server-Modus, sonst Spracherkennung des Browsers; in der Demo immer Browser |
STT_MODEL, STT_LANGUAGE, STT_API_KEY |
deepdml/faster-whisper-large-v3-turbo-ct2 / de / leer |
Modell, Sprache (auto = erkennen), Schlüssel falls der Whisper-Server einen verlangt |
STT_DISABLED |
false |
true blendet die Spracheingabe überall aus |
STT_MAX_SECONDS, STT_MAX_UPLOAD_MB |
600 / 25 |
Längste Aufnahme (stoppt im Browser), größte Aufnahme |
STT_MAX_PARALLEL, STT_PER_USER_PER_HOUR, STT_TIMEOUT_SECONDS |
1 / 60 / 120 |
Gleichzeitige Erkennungen (weitere warten), Erkennungen pro Person und Stunde, Höchstdauer inkl. Warten |
LEGAL_IMPRINT_URL, LEGAL_PRIVACY_URL |
leer | Impressum/Datenschutz als externer Link (nur http(s)) |
LEGAL_IMPRINT_CONTENT, LEGAL_PRIVACY_CONTENT (oder …_CONTENT_FILE mit Dateipfad) |
leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. |
Hinweis: DATABASE_URL mit postgres… führt dazu, dass der Standardpfad in DATA_DIR genutzt wird; Postgres ist nicht implementiert.
Authentifizierung
- Login mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder
login. Passwörter: bcrypt (DefaultCost). - Login-Sperre: Fehlversuche zählen im Speicher in drei Töpfen: Konto + Adresse (3), Konto (20), Adresse (30), je innerhalb von 15 Minuten; ist einer voll, gibt es 15 Minuten lang
429mitRetry-After. Unbekannte Namen zählen wie Konten (kein Erraten, welche existieren). Erfolg leert die Kontotöpfe, ein Passwort-Reset (Mail oder Admin) hebt die Sperre auf. Die Client-Adresse kommt nur von vertrauenswürdigen Proxys (TRUSTED_PROXIES). Dieselben Töpfe begrenzen pro Adresse Registrierungen (20) und „Passwort vergessen“ (10) je 15 Minuten; hier zählt jede Anfrage. - Einladungscodes:
INV-plus 100 Zufallsbits in Base32. Die Registrierung beansprucht den Code, bevor das Konto entsteht (kein doppeltes Einlösen). - JWT und Sitzungen: HS256, Claims
userId,email,displayName,tokenVersion,jti. Jede Anmeldung legt eine Zeile insessionsan (Gerät, IP, letzte Nutzung);jtiist deren ID. Gültig ist ein Token nur, wenn die Sitzung aktiv ist (nicht beendet, höchstens 30 Tage unbenutzt), das Konto aktiv ist undtokenVersionmitusers.token_versionübereinstimmt. Ist das Token älter als 24 Stunden, schickt der Server im HeaderX-Session-Tokenein neues für dieselbe Sitzung; das Frontend übernimmt es (request()inlib/api.ts). Aktive Geräte bleiben so angemeldet, unbenutzte fallen nach 30 Tagen heraus (internal/api/sessions.go). Neue Passwörter brauchen mindestens 8 Zeichen (auth.ValidatePassword).token_versionwird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht. - Logout beendet die Sitzung des Geräts;
GET /auth/sessionslistet die Geräte,DELETE /auth/sessions/{id}meldet eines ab. Passwort und Login-E-Mail ändern sich nur mit dem aktuellen Passwort (currentPassword, falsche Eingaben zählen für die Login-Sperre); ein neues Passwort meldet alle anderen Geräte ab, dieses Gerät erhält perX-Session-Tokenein neues Token. Ist SMTP eingerichtet, geht eine Hinweismail an die bisherige Adresse. - API-Keys:
mindsift_sec_<hex>, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder inX-API-Keyund darf nur Daten anliefern (POST /inbox,POST /webhooks/ingest); alle anderen Endpunkte antworten mit403. - Passwort vergessen: nur wenn SMTP und
BASE_URLgesetzt sind (nicht im Demo-Modus;passwordResetin/auth/status).POST /auth/forgot-passwordantwortet immer gleich und verschickt die Mail im Hintergrund, höchstens alle 2 Minuten pro Konto. Der LinkBASE_URL/?resetToken=…gilt eine Stunde und einmal; gespeichert wird nur der SHA-256-Hash (password_reset_tokens).POST /auth/reset-passwordsetzt das Passwort, entwertet alle offenen Links des Kontos und erhöhttoken_version(alle Sitzungen enden). Die App entfernt den Token sofort aus der Adresszeile. - Registrierung: nur mit Einladungscode oder wenn
allow_registrationaktiv ist. Das erste Konto entsteht über/auth/setupund wird Administrator.
Medien in Notizen
Bilder und Audio liegen in $DATA_DIR/media/<docId>/<id>.<ext>, beschrieben in der Tabelle media (Migration 5, internal/storage/media.go). Ein Block verweist über seinen Inhalt darauf, z. B. Typ image mit ; damit laufen Verweise ohne Änderung am Sync-Modell durch Synchronisation, Rückgängig und Konflikte. Hochladen (POST /documents/{id}/media) braucht Schreibrecht auf die Notiz, Lesen (GET /media/{id}) Leserecht auf die Notiz der Datei oder auf eine Notiz, deren Blöcke sie zeigen (kopierte Blöcke). Das Format wird am Inhalt erkannt (JPEG, PNG, GIF, WebP; WebM, Ogg, MP3, WAV, M4A), SVG und alles andere wird abgelehnt; Antworten tragen eine eigene CSP mit sandbox. Externe Bilder kopiert der Server einmal (POST /documents/{id}/media/fetch, nur https und öffentliche Adressen wie bei Webhooks, nicht in der Demo). Grenzen: MEDIA_MAX_IMAGE_MB, MEDIA_MAX_AUDIO_MB, MEDIA_QUOTA_MB, in der Demo MEDIA_DEMO_MAX_MB. Backup-ZIP (media/<id><ext>) und Markdown-Archiv (attachments/, Einbettung  relativ zur Notiz) enthalten die Dateien der eigenen Notizen; beim Import bekommen sie neue IDs. Der Obsidian-Import übernimmt Bilder und Audio, die eine Notiz allein in einem Punkt einbettet (![[datei.png]] oder ); andere Dateien erscheinen in der Vorschau als übersprungen. Die Verdichtung (alle 6 Stunden) löscht Dateien, die kein Block mehr erwähnt (auch keine gelöschten im Papierkorb oder Protokoll), frühestens einen Tag nach dem Hochladen. Der Demo-Reset leert media/.
Notizen umbauen
internal/storage/restructure.go: Ast auslagern liest den Ast, legt die neue Notiz an (Titel eindeutig, gleiche direkte und Gruppen-Freigaben, Besitzer der Ursprungsnotiz bekommt Schreibrecht) und ersetzt den Ast dann über das Operationsprotokoll durch [[Titel]] (Hashtags bleiben am Link). Zusammenführen hängt die Quelle als h2-Überschrift mit ihren Punkten als Unterpunkte an die Zielnotiz an (ebenfalls über Operationen, offene Editoren folgen live) und legt die Quelle in den Papierkorb; „Rückgängig“ löscht die Überschrift samt Unterpunkten und stellt die Quelle wieder her. Die Aktion läuft im Namen des Editor-Clients (clientId), damit dessen eigene Block-Sperren nicht stören. Bilder und Aufnahmen funktionieren weiter, weil der Zugriff über die Notizen läuft, die sie zeigen.
Link-Vorschau
Punkte mit einer reinen https-Adresse zeigen darüber eine Karte (LinkPreviewCard, lib/linkPreview.ts). GET /link-preview?url= holt die Seite serverseitig mit demselben Schutz wie Webhooks (nur https, keine internen Adressen, Weiterleitungen geprüft), liest og:*/twitter:*, <title>, description und das Icon aus dem <head> (höchstens 1 MB HTML, Bild 2 MB, Icon 100 KB, kein SVG) und speichert alles in link_previews (Migration 6): Erfolg 7 Tage, nichts Brauchbares 1 Tag, nach 30 Tagen gelöscht. Bilder kommen über GET /link-preview/{key}/image|icon mit Login. Pro Adresse höchstens 300 Abrufe in 15 Minuten (Antworten aus dem Cache zählen nicht). Ausblenden einer Karte macht aus der Adresse [Titel](url) (ein benannter Link hat keine Karte), damit synchronisiert die Wahl mit der Notiz; „Link-Vorschauen anzeigen“ unter Darstellung gilt pro Gerät. Im Demo-Modus aus.
Spracherkennung
Nur über Umgebungsvariablen (STT_*, siehe oben), nicht in der Oberfläche; Administration → Server & Isolation zeigt den aktiven Modus, den Zustand des Whisper-Servers und bietet eine Testaufnahme. Alle Mikrofon-Knöpfe (Quick-Dump-Leiste, mobiles Erfassen-Sheet, Halten des +-Knopfs) nutzen denselben Hook useVoiceInput (frontend/src/lib/voice.ts, useVoiceInput.ts): server nimmt auf und zeigt „Wird erkannt…“, browser liefert Text schon beim Sprechen, off oder ein Browser ohne die nötige Funktion blendet das Mikrofon aus. Im Server-Modus zeigen Wellen den echten Mikrofonpegel (VoiceWave), die Aufnahme endet spätestens nach STT_MAX_SECONDS. Der Browser nimmt per MediaRecorder auf (WebM/Opus, in Safari MP4/AAC) und schickt die Aufnahme an POST /voice/transcribe; MindSift reicht sie an STT_URL/v1/audio/transcriptions weiter (model, language) und gibt nur den Text zurück. Nichts wird gespeichert oder protokolliert außer Fehlern (internal/api/voice.go). Beim Start wartet MindSift auf den Whisper-Server und stößt den Download des Modells an, falls es fehlt (POST /v1/models/<id> bei speaches).
docker-compose.yml enthält den Dienst whisper (speaches 0.8.3, CPU) als auskommentierten Block: Rauten entfernen, STT_URL=http://whisper:8000 in .env setzen, docker compose up -d; das Modell liegt in ./whisper (Ordner gehört UID 1000). Gemessen auf einem Desktop mit 16 Kernen (int8) für einen Satz von 4 s: Systran/faster-whisper-small 1,6 s, deepdml/faster-whisper-large-v3-turbo-ct2 8 s bei besserer Erkennung (RAM ca. 1 bzw. 2 GB). Auf geteilten vCPUs ist mit dem Mehrfachen zu rechnen.
Hintergrundjob: Alarme
startAlarmTicker prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist der Alarmzeitpunkt erreicht (Fälligkeit mit Uhrzeit bzw. 9:00 bei ganztägigen Aufgaben, minus Vorlaufzeit des Benutzers, Zeitzone TZ), sendet er über die Kanäle des Benutzers (email, discord, webhook) und setzt alarm_sent, sobald mindestens ein Kanal erfolgreich war. Nach 3 Durchläufen ohne Erfolg gibt er auf (alarm_failures). Details und Einschränkungen: modules/tasks.md.
Hintergrundjob: Verdichtung der Notiz-Synchronisation
startCompaction räumt beim Start und dann alle 6 Stunden auf: abgelaufene Leases und Sitzungen im Speicher, Log-Einträge älter als 30 Tage (private Notizen: 7 Tage), Grabsteine älter als 30 Tage und Idempotenz-Ergebnisse älter als 90 Tage. Details: database.md.
Build
Stage 1 node:22-alpine tsc, vitest, vite build → frontend/dist
Stage 2 golang:1.23 go vet, go test, go build → /app/mindsift (dist wird eingebettet)
Stage 3 alpine:3.21 nur Binary, ca-certificates, tzdata
Image-Tags (CI): Push auf dev → :dev; Push/Merge auf main → :latest; Git-Tag v* (z. B. v1.2.0) → :v1.2.0 und :stable; Vorabversionen (v1.2.0-rc.1) nur :v1.2.0-rc.1, :stable bleibt. Andere Branches bauen nicht.
Versionierung: Ab v0.6.0 nach Semantic Versioning: MAJOR.MINOR.PATCH, Tags dreiteilig mit v (v0.6.0, v1.0.0-rc.1). Solange die Hauptversion 0 ist, erhöhen inkompatible Änderungen (API, Datenformate, Konfiguration) die mittlere Stelle, Fehlerbehebungen die letzte. Jede Änderung, die Benutzer oder Betreiber betrifft, kommt in CHANGELOG.md unter „Unreleased“ (Format Keep a Changelog); beim Release wird der Abschnitt zur Version mit Datum, frontend/package.json bekommt die Versionsnummer.
Die angezeigte Version (Kopf der App) setzt der Build-Parameter APP_VERSION: Die CI übergibt bei Git-Tags den Tag-Namen (v0.6.0), sonst zeigt die App die Version aus frontend/package.json mit -dev (v0.6.0-dev). Umgesetzt in frontend/vite.config.ts und frontend/src/version.ts.
Die Tests laufen beim Image-Build (Frontend: npm run test:run, Backend: go test -v ./...); ein Fehler bricht den Build ab. Frontend-Tests liegen unter frontend/src/**/__tests__/ (Vitest + jsdom + Testing Library, Setup in frontend/src/test/setup.ts).
Hintergrundjob: Backups
backups.go / backup.go: Ist „Automatische Backups“ in den Admin-Einstellungen an, läuft einmal täglich zur eingestellten Uhrzeit (backup_time, Zeitzone TZ; nach einem Neustart wird ein verpasster Lauf am selben Tag nachgeholt):
backups/system/mindsift-db-JJJJ-MM-TT-HHMM.db.gz: Abzug der ganzen Datenbank perVACUUM INTO(stimmig, auch während geschrieben wird), gzip.backups/users/<id>/mindsift-backup-….zip: dasselbe ZIP wie „Backup herunterladen“ für jedes aktive Konto, mit den Bildern und Aufnahmen der eigenen Notizen untermedia/.- Achtung: Der Datenbank-Abzug enthält keine Mediendateien. Wer den Server sichert, sichert den ganzen Ordner
data/(Datenbank,media/,backups/). - Danach werden Dateien älter als
backup_retention_days(Standard 3) gelöscht. Dateien entstehen über.partund Umbenennen, halbe Backups tauchen nie in der Liste auf. Die API liefert nur Dateien mit genau diesem Namensmuster aus. - Im Demo-Modus läuft kein Zeitplan. Zurückspielen eines Datenbank-Abzugs: Container stoppen,
mindsift.dbdurch die entpackte Datei ersetzen, starten. Benutzer spielen ihre eigenen Backups in den Kontoeinstellungen ein, auch nur für einzelne Bereiche (Notizen, Aufgaben, Listen, Inbox, Themes).
Offline (PWA)
- Service Worker (public/sw.js): Seitenaufrufe Network-First mit der zuletzt geladenen
index.htmlals Rückfall, Bundles unter/assets/(Hash im Namen) aus dem Cache, Icons und Manifest Stale-While-Revalidate. API-Anfragen fasst er nie an. Alte Caches verschwinden beim Aktivieren einer neuenVERSION, Bundles sind auf 80 Einträge begrenzt. - Lese-Cache (lib/offline.ts): Jede erfolgreiche GET-Antwort landet pro Benutzer in IndexedDB (
mindsift-offline). Ohne Verbindung liefertrequest()die letzte bekannte Antwort; so startet die App offline angemeldet und zeigt Gedanken, Aufgaben und Listen. Ausgenommen: Streams, Long-Polling, Notiz-Sync (eigener Speicher, modules/collaboration.md), Export, Suche. Abmelden leert Cache und Warteschlange. - Warteschlange (Outbox): Offline erfasste Gedanken (
POST /inbox) und abgehakte Aufgaben bzw. Listeneinträge (PATCH) werden inlocalStoragegesammelt, wiederholtes Umschalten zählt nur mit dem Endstand. Gesendet wird in Reihenfolge beimonline-Ereignis, beim Start und alle 30 s, solange etwas wartet; vom Server abgelehnte Einträge (4xx) entfallen. Andere Änderungen brauchen weiterhin eine Verbindung. - Hinweis: Ein Banner über der unteren Navigation zeigt „Offline“ und die Zahl wartender Änderungen bzw. „Synchronisiere…“.
App-Icons, PWA & Social-Preview (Open Graph)
MindSift verwendet plattformübergreifend ein einheitliches App-Icon-Design, das exakt dem Icon im Kopfbereich der mobilen App entspricht:
- Design & Geometrie: Sanft abgerundetes Quadrat (
rx=108auf 512x512 Canvas bzw.rounded-xl), Primärblau (--accent-primary: #2563eb) als Hintergrund und zentriertes Lucide Brain Icon in reinem Weiß (--text-on-accent: #ffffff).
Asset-Matrix (frontend/public/)
| Datei | Zweck & Zielplattform | Besonderheit |
|---|---|---|
icon.svg |
Modernes Standard-Icon & SVG-Favicon | Vektor-Original mit abgerundeten Ecken |
icon-192x192.png, icon-512x512.png |
PWA-Installation (purpose: "any"), Desktop Linux/Windows |
Erforderlich für Chromium unter Linux (erzeugt sonst kein .desktop-Icon) |
icon-maskable-192x192.png, icon-maskable-512x512.png, icon-maskable.svg |
Android Adaptive Icons (purpose: "maskable") |
Full-Bleed Hintergrund (#2563eb) ohne transparente Ecken, Brain im 80%-Sicherheitsbereich |
apple-touch-icon.png (180x180) |
iOS Safari („Zum Home-Bildschirm“) | Opaker Hintergrund verhindert schwarze Ränder beim automatischen Apple-Squircle-Maskieren |
favicon.ico, favicon-32x32.png, favicon-16x16.png |
Browser-Tabs, Lesezeichen, Chronik | Multi-Resolution-ICO (16/32/48) für alle Desktop-Browser |
og-image.png (1200x630), og-image-square.png (512x512) |
Link-Vorschau in WhatsApp, Discord, Telegram, X/Twitter | Dunkler #0b0f19-Hintergrund mit Icon & Claim; Datei-Größe < 120 KB (WhatsApp-Limit < 300 KB) |
Dynamische OpenGraph-URL-Injektion
Messenger-Crawler (insbesondere WhatsApp) werten relative Bild-URLs in og:image oft nicht korrekt aus. Der Go-SPA-Handler in internal/api/server.go ersetzt beim Ausliefern von index.html automatisch relative Pfade (/og-image.png, og:url) durch die absolute Server-Origin (scheme://host), ermittelt über X-Forwarded-Proto und X-Forwarded-Host bzw. req.Host.