// 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 }