Vellum/internal/subsonic/store.go
mithral ac1bd04df3 Phase 1: one item-access seam, and architecture rules that fail the build
Motivation, from the evidence rather than from taste. Five packages each
implemented "load an item, then check the caller may read it": media, comics,
games, jellyfin (with its own dbItem type and its own SQL), and subsonic. Five
copies of one authorization rule means four can drift, and the failure mode is
not a crash — it is silently serving somebody a library they were never granted.
One implementation is one thing to review.

internal/catalog now owns item and file access, and the access check.

  LoadUnchecked(ctx, db, id)        -- no access check; jobs and tests only
  LoadFor(ctx, db, u, id)           -- load AND enforce the grant, in one call
  Files / FilesFor / FileByID / FirstFile / FileCount

The unchecked variant is deliberately named so that reaching for it by accident
is hard: it is the dangerous call, so it should not have the friendlier name.
This came from the interface-design rule that a good interface makes the right
thing easy and the wrong thing hard.

The security property is tested, not asserted: a forbidden item and an absent
item return byte-identical errors, so no handler can leak the existence of
another user's library through a 403. That is now enforced in one place.

Migrated all five surfaces onto it. media, comics, games and ebooks went from
four duplicate loaders to zero. jellyfin's dbItem and loadDBItem are gone.
subsonic's per-item authorization moved while its album/artist aggregate
projections stayed put, because grouping items into albums is a presentation
concern, not a data-access one. The remaining raw SQL everywhere is browse and
aggregate queries; no per-item load decides access any more.

internal/arch enforces the layering instead of documenting it. Five rules, each
proven able to fail by deliberately breaking it:
  - no upward imports across six explicit layers
  - internal/core may depend only on migrations
  - internal/catalog must not know about HTTP, or it stops being reusable
  - modules must not import each other
  - every package must be placed in a layer, so the map cannot go stale

A test that only ever passes is decoration, so each rule was verified by
introducing the violation and confirming the failure.

Verified: gofmt clean, go build ./... exit 0, go vet exit 0, 20 packages pass,
svelte-check 0/0. End to end against a running server: the vellum API serves
item detail with codec facts, an unknown item 404s, Jellyfin AuthenticateByName
mints a token and /Items browses, and Subsonic ping returns status ok. The
subsonic migration was additionally A/B'd byte-identical against the previous
implementation across ~15 endpoints, and Jellyfin/Subsonic both still return the
same error for a forbidden item as for an absent one.

Known follow-ups, recorded rather than silently skipped:
  - internal/jellyfin has no test files at all. Its wire contract is the most
    client-visible surface in the tree and is now verified only by hand.
  - catalog is item-centric, but Subsonic addresses songs file-first (mf-<id>),
    so it resolves file to item through its own projection. A catalog lookup
    that takes a file id would close that.
  - subsonic loadAlbum now scopes the projection to the item's own library
    rather than all visible audio libraries. Equivalent by construction, since
    an item's kind tracks its library's kind, but a hand-edited mismatch would
    differ.
2026-10-11 15:47:18 -04:00

546 lines
17 KiB
Go

