Vellum/internal/catalog/catalog.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

229 lines
7.6 KiB
Go

// Package catalog is the one place that reads items and files out of the
// database, and the one place that enforces "may this user read this item".
//
// It exists because that check had been reimplemented five times — in media,
// comics, games, jellyfin and subsonic — each with its own SQL and its own idea
// of what to do when access is denied. Five copies of one authorization rule
// means four of them can drift, and the failure mode is not a crash: it is
// silently serving somebody a library they were not granted. One implementation
// is one thing to review.
//
// Consumers depend on this package rather than on *sql.DB, which is also what
// makes the storage engine swappable: a Postgres implementation would satisfy
// this package's surface instead of editing 186 statements scattered across the
// tree.
//
// This package holds no HTTP knowledge. It returns typed errors and lets the
// caller choose a status code.
package catalog
import (
"context"
"database/sql"
"errors"
"fmt"
"path/filepath"
"time"
"github.com/mithral/vellum/internal/auth"
)
// ErrNotFound means the item does not exist, OR exists but the caller may not
// read it.
//
// The two are deliberately indistinguishable. A caller that could tell them
// apart would leak the existence of other people's libraries through a 403, so
// every handler turns this into a plain 404.
var ErrNotFound = errors.New("catalog: item not found")
// Item is one media item plus the root of its library.
type Item struct {
ID int64
LibraryID int64
Kind string
Path string // path relative to the library root
Title string
Year int
Missing bool
// RootPath is the absolute library root, so AbsPath can be computed.
RootPath string
// ModTime is the backing file's modification time, used for conditional
// GETs. Zero when unknown.
ModTime time.Time
}
// AbsPath returns the absolute filesystem path of the item's file.
//
// item.path is stored relative to the library root and the root is stored
// resolved, so this join cannot escape the library.
func (i Item) AbsPath() string {
return filepath.Join(i.RootPath, filepath.FromSlash(i.Path))
}
// File is one physical file backing an item, including the ffprobe facts.
//
// The JSON tags are the frozen MediaFile contract in web/src/lib/api.ts. The
// codec fields are what let a CLIENT decide whether it can direct-play, since
// Vellum never transcodes and playability depends on the client, not the file.
type File struct {
ID int64 `json:"id"`
Path string `json:"path"`
Size int64 `json:"size"`
Container string `json:"container"`
Missing bool `json:"missing"`
DurationMS int64 `json:"duration_ms"`
VCodec string `json:"vcodec"`
ACodec string `json:"acodec"`
Width int `json:"width"`
Height int `json:"height"`
}
const itemSelect = `
SELECT i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year, 0), i.missing, l.root_path,
COALESCE((SELECT f.mtime FROM media_files f WHERE f.item_id = i.id ORDER BY f.id LIMIT 1), '')
FROM media_items i
JOIN libraries l ON l.id = i.library_id
WHERE i.id = ?`
// LoadUnchecked reads an item WITHOUT checking access. Prefer LoadFor in any handler
// that serves a request: this exists for the scanner, jobs and tests, which
// have already established the right to touch the row.
func LoadUnchecked(ctx context.Context, db *sql.DB, id int64) (Item, error) {
var (
it Item
mtime string
)
err := db.QueryRowContext(ctx, itemSelect, id).Scan(
&it.ID, &it.LibraryID, &it.Kind, &it.Path, &it.Title,
&it.Year, &it.Missing, &it.RootPath, &mtime)
if errors.Is(err, sql.ErrNoRows) {
return Item{}, ErrNotFound
}
if err != nil {
return Item{}, fmt.Errorf("catalog: load item: %w", err)
}
if nanos := parseUnixNano(mtime); nanos > 0 {
it.ModTime = time.Unix(0, nanos).UTC()
}
return it, nil
}
// LoadFor reads an item and confirms the user may read its library.
//
// This is the authorization chokepoint. Every HTTP surface — the web app, the
// modules, Jellyfin, Subsonic — is expected to go through it, so there is one
// implementation to audit rather than five to keep in sync.
func LoadFor(ctx context.Context, db *sql.DB, u auth.User, id int64) (Item, error) {
it, err := LoadUnchecked(ctx, db, id)
if err != nil {
return Item{}, err
}
allowed, err := auth.CanAccessLibrary(ctx, db, u, it.LibraryID)
if err != nil {
return Item{}, fmt.Errorf("catalog: check access: %w", err)
}
if !allowed {
// Deliberately the same error as a missing row: see ErrNotFound.
return Item{}, ErrNotFound
}
return it, nil
}
// Files returns an item's files in order, with codec facts.
func Files(ctx context.Context, db *sql.DB, itemID int64) ([]File, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, path, size, container, missing,
duration_ms, vcodec, acodec, width, height
FROM media_files WHERE item_id = ? ORDER BY id`, itemID)
if err != nil {
return nil, fmt.Errorf("catalog: list files: %w", err)
}
defer rows.Close()
out := []File{}
for rows.Next() {
var f File
if err := rows.Scan(&f.ID, &f.Path, &f.Size, &f.Container, &f.Missing,
&f.DurationMS, &f.VCodec, &f.ACodec, &f.Width, &f.Height); err != nil {
return nil, fmt.Errorf("catalog: scan file: %w", err)
}
out = append(out, f)
}
return out, rows.Err()
}
// FilesFor returns an item's files after confirming access. Callers that
// already hold an authorized Item should use Files to avoid a second check.
func FilesFor(ctx context.Context, db *sql.DB, u auth.User, itemID int64) ([]File, error) {
if _, err := LoadFor(ctx, db, u, itemID); err != nil {
return nil, err
}
return Files(ctx, db, itemID)
}
// FileByID returns one file of an item, scoped to that item so a file id from
// another item cannot be fetched through it.
func FileByID(ctx context.Context, db *sql.DB, itemID, fileID int64) (File, error) {
var f File
err := db.QueryRowContext(ctx, `
SELECT id, path, size, container, missing,
duration_ms, vcodec, acodec, width, height
FROM media_files WHERE item_id = ? AND id = ?`, itemID, fileID).Scan(
&f.ID, &f.Path, &f.Size, &f.Container, &f.Missing,
&f.DurationMS, &f.VCodec, &f.ACodec, &f.Width, &f.Height)
if errors.Is(err, sql.ErrNoRows) {
return File{}, ErrNotFound
}
if err != nil {
return File{}, fmt.Errorf("catalog: load file: %w", err)
}
return f, nil
}
// FirstFile returns the item's first file, which is what single-file playback
// and cover extraction both want.
func FirstFile(ctx context.Context, db *sql.DB, itemID int64) (File, error) {
var f File
err := db.QueryRowContext(ctx, `
SELECT id, path, size, container, missing,
duration_ms, vcodec, acodec, width, height
FROM media_files WHERE item_id = ? ORDER BY id LIMIT 1`, itemID).Scan(
&f.ID, &f.Path, &f.Size, &f.Container, &f.Missing,
&f.DurationMS, &f.VCodec, &f.ACodec, &f.Width, &f.Height)
if errors.Is(err, sql.ErrNoRows) {
return File{}, ErrNotFound
}
if err != nil {
return File{}, fmt.Errorf("catalog: first file: %w", err)
}
return f, nil
}
// FileCount reports how many files back an item.
func FileCount(ctx context.Context, db *sql.DB, itemID int64) (int, error) {
var n int
err := db.QueryRowContext(ctx,
`SELECT count(*) FROM media_files WHERE item_id = ?`, itemID).Scan(&n)
if err != nil {
return 0, fmt.Errorf("catalog: file count: %w", err)
}
return n, nil
}
// parseUnixNano parses a base-10 nanosecond timestamp, returning 0 on anything
// that is not all digits.
func parseUnixNano(s string) int64 {
if s == "" {
return 0
}
var n int64
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return 0
}
n = n*10 + int64(s[i]-'0')
}
return n
}