Vellum now speaks two foreign protocols, so the clients people already own can
use it. Both are translation layers over queries that already exist, and both go
through the same grant chokepoint as the web app — a token is a second way to be
a user, never a second set of permissions.
Shared foundation (coordinator, written before dispatch)
- migrations/0012_api_tokens.sql + internal/apitoken. Both protocols need a
credential that is not the session cookie, and letting two agents each invent
a token table would have produced two incompatible ones.
- The table stores two shapes for a real reason: a Jellyfin token is a bearer
value so only its hash is kept, while a Subsonic key must be recoverable
because verifying `t` means computing md5(key + salt) server-side. token_plain
is therefore the one column in the schema holding a usable credential. It is a
generated random secret, never a user password, and it is revocable.
Wave 9 - Jellyfin
- /System/Info/Public, /Users/AuthenticateByName, /Users/Me, /Users/{id},
/Items, /Users/{id}/Items, /Items/{id}, /Items/{id}/Images/Primary,
/Items/{id}/PlaybackInfo, /Videos/{id}/stream, /Audio/{id}/stream,
/Sessions/Playing{,/Progress,/Stopped}.
- Auth accepts X-Emby-Token, X-MediaBrowser-Token, ?api_key=, and
Authorization: MediaBrowser Token="...". The ?api_key= form is the one that
matters: <video> elements cannot set headers.
- Honest about the one thing Vellum cannot do. PlaybackInfo reports
SupportsDirectPlay/SupportsDirectStream true and SupportsTranscoding FALSE,
with a DirectStreamUrl carrying the api_key. A client that disables both
direct modes is demanding a transcode and is refused with ErrorCode
"NoCompatibleStream" — rather than handed a TranscodingUrl that would never
resolve, which is how a client hangs forever.
Wave 10 - Subsonic
- /rest/{method} dispatch with ping, getLicense, getMusicFolders, getIndexes,
getArtists/getArtist/getAlbum, getMusicDirectory, getSong, search3, stream,
getCoverArt, getAlbumList2, getRandomSongs, scrobble, getUser, getPlaylists,
plus GET /api/v1/subsonic/credentials to hand a user their key.
- Auth via t=md5(key+salt), p=key, constant-time comparison. A bad credential
returns HTTP 200 with the envelope status "failed" and error code 40 — NOT an
HTTP 401. Subsonic clients read the envelope, and getting this wrong is the
single most common integration failure in that protocol.
- stream supports format=raw and never invokes ffmpeg.
Coordinator wired both into main.go (the line agents were told to leave alone)
and verified against real requests.
Verified end to end, 18 packages pass, gofmt/vet clean, svelte-check 0/0:
- Jellyfin: discovery, AuthenticateByName minting a token, /Items reporting 18,
PlaybackInfo showing SupportsTranscoding=false, and a Range request to
/Videos/{id}/stream using ONLY ?api_key= returning 206 with Content-Range.
- Subsonic: ping as JSON and XML, md5(key+salt) auth succeeding, and a bad token
returning the failed envelope with code 40.
- Scoping, both protocols, with a genuinely non-admin user: 0 items, 404 on an
admin-only item, 404 on its stream, 0 music folders; admin still sees 18.
- The SPA is not shadowed by the root-mounted compat paths: /, /healthz and
/api/v1/version all still answer correctly.
Both agents failed with an HTTP 400 at the REPORT stage, not during the work;
their files were complete and compile, so the work was salvaged and verified
here rather than redone.
529 lines
16 KiB
Go
529 lines
16 KiB
Go
package subsonic
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"net/http"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/mithral/vellum/internal/auth"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
|
|
func (h *Handlers) loadAlbum(ctx context.Context, u auth.User, itemID int64) (albumInfo, bool, error) {
|
|
libs, err := h.audioLibraryIDs(ctx, u)
|
|
if err != nil {
|
|
return albumInfo{}, false, err
|
|
}
|
|
albums, err := h.albumsWhere(ctx, libs, ` 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
|
|
}
|
|
|
|
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
|
|
}
|
|
allowed, err := auth.CanAccessLibrary(ctx, h.db, u, tracks[0].LibraryID)
|
|
if err != nil {
|
|
return track{}, false, err
|
|
}
|
|
if !allowed {
|
|
return track{}, false, nil
|
|
}
|
|
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)
|
|
}
|