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

+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")
}
}