package database import ( "database/sql" "errors" "time" "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 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") func CountUsers(db *sql.DB) (int, error) { var n int err := db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n) 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. 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 if passwordHash != "" { hash = sql.NullString{String: passwordHash, Valid: true} } _, err := db.Exec(`INSERT INTO users (id, username, email, password_hash, is_admin) VALUES (?, ?, ?, ?, ?)`, id, username, email, nullStringVal(hash), boolInt(isAdmin)) if err != nil { return nil, err } return GetUserByID(db, id) } // CreateExternalUser inserts a user with no local password, created on // first login via a trusted reverse-proxy identity header. func CreateExternalUser(db *sql.DB, username string) (*User, error) { id := idgen.New("us") _, err := db.Exec(`INSERT INTO users (id, username, external_auth) VALUES (?, ?, 1)`, id, username) if err != nil { return nil, err } return GetUserByID(db, id) } func GetUserByID(db *sql.DB, id string) (*User, error) { 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 `+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, &pSettings, &pStream, &pDownload, &pUpload, &pPlaylist, &pCoverArt, &pComment, &pPodcast, &pShare, &pJukebox, &pVideoConv, &u.SubsonicAuthSecret, &createdAt, &lastLogin) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } 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 ListUsers(db *sql.DB) ([]*User, error) { 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() { u, err := scanUserRow(rows) if err != nil { return nil, err } 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 } func SetUserAdmin(db *sql.DB, userID string, isAdmin bool) error { _, err := db.Exec(`UPDATE users SET is_admin = ? WHERE id = ?`, boolInt(isAdmin), userID) 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 } func TouchLastLogin(db *sql.DB, userID string) error { _, err := db.Exec(`UPDATE users SET last_login_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, userID) 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 } return 0 } func nullStringVal(s sql.NullString) any { if !s.Valid { return nil } return s.String } func parseTime(s string) (time.Time, error) { return time.Parse("2006-01-02T15:04:05.999999999Z", s) } // SetUserEmail updates a user's email address. func SetUserEmail(db *sql.DB, userID, email string) error { _, err := db.Exec(`UPDATE users SET email = ? WHERE id = ?`, email, userID) return err }