Files
MindSift/internal/api/media.go
T
sandrodachandClaude Opus 5.5 177fa49363
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
feat(media): storage for images and audio in notes (M1)
- 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>
2026-10-29 22:57:59 +01:00

296 lines
10 KiB
Go

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
}