Files
MindSift/internal/storage/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

120 lines
4.0 KiB
Go

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()
}