Files
MindSift/Doku/architecture.md
T
sandrodachandClaude Opus 5.5 7801cd13b9
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m49s
feat(voice): self-hosted speech recognition via Whisper (STT_*)
- STT_URL switches voice input to an OpenAI-compatible Whisper server
  (e.g. speaches); without it the browser's recognition stays. Configured
  by environment only, with limits for upload size, parallel jobs, hourly
  budget per user and timeout. The demo always uses the browser.
- POST /voice/transcribe forwards recordings and returns the text; nothing
  is stored. On startup MindSift waits for the server and asks it to
  download the model if missing.
- Admin card shows the active mode and server state, with a test recording.
- docker-compose: optional "whisper" service (speaches 0.8.3 CPU, profile
  "whisper", models in ./whisper).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:12:57 +01:00

18 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

  1. config.Load() liest die Umgebung (siehe unten).
  2. storage.NewStore() öffnet SQLite (busy_timeout, WAL, max. 8 Verbindungen) und führt migrate() aus.
  3. SMTP-Werte aus der Umgebung werden in system_settings übernommen, sofern dort noch nichts steht.
  4. Mit Demo-Flag legt EnsureDemoAccounts die Konten admin/admin und user/user an.
  5. api.NewServer startet 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_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 429 mit Retry-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 in sessions an (Gerät, IP, letzte Nutzung); jti ist deren ID. Gültig ist ein Token nur, wenn die Sitzung aktiv ist (nicht beendet, höchstens 30 Tage unbenutzt), das Konto aktiv ist und tokenVersion mit users.token_version übereinstimmt. Ist das Token älter als 24 Stunden, schickt der Server im Header X-Session-Token ein neues für dieselbe Sitzung; das Frontend übernimmt es (request() in lib/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_version wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
  • Logout beendet die Sitzung des Geräts; GET /auth/sessions listet 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 per X-Session-Token ein 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 in X-API-Key und darf nur Daten anliefern (POST /inbox, POST /webhooks/ingest); alle anderen Endpunkte antworten mit 403.
  • Passwort vergessen: nur wenn SMTP und BASE_URL gesetzt sind (nicht im Demo-Modus; passwordReset in /auth/status). POST /auth/forgot-password antwortet immer gleich und verschickt die Mail im Hintergrund, höchstens alle 2 Minuten pro Konto. Der Link BASE_URL/?resetToken=… gilt eine Stunde und einmal; gespeichert wird nur der SHA-256-Hash (password_reset_tokens). POST /auth/reset-password setzt das Passwort, entwertet alle offenen Links des Kontos und erhöht token_version (alle Sitzungen enden). Die App entfernt den Token sofort aus der Adresszeile.
  • Registrierung: nur mit Einladungscode oder wenn allow_registration aktiv ist. Das erste Konto entsteht über /auth/setup und wird Administrator.

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. Im Server-Modus nimmt der Browser per MediaRecorder auf 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 bringt den optionalen Dienst whisper (speaches 0.8.3, CPU, Profil whisper) mit; 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 per VACUUM INTO (stimmig, auch während geschrieben wird), gzip.
  • backups/users/<id>/mindsift-backup-….zip: dasselbe ZIP wie „Backup herunterladen“ für jedes aktive Konto.
  • Danach werden Dateien älter als backup_retention_days (Standard 3) gelöscht. Dateien entstehen über .part und 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.db durch 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.html als 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 neuen VERSION, 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 liefert request() 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 in localStorage gesammelt, wiederholtes Umschalten zählt nur mit dem Endstand. Gesendet wird in Reihenfolge beim online-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=108 auf 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.