feat(media): storage for images and audio in notes (M1)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
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:
1 parent
e385a44fb9
commit
177fa49363
16 files changed
+637
No files matched your search
@@ -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/"):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: ""}}, 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user