The service worker now caches the app shell: page loads are network first with the last index.html as fallback, hashed bundles come from the cache, icons and manifest are refreshed in the background; API calls are never intercepted. GET answers are kept per user in IndexedDB and served when the server cannot be reached, so the app starts logged in with the last known thoughts, tasks and lists. Captured thoughts and checked tasks or list items go to an outbox that is sent in order when the connection returns (also at start and every 30 s). A banner shows offline state and waiting changes. Logging out clears cache and outbox. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
131 lines
12 KiB
Markdown
131 lines
12 KiB
Markdown
# 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](../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` (Routen, Handler, Alarm-Ticker, SMTP/Discord/Webhook-Versand) |
|
|
| `internal/storage/` | `sqlite.go` (Schema, Migrationen, gesamte Datenlogik) |
|
|
| `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 ([FLAW-04](flaws.md)) |
|
|
| `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 |
|
|
| `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) |
|
|
| `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`).
|
|
- **JWT:** HS256, Laufzeit 30 Tage, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Gültig nur, wenn Token nicht in `revoked_tokens` steht, das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
|
|
- **Logout** schreibt den Token-Hash in `revoked_tokens`.
|
|
- **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` ([FLAW-07](flaws.md)).
|
|
- **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.
|
|
|
|
## 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. Details und Einschränkungen: [modules/tasks.md](modules/tasks.md), [FLAW-12](flaws.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](database.md#verdichtung-des-notiz-logs).
|
|
|
|
## 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`. Andere Branches bauen nicht.
|
|
|
|
Die angezeigte Version (Kopf der App) setzt der Build-Parameter `APP_VERSION`: Die CI übergibt bei Git-Tags den Tag-Namen (`v0.2`), sonst zeigt die App die Version aus `frontend/package.json` mit `-dev` (`v0.2-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`).
|
|
|
|
## Offline (PWA)
|
|
|
|
- **Service Worker** ([public/sw.js](../frontend/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](../frontend/src/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](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`](../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`.
|