- docker-compose.yml only maps variables; every variable is explained in .env.example (German, with defaults, units and choices). - The whisper service is a commented block to switch on instead of a compose profile. - TZ, TRUSTED_PROXIES, LOGIN_PROTECTION, DEMO_RESET_MINUTES and LEGAL_*_CONTENT are now passed through (they had no effect from .env). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
19 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_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.
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.- 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.