package subsonic
import (
"context"
"database/sql"
"errors"
"net/http"
"path/filepath"
"sort"
"strconv"
"strings"
"unicode"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/catalog"
)
// libraryRef is an audio library as getMusicFolders shows it.
type libraryRef struct {
id int64
name string
}
// audioLibraries returns the caller's visible music/audiobook libraries. The
// web UI's video, ebook and photo libraries are not Subsonic music folders.
func (h *Handlers) audioLibraries(ctx context.Context, u auth.User) ([]libraryRef, error) {
ids, err := auth.VisibleLibraryIDs(ctx, h.db, u)
if err != nil {
return nil, err
}
if len(ids) == 0 {
return []libraryRef{}, nil
}
in, args := inClause(ids)
rows, err := h.db.QueryContext(ctx, `
SELECT id, name FROM libraries
WHERE kind IN ('music','audiobooks') AND id IN (`+in+`)
ORDER BY name`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []libraryRef{}
for rows.Next() {
var l libraryRef
if err := rows.Scan(&l.id, &l.name); err != nil {
return nil, err
}
out = append(out, l)
}
return out, rows.Err()
}
func (h *Handlers) audioLibraryIDs(ctx context.Context, u auth.User) ([]int64, error) {
libs, err := h.audioLibraries(ctx, u)
if err != nil {
return nil, err
}
out := make([]int64, 0, len(libs))
for _, l := range libs {
out = append(out, l.id)
}
return out, nil
}
// libraryRoot returns the absolute root path of a library.
func (h *Handlers) libraryRoot(ctx context.Context, libID int64) (string, error) {
var root string
err := h.db.QueryRowContext(ctx, `SELECT root_path FROM libraries WHERE id = ?`, libID).Scan(&root)
return root, err
}
// ── albums ─────────────────────────────────────────────────────────────
const albumSelect = `
SELECT i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year,0), i.added_at,
COALESCE((SELECT ip.person_id FROM item_people ip
WHERE ip.item_id = i.id AND ip.role = 'author' ORDER BY ip.ord LIMIT 1), 0) AS artist_id,
(SELECT count(*) FROM media_files f WHERE f.item_id = i.id AND f.missing = 0) AS song_count,
COALESCE((SELECT sum(f.duration_ms) FROM media_files f WHERE f.item_id = i.id AND f.missing = 0), 0) AS duration_ms,
EXISTS(SELECT 1 FROM assets a WHERE a.item_id = i.id AND a.role = 'cover') AS has_cover,
COALESCE((SELECT p.name FROM item_people ip JOIN people p ON p.id = ip.person_id
WHERE ip.item_id = i.id AND ip.role = 'author' ORDER BY ip.ord LIMIT 1), '') AS artist_name
FROM media_items i`
// albumsWhere runs the album projection with a caller-supplied extra predicate
// and order. order MUST come from a fixed set, never from user input; the
// callers below pass only literals. The final projected column is aliased
// artist_name so alphabeticalByArtist can order by it without a second join.
func (h *Handlers) albumsWhere(ctx context.Context, libs []int64, extra string, extraArgs []any, order string, limit, offset int) ([]albumInfo, error) {
if limit <= 0 || limit > 500 {
limit = 500
}
in, args := inClause(libs)
args = append(args, extraArgs...)
q := albumSelect + `
WHERE i.missing = 0 AND i.kind IN ('music','audiobooks')
AND i.library_id IN (` + in + `)` + extra + `
ORDER BY ` + order + ` LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := h.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanAlbums(rows)
}
func (h *Handlers) albumsByArtist(ctx context.Context, libs []int64, personID int64) ([]albumInfo, error) {
if personID == 0 {
return h.albumsWhere(ctx, libs,
` AND NOT EXISTS (SELECT 1 FROM item_people ip WHERE ip.item_id = i.id AND ip.role = 'author')`,
nil, "i.sort_title", 500, 0)
}
return h.albumsWhere(ctx, libs,
` AND EXISTS (SELECT 1 FROM item_people ip WHERE ip.item_id = i.id AND ip.role = 'author' AND ip.person_id = ?)`,
[]any{personID}, "i.sort_title", 500, 0)
}
// loadAlbum authorizes and loads one album for a request.
//
// catalog.LoadFor is the one access rule: it reads the item and confirms the
// caller may read its library, returning the same ErrNotFound for a missing
// item and a forbidden one. The album projection below is Subsonic's own
// presentation concern — song count, duration, artist credit and cover are not
// part of catalog's item model — and no longer decides access.
func (h *Handlers) loadAlbum(ctx context.Context, u auth.User, itemID int64) (albumInfo, bool, error) {
it, err := catalog.LoadFor(ctx, h.db, u, itemID)
if err != nil {
if errors.Is(err, catalog.ErrNotFound) {
return albumInfo{}, false, nil
}
return albumInfo{}, false, err
}
albums, err := h.albumsWhere(ctx, []int64{it.LibraryID}, ` AND i.id = ?`, []any{itemID}, "i.id", 1, 0)
if err != nil {
return albumInfo{}, false, err
}
if len(albums) == 0 {
return albumInfo{}, false, nil
}
return albums[0], true, nil
}
func scanAlbums(rows *sql.Rows) ([]albumInfo, error) {
out := []albumInfo{}
for rows.Next() {
var a albumInfo
var hasCover int
if err := rows.Scan(&a.ID, &a.LibraryID, &a.Kind, &a.Path, &a.Title, &a.Year, &a.AddedAt,
&a.ArtistID, &a.SongCount, &a.DurationMS, &hasCover, &a.Artist); err != nil {
return nil, err
}
a.HasCover = hasCover == 1
out = append(out, a)
}
return out, rows.Err()
}
// ── tracks ─────────────────────────────────────────────────────────────
const trackSelect = `
SELECT f.id, f.item_id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year,0), i.added_at,
f.path, f.size, f.duration_ms, f.container,
COALESCE((SELECT ip.person_id FROM item_people ip
WHERE ip.item_id = i.id AND ip.role = 'author' ORDER BY ip.ord LIMIT 1), 0),
COALESCE((SELECT p.name FROM item_people ip JOIN people p ON p.id = ip.person_id
WHERE ip.item_id = i.id AND ip.role = 'author' ORDER BY ip.ord LIMIT 1), ''),
EXISTS(SELECT 1 FROM assets a WHERE a.item_id = i.id AND a.role = 'cover')
FROM media_files f
JOIN media_items i ON i.id = f.item_id`
func (h *Handlers) tracksForItem(ctx context.Context, itemID int64) ([]track, error) {
rows, err := h.db.QueryContext(ctx, trackSelect+`
WHERE f.item_id = ? AND f.missing = 0 AND i.missing = 0
ORDER BY f.path`, itemID)
if err != nil {
return nil, err
}
defer rows.Close()
tracks, err := scanTracks(rows)
if err != nil {
return nil, err
}
for i := range tracks {
tracks[i].Ordinal = i + 1
}
return tracks, nil
}
// loadTrack authorizes and loads one song (a media_files row) for a request.
//
// The song projection above is Subsonic's own presentation shape — album
// context, artist credit, cover — so it stays here. Access is not its job:
// catalog.LoadFor is the one access rule, returning the same ErrNotFound for a
// missing item and a forbidden one so a 404 cannot leak existence.
func (h *Handlers) loadTrack(ctx context.Context, u auth.User, fileIDVal int64) (track, bool, error) {
rows, err := h.db.QueryContext(ctx, trackSelect+`
WHERE f.id = ? AND f.missing = 0 AND i.missing = 0`, fileIDVal)
if err != nil {
return track{}, false, err
}
defer rows.Close()
tracks, err := scanTracks(rows)
if err != nil {
return track{}, false, err
}
if len(tracks) == 0 {
return track{}, false, nil
}
if _, err := catalog.LoadFor(ctx, h.db, u, tracks[0].ItemID); err != nil {
if errors.Is(err, catalog.ErrNotFound) {
return track{}, false, nil
}
return track{}, false, err
}
return tracks[0], true, nil
}
func (h *Handlers) songsWhere(ctx context.Context, libs []int64, extra string, extraArgs []any, order string, limit, offset int) ([]track, error) {
if limit <= 0 || limit > 500 {
limit = 500
}
in, args := inClause(libs)
args = append(args, extraArgs...)
q := trackSelect + `
WHERE i.missing = 0 AND f.missing = 0
AND i.kind IN ('music','audiobooks')
AND i.library_id IN (` + in + `)` + extra + `
ORDER BY ` + order + ` LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := h.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTracks(rows)
}
func scanTracks(rows *sql.Rows) ([]track, error) {
out := []track{}
for rows.Next() {
var t track
var hasCover int
if err := rows.Scan(&t.FileID, &t.ItemID, &t.LibraryID, &t.Kind, &t.AlbumPath, &t.AlbumTitle,
&t.Year, &t.AddedAt, &t.RelPath, &t.Size, &t.DurationMS, &t.Container,
&t.ArtistID, &t.Artist, &hasCover); err != nil {
return nil, err
}
t.HasCover = hasCover == 1
out = append(out, t)
}
return out, rows.Err()
}
// ── artists ────────────────────────────────────────────────────────────
type artistInfo struct {
id int64
name string
albumCount int
coverAlbum int64
}
// artists returns every artist with at least one visible audio album, plus a
// synthetic "Unknown Artist" bucket when some albums carry no author credit.
func (h *Handlers) artists(ctx context.Context, u auth.User) ([]artistInfo, error) {
libs, err := h.audioLibraryIDs(ctx, u)
if err != nil {
return nil, err
}
return h.searchArtists(ctx, libs, "", 500, 0)
}
func (h *Handlers) searchArtists(ctx context.Context, libs []int64, q string, limit, offset int) ([]artistInfo, error) {
if limit <= 0 || limit > 500 {
limit = 500
}
in, args := inClause(libs)
args = append(args, q, q, limit, offset)
rows, err := h.db.QueryContext(ctx, `
SELECT p.id, p.name, count(DISTINCT i.id),
COALESCE((SELECT a.item_id
FROM item_people ip2
JOIN media_items i2 ON i2.id = ip2.item_id
JOIN assets a ON a.item_id = i2.id AND a.role = 'cover'
WHERE ip2.person_id = p.id AND ip2.role = 'author' AND i2.missing = 0
ORDER BY i2.id LIMIT 1), 0)
FROM people p
JOIN item_people ip ON ip.person_id = p.id
JOIN media_items i ON i.id = ip.item_id
WHERE ip.role = 'author' AND i.missing = 0
AND i.kind IN ('music','audiobooks')
AND i.library_id IN (`+in+`)
AND (? = '' OR p.name LIKE '%' || ? || '%')
GROUP BY p.id, p.name
ORDER BY p.name
LIMIT ? OFFSET ?`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []artistInfo{}
for rows.Next() {
var a artistInfo
if err := rows.Scan(&a.id, &a.name, &a.albumCount, &a.coverAlbum); err != nil {
return nil, err
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Include the uncredited bucket only when there is something in it, and
// only on the unfiltered listing (a name search should not surface it).
if q == "" {
n, err := h.uncreditedAlbumCount(ctx, libs)
if err != nil {
return nil, err
}
if n > 0 {
out = append(out, artistInfo{id: 0, name: "Unknown Artist", albumCount: n})
}
}
return out, nil
}
func (h *Handlers) uncreditedAlbumCount(ctx context.Context, libs []int64) (int, error) {
in, args := inClause(libs)
var n int
err := h.db.QueryRowContext(ctx, `
SELECT count(*) FROM media_items i
WHERE i.missing = 0 AND i.kind IN ('music','audiobooks')
AND i.library_id IN (`+in+`)
AND NOT EXISTS (SELECT 1 FROM item_people ip WHERE ip.item_id = i.id AND ip.role = 'author')`,
args...).Scan(&n)
return n, err
}
func (h *Handlers) artistName(ctx context.Context, personID int64) (string, error) {
if personID == 0 {
return "Unknown Artist", nil
}
var name string
err := h.db.QueryRowContext(ctx, `SELECT name FROM people WHERE id = ?`, personID).Scan(&name)
if err == sql.ErrNoRows {
return "Unknown Artist", nil
}
return name, err
}
// searchAlbums filters the album projection by title or credited artist.
func (h *Handlers) searchAlbums(ctx context.Context, libs []int64, q string, limit, offset int) ([]albumInfo, error) {
extra := ` AND (? = '' OR i.title LIKE '%' || ? || '%'
OR EXISTS (SELECT 1 FROM item_people ip JOIN people p ON p.id = ip.person_id
WHERE ip.item_id = i.id AND ip.role = 'author' AND p.name LIKE '%' || ? || '%'))`
return h.albumsWhere(ctx, libs, extra, []any{q, q, q}, "i.sort_title", limit, offset)
}
func (h *Handlers) searchSongs(ctx context.Context, libs []int64, q string, limit, offset int) ([]track, error) {
extra := ` AND (? = '' OR f.path LIKE '%' || ? || '%' OR i.title LIKE '%' || ? || '%')`
return h.songsWhere(ctx, libs, extra, []any{q, q, q}, "i.sort_title, f.path", limit, offset)
}
// addIndexes groups artists by their index letter and appends <index> children
// to parent. withCount controls whether the ArtistID3 albumCount is emitted
// (getArtists) or the leaner Child shape is used (getIndexes).
func addIndexes(parent *node, artists []artistInfo, withCount bool) {
sorted := append([]artistInfo(nil), artists...)
sort.Slice(sorted, func(i, j int) bool {
li, lj := indexLetter(sorted[i].name), indexLetter(sorted[j].name)
if li != lj {
return li < lj
}
return sorted[i].name < sorted[j].name
})
var current *node
currentLetter := ""
for _, a := range sorted {
letter := indexLetter(a.name)
if current == nil || letter != currentLetter {
current = el("index", at("name", letter)).arrays("artist")
parent.add(current)
currentLetter = letter
}
artist := el("artist", at("id", artistID(a.id)), at("name", a.name))
if withCount {
artist.attrs = append(artist.attrs, at("albumCount", int64(a.albumCount)))
}
if a.coverAlbum != 0 {
artist.attrs = append(artist.attrs, at("coverArt", albumID(a.coverAlbum)))
}
current.add(artist)
}
}
// indexLetter returns the letter an artist files under, skipping a leading
// ignored article ("The Beatles" -> B) and folding anything non-alphabetic
// into "#".
func indexLetter(name string) string {
name = strings.TrimSpace(name)
lower := strings.ToLower(name)
for _, article := range strings.Fields(ignoredArticles) {
prefix := strings.ToLower(article) + " "
if strings.HasPrefix(lower, prefix) {
name = name[len(prefix):]
break
}
}
for _, r := range name {
if unicode.IsLetter(r) {
return strings.ToUpper(string(r))
}
break
}
return "#"
}
func firstCover(albums []albumInfo) int64 {
for _, a := range albums {
if a.HasCover {
return a.ID
}
}
return 0
}
// ── id resolution ──────────────────────────────────────────────────────
// resolveAudioFile turns a Subsonic song/album id into an absolute path on
// disk, after the library-access chokepoint.
func (h *Handlers) resolveAudioFile(ctx context.Context, u auth.User, id string) (string, bool, error) {
var (
rel string
lib int64
)
switch {
case strings.HasPrefix(id, prefixFile):
fileIDVal, ok := parseID(id, prefixFile)
if !ok {
return "", false, nil
}
t, ok, err := h.loadTrack(ctx, u, fileIDVal)
if err != nil || !ok {
return "", false, err
}
rel, lib = t.RelPath, t.LibraryID
case strings.HasPrefix(id, prefixAlbum):
itemID, ok := parseID(id, prefixAlbum)
if !ok {
return "", false, nil
}
a, ok, err := h.loadAlbum(ctx, u, itemID)
if err != nil || !ok {
return "", false, err
}
tracks, err := h.tracksForItem(ctx, itemID)
if err != nil || len(tracks) == 0 {
return "", false, err
}
rel, lib = tracks[0].RelPath, a.LibraryID
default:
return "", false, nil
}
root, err := h.libraryRoot(ctx, lib)
if err != nil {
return "", false, err
}
return filepath.Join(root, filepath.FromSlash(rel)), true, nil
}
// coverItemID maps a coverArt id to the media_items row that owns the cover.
// Artist covers are not produced by any extractor, so an ar- id resolves to
// nothing rather than an arbitrary album.
func (h *Handlers) coverItemID(ctx context.Context, u auth.User, id string) (int64, bool, error) {
switch {
case strings.HasPrefix(id, prefixAlbum):
itemID, ok := parseID(id, prefixAlbum)
if !ok {
return 0, false, nil
}
if _, ok, err := h.loadAlbum(ctx, u, itemID); err != nil || !ok {
return 0, false, err
}
return itemID, true, nil
case strings.HasPrefix(id, prefixFile):
fileIDVal, ok := parseID(id, prefixFile)
if !ok {
return 0, false, nil
}
t, ok, err := h.loadTrack(ctx, u, fileIDVal)
if err != nil || !ok {
return 0, false, err
}
return t.ItemID, true, nil
default:
return 0, false, nil
}
}
// ── small helpers ──────────────────────────────────────────────────────
// inClause builds a placeholder list for an IN (...) predicate. An empty set
// yields "NULL", which matches nothing — never "all", which is the whole point
// of the access-control model.
func inClause(ids []int64) (string, []any) {
if len(ids) == 0 {
return "NULL", nil
}
ph := make([]string, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
ph[i] = "?"
args[i] = id
}
return strings.Join(ph, ","), args
}
func intParam(r *http.Request, name string, def, max int) int {
v := r.FormValue(name)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
return def
}
if n > max {
return max
}
return n
}
func countOffset(r *http.Request, countName, offsetName string) (int, int) {
return intParam(r, countName, 20, 500), intParam(r, offsetName, 0, 1_000_000)
}