Files
vibesonic/internal/database/users.go
T
owenandClaude Sonnet 5 789bf91eed Complete Phase 4: security hardening, player/playlist/admin UI, docs
Security:
- New internal/netguard package: podcast feed/enclosure fetching now
  refuses to dial non-public IPs (loopback/RFC1918/link-local/cloud
  metadata/CGNAT), validated per-connection including across redirects,
  closing an SSRF gap where a malicious or compromised feed URL could
  probe the server's internal network. Configurable via
  VIBESONIC_PODCASTS_ALLOW_PRIVATE_NETWORKS for intentional LAN feeds.
- Podcast episode downloads capped at 4GB (was unbounded, disk-exhaustion risk)
- Cover art decoding now rejects images above a 40-megapixel budget
  before full decode (decompression-bomb guard)
- HTTP server sets ReadHeaderTimeout (slowloris hardening)
- Admin folder-scan redirect now validated as same-site instead of
  trusting the raw Referer header (open-redirect fix)
- Legacy Subsonic token/salt auth now uses a constant-time comparison

Web UI completion:
- Persistent player: shuffle and repeat (off/all/one) controls, wired
  into queue advance/prev logic and persisted like other player state
- Admin user-permissions form: adminRole toggle (with a guard against
  demoting the last remaining admin) and per-user music-folder access
  checkboxes, backed by existing schema/enforcement that was previously
  unreachable from the UI
- Playlist M3U8 import/export

Docs: added docs/client-compatibility.md (honest tracking template —
no client testing has been performed), docs/deployment.md,
docs/security.md, docs/backup.md; refreshed README and cross-links;
added a footnote in docs/subsonic-compatibility.md distinguishing
spec-standard from VibeSonic-original OpenSubsonic extensions.

Also added Go fuzz tests for the external-auth CIDR/IP parser and the
LRC lyrics parser.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
2026-08-23 09:18:17 -05:00

297 lines
9.5 KiB
Go

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()
}
// CountAdmins returns the number of enabled admin accounts, so callers
// can refuse to demote or disable the last one.
func CountAdmins(db *sql.DB) (int, error) {
var n int
err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE is_admin = 1 AND disabled = 0`).Scan(&n)
return n, 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
}