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
191 lines
5.2 KiB
Go
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)
|
|
}
|