Phase 2: Subsonic REST API framework (auth, response formatting, error handling, routing) plus user permission model
This commit is contained in:
+151
-29
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user