feat(media): storage for images and audio in notes (M1)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s

- Table media (migration 5), files in $DATA_DIR/media/<docId>/.
- 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:<id>), 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 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 22:57:59 +01:00
1 parent e385a44fb9
commit 177fa49363
16 files changed
+637

No files matched your search

+13
View File
@@ -81,6 +81,19 @@ LEGAL_PRIVACY_CONTENT=
LEGAL_IMPRINT_CONTENT_FILE= LEGAL_IMPRINT_CONTENT_FILE=
LEGAL_PRIVACY_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) ──────────────────────────────────────────────────────────────────── # ─── Spracherkennung (optional) ────────────────────────────────────────────────────────────────────
# Ohne STT_URL nutzt MindSift die Spracherkennung des Browsers (Chrome, Edge, Safari; die Aufnahme geht # 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 # dabei an Google, Microsoft bzw. Apple). Mit eigenem Whisper-Server bleibt alles auf deinem Server und
+1
View File
@@ -9,6 +9,7 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
### Added ### 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. - 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`. - 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. - 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 ### Changed
+3
View File
@@ -17,6 +17,9 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) | | 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 | `/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 | `/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) | | 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 | `/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 | | GET | `/events` | SSE-Fallback; `?ticket=` oder `Authorization`-Header |
+4
View File
@@ -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. - **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. - **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/<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 `![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 ## 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). 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).
+1
View File
@@ -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) | | `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` | | `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/<doc_id>/<id>.<ext>` | `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) | | `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` | | `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` | | `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` |
+2
View File
@@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"path/filepath"
"syscall" "syscall"
"time" "time"
_ "time/tzdata" // time zones without system tzdata (scratch/alpine images) _ "time/tzdata" // time zones without system tzdata (scratch/alpine images)
@@ -46,6 +47,7 @@ func main() {
if cfg.DemoMode { if cfg.DemoMode {
slog.Info("🧪 Demo-Modus aktiv: Setze Datenbank zurück und richte Demo-Accounts ein...") 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 { if err := store.ResetAndSeedDemo(); err != nil {
slog.Warn("⚠️ Fehler beim Einrichten des Demo-Modus", "error", err) slog.Warn("⚠️ Fehler beim Einrichten des Demo-Modus", "error", err)
} else { } else {
+5
View File
@@ -38,6 +38,11 @@ services:
- LEGAL_PRIVACY_CONTENT=${LEGAL_PRIVACY_CONTENT:-} - LEGAL_PRIVACY_CONTENT=${LEGAL_PRIVACY_CONTENT:-}
- LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-} - LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-}
- LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_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 # Spracherkennung
- STT_URL=${STT_URL:-} - STT_URL=${STT_URL:-}
- STT_MODEL=${STT_MODEL:-deepdml/faster-whisper-large-v3-turbo-ct2} - STT_MODEL=${STT_MODEL:-deepdml/faster-whisper-large-v3-turbo-ct2}
+2
View File
@@ -14,6 +14,8 @@ const defaultMaxBody = 2 << 20
func (s *Server) bodyLimitFor(path string) int64 { func (s *Server) bodyLimitFor(path string) int64 {
switch { switch {
case strings.HasPrefix(path, "/api/v1/documents/") && strings.HasSuffix(path, "/media"):
return s.mediaUploadLimit()
case path == "/api/v1/voice/transcribe": case path == "/api/v1/voice/transcribe":
return int64(s.cfg.Speech.MaxUploadMB)<<20 + 1<<20 return int64(s.cfg.Speech.MaxUploadMB)<<20 + 1<<20
case strings.HasPrefix(path, "/api/v1/import/"): case strings.HasPrefix(path, "/api/v1/import/"):
+3
View File
@@ -4,6 +4,7 @@ import (
"errors" "errors"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -59,6 +60,8 @@ func (s *Server) setDemoNextReset(t time.Time) {
func (s *Server) resetDemo() { func (s *Server) resetDemo() {
s.demo.gate.Lock() s.demo.gate.Lock()
err := s.store.ResetAndSeedDemo() 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() s.demo.gate.Unlock()
if err != nil { if err != nil {
slog.Error("Demo-Reset fehlgeschlagen", "error", err) slog.Error("Demo-Reset fehlgeschlagen", "error", err)
+1
View File
@@ -270,6 +270,7 @@ func (s *Server) startCompaction() {
func (s *Server) compactOnce() { func (s *Server) compactOnce() {
s.leases.Sweep() s.leases.Sweep()
s.compactMedia(time.Now())
st, err := s.store.CompactDocs(time.Now()) st, err := s.store.CompactDocs(time.Now())
if err != nil { if err != nil {
slog.Error("Verdichtung des Änderungsprotokolls fehlgeschlagen", "error", err) slog.Error("Verdichtung des Änderungsprotokolls fehlgeschlagen", "error", err)
+295
View File
@@ -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
}
+166
View File
@@ -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("<svg onload=alert(1)>"), "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")
}
}
+3
View File
@@ -132,6 +132,9 @@ func (s *Server) Router() http.Handler {
r.Post("/api/v1/events/rooms", s.handleSSERooms) r.Post("/api/v1/events/rooms", s.handleSSERooms)
r.Post("/api/v1/auth/stream-ticket", s.handleStreamTicket) r.Post("/api/v1/auth/stream-ticket", s.handleStreamTicket)
r.Post("/api/v1/voice/transcribe", s.handleVoiceTranscribe) 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.Get("/api/v1/auth/sessions", s.handleListSessions)
r.Delete("/api/v1/auth/sessions/{id}", s.notInDemo(s.handleEndSession)) r.Delete("/api/v1/auth/sessions/{id}", s.notInDemo(s.handleEndSession))
r.Post("/api/v1/auth/logout", s.handleLogout) r.Post("/api/v1/auth/logout", s.handleLogout)
+18
View File
@@ -46,6 +46,18 @@ type Config struct {
// Speech recognition, configured only by environment (STT_*) // Speech recognition, configured only by environment (STT_*)
Speech SpeechConfig 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 // 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), PerUserPerHour: envInt("STT_PER_USER_PER_HOUR", 60, 1, 100000),
TimeoutSeconds: envInt("STT_TIMEOUT_SECONDS", 120, 5, 3600), 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{ Legal: LegalTexts{
ImprintURL: legalURL("LEGAL_IMPRINT_URL"), ImprintURL: legalURL("LEGAL_IMPRINT_URL"),
ImprintContent: legalContent("LEGAL_IMPRINT_CONTENT"), ImprintContent: legalContent("LEGAL_IMPRINT_CONTENT"),
+119
View File
@@ -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/<docID>/<id><ext>; 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()
}
+1
View File
@@ -25,6 +25,7 @@ type migration struct {
var migrations = []migration{ var migrations = []migration{
{3, "Basis-Schema v0.4.1", (*Store).migrateBaseline}, {3, "Basis-Schema v0.4.1", (*Store).migrateBaseline},
{4, "Sitzungen", (*Store).migrateSessions}, {4, "Sitzungen", (*Store).migrateSessions},
{5, "Medien", (*Store).migrateMedia},
} }
func (s *Store) migrate() error { func (s *Store) migrate() error {