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

359 lines
10 KiB
Go

package jellyfin
import (
"context"
"database/sql"
"errors"
"sort"
"strconv"
"strings"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/catalog"
)
// defaultLimit caps an unbounded browse so a client that omits Limit cannot
// pull an entire library in one response. maxLimit is the hard ceiling.
const (
defaultLimit = 300
maxLimit = 1000
)
// overlayColumns / overlayFrom carry the per-user state and the two browse-only
// columns (sort title, parent) that catalog.Item does not expose. They never
// read media_files and never check a grant: catalog.LoadFor loads the item and
// enforces the grant, and catalog.FirstFile reads the file, so the one
// authorization rule lives in one place instead of a sixth copy here.
//
// Per-user state comes from a windowed progress join that picks the most
// recently updated row per item, plus item_views and favorites.
const overlayColumns = `i.sort_title, i.parent_id,
COALESCE(p.position,0), COALESCE(p.percent,0), COALESCE(p.finished,0),
COALESCE(v.views,0), COALESCE(fa.user_id,0),
COALESCE((SELECT 1 FROM assets a WHERE a.item_id=i.id AND a.role='cover' LIMIT 1),0)`
const overlayFrom = `FROM media_items i
LEFT JOIN (SELECT item_id, position, percent, finished,
ROW_NUMBER() OVER (PARTITION BY item_id ORDER BY updated_at DESC) AS rn
FROM progress WHERE user_id = ?) p ON p.item_id = i.id AND p.rn = 1
LEFT JOIN item_views v ON v.user_id = ? AND v.item_id = i.id
LEFT JOIN favorites fa ON fa.user_id = ? AND fa.item_id = i.id`
// itemColumns is the browse projection: the catalog.Item identity columns plus
// the per-user overlay. The list and single-item paths share it so the two can
// never disagree about what a browse row contains. File facts are attached
// separately via catalog.FirstFile.
const itemColumns = `i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year,0), i.missing, ` +
overlayColumns
// listQuery is everything the browse query needs.
type listQuery struct {
userID int64
libraryIDs []int64
parentItemID int64 // 0 means "not scoped to an item's children"
kinds []string
sortColumn string
descending bool
limit int
offset int
searchTerm string
filters []string
includeSources bool
}
// queryItems runs the browse query and its matching count.
//
// The browse rows are drained before the per-item file lookups run: catalog
// reads one file at a time, and issuing those queries against a still-open
// result set is the classic nested-query footgun.
func (h *Handlers) queryItems(ctx context.Context, q listQuery) ([]itemView, int, error) {
where, whereArgs := buildWhere(q)
var total int
if err := h.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM media_items i WHERE `+where, whereArgs...).Scan(&total); err != nil {
return nil, 0, err
}
order := q.sortColumn
if q.descending {
order += " DESC"
} else {
order += " ASC"
}
sqlText := `SELECT ` + itemColumns + ` ` + overlayFrom +
` WHERE ` + where + ` ORDER BY ` + order + ` LIMIT ? OFFSET ?`
args := []any{q.userID, q.userID, q.userID}
args = append(args, whereArgs...)
args = append(args, q.limit, q.offset)
rows, err := h.db.QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []itemView{}
for rows.Next() {
v, err := scanItem(rows)
if err != nil {
return nil, 0, err
}
out = append(out, v)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
rows.Close()
for i := range out {
if err := h.attachFile(ctx, &out[i]); err != nil {
return nil, 0, err
}
}
return out, total, nil
}
// attachFile fills the first-file facts from internal/catalog, the one place
// that reads media_files. An item with no file keeps a zero File, which is
// exactly the defaults the old media_files LEFT JOIN produced.
func (h *Handlers) attachFile(ctx context.Context, v *itemView) error {
f, err := h.firstFile(ctx, v.Item.ID)
if err != nil {
return err
}
v.File = f
return nil
}
// firstFile returns the item's first file, or a zero File when the item has
// none. It is catalog.FirstFile with the "no file" case flattened, for callers
// that only need the facts rather than a 404.
func (h *Handlers) firstFile(ctx context.Context, itemID int64) (catalog.File, error) {
f, err := catalog.FirstFile(ctx, h.db, itemID)
if errors.Is(err, catalog.ErrNotFound) {
return catalog.File{}, nil
}
return f, err
}
// loadOverlay fills the browse-only and per-user fields of an already-loaded
// item. It reads no file and performs no access check: catalog.LoadFor already
// did both.
func (h *Handlers) loadOverlay(ctx context.Context, userID, itemID int64, v *itemView) error {
row := h.db.QueryRowContext(ctx,
`SELECT `+overlayColumns+` `+overlayFrom+` WHERE i.id = ?`,
userID, userID, userID, itemID)
var (
parentID sql.NullInt64
finished int
favUser int64
hasCover int
)
if err := row.Scan(
&v.SortTitle, &parentID,
&v.PositionMS, &v.Percent, &finished,
&v.PlayCount, &favUser, &hasCover,
); err != nil {
return err
}
v.ParentID = parentID
v.Finished = finished == 1
v.Favorite = favUser != 0
v.HasCover = hasCover == 1
return nil
}
// itemVisible reports whether the caller may read the item's library. A missing
// item is false, not an error. It delegates to catalog.LoadFor, the same
// chokepoint every other surface uses, rather than reimplementing the grant
// rule here.
func (h *Handlers) itemVisible(ctx context.Context, u auth.User, id int64) (bool, error) {
if _, err := catalog.LoadFor(ctx, h.db, u, id); err != nil {
if errors.Is(err, catalog.ErrNotFound) {
return false, nil
}
return false, err
}
return true, nil
}
// scanItem reads one row of the shared item column list.
func scanItem(row interface{ Scan(...any) error }) (itemView, error) {
var (
v itemView
parentID sql.NullInt64
finished int
favUser int64
hasCover int
)
err := row.Scan(
&v.Item.ID, &v.Item.LibraryID, &v.Item.Kind, &v.Item.Path, &v.Item.Title,
&v.Item.Year, &v.Item.Missing,
&v.SortTitle, &parentID,
&v.PositionMS, &v.Percent, &finished,
&v.PlayCount, &favUser, &hasCover,
)
if err != nil {
return itemView{}, err
}
v.ParentID = parentID
v.Finished = finished == 1
v.Favorite = favUser != 0
v.HasCover = hasCover == 1
return v, nil
}
// buildWhere assembles the WHERE clause and its arguments. An empty library set
// matches nothing, which is the correct outcome for a user with no grants.
func buildWhere(q listQuery) (string, []any) {
clauses := []string{"i.missing = 0"}
var args []any
if len(q.libraryIDs) == 0 {
clauses = append(clauses, "1 = 0")
} else {
in, inArgs := placeholders(q.libraryIDs)
clauses = append(clauses, "i.library_id IN ("+in+")")
args = append(args, inArgs...)
}
if q.parentItemID > 0 {
clauses = append(clauses, "i.parent_id = ?")
args = append(args, q.parentItemID)
}
if len(q.kinds) > 0 {
in, inArgs := placeholdersStr(q.kinds)
clauses = append(clauses, "i.kind IN ("+in+")")
args = append(args, inArgs...)
}
if q.searchTerm != "" {
clauses = append(clauses, "i.title LIKE ?")
args = append(args, "%"+q.searchTerm+"%")
}
// Filters are sorted so the SQL (and its argument order) is deterministic.
filters := append([]string(nil), q.filters...)
sort.Strings(filters)
for _, f := range filters {
switch f {
case "IsResumable":
clauses = append(clauses,
`EXISTS (SELECT 1 FROM progress p WHERE p.user_id = ? AND p.item_id = i.id AND p.finished = 0 AND p.position > 0)`)
args = append(args, q.userID)
case "IsPlayed":
clauses = append(clauses,
`EXISTS (SELECT 1 FROM progress p WHERE p.user_id = ? AND p.item_id = i.id AND p.finished = 1)`)
args = append(args, q.userID)
case "IsUnplayed":
clauses = append(clauses,
`NOT EXISTS (SELECT 1 FROM progress p WHERE p.user_id = ? AND p.item_id = i.id AND p.finished = 1)`)
args = append(args, q.userID)
case "IsFavorite":
clauses = append(clauses,
`EXISTS (SELECT 1 FROM favorites f WHERE f.user_id = ? AND f.item_id = i.id)`)
args = append(args, q.userID)
}
}
return strings.Join(clauses, " AND "), args
}
// ── small parsing helpers ──────────────────────────────────────────────
func placeholders(ids []int64) (string, []any) {
parts := make([]string, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
parts[i] = "?"
args[i] = id
}
return strings.Join(parts, ","), args
}
func placeholdersStr(vals []string) (string, []any) {
parts := make([]string, len(vals))
args := make([]any, len(vals))
for i, v := range vals {
parts[i] = "?"
args[i] = v
}
return strings.Join(parts, ","), args
}
// splitCSV splits a Jellyfin comma list, dropping empties.
func splitCSV(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}
out := []string{}
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func containsFold(list []string, want string) bool {
for _, v := range list {
if strings.EqualFold(v, want) {
return true
}
}
return false
}
func containsInt64(list []int64, want int64) bool {
for _, v := range list {
if v == want {
return true
}
}
return false
}
func atoiDefault(s string, def int) int {
if s == "" {
return def
}
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
return def
}
return n
}
func parseBool(s string) bool {
switch strings.ToLower(strings.TrimSpace(s)) {
case "1", "true", "yes":
return true
}
return false
}
// sortColumn maps a Jellyfin SortBy value to a SQL column. Only the first
// recognised name is used; unknown names fall back to sort_title.
func sortColumn(sortBy string) string {
for _, s := range strings.Split(sortBy, ",") {
switch strings.TrimSpace(s) {
case "DateCreated":
return "i.added_at"
case "ProductionYear":
return "i.year"
case "Random":
return "RANDOM()"
case "SortName":
return "i.sort_title"
}
}
return "i.sort_title"
}
// isDescending reads a Jellyfin SortOrder ("Descending"/"Desc"/"desc").
func isDescending(order string) bool {
switch strings.ToLower(strings.TrimSpace(order)) {
case "descending", "desc":
return true
}
return false
}