From 177fa493635fd859ef69920d9d1cca86937f88a4 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 22:57:59 +0100 Subject: [PATCH] feat(media): storage for images and audio in notes (M1) - Table media (migration 5), files in $DATA_DIR/media//. - POST /documents/{id}/media (write access), POST .../media/fetch copies an external https image (SSRF-guarded, not in the demo), GET /media/{id} with range requests for readers of a note that shows the file. - Format detected from the content (no SVG); own sandbox CSP on answers. - Blocks point to files through their content (media:), so the note sync needs no change; compaction removes files no block mentions any more after a day. Demo reset and demo start clear the folder. - Limits MEDIA_MAX_IMAGE_MB, MEDIA_MAX_AUDIO_MB, MEDIA_QUOTA_MB, MEDIA_DEMO_MAX_MB. Co-Authored-By: Claude Opus 5.5 --- .env.example | 13 ++ CHANGELOG.md | 1 + Doku/api/rest.md | 3 + Doku/architecture.md | 4 + Doku/database.md | 1 + cmd/server/main.go | 2 + docker-compose.yml | 5 + internal/api/bodylimit.go | 2 + internal/api/demo.go | 3 + internal/api/docsync.go | 1 + internal/api/media.go | 295 +++++++++++++++++++++++++++++++++ internal/api/media_test.go | 166 +++++++++++++++++++ internal/api/server.go | 3 + internal/config/config.go | 18 ++ internal/storage/media.go | 119 +++++++++++++ internal/storage/migrations.go | 1 + 16 files changed, 637 insertions(+) create mode 100644 internal/api/media.go create mode 100644 internal/api/media_test.go create mode 100644 internal/storage/media.go diff --git a/.env.example b/.env.example index 1923548..2003c15 100644 --- a/.env.example +++ b/.env.example @@ -81,6 +81,19 @@ LEGAL_PRIVACY_CONTENT= LEGAL_IMPRINT_CONTENT_FILE= LEGAL_PRIVACY_CONTENT_FILE= +# ─── Bilder und Audio in Notizen ─────────────────────────────────────────────────────────────────── +# Dateien liegen in data/media/ und sind nur für Personen sichtbar, die die Notiz lesen dürfen. Nicht mehr +# verwendete Dateien räumt MindSift nach einem Tag selbst weg. + +# Größtes Bild in MB (der Browser verkleinert Fotos vorher auf höchstens 2048 Pixel). +MEDIA_MAX_IMAGE_MB=15 +# Größte Audiodatei in MB (Sprachnotiz oder hochgeladene Datei). +MEDIA_MAX_AUDIO_MB=50 +# Speicherplatz für Medien pro Person in MB; 0 = unbegrenzt. +MEDIA_QUOTA_MB=0 +# Nur in der Demo: größte Datei in MB (Dateien verschwinden beim Demo-Reset). +MEDIA_DEMO_MAX_MB=3 + # ─── Spracherkennung (optional) ──────────────────────────────────────────────────────────────────── # Ohne STT_URL nutzt MindSift die Spracherkennung des Browsers (Chrome, Edge, Safari; die Aufnahme geht # dabei an Google, Microsoft bzw. Apple). Mit eigenem Whisper-Server bleibt alles auf deinem Server und diff --git a/CHANGELOG.md b/CHANGELOG.md index 53bd45c..359def9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ### Added - Eigene Spracherkennung (optional): Mit `STT_URL` schickt MindSift Aufnahmen an einen selbst betriebenen Whisper-Server (OpenAI-kompatibel, z. B. speaches) statt an Google oder Microsoft. Einstellung nur per Umgebungsvariablen (`STT_*`) inklusive Grenzwerten; `docker-compose.yml` enthält den Dienst `whisper` als auskommentierten Block zum Einschalten, das Modell lädt MindSift beim ersten Start selbst. Administration → Server & Isolation zeigt den aktiven Modus und bietet eine Testaufnahme. In der Demo bleibt es bei der Browser-Erkennung. - API: `POST /voice/transcribe`, `GET /admin/voice`; `/auth/status` meldet `voice.mode`. +- Medienablage für Notizen (Grundlage für Bilder und Audio): Hochladen, Kopieren externer Bilder, Abruf nur mit Leserecht; Grenzen per `MEDIA_*`; nicht mehr verwendete Dateien werden automatisch entfernt. API: `POST /documents/{id}/media`, `POST /documents/{id}/media/fetch`, `GET /media/{id}`. - Code-Blöcke in Notizen: Sprachauswahl (rund 50 Sprachen, bei Bedarf nachgeladen), Farben beim Tippen im Stil von VS Code Dark+/Light+ für alle Themes, „Kopieren“, Tab/Shift+Tab rückt ein und aus. `/code yaml` oder ```` ```yaml ```` legt die Sprache direkt fest. ### Changed diff --git a/Doku/api/rest.md b/Doku/api/rest.md index b5363ce..d5d4cf7 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -17,6 +17,9 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) | | POST | `/webhooks/ingest` | Einliefern per Webhook, Key als `X-API-Key` (empfohlen), Bearer oder `?token=` (in der URL nur API-Keys, keine Sitzungs-Tokens). Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; `target` (oder `type`): `triage` (Standard, Inbox), `task` (optional `taskListId`), `list_item` (`listId`, `category`, `quantity`). Klartext-Body wird als Inhalt genommen. `403` ohne passenden Scope oder ohne Schreibrecht auf die Liste | | POST | `/auth/stream-ticket` | Einmal-Ticket (60 s) für WebSocket/SSE | +| POST | `/documents/{id}/media` | Bild oder Audio zu einer Notiz hochladen (`multipart/form-data`, Feld `file`; Schreibrecht nötig) → `{id, kind, mime, size, url}`. `415` falsches Format, `413` zu groß oder Kontingent voll | +| POST | `/documents/{id}/media/fetch` | Externes Bild kopieren: `{url}` (nur https, keine internen Adressen; nicht in der Demo) → wie oben | +| GET | `/media/{id}` | Datei abrufen (Leserecht auf eine Notiz, die sie zeigt), mit Range-Anfragen; sonst `404` | | POST | `/voice/transcribe` | Nur im Server-Modus der Spracherkennung: `multipart/form-data` mit `file` (Aufnahme, z. B. WebM/Opus oder MP4/AAC) → `{text}`. `404` ohne `STT_URL`, `413` zu groß, `429` Stundenlimit, `503` ausgelastet, `502` Whisper-Fehler (Details im Log) | | GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); `?ticket=` oder `Authorization`-Header | | GET | `/events` | SSE-Fallback; `?ticket=` oder `Authorization`-Header | diff --git a/Doku/architecture.md b/Doku/architecture.md index b3d2644..8aa3456 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -90,6 +90,10 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in - **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. +## Medien in Notizen + +Bilder und Audio liegen in `$DATA_DIR/media//.`, beschrieben in der Tabelle `media` (Migration 5, `internal/storage/media.go`). Ein Block verweist über seinen Inhalt darauf, z. B. Typ `image` mit `![Beschriftung](media:med_…)`; 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`. 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/`. + ## 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/` bei speaches). diff --git a/Doku/database.md b/Doku/database.md index 535b0a7..9b05e51 100644 --- a/Doku/database.md +++ b/Doku/database.md @@ -10,6 +10,7 @@ SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `j |---|---|---| | `users` | Konten | `email` (eindeutig), `username`, `password_hash`, `display_name`, `role` (`admin`/`member`), `is_active`, `token_version`, `alarm_channels` (kommagetrennt), `alarm_email`, `discord_webhook_url`, `custom_webhook_url`, `notify_before_minutes`, `avatar_url`, `organization_id` (leer = keine Organisation) | | `user_invites` | Einladungscodes | `code`, `email`, `role`, `organization_id`, `expires_at`, `used_by` | +| `media` | Bilder und Audio in Notizen (Migration 5); Datei unter `$DATA_DIR/media//.` | `id`, `doc_id`, `owner_id`, `kind` (`image`/`audio`), `mime`, `size`, `source_url` (kopiertes externes Bild), `created_at` | | `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) | | `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` | | `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` | diff --git a/cmd/server/main.go b/cmd/server/main.go index 0c01f57..e3b9bf0 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "os/signal" + "path/filepath" "syscall" "time" _ "time/tzdata" // time zones without system tzdata (scratch/alpine images) @@ -46,6 +47,7 @@ func main() { if cfg.DemoMode { slog.Info("🧪 Demo-Modus aktiv: Setze Datenbank zurück und richte Demo-Accounts ein...") + _ = os.RemoveAll(filepath.Join(filepath.Dir(cfg.DatabasePath), "media")) // uploads of the last run if err := store.ResetAndSeedDemo(); err != nil { slog.Warn("⚠️ Fehler beim Einrichten des Demo-Modus", "error", err) } else { diff --git a/docker-compose.yml b/docker-compose.yml index 0d55d77..78d0be4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,11 @@ services: - LEGAL_PRIVACY_CONTENT=${LEGAL_PRIVACY_CONTENT:-} - LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-} - LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_FILE:-} + # Bilder und Audio in Notizen + - MEDIA_MAX_IMAGE_MB=${MEDIA_MAX_IMAGE_MB:-15} + - MEDIA_MAX_AUDIO_MB=${MEDIA_MAX_AUDIO_MB:-50} + - MEDIA_QUOTA_MB=${MEDIA_QUOTA_MB:-0} + - MEDIA_DEMO_MAX_MB=${MEDIA_DEMO_MAX_MB:-3} # Spracherkennung - STT_URL=${STT_URL:-} - STT_MODEL=${STT_MODEL:-deepdml/faster-whisper-large-v3-turbo-ct2} diff --git a/internal/api/bodylimit.go b/internal/api/bodylimit.go index 1c3eb46..6172492 100644 --- a/internal/api/bodylimit.go +++ b/internal/api/bodylimit.go @@ -14,6 +14,8 @@ const defaultMaxBody = 2 << 20 func (s *Server) bodyLimitFor(path string) int64 { switch { + case strings.HasPrefix(path, "/api/v1/documents/") && strings.HasSuffix(path, "/media"): + return s.mediaUploadLimit() case path == "/api/v1/voice/transcribe": return int64(s.cfg.Speech.MaxUploadMB)<<20 + 1<<20 case strings.HasPrefix(path, "/api/v1/import/"): diff --git a/internal/api/demo.go b/internal/api/demo.go index 19d69ce..a4d00f4 100644 --- a/internal/api/demo.go +++ b/internal/api/demo.go @@ -4,6 +4,7 @@ import ( "errors" "log/slog" "net/http" + "os" "strings" "sync" "time" @@ -59,6 +60,8 @@ func (s *Server) setDemoNextReset(t time.Time) { func (s *Server) resetDemo() { s.demo.gate.Lock() err := s.store.ResetAndSeedDemo() + // Uploaded images and recordings belong to the old state; their rows are gone with the tables + _ = os.RemoveAll(s.mediaRoot()) s.demo.gate.Unlock() if err != nil { slog.Error("Demo-Reset fehlgeschlagen", "error", err) diff --git a/internal/api/docsync.go b/internal/api/docsync.go index 65f2e19..ff385b1 100644 --- a/internal/api/docsync.go +++ b/internal/api/docsync.go @@ -270,6 +270,7 @@ func (s *Server) startCompaction() { func (s *Server) compactOnce() { s.leases.Sweep() + s.compactMedia(time.Now()) st, err := s.store.CompactDocs(time.Now()) if err != nil { slog.Error("Verdichtung des Änderungsprotokolls fehlgeschlagen", "error", err) diff --git a/internal/api/media.go b/internal/api/media.go new file mode 100644 index 0000000..5947c2c --- /dev/null +++ b/internal/api/media.go @@ -0,0 +1,295 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/sandrodach/mindsift/internal/auth" + "github.com/sandrodach/mindsift/internal/storage" +) + +// Images and audio in notes. Uploads need write access to the note, reading needs read access to a note +// that shows the file. Files are served through the API with the normal login (the browser loads them +// with fetch and shows a blob: URL), never under a public link. + +const mediaGrace = 24 * time.Hour // uploads not yet placed in a block are kept this long + +// Accepted formats (detected from the content, not from the name): file extension and kind +var mediaTypes = map[string]struct{ ext, kind string }{ + "image/jpeg": {".jpg", "image"}, + "image/png": {".png", "image"}, + "image/gif": {".gif", "image"}, + "image/webp": {".webp", "image"}, + "audio/webm": {".webm", "audio"}, + "video/webm": {".webm", "audio"}, // Chrome's audio-only recordings are detected as video/webm + "audio/ogg": {".ogg", "audio"}, + "application/ogg": {".ogg", "audio"}, + "audio/mpeg": {".mp3", "audio"}, + "audio/wave": {".wav", "audio"}, + "audio/wav": {".wav", "audio"}, + "audio/mp4": {".m4a", "audio"}, + "video/mp4": {".m4a", "audio"}, // Safari records MP4/AAC + "audio/aac": {".aac", "audio"}, +} + +func (s *Server) mediaRoot() string { + return filepath.Join(filepath.Dir(s.cfg.DatabasePath), "media") +} + +func (s *Server) mediaPath(m *storage.Media) string { + ext := ".bin" + if t, ok := mediaTypes[m.Mime]; ok { + ext = t.ext + } + return filepath.Join(s.mediaRoot(), filepath.Base(m.DocID), filepath.Base(m.ID)+ext) +} + +// mediaLimit is the largest allowed file of a kind in bytes. +func (s *Server) mediaLimit(kind string) int64 { + mb := s.cfg.Media.MaxImageMB + if kind == "audio" { + mb = s.cfg.Media.MaxAudioMB + } + if s.cfg.DemoMode && s.cfg.Media.DemoMaxMB < mb { + mb = s.cfg.Media.DemoMaxMB + } + return int64(mb) << 20 +} + +// detectMedia recognises the format from the first bytes. Browsers send audio as e.g. +// "audio/webm;codecs=opus"; that name is kept when the content looks like a container we accept. +func detectMedia(data []byte, declared string) (mime, kind string, ok bool) { + sniffed := http.DetectContentType(data) + if i := strings.IndexByte(sniffed, ';'); i >= 0 { + sniffed = sniffed[:i] + } + // Go does not know WebP and M4A by name; check their signatures directly + switch { + case len(data) >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP": + sniffed = "image/webp" + case len(data) >= 12 && string(data[4:8]) == "ftyp": + sniffed = "video/mp4" + } + t, ok := mediaTypes[sniffed] + if !ok { + return "", "", false + } + if t.kind == "audio" { + if d := strings.ToLower(strings.TrimSpace(strings.SplitN(declared, ";", 2)[0])); strings.HasPrefix(d, "audio/") { + if dt, known := mediaTypes[d]; known && dt.kind == "audio" { + sniffed = d + } + } else if sniffed == "video/webm" { + sniffed = "audio/webm" + } else if sniffed == "video/mp4" { + sniffed = "audio/mp4" + } + } + return sniffed, t.kind, true +} + +func (s *Server) canEditDoc(userID, docID string) bool { + role := s.store.DocRole(userID, docID) + return role == "owner" || role == "editor" +} + +// storeMedia checks limits and writes the file; size limits must have been applied while reading. +func (s *Server) storeMedia(w http.ResponseWriter, userID, docID string, data []byte, declared, sourceURL string) (*storage.Media, bool) { + mime, kind, ok := detectMedia(data, declared) + if !ok { + writeError(w, http.StatusUnsupportedMediaType, "Nicht unterstütztes Format (Bilder: JPEG, PNG, GIF, WebP; Audio: WebM, Ogg, MP3, WAV, M4A)") + return nil, false + } + if limit := s.mediaLimit(kind); int64(len(data)) > limit { + writeError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("Datei zu groß (höchstens %d MB)", limit>>20)) + return nil, false + } + if quota := int64(s.cfg.Media.QuotaMB) << 20; quota > 0 { + used, _ := s.store.MediaUsage(userID) + if used+int64(len(data)) > quota { + writeError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("Speicherplatz für Medien aufgebraucht (%d MB)", s.cfg.Media.QuotaMB)) + return nil, false + } + } + m, err := s.store.CreateMedia(docID, userID, kind, mime, int64(len(data)), sourceURL) + if err != nil { + writeError(w, http.StatusInternalServerError, "Datei konnte nicht gespeichert werden") + return nil, false + } + path := s.mediaPath(m) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err == nil { + err = os.WriteFile(path, data, 0o644) + if err == nil { + return m, true + } + slog.Error("Mediendatei konnte nicht geschrieben werden", "path", path, "error", err) + } + _ = s.store.DeleteMediaRow(m.ID) + writeError(w, http.StatusInternalServerError, "Datei konnte nicht gespeichert werden") + return nil, false +} + +func mediaResponse(m *storage.Media) map[string]interface{} { + return map[string]interface{}{"id": m.ID, "kind": m.Kind, "mime": m.Mime, "size": m.Size, "url": "/api/v1/media/" + m.ID} +} + +// handleUploadMedia: POST /documents/{id}/media (multipart, field "file") +func (s *Server) handleUploadMedia(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + docID := chi.URLParam(r, "id") + if !s.canEditDoc(userID, docID) { + writeError(w, http.StatusForbidden, "Keine Schreibrechte für diese Notiz") + return + } + allowLongTransfer(w) + file, header, err := r.FormFile("file") + if err != nil { + var tooBig *http.MaxBytesError + if errors.As(err, &tooBig) { + writeError(w, http.StatusRequestEntityTooLarge, "Datei zu groß") + return + } + writeError(w, http.StatusBadRequest, "Keine Datei im Feld \"file\"") + return + } + defer file.Close() + data, err := io.ReadAll(file) + if err != nil || len(data) == 0 { + writeError(w, http.StatusBadRequest, "Datei konnte nicht gelesen werden") + return + } + if m, ok := s.storeMedia(w, userID, docID, data, header.Header.Get("Content-Type"), ""); ok { + writeJSON(w, http.StatusCreated, mediaResponse(m)) + } +} + +// handleFetchMedia: POST /documents/{id}/media/fetch {url} copies an external image, so readers of the +// note do not contact the other server and the image stays when it disappears there. +func (s *Server) handleFetchMedia(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + docID := chi.URLParam(r, "id") + if !s.canEditDoc(userID, docID) { + writeError(w, http.StatusForbidden, "Keine Schreibrechte für diese Notiz") + return + } + var req struct { + URL string `json:"url"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.URL) == "" { + writeError(w, http.StatusBadRequest, "Bild-URL erforderlich") + return + } + src := strings.TrimSpace(req.URL) + // Same protection as alarm webhooks: https only, no addresses in the server's own network + if err := validateWebhookURL(src, false, false); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + limit := s.mediaLimit("image") + httpReq, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, src, nil) + httpReq.Header.Set("Accept", "image/*") + httpReq.Header.Set("User-Agent", "MindSift (image copy)") + resp, err := guardedClient(false).Do(httpReq) + if err != nil { + writeError(w, http.StatusBadGateway, "Bild konnte nicht geladen werden") + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + writeError(w, http.StatusBadGateway, fmt.Sprintf("Bild konnte nicht geladen werden (HTTP %d)", resp.StatusCode)) + return + } + data, err := io.ReadAll(io.LimitReader(resp.Body, limit+1)) + if err != nil { + writeError(w, http.StatusBadGateway, "Bild konnte nicht geladen werden") + return + } + if int64(len(data)) > limit { + writeError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("Bild zu groß (höchstens %d MB)", limit>>20)) + return + } + if _, kind, ok := detectMedia(data, ""); !ok || kind != "image" { + writeError(w, http.StatusUnsupportedMediaType, "Unter dieser Adresse liegt kein Bild (JPEG, PNG, GIF, WebP)") + return + } + if m, ok := s.storeMedia(w, userID, docID, data, "", src); ok { + writeJSON(w, http.StatusCreated, mediaResponse(m)) + } +} + +// canReadMedia: the user may read the note the file was uploaded to, or a note whose blocks show it +// (e.g. a block copied into another note). +func (s *Server) canReadMedia(userID string, m *storage.Media) bool { + if s.store.DocRole(userID, m.DocID) != "" { + return true + } + docs, _ := s.store.MediaReferencedIn(m.ID) + for _, d := range docs { + if s.store.DocRole(userID, d) != "" { + return true + } + } + return false +} + +// handleGetMedia: GET /media/{id} with range requests (seeking in audio). +func (s *Server) handleGetMedia(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + m, err := s.store.GetMedia(chi.URLParam(r, "id")) + if err != nil || !s.canReadMedia(userID, m) { + writeError(w, http.StatusNotFound, storage.ErrMediaNotFound.Error()) + return + } + f, err := os.Open(s.mediaPath(m)) + if err != nil { + writeError(w, http.StatusNotFound, storage.ErrMediaNotFound.Error()) + return + } + defer f.Close() + allowLongTransfer(w) + w.Header().Set("Content-Type", m.Mime) + // The content of an ID never changes; the login is checked on every request anyway + w.Header().Set("Cache-Control", "private, max-age=31536000, immutable") + w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox") + created, _ := time.Parse(time.RFC3339, m.CreatedAt) + http.ServeContent(w, r, "", created, f) +} + +// compactMedia deletes files no block mentions any more (after the grace time) and their folders. +func (s *Server) compactMedia(now time.Time) { + orphans, err := s.store.UnreferencedMedia(now.Add(-mediaGrace)) + if err != nil { + slog.Error("Aufräumen der Mediendateien fehlgeschlagen", "error", err) + return + } + for i := range orphans { + m := &orphans[i] + if err := os.Remove(s.mediaPath(m)); err != nil && !os.IsNotExist(err) { + slog.Warn("Mediendatei konnte nicht gelöscht werden", "id", m.ID, "error", err) + continue + } + _ = s.store.DeleteMediaRow(m.ID) + _ = os.Remove(filepath.Dir(s.mediaPath(m))) // only succeeds when the folder is empty + } + if len(orphans) > 0 { + slog.Info("Nicht mehr verwendete Mediendateien gelöscht", "count", len(orphans)) + } +} + +// bodyLimit for uploads: the largest kind plus room for the multipart envelope +func (s *Server) mediaUploadLimit() int64 { + img, audio := s.mediaLimit("image"), s.mediaLimit("audio") + if audio > img { + return audio + 1<<20 + } + return img + 1<<20 +} diff --git a/internal/api/media_test.go b/internal/api/media_test.go new file mode 100644 index 0000000..8921a39 --- /dev/null +++ b/internal/api/media_test.go @@ -0,0 +1,166 @@ +package api + +import ( + "bytes" + "encoding/json" + "image" + "image/png" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/sandrodach/mindsift/internal/auth" + "github.com/sandrodach/mindsift/internal/domain" +) + +func tinyPNG(t *testing.T, extra int) []byte { + t.Helper() + var buf bytes.Buffer + if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 4, 4))); err != nil { + t.Fatal(err) + } + buf.Write(make([]byte, extra)) // padding behind the image data, still detected as PNG + return buf.Bytes() +} + +func mediaUpload(t *testing.T, router http.Handler, token, docID string, data []byte, contentType string) *httptest.ResponseRecorder { + t.Helper() + var body bytes.Buffer + mw := multipart.NewWriter(&body) + h := make(map[string][]string) + h["Content-Disposition"] = []string{`form-data; name="file"; filename="datei"`} + h["Content-Type"] = []string{contentType} + fw, _ := mw.CreatePart(h) + _, _ = fw.Write(data) + _ = mw.Close() + req := httptest.NewRequest("POST", "/api/v1/documents/"+docID+"/media", &body) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +func TestMediaUploadReadAndAccess(t *testing.T) { + srv, store, token := setupTestServer(t) + srv.cfg.Media.MaxImageMB, srv.cfg.Media.MaxAudioMB = 1, 2 + router := srv.Router() + claims, _ := auth.ValidateToken(testJWTSecret, token) + doc, _ := store.CreateDocument(claims.UserID, "", "Bilder", "🖼️", "", nil, nil, false) + + img := tinyPNG(t, 0) + rec := mediaUpload(t, router, token, doc.ID, img, "image/png") + if rec.Code != http.StatusCreated { + t.Fatalf("upload: %d %s", rec.Code, rec.Body.String()) + } + var up struct{ ID, Kind, Mime, URL string } + _ = json.Unmarshal(rec.Body.Bytes(), &up) + if up.Kind != "image" || up.Mime != "image/png" || up.URL != "/api/v1/media/"+up.ID { + t.Fatalf("unexpected upload answer: %+v", up) + } + + get := sessionCall(router, "GET", up.URL, token, "", "") + if get.Code != http.StatusOK || get.Header().Get("Content-Type") != "image/png" || !bytes.Equal(get.Body.Bytes(), img) { + t.Fatalf("read back: %d %s", get.Code, get.Header().Get("Content-Type")) + } + // Someone without access to the note sees nothing and cannot upload + _, strangerToken := newTestUser(t, store, "fremd@example.com") + if rec := sessionCall(router, "GET", up.URL, strangerToken, "", ""); rec.Code != http.StatusNotFound { + t.Fatalf("stranger read: want 404, got %d", rec.Code) + } + if rec := mediaUpload(t, router, strangerToken, doc.ID, img, "image/png"); rec.Code != http.StatusForbidden { + t.Fatalf("stranger upload: want 403, got %d", rec.Code) + } + // Only real images and recordings, within the limits + if rec := mediaUpload(t, router, token, doc.ID, []byte(""), "image/svg+xml"); rec.Code != http.StatusUnsupportedMediaType { + t.Fatalf("svg: want 415, got %d", rec.Code) + } + if rec := mediaUpload(t, router, token, doc.ID, tinyPNG(t, 1<<20+10), "image/png"); rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("too large: want 413, got %d %s", rec.Code, rec.Body.String()) + } + // External images must not point into the server's own network + if rec := sessionCall(router, "POST", "/api/v1/documents/"+doc.ID+"/media/fetch", token, `{"url":"https://127.0.0.1/bild.png"}`, ""); rec.Code != http.StatusBadRequest { + t.Fatalf("fetch from localhost: want 400, got %d", rec.Code) + } +} + +func TestMediaQuota(t *testing.T) { + srv, store, token := setupTestServer(t) + srv.cfg.Media.MaxImageMB, srv.cfg.Media.QuotaMB = 2, 1 + router := srv.Router() + claims, _ := auth.ValidateToken(testJWTSecret, token) + doc, _ := store.CreateDocument(claims.UserID, "", "Quota", "", "", nil, nil, false) + if rec := mediaUpload(t, router, token, doc.ID, tinyPNG(t, 700<<10), "image/png"); rec.Code != http.StatusCreated { + t.Fatalf("first upload: %d", rec.Code) + } + if rec := mediaUpload(t, router, token, doc.ID, tinyPNG(t, 700<<10), "image/png"); rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("over the quota: want 413, got %d", rec.Code) + } +} + +func TestMediaCleanupAndCopiedBlocks(t *testing.T) { + srv, store, token := setupTestServer(t) + srv.cfg.Media.MaxImageMB, srv.cfg.Media.MaxAudioMB = 2, 2 + router := srv.Router() + claims, _ := auth.ValidateToken(testJWTSecret, token) + doc, _ := store.CreateDocument(claims.UserID, "", "Quelle", "", "", nil, nil, false) + upload := func() string { + var up struct{ ID string } + _ = json.Unmarshal(mediaUpload(t, router, token, doc.ID, tinyPNG(t, 0), "image/png").Body.Bytes(), &up) + return up.ID + } + used, unused := upload(), upload() + + // A second user's note shows the used image (block copied there); that user may read it + other, otherToken := newTestUser(t, store, "kopie@example.com") + _, _ = store.CreateDocument(other.ID, "", "Kopie", "", "", []domain.DocumentBlock{{ID: "blk_copy", Type: "image", Content: "![Foto](media:" + used + ")"}}, nil, false) + if rec := sessionCall(router, "GET", "/api/v1/media/"+used, otherToken, "", ""); rec.Code != http.StatusOK { + t.Fatalf("copied block: want 200, got %d", rec.Code) + } + + // Within the grace time nothing is removed, afterwards only the unused file + srv.compactMedia(time.Now()) + if _, err := store.GetMedia(unused); err != nil { + t.Fatal("fresh uploads must survive the grace time") + } + srv.compactMedia(time.Now().Add(2 * mediaGrace)) + if _, err := store.GetMedia(unused); err == nil { + t.Fatal("unused file must be removed") + } + m, err := store.GetMedia(used) + if err != nil { + t.Fatal("referenced file must stay") + } + if _, err := os.Stat(srv.mediaPath(m)); err != nil { + t.Fatalf("referenced file missing on disk: %v", err) + } +} + +func TestDetectMedia(t *testing.T) { + webm := append([]byte{0x1A, 0x45, 0xDF, 0xA3}, make([]byte, 40)...) + m4a := append([]byte{0, 0, 0, 0x20}, []byte("ftypM4A ")...) + m4a = append(m4a, make([]byte, 40)...) + webp := append([]byte("RIFF\x00\x00\x00\x00WEBPVP8 "), make([]byte, 40)...) + cases := []struct { + data []byte + declared string + wantMime, wantKd string + }{ + {webm, "audio/webm;codecs=opus", "audio/webm", "audio"}, + {webm, "", "audio/webm", "audio"}, + {m4a, "audio/mp4", "audio/mp4", "audio"}, + {webp, "", "image/webp", "image"}, + } + for _, c := range cases { + mime, kind, ok := detectMedia(c.data, c.declared) + if !ok || mime != c.wantMime || kind != c.wantKd { + t.Fatalf("detect(%q): got %s %s %v", c.declared, mime, kind, ok) + } + } + if _, _, ok := detectMedia([]byte("#!/bin/sh\nrm -rf /"), "audio/webm"); ok { + t.Fatal("scripts must not pass as audio") + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 8c33eec..66e6df6 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -132,6 +132,9 @@ func (s *Server) Router() http.Handler { r.Post("/api/v1/events/rooms", s.handleSSERooms) r.Post("/api/v1/auth/stream-ticket", s.handleStreamTicket) r.Post("/api/v1/voice/transcribe", s.handleVoiceTranscribe) + r.Post("/api/v1/documents/{id}/media", s.handleUploadMedia) + r.Post("/api/v1/documents/{id}/media/fetch", s.notInDemo(s.handleFetchMedia)) + r.Get("/api/v1/media/{id}", s.handleGetMedia) r.Get("/api/v1/auth/sessions", s.handleListSessions) r.Delete("/api/v1/auth/sessions/{id}", s.notInDemo(s.handleEndSession)) r.Post("/api/v1/auth/logout", s.handleLogout) diff --git a/internal/config/config.go b/internal/config/config.go index 8e7cc63..8476d33 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -46,6 +46,18 @@ type Config struct { // Speech recognition, configured only by environment (STT_*) Speech SpeechConfig + + // Images and audio in notes (MEDIA_*) + Media MediaConfig +} + +// MediaConfig limits uploads of images and audio recordings in notes. Sizes in MB; QuotaMB 0 = no limit +// per user. The demo uses DemoMaxMB for every file. +type MediaConfig struct { + MaxImageMB int + MaxAudioMB int + QuotaMB int + DemoMaxMB int } // SpeechConfig: with STT_URL set, voice input is transcribed by a self-hosted, OpenAI-compatible @@ -222,6 +234,12 @@ func Load() *Config { PerUserPerHour: envInt("STT_PER_USER_PER_HOUR", 60, 1, 100000), TimeoutSeconds: envInt("STT_TIMEOUT_SECONDS", 120, 5, 3600), }, + Media: MediaConfig{ + MaxImageMB: envInt("MEDIA_MAX_IMAGE_MB", 15, 1, 1000), + MaxAudioMB: envInt("MEDIA_MAX_AUDIO_MB", 50, 1, 2000), + QuotaMB: envInt("MEDIA_QUOTA_MB", 0, 0, 10000000), + DemoMaxMB: envInt("MEDIA_DEMO_MAX_MB", 3, 1, 100), + }, Legal: LegalTexts{ ImprintURL: legalURL("LEGAL_IMPRINT_URL"), ImprintContent: legalContent("LEGAL_IMPRINT_CONTENT"), diff --git a/internal/storage/media.go b/internal/storage/media.go new file mode 100644 index 0000000..e6a2505 --- /dev/null +++ b/internal/storage/media.go @@ -0,0 +1,119 @@ +package storage + +import ( + "database/sql" + "errors" + "time" + + "github.com/sandrodach/mindsift/internal/domain" +) + +// Media files of notes (images, audio). The file lies in $DATA_DIR/media//; a block +// points to it through its content, e.g. "![Beschriftung](media:med_…)", so the reference travels with +// the normal note sync, undo and conflict handling. A file is kept as long as any block (also deleted +// ones still in the log or a note in the trash) mentions it; CompactMedia removes the rest. + +type Media struct { + ID string `json:"id"` + DocID string `json:"docId"` + OwnerID string `json:"ownerId"` + Kind string `json:"kind"` // image | audio + Mime string `json:"mime"` + Size int64 `json:"size"` + SourceURL string `json:"sourceUrl,omitempty"` // copied from this address (external image) + CreatedAt string `json:"createdAt"` +} + +var ErrMediaNotFound = errors.New("Datei nicht gefunden") + +func (s *Store) migrateMedia() error { + return s.execInTx( + stmt(`CREATE TABLE IF NOT EXISTS media ( + id TEXT PRIMARY KEY, + doc_id TEXT NOT NULL, + owner_id TEXT NOT NULL, + kind TEXT NOT NULL, + mime TEXT NOT NULL, + size INTEGER NOT NULL DEFAULT 0, + source_url TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL + )`), + stmt(`CREATE INDEX IF NOT EXISTS idx_media_doc ON media(doc_id)`), + stmt(`CREATE INDEX IF NOT EXISTS idx_media_owner ON media(owner_id)`), + ) +} + +func (s *Store) CreateMedia(docID, ownerID, kind, mime string, size int64, sourceURL string) (*Media, error) { + m := &Media{ID: domain.NewID("med"), DocID: docID, OwnerID: ownerID, Kind: kind, Mime: mime, Size: size, + SourceURL: sourceURL, CreatedAt: nowUTC()} + _, err := s.db.Exec(`INSERT INTO media (id, doc_id, owner_id, kind, mime, size, source_url, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + m.ID, m.DocID, m.OwnerID, m.Kind, m.Mime, m.Size, m.SourceURL, m.CreatedAt) + if err != nil { + return nil, err + } + return m, nil +} + +func (s *Store) GetMedia(id string) (*Media, error) { + var m Media + err := s.db.QueryRow(`SELECT id, doc_id, owner_id, kind, mime, size, source_url, created_at FROM media WHERE id = ?`, id). + Scan(&m.ID, &m.DocID, &m.OwnerID, &m.Kind, &m.Mime, &m.Size, &m.SourceURL, &m.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrMediaNotFound + } + if err != nil { + return nil, err + } + return &m, nil +} + +// DeleteMediaRow forgets a file (the caller removes it from disk). +func (s *Store) DeleteMediaRow(id string) error { + _, err := s.db.Exec(`DELETE FROM media WHERE id = ?`, id) + return err +} + +// MediaUsage is the stored size of all files the user uploaded. +func (s *Store) MediaUsage(ownerID string) (int64, error) { + var n sql.NullInt64 + err := s.db.QueryRow(`SELECT SUM(size) FROM media WHERE owner_id = ?`, ownerID).Scan(&n) + return n.Int64, err +} + +// MediaReferencedIn lists the notes whose blocks (deleted ones included) point to the file. A block +// copied into another note keeps working there. +func (s *Store) MediaReferencedIn(id string) ([]string, error) { + rows, err := s.db.Query(`SELECT DISTINCT document_id FROM blocks WHERE instr(content, ?) > 0`, "media:"+id) + if err != nil { + return nil, err + } + defer rows.Close() + var docs []string + for rows.Next() { + var d string + if rows.Scan(&d) == nil { + docs = append(docs, d) + } + } + return docs, rows.Err() +} + +// UnreferencedMedia returns files older than the grace time that no block mentions any more. The grace +// time covers uploads whose block has not reached the server yet (offline, slow sync). +func (s *Store) UnreferencedMedia(olderThan time.Time) ([]Media, error) { + rows, err := s.db.Query(`SELECT id, doc_id, owner_id, kind, mime, size, source_url, created_at FROM media m + WHERE created_at < ? AND NOT EXISTS (SELECT 1 FROM blocks b WHERE instr(b.content, 'media:' || m.id) > 0)`, + olderThan.UTC().Format(time.RFC3339)) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Media + for rows.Next() { + var m Media + if rows.Scan(&m.ID, &m.DocID, &m.OwnerID, &m.Kind, &m.Mime, &m.Size, &m.SourceURL, &m.CreatedAt) == nil { + out = append(out, m) + } + } + return out, rows.Err() +} diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go index 6f8a431..461c113 100644 --- a/internal/storage/migrations.go +++ b/internal/storage/migrations.go @@ -25,6 +25,7 @@ type migration struct { var migrations = []migration{ {3, "Basis-Schema v0.4.1", (*Store).migrateBaseline}, {4, "Sitzungen", (*Store).migrateSessions}, + {5, "Medien", (*Store).migrateMedia}, } func (s *Store) migrate() error {