Phase 2: Subsonic REST API framework (auth, response formatting, error handling, routing) plus user permission model

This commit is contained in:
2026-08-22 23:58:54 -05:00
parent f81c8704a5
commit b5e2bf01e2
26 changed files with 1772 additions and 75 deletions
+151 -29
View File
@@ -8,16 +8,42 @@ import (
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
)
// Permissions mirrors the Subsonic REST API's per-user role model.
// AdminRole is represented separately by User.IsAdmin (an admin
// implicitly has every permission below regardless of these flags).
type Permissions struct {
Settings bool
Stream bool
Download bool
Upload bool
Playlist bool
CoverArt bool
Comment bool
Podcast bool
Share bool
Jukebox bool
VideoConversion bool
}
type User struct {
ID string
Username string
Email string
PasswordHash sql.NullString
IsAdmin bool
Disabled bool
ExternalAuth bool
CreatedAt time.Time
LastLoginAt sql.NullTime
ID string
Username string
Email string
PasswordHash sql.NullString
IsAdmin bool
Disabled bool
ExternalAuth bool
Permissions Permissions
SubsonicAuthSecret sql.NullString // AES-GCM encrypted password, for Subsonic token auth; see internal/subsonic
CreatedAt time.Time
LastLoginAt sql.NullTime
}
// Allows reports whether the user may perform the action gated by
// permission p — admins can do everything regardless of individual
// flags.
func (u *User) Allows(p bool) bool {
return u.IsAdmin || p
}
var ErrNotFound = errors.New("not found")
@@ -28,8 +54,14 @@ func CountUsers(db *sql.DB) (int, error) {
return n, err
}
const userColumns = `id, username, email, password_hash, is_admin, disabled, external_auth,
role_settings, role_stream, role_download, role_upload, role_playlist, role_cover_art,
role_comment, role_podcast, role_share, role_jukebox, role_video_conversion,
subsonic_auth_secret, created_at, last_login_at`
// CreateUser inserts a new user. passwordHash may be empty for
// external-auth-only accounts.
// external-auth-only accounts. New non-admin users get the default
// permission set (stream/download/playlist/coverArt/comment/share).
func CreateUser(db *sql.DB, username, email, passwordHash string, isAdmin bool) (*User, error) {
id := idgen.New("us")
var hash sql.NullString
@@ -56,19 +88,22 @@ func CreateExternalUser(db *sql.DB, username string) (*User, error) {
}
func GetUserByID(db *sql.DB, id string) (*User, error) {
return scanUser(db.QueryRow(`SELECT id, username, email, password_hash, is_admin, disabled, external_auth, created_at, last_login_at FROM users WHERE id = ?`, id))
return scanUser(db.QueryRow(`SELECT `+userColumns+` FROM users WHERE id = ?`, id))
}
func GetUserByUsername(db *sql.DB, username string) (*User, error) {
return scanUser(db.QueryRow(`SELECT id, username, email, password_hash, is_admin, disabled, external_auth, created_at, last_login_at FROM users WHERE username = ?`, username))
return scanUser(db.QueryRow(`SELECT `+userColumns+` FROM users WHERE username = ?`, username))
}
func scanUser(row *sql.Row) (*User, error) {
var u User
var isAdmin, disabled, external int
var pSettings, pStream, pDownload, pUpload, pPlaylist, pCoverArt, pComment, pPodcast, pShare, pJukebox, pVideoConv int
var createdAt string
var lastLogin sql.NullString
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &isAdmin, &disabled, &external, &createdAt, &lastLogin)
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &isAdmin, &disabled, &external,
&pSettings, &pStream, &pDownload, &pUpload, &pPlaylist, &pCoverArt, &pComment, &pPodcast, &pShare, &pJukebox, &pVideoConv,
&u.SubsonicAuthSecret, &createdAt, &lastLogin)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
@@ -78,6 +113,11 @@ func scanUser(row *sql.Row) (*User, error) {
u.IsAdmin = isAdmin != 0
u.Disabled = disabled != 0
u.ExternalAuth = external != 0
u.Permissions = Permissions{
Settings: pSettings != 0, Stream: pStream != 0, Download: pDownload != 0, Upload: pUpload != 0,
Playlist: pPlaylist != 0, CoverArt: pCoverArt != 0, Comment: pComment != 0, Podcast: pPodcast != 0,
Share: pShare != 0, Jukebox: pJukebox != 0, VideoConversion: pVideoConv != 0,
}
u.CreatedAt, _ = parseTime(createdAt)
if lastLogin.Valid {
if t, err := parseTime(lastLogin.String); err == nil {
@@ -88,39 +128,68 @@ func scanUser(row *sql.Row) (*User, error) {
}
func ListUsers(db *sql.DB) ([]*User, error) {
rows, err := db.Query(`SELECT id, username, email, password_hash, is_admin, disabled, external_auth, created_at, last_login_at FROM users ORDER BY username COLLATE NOCASE`)
rows, err := db.Query(`SELECT ` + userColumns + ` FROM users ORDER BY username COLLATE NOCASE`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*User
for rows.Next() {
var u User
var isAdmin, disabled, external int
var createdAt string
var lastLogin sql.NullString
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &isAdmin, &disabled, &external, &createdAt, &lastLogin); err != nil {
u, err := scanUserRow(rows)
if err != nil {
return nil, err
}
u.IsAdmin = isAdmin != 0
u.Disabled = disabled != 0
u.ExternalAuth = external != 0
u.CreatedAt, _ = parseTime(createdAt)
if lastLogin.Valid {
if t, err := parseTime(lastLogin.String); err == nil {
u.LastLoginAt = sql.NullTime{Time: t, Valid: true}
}
}
out = append(out, &u)
out = append(out, u)
}
return out, rows.Err()
}
func scanUserRow(rows *sql.Rows) (*User, error) {
var u User
var isAdmin, disabled, external int
var pSettings, pStream, pDownload, pUpload, pPlaylist, pCoverArt, pComment, pPodcast, pShare, pJukebox, pVideoConv int
var createdAt string
var lastLogin sql.NullString
err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &isAdmin, &disabled, &external,
&pSettings, &pStream, &pDownload, &pUpload, &pPlaylist, &pCoverArt, &pComment, &pPodcast, &pShare, &pJukebox, &pVideoConv,
&u.SubsonicAuthSecret, &createdAt, &lastLogin)
if err != nil {
return nil, err
}
u.IsAdmin = isAdmin != 0
u.Disabled = disabled != 0
u.ExternalAuth = external != 0
u.Permissions = Permissions{
Settings: pSettings != 0, Stream: pStream != 0, Download: pDownload != 0, Upload: pUpload != 0,
Playlist: pPlaylist != 0, CoverArt: pCoverArt != 0, Comment: pComment != 0, Podcast: pPodcast != 0,
Share: pShare != 0, Jukebox: pJukebox != 0, VideoConversion: pVideoConv != 0,
}
u.CreatedAt, _ = parseTime(createdAt)
if lastLogin.Valid {
if t, err := parseTime(lastLogin.String); err == nil {
u.LastLoginAt = sql.NullTime{Time: t, Valid: true}
}
}
return &u, nil
}
func SetUserPassword(db *sql.DB, userID, passwordHash string) error {
_, err := db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, userID)
return err
}
// SetUserSubsonicAuthSecret stores (or clears, with encrypted="") the
// AES-GCM encrypted copy of the user's current plaintext password, used
// only to verify legacy Subsonic token authentication.
func SetUserSubsonicAuthSecret(db *sql.DB, userID, encrypted string) error {
var v any
if encrypted != "" {
v = encrypted
}
_, err := db.Exec(`UPDATE users SET subsonic_auth_secret = ? WHERE id = ?`, v, userID)
return err
}
func SetUserDisabled(db *sql.DB, userID string, disabled bool) error {
_, err := db.Exec(`UPDATE users SET disabled = ? WHERE id = ?`, boolInt(disabled), userID)
return err
@@ -131,6 +200,18 @@ func SetUserAdmin(db *sql.DB, userID string, isAdmin bool) error {
return err
}
// SetUserPermissions updates the full Subsonic permission set for a user.
func SetUserPermissions(db *sql.DB, userID string, p Permissions) error {
_, err := db.Exec(`UPDATE users SET
role_settings=?, role_stream=?, role_download=?, role_upload=?, role_playlist=?,
role_cover_art=?, role_comment=?, role_podcast=?, role_share=?, role_jukebox=?, role_video_conversion=?
WHERE id=?`,
boolInt(p.Settings), boolInt(p.Stream), boolInt(p.Download), boolInt(p.Upload), boolInt(p.Playlist),
boolInt(p.CoverArt), boolInt(p.Comment), boolInt(p.Podcast), boolInt(p.Share), boolInt(p.Jukebox), boolInt(p.VideoConversion),
userID)
return err
}
func DeleteUser(db *sql.DB, userID string) error {
_, err := db.Exec(`DELETE FROM users WHERE id = ?`, userID)
return err
@@ -141,6 +222,47 @@ func TouchLastLogin(db *sql.DB, userID string) error {
return err
}
// --- Per-user music folder restrictions ---
// AllowedMusicFolderIDs returns the music folder IDs a user is
// restricted to, or nil if the user has no restriction (sees every
// folder — the default and common case).
func AllowedMusicFolderIDs(db *sql.DB, userID string) ([]string, error) {
rows, err := db.Query(`SELECT music_folder_id FROM user_music_folders WHERE user_id = ?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
// SetAllowedMusicFolders replaces a user's folder restriction list. An
// empty ids slice removes all restrictions (unrestricted access).
func SetAllowedMusicFolders(db *sql.DB, userID string, ids []string) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM user_music_folders WHERE user_id = ?`, userID); err != nil {
return err
}
for _, id := range ids {
if _, err := tx.Exec(`INSERT INTO user_music_folders (user_id, music_folder_id) VALUES (?, ?)`, userID, id); err != nil {
return err
}
}
return tx.Commit()
}
func boolInt(b bool) int {
if b {
return 1