Files
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

191 lines
5.2 KiB
Go

// Package app wires together every subsystem (database, auth, scanner,
// cover art, web UI) into a single running VibeSonic process.
package app
import (
"context"
"database/sql"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"time"
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
"git.noroutes.net/NoRoutes/vibetunes/internal/config"
"git.noroutes.net/NoRoutes/vibetunes/internal/coverart"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/jukebox"
"git.noroutes.net/NoRoutes/vibetunes/internal/metadata"
"git.noroutes.net/NoRoutes/vibetunes/internal/podcasts"
"git.noroutes.net/NoRoutes/vibetunes/internal/scanner"
"git.noroutes.net/NoRoutes/vibetunes/internal/subsonic"
"git.noroutes.net/NoRoutes/vibetunes/internal/transcoding"
"git.noroutes.net/NoRoutes/vibetunes/internal/web"
)
type App struct {
Cfg config.Config
DB *sql.DB
Log *slog.Logger
Scanner *scanner.Scanner
Web *web.Web
Subsonic *subsonic.Server
}
// Open initializes the database and every subsystem, and imports
// VIBESONIC_MUSIC_DIRS as music folders on first run. It does not start
// any listeners or background loops.
func Open(cfg config.Config, log *slog.Logger) (*App, error) {
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
return nil, fmt.Errorf("create data dir: %w", err)
}
db, err := database.Open(cfg.DBPath)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
if err := importConfiguredMusicDirs(db, cfg.MusicDirs); err != nil {
db.Close()
return nil, fmt.Errorf("importing VIBESONIC_MUSIC_DIRS: %w", err)
}
if cfg.SessionSecret == "" {
log.Warn("VIBESONIC_SESSION_SECRET is not set; sessions are still database-backed and safe, but set this in production for defense in depth")
}
prober := &metadata.Prober{FFprobePath: cfg.FFprobePath}
sc := scanner.New(db, prober, log)
sc.FFmpegPath = cfg.FFmpegPath
coverCache := filepath.Join(cfg.DataDir, "cache", "covers")
covers := coverart.New(coverCache, cfg.FFmpegPath)
sessions := &auth.Sessions{DB: db, CookieSecure: cfg.CookieSecure}
extauth := &auth.ExtAuth{DB: db, Config: cfg.ExtAuth}
mw := &auth.Middleware{Sessions: sessions, ExtAuth: extauth}
csrf := &auth.CSRF{CookieSecure: cfg.CookieSecure}
encryptionKey, err := auth.EncryptionKey(db, cfg.SessionSecret)
if err != nil {
db.Close()
return nil, fmt.Errorf("deriving auth encryption key: %w", err)
}
podcastMgr := podcasts.NewManager(db, cfg.DataDir, cfg.FFprobePath, log, cfg.PodcastsAllowPrivateNetworks)
if err := podcastMgr.RecoverInterruptedDownloads(); err != nil {
log.Warn("podcast download recovery failed", "err", err)
}
jukeboxCtl := jukebox.New(cfg.MPVPath)
webApp := &web.Web{
DB: db,
Config: cfg,
Sessions: sessions,
CSRF: csrf,
Middleware: mw,
Scanner: sc,
CoverArt: covers,
EncryptionKey: encryptionKey,
Podcasts: podcastMgr,
Jukebox: jukeboxCtl,
Log: log,
}
subsonicSrv := &subsonic.Server{
DB: db,
Config: cfg,
EncryptionKey: encryptionKey,
Scanner: sc,
CoverArt: covers,
Transcoder: transcoding.NewManager(cfg.FFmpegPath),
Podcasts: podcastMgr,
Jukebox: jukeboxCtl,
Log: log,
}
return &App{Cfg: cfg, DB: db, Log: log, Scanner: sc, Web: webApp, Subsonic: subsonicSrv}, nil
}
func (a *App) Close() error {
a.Web.Jukebox.Close()
return a.DB.Close()
}
// importConfiguredMusicDirs adds any VIBESONIC_MUSIC_DIRS entries not
// already present as music folders, keyed by path so restarts don't
// create duplicates.
func importConfiguredMusicDirs(db *sql.DB, dirs []string) error {
if len(dirs) == 0 {
return nil
}
existing, err := database.ListMusicFolders(db)
if err != nil {
return err
}
byPath := make(map[string]bool, len(existing))
for _, f := range existing {
byPath[f.Path] = true
}
for _, dir := range dirs {
if byPath[dir] {
continue
}
name := filepath.Base(dir)
if _, err := database.CreateMusicFolder(db, name, dir, "music"); err != nil {
return err
}
}
return nil
}
// Run starts the HTTP server and the scheduled scanner loop, blocking
// until ctx is cancelled, then shuts down gracefully.
func (a *App) Run(ctx context.Context) error {
go a.Scanner.RunScheduled(ctx, a.Cfg.ScanInterval)
go func() {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := database.DeleteExpiredSessions(a.DB); err != nil {
a.Log.Warn("session cleanup failed", "err", err)
}
}
}
}()
mux := http.NewServeMux()
mux.Handle("/rest/", a.Subsonic.Router())
mux.Handle("/", a.Web.NewRouter())
srv := &http.Server{
Addr: a.Cfg.ListenAddr,
Handler: mux,
ReadHeaderTimeout: 15 * time.Second,
}
errCh := make(chan error, 1)
go func() {
a.Log.Info("listening", "addr", a.Cfg.ListenAddr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errCh <- err
}
}()
select {
case <-ctx.Done():
case err := <-errCh:
return err
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}