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
This commit is contained in:
+4
-3
@@ -73,7 +73,7 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) {
|
||||
return nil, fmt.Errorf("deriving auth encryption key: %w", err)
|
||||
}
|
||||
|
||||
podcastMgr := podcasts.NewManager(db, cfg.DataDir, cfg.FFprobePath, log)
|
||||
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)
|
||||
}
|
||||
@@ -165,8 +165,9 @@ func (a *App) Run(ctx context.Context) error {
|
||||
mux.Handle("/", a.Web.NewRouter())
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: a.Cfg.ListenAddr,
|
||||
Handler: mux,
|
||||
Addr: a.Cfg.ListenAddr,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
// FuzzParseCIDROrIP guards against a crash in the
|
||||
// VIBESONIC_EXTAUTH_TRUSTED_SOURCES parser, which is security-sensitive:
|
||||
// it decides which peers are trusted to assert an authenticated
|
||||
// identity via external auth.
|
||||
func FuzzParseCIDROrIP(f *testing.F) {
|
||||
f.Add("127.0.0.1/32")
|
||||
f.Add("10.0.0.0/8")
|
||||
f.Add("::1")
|
||||
f.Add("fd00::/8")
|
||||
f.Add("")
|
||||
f.Add("not-an-ip")
|
||||
f.Add("127.0.0.1/99")
|
||||
f.Add("999.999.999.999")
|
||||
f.Add("::/0")
|
||||
|
||||
f.Fuzz(func(t *testing.T, s string) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("parseCIDROrIP panicked on %q: %v", s, r)
|
||||
}
|
||||
}()
|
||||
_, _ = parseCIDROrIP(s)
|
||||
})
|
||||
}
|
||||
@@ -33,6 +33,15 @@ type Config struct {
|
||||
// to the outside world.
|
||||
EnableExternalMetadata bool
|
||||
|
||||
// PodcastsAllowPrivateNetworks disables SSRF protection on podcast
|
||||
// feed/enclosure fetching, allowing feeds hosted on loopback/LAN/
|
||||
// link-local addresses. Off by default: podcast URLs are supplied
|
||||
// by an admin (or, in principle, a compromised feed's redirects)
|
||||
// and must not be usable to probe the server's own internal
|
||||
// network. Only enable this for deployments that intentionally
|
||||
// host podcast feeds on a private network.
|
||||
PodcastsAllowPrivateNetworks bool
|
||||
|
||||
ExtAuth ExtAuthConfig
|
||||
}
|
||||
|
||||
@@ -62,6 +71,8 @@ func Load() (Config, error) {
|
||||
CookieSecure: envBool("VIBESONIC_COOKIE_SECURE", true),
|
||||
|
||||
EnableExternalMetadata: envBool("VIBESONIC_ENABLE_EXTERNAL_METADATA", false),
|
||||
|
||||
PodcastsAllowPrivateNetworks: envBool("VIBESONIC_PODCASTS_ALLOW_PRIVATE_NETWORKS", false),
|
||||
}
|
||||
cfg.DBPath = envOr("VIBESONIC_DB", filepath.Join(cfg.DataDir, "vibesonic.db"))
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
_ "image/gif"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -19,6 +20,12 @@ import (
|
||||
"golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
// maxDecodePixels bounds the decoded pixel count of a source image so a
|
||||
// small, highly-compressed file (a "decompression bomb") can't force an
|
||||
// enormous in-memory pixel buffer allocation. 40 megapixels comfortably
|
||||
// covers any legitimate cover art.
|
||||
const maxDecodePixels = 40_000_000
|
||||
|
||||
// commonFolderImageNames are checked, in priority order, for a
|
||||
// folder-level cover image when a file has no embedded artwork.
|
||||
var commonFolderImageNames = []string{
|
||||
@@ -112,6 +119,15 @@ func (s *Store) GetOrCreate(sourcePath, contentHash string, size int) (string, e
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
if cfg, _, err := image.DecodeConfig(src); err == nil {
|
||||
if cfg.Width*cfg.Height > maxDecodePixels {
|
||||
return "", fmt.Errorf("source image %s exceeds %d pixel limit (%dx%d)", sourcePath, maxDecodePixels, cfg.Width, cfg.Height)
|
||||
}
|
||||
}
|
||||
if _, err := src.Seek(0, io.SeekStart); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
img, _, err := image.Decode(src)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decoding image: %w", err)
|
||||
|
||||
@@ -144,6 +144,14 @@ func ListUsers(db *sql.DB) ([]*User, error) {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package lyrics
|
||||
|
||||
import "testing"
|
||||
|
||||
// FuzzParse guards against a crash/panic in the LRC parser when fed
|
||||
// arbitrary bytes — lyrics come from sidecar files and embedded tags
|
||||
// found while scanning a music library, so the content is effectively
|
||||
// untrusted input.
|
||||
func FuzzParse(f *testing.F) {
|
||||
f.Add("[00:12.34]First line\n[00:15.00]Second line")
|
||||
f.Add("[la:en]\n[00:12.34]<00:12.34>Hello <00:12.90>world")
|
||||
f.Add("")
|
||||
f.Add("[not a tag")
|
||||
f.Add("[99:99.999999999999]overflowing timestamp")
|
||||
f.Add("[00:12.34][00:15.00]shared timestamp line")
|
||||
f.Add("no timestamps at all, just plain text")
|
||||
|
||||
f.Fuzz(func(t *testing.T, content string) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("Parse panicked on %q: %v", content, r)
|
||||
}
|
||||
}()
|
||||
Parse(content)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Package netguard builds HTTP clients that refuse to connect to
|
||||
// non-public IP addresses. It exists so features that fetch
|
||||
// externally-controlled URLs (podcast feeds and enclosures, most
|
||||
// notably) cannot be turned into a proxy onto the server's own
|
||||
// loopback interface, its LAN, or cloud-metadata endpoints
|
||||
// (169.254.169.254 and friends) via a malicious or compromised feed
|
||||
// URL, a redirect, or DNS rebinding.
|
||||
//
|
||||
// The check is enforced at dial time, on the specific IP address about
|
||||
// to be connected to, for every connection the client makes — including
|
||||
// ones made while following a redirect. Because the resolved IP is
|
||||
// validated immediately before dialing that exact address (rather than
|
||||
// re-resolving the hostname later), a DNS record that changes between
|
||||
// requests cannot rebind a validated hostname onto a blocked address.
|
||||
package netguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AllowedSchemes are the only URL schemes a guarded client will ever
|
||||
// dial; anything else (file://, ftp://, gopher://, ...) is rejected
|
||||
// before a connection is attempted.
|
||||
var AllowedSchemes = map[string]bool{"http": true, "https": true}
|
||||
|
||||
// ValidateURL rejects an obviously-bad externally-supplied URL (wrong
|
||||
// scheme, missing host) before it's stored or fetched. This is a
|
||||
// fast, user-facing sanity check; the actual SSRF protection is
|
||||
// enforced at connection time by the client NewSafeHTTPClient returns,
|
||||
// regardless of what passes this check.
|
||||
func ValidateURL(rawURL string) error {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
if !AllowedSchemes[u.Scheme] {
|
||||
return fmt.Errorf("URL must be http or https")
|
||||
}
|
||||
if u.Host == "" {
|
||||
return fmt.Errorf("URL must include a host")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewSafeHTTPClient returns an *http.Client that only connects to
|
||||
// public IP addresses, unless allowPrivateNetworks is true (for
|
||||
// deployments that intentionally point podcast feeds at a LAN/self-
|
||||
// hosted server). It also refuses to follow a redirect more than 5
|
||||
// times or onto a non-http(s) scheme.
|
||||
func NewSafeHTTPClient(timeout time.Duration, allowPrivateNetworks bool) *http.Client {
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||
transport := &http.Transport{
|
||||
DialContext: safeDialContext(dialer, allowPrivateNetworks),
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ResponseHeaderTimeout: 20 * time.Second,
|
||||
ForceAttemptHTTP2: true,
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: transport,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return fmt.Errorf("stopped after 5 redirects")
|
||||
}
|
||||
if !AllowedSchemes[req.URL.Scheme] {
|
||||
return fmt.Errorf("refusing to follow redirect to unsupported scheme %q", req.URL.Scheme)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func safeDialContext(base *net.Dialer, allowPrivateNetworks bool) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If the host is already a literal IP, LookupIP still handles
|
||||
// it (returning it unchanged) without a real DNS round trip.
|
||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, ip := range ips {
|
||||
if !allowPrivateNetworks && !IsPublicIP(ip) {
|
||||
lastErr = fmt.Errorf("refusing to connect to non-public address %s", ip)
|
||||
continue
|
||||
}
|
||||
conn, dialErr := base.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||
if dialErr == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = dialErr
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no addresses found for %s", host)
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
}
|
||||
|
||||
// IsPublicIP reports whether ip is a globally routable unicast address
|
||||
// that a server-side fetcher should be allowed to connect to. It
|
||||
// rejects loopback, link-local (including the 169.254.169.254 cloud
|
||||
// metadata address), RFC1918/RFC4193 private ranges, carrier-grade NAT
|
||||
// (100.64.0.0/10), multicast, and unspecified addresses.
|
||||
func IsPublicIP(ip net.IP) bool {
|
||||
ip = ip.To16()
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
if cgnat.Contains(v4) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case ip.IsLoopback(),
|
||||
ip.IsPrivate(),
|
||||
ip.IsLinkLocalUnicast(),
|
||||
ip.IsLinkLocalMulticast(),
|
||||
ip.IsInterfaceLocalMulticast(),
|
||||
ip.IsMulticast(),
|
||||
ip.IsUnspecified():
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var cgnat = mustParseCIDR("100.64.0.0/10")
|
||||
|
||||
func mustParseCIDR(s string) *net.IPNet {
|
||||
_, n, err := net.ParseCIDR(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package netguard
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// FuzzValidateURL guards against a crash in the podcast feed/enclosure
|
||||
// URL sanity check — the URLs it validates are admin-supplied but the
|
||||
// check must never panic regardless of input.
|
||||
func FuzzValidateURL(f *testing.F) {
|
||||
f.Add("https://example.com/feed.xml")
|
||||
f.Add("http://127.0.0.1/feed")
|
||||
f.Add("ftp://example.com")
|
||||
f.Add("not a url at all")
|
||||
f.Add("")
|
||||
f.Add("http://")
|
||||
f.Add("javascript:alert(1)")
|
||||
f.Add("http://[::1]:8080/feed")
|
||||
|
||||
f.Fuzz(func(t *testing.T, raw string) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("ValidateURL panicked on %q: %v", raw, r)
|
||||
}
|
||||
}()
|
||||
_ = ValidateURL(raw)
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzIsPublicIP guards the address classifier that decides whether a
|
||||
// resolved IP is safe to connect to.
|
||||
func FuzzIsPublicIP(f *testing.F) {
|
||||
f.Add("8.8.8.8")
|
||||
f.Add("127.0.0.1")
|
||||
f.Add("::1")
|
||||
f.Add("169.254.169.254")
|
||||
f.Add("100.64.0.1")
|
||||
f.Add("::ffff:127.0.0.1")
|
||||
|
||||
f.Fuzz(func(t *testing.T, s string) {
|
||||
ip := net.ParseIP(s)
|
||||
if ip == nil {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("IsPublicIP panicked on %q: %v", s, r)
|
||||
}
|
||||
}()
|
||||
IsPublicIP(ip)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package netguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIsPublicIP(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"8.8.8.8": true,
|
||||
"1.1.1.1": true,
|
||||
"127.0.0.1": false,
|
||||
"10.0.0.1": false,
|
||||
"172.16.0.1": false,
|
||||
"192.168.1.1": false,
|
||||
"169.254.169.254": false, // cloud metadata
|
||||
"100.64.0.1": false, // CGNAT
|
||||
"0.0.0.0": false,
|
||||
"::1": false,
|
||||
"fe80::1": false,
|
||||
"fc00::1": false,
|
||||
"2606:4700::1111": true,
|
||||
}
|
||||
for s, want := range cases {
|
||||
ip := net.ParseIP(s)
|
||||
if ip == nil {
|
||||
t.Fatalf("bad test IP %q", s)
|
||||
}
|
||||
if got := IsPublicIP(ip); got != want {
|
||||
t.Errorf("IsPublicIP(%q) = %v, want %v", s, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeHTTPClientBlocksLoopback(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewSafeHTTPClient(5*time.Second, false)
|
||||
_, err := client.Get(srv.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected request to loopback server to be blocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeHTTPClientAllowsLoopbackWhenPermitted(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewSafeHTTPClient(5*time.Second, true)
|
||||
resp, err := client.Get(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("expected request to loopback server to succeed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestSafeHTTPClientBlocksRedirectToPrivate(t *testing.T) {
|
||||
blocked := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("should never be reached"))
|
||||
}))
|
||||
defer blocked.Close()
|
||||
|
||||
// A "public-looking" server (still loopback in this test, but with
|
||||
// AllowPrivateNetworks=false the initial dial to it would itself be
|
||||
// blocked — so instead verify redirect scheme enforcement directly
|
||||
// via CheckRedirect using a context that never dials at all).
|
||||
client := NewSafeHTTPClient(5*time.Second, false)
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, blocked.URL, nil)
|
||||
if err := client.CheckRedirect(req, nil); err != nil {
|
||||
t.Fatalf("unexpected error for allowed scheme: %v", err)
|
||||
}
|
||||
req.URL.Scheme = "file"
|
||||
if err := client.CheckRedirect(req, nil); err == nil {
|
||||
t.Fatal("expected file:// redirect to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,16 @@ import (
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/metadata"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/netguard"
|
||||
)
|
||||
|
||||
const maxConcurrentDownloads = 2
|
||||
const (
|
||||
maxConcurrentDownloads = 2
|
||||
// maxEnclosureSize bounds a single episode download so a malicious
|
||||
// or misbehaving feed can't exhaust disk space; legitimate podcast
|
||||
// episodes are essentially never larger than this.
|
||||
maxEnclosureSize = 4 << 30 // 4GB
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
DB *sql.DB
|
||||
@@ -37,12 +44,18 @@ type Manager struct {
|
||||
sem chan struct{}
|
||||
}
|
||||
|
||||
func NewManager(db *sql.DB, dataDir, ffprobePath string, log *slog.Logger) *Manager {
|
||||
// NewManager builds a podcast manager whose HTTP client refuses to
|
||||
// connect to non-public (loopback/LAN/link-local/cloud-metadata)
|
||||
// addresses — feed URLs and episode enclosure URLs are attacker- or
|
||||
// admin-supplied and must never turn VibeSonic into an internal
|
||||
// network proxy. Set allowPrivateNetworks only for deployments that
|
||||
// intentionally host feeds on a private network.
|
||||
func NewManager(db *sql.DB, dataDir, ffprobePath string, log *slog.Logger, allowPrivateNetworks bool) *Manager {
|
||||
return &Manager{
|
||||
DB: db,
|
||||
DataDir: dataDir,
|
||||
FFprobePath: ffprobePath,
|
||||
HTTPClient: &http.Client{Timeout: 60 * time.Second},
|
||||
HTTPClient: netguard.NewSafeHTTPClient(60*time.Second, allowPrivateNetworks),
|
||||
Log: log,
|
||||
sem: make(chan struct{}, maxConcurrentDownloads),
|
||||
}
|
||||
@@ -78,6 +91,11 @@ func (m *Manager) RefreshChannel(ctx context.Context, channelID string) error {
|
||||
}
|
||||
_ = database.SetPodcastChannelStatus(m.DB, channelID, "downloading", "")
|
||||
|
||||
if err := netguard.ValidateURL(channel.URL); err != nil {
|
||||
_ = database.SetPodcastChannelStatus(m.DB, channelID, "error", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, channel.URL, nil)
|
||||
if err != nil {
|
||||
_ = database.SetPodcastChannelStatus(m.DB, channelID, "error", err.Error())
|
||||
@@ -235,6 +253,9 @@ func (m *Manager) DownloadEpisode(ctx context.Context, episodeID string) error {
|
||||
}
|
||||
|
||||
func (m *Manager) downloadToFile(ctx context.Context, sourceURL, destPath string) error {
|
||||
if err := netguard.ValidateURL(sourceURL); err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -253,11 +274,17 @@ func (m *Manager) downloadToFile(ctx context.Context, sourceURL, destPath string
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, resp.Body); err != nil {
|
||||
n, err := io.Copy(out, io.LimitReader(resp.Body, maxEnclosureSize+1))
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if n > maxEnclosureSize {
|
||||
out.Close()
|
||||
os.Remove(tmp)
|
||||
return fmt.Errorf("enclosure exceeds maximum allowed size of %d bytes", maxEnclosureSize)
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
|
||||
@@ -79,7 +79,10 @@ func newTestManager(t *testing.T) (*Manager, string) {
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return NewManager(db, dir, "", log), dir
|
||||
// Tests talk to httptest.Server, which listens on loopback, so
|
||||
// private networks must be allowed here even though production
|
||||
// defaults to blocking them (see internal/netguard).
|
||||
return NewManager(db, dir, "", log, true), dir
|
||||
}
|
||||
|
||||
func TestRefreshChannel(t *testing.T) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package subsonic
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
@@ -60,7 +61,7 @@ func authenticate(db *sql.DB, encryptionKey []byte, p params) (*database.User, *
|
||||
}
|
||||
sum := md5.Sum([]byte(plaintext + salt))
|
||||
expected := hex.EncodeToString(sum[:])
|
||||
if !strings.EqualFold(expected, token) {
|
||||
if subtle.ConstantTimeCompare([]byte(expected), []byte(strings.ToLower(token))) != 1 {
|
||||
return nil, errWrongCredentials
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/netguard"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -176,6 +177,9 @@ func handleCreatePodcastChannel(rc *Context) *apiError {
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
if err := netguard.ValidateURL(feedURL); err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
channel, err := database.CreatePodcastChannel(rc.Server.DB, feedURL)
|
||||
if err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
|
||||
@@ -40,7 +40,7 @@ func newTestServer(t *testing.T) (*Server, *sql.DB) {
|
||||
Scanner: scanner.New(db, &metadata.Prober{}, log),
|
||||
CoverArt: coverart.New(t.TempDir(), ""),
|
||||
Transcoder: transcoding.NewManager(""),
|
||||
Podcasts: podcasts.NewManager(db, t.TempDir(), "", log),
|
||||
Podcasts: podcasts.NewManager(db, t.TempDir(), "", log, true),
|
||||
Jukebox: jukebox.New(""),
|
||||
Log: log,
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package web
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
|
||||
@@ -48,6 +49,16 @@ func (h *Web) handleAdminUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/users", http.StatusFound)
|
||||
}
|
||||
|
||||
// userPermissionsPageData bundles the target user with the data the
|
||||
// permissions form needs beyond the plain user row: every music folder
|
||||
// (so the form can render a checkbox per folder) and which of those the
|
||||
// user is currently restricted to.
|
||||
type userPermissionsPageData struct {
|
||||
*database.User
|
||||
Folders []*database.MusicFolder
|
||||
AllowedFolders map[string]bool
|
||||
}
|
||||
|
||||
func (h *Web) handleAdminUserPermissions(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
target, err := database.GetUserByID(h.DB, id)
|
||||
@@ -55,12 +66,31 @@ func (h *Web) handleAdminUserPermissions(w http.ResponseWriter, r *http.Request)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.page(w, r, "admin_user_permissions", "admin_users", "Permissions: "+target.Username, target)
|
||||
folders, err := database.ListMusicFolders(h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
allowedIDs, err := database.AllowedMusicFolderIDs(h.DB, id)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
allowed := make(map[string]bool, len(allowedIDs))
|
||||
for _, fid := range allowedIDs {
|
||||
allowed[fid] = true
|
||||
}
|
||||
h.page(w, r, "admin_user_permissions", "admin_users", "Permissions: "+target.Username, userPermissionsPageData{
|
||||
User: target,
|
||||
Folders: folders,
|
||||
AllowedFolders: allowed,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Web) handleAdminUserPermissionsUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, err := database.GetUserByID(h.DB, id); err != nil {
|
||||
target, err := database.GetUserByID(h.DB, id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
@@ -86,6 +116,32 @@ func (h *Web) handleAdminUserPermissionsUpdate(w http.ResponseWriter, r *http.Re
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
wantAdmin := get("admin")
|
||||
if target.IsAdmin && !wantAdmin {
|
||||
// Refuse to demote the last remaining admin — otherwise the
|
||||
// instance could end up with no one able to reach /admin/*.
|
||||
n, err := database.CountAdmins(h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if n <= 1 {
|
||||
http.Error(w, "cannot remove admin role from the last remaining admin", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := database.SetUserAdmin(h.DB, id, wantAdmin); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
folderIDs := r.Form["music_folders"]
|
||||
if err := database.SetAllowedMusicFolders(h.DB, id, folderIDs); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/admin/users", http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -189,11 +245,15 @@ func (h *Web) handleAdminFolderScan(w http.ResponseWriter, r *http.Request) {
|
||||
if h.Scanner != nil {
|
||||
go func() { _ = h.Scanner.ScanFolder(context.Background(), id) }()
|
||||
}
|
||||
referer := r.Header.Get("Referer")
|
||||
if referer == "" {
|
||||
referer = "/admin/scanner"
|
||||
dest := "/admin/scanner"
|
||||
if referer := r.Header.Get("Referer"); referer != "" {
|
||||
if u, err := url.Parse(referer); err == nil {
|
||||
if safe := safeRedirectPath(u.Path); safe != "/" {
|
||||
dest = safe
|
||||
}
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, referer, http.StatusFound)
|
||||
http.Redirect(w, r, dest, http.StatusFound)
|
||||
}
|
||||
|
||||
// --- Scanner status ---
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/netguard"
|
||||
)
|
||||
|
||||
func (h *Web) handleAdminPodcasts(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -35,6 +36,10 @@ func (h *Web) handleAdminPodcastCreate(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/podcasts", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := netguard.ValidateURL(feedURL); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
channel, err := database.CreatePodcastChannel(h.DB, feedURL)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
// handlePlaylistExport streams a playlist out as a standard M3U8 file:
|
||||
// an #EXTM3U header, an #EXTINF duration/title line per track, and the
|
||||
// track's absolute on-disk path (folder root + relative path) so a
|
||||
// re-import against the same library's folders resolves cleanly.
|
||||
func (h *Web) handlePlaylistExport(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id := r.PathValue("id")
|
||||
playlist, err := database.GetPlaylist(h.DB, id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if playlist.OwnerID != user.ID && !playlist.IsPublic {
|
||||
http.Error(w, "403 Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
entries, err := database.ListPlaylistEntries(h.DB, id)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
folders, err := database.ListMusicFolders(h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
folderByID := make(map[string]*database.MusicFolder, len(folders))
|
||||
for _, f := range folders {
|
||||
folderByID[f.ID] = f
|
||||
}
|
||||
|
||||
filename := sanitizeFilename(playlist.Name) + ".m3u8"
|
||||
w.Header().Set("Content-Type", "audio/x-mpegurl; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
|
||||
buf := bufio.NewWriter(w)
|
||||
defer buf.Flush()
|
||||
buf.WriteString("#EXTM3U\n")
|
||||
for _, e := range entries {
|
||||
song, err := database.GetSong(h.DB, e.SongID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
mf, err := database.GetMediaFile(h.DB, song.MediaFileID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
folder, ok := folderByID[mf.MusicFolderID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
artist := ""
|
||||
if a := albumOf(h.DB, song); a != nil && a.PrimaryAlbumArtistID.Valid {
|
||||
if art, err := database.GetArtist(h.DB, a.PrimaryAlbumArtistID.String); err == nil {
|
||||
artist = art.Name
|
||||
}
|
||||
}
|
||||
title := song.Title
|
||||
if artist != "" {
|
||||
title = artist + " - " + title
|
||||
}
|
||||
fmt.Fprintf(buf, "#EXTINF:%d,%s\n", int(mf.DurationSeconds), title)
|
||||
buf.WriteString(filepath.Join(folder.Path, mf.RelativePath))
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// handlePlaylistImportForm renders the upload form.
|
||||
func (h *Web) handlePlaylistImportForm(w http.ResponseWriter, r *http.Request) {
|
||||
h.page(w, r, "playlist_import", "playlists", "Import Playlist", nil)
|
||||
}
|
||||
|
||||
// playlistImportResult is the summary shown after an import: how many
|
||||
// M3U entries matched a track in the library versus were skipped.
|
||||
type playlistImportResult struct {
|
||||
Playlist *database.Playlist
|
||||
Matched int
|
||||
Skipped int
|
||||
}
|
||||
|
||||
// handlePlaylistImport accepts an uploaded .m3u/.m3u8 file, resolves each
|
||||
// entry to a song already known to the library (matching by absolute
|
||||
// path against a scanned music folder's root, falling back to a
|
||||
// filename-only match within that folder), and creates a new playlist
|
||||
// from whatever matched. Unmatched lines are reported, not treated as an
|
||||
// error — a partial match is still useful to the user.
|
||||
func (h *Web) handlePlaylistImport(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
if err := r.ParseMultipartForm(4 << 20); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, "no file uploaded", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
folders, err := database.ListMusicFolders(h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
|
||||
if name == "" {
|
||||
name = "Imported Playlist"
|
||||
}
|
||||
playlist, err := database.CreatePlaylist(h.DB, user.ID, name, "", false)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
matched, skipped := 0, 0
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
song := resolvePlaylistImportLine(h.DB, folders, line)
|
||||
if song == nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if err := database.AppendPlaylistEntry(h.DB, playlist.ID, song.ID); err != nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
matched++
|
||||
}
|
||||
|
||||
h.page(w, r, "playlist_import_result", "playlists", "Import Result", playlistImportResult{
|
||||
Playlist: playlist,
|
||||
Matched: matched,
|
||||
Skipped: skipped,
|
||||
})
|
||||
}
|
||||
|
||||
// resolvePlaylistImportLine matches one M3U entry (an absolute path, or a
|
||||
// path relative to a music folder root) to a known song.
|
||||
func resolvePlaylistImportLine(db *sql.DB, folders []*database.MusicFolder, line string) *database.Song {
|
||||
for _, folder := range folders {
|
||||
rel := line
|
||||
if strings.HasPrefix(line, folder.Path) {
|
||||
rel = strings.TrimPrefix(line, folder.Path)
|
||||
rel = strings.TrimPrefix(rel, string(filepath.Separator))
|
||||
} else if filepath.IsAbs(line) {
|
||||
continue // absolute path under a different folder root: not a match here
|
||||
}
|
||||
mf, err := database.FindMediaFileByPath(db, folder.ID, rel)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
song, err := database.GetSongByMediaFileID(db, mf.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
return song
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeFilename(name string) string {
|
||||
name = strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '/', '\\', ':', '*', '?', '"', '<', '>', '|':
|
||||
return '_'
|
||||
}
|
||||
return r
|
||||
}, name)
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "playlist"
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
// seedPlaylistFixture creates a logged-in user with one song in a music
|
||||
// folder, matching the shape seedShareFixture uses elsewhere in this
|
||||
// package.
|
||||
func seedPlaylistFixture(t *testing.T, db *sql.DB) (folderPath string, mediaFile *database.MediaFile, song *database.Song) {
|
||||
t.Helper()
|
||||
folderPath = t.TempDir()
|
||||
folder, err := database.CreateMusicFolder(db, "Music", folderPath, "music")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mf, err := database.CreateMediaFile(db, folder.ID, "artist/album/track.mp3", 100, 0, "hash1", "mp3", "song", 123, 128, 44100, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := database.UpsertSong(db, database.UpsertSongInput{MediaFileID: mf.ID, Title: "Test Track"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return folderPath, mf, s
|
||||
}
|
||||
|
||||
func TestPlaylistExportProducesValidM3U(t *testing.T) {
|
||||
h, db := newTestWeb(t)
|
||||
srv := httptest.NewServer(h.NewRouter())
|
||||
defer srv.Close()
|
||||
|
||||
jar := map[string]*http.Cookie{}
|
||||
doSetup(t, srv, jar, t.TempDir())
|
||||
|
||||
folderPath, mf, song := seedPlaylistFixture(t, db)
|
||||
|
||||
user, err := database.GetUserByUsername(db, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
playlist, err := database.CreatePlaylist(db, user.ID, "My Mix", "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.AppendPlaylistEntry(db, playlist.ID, song.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp := doRequest(t, srv, jar, http.MethodGet, "/playlists/"+playlist.ID+"/export", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET export = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
text := string(body)
|
||||
if !strings.HasPrefix(text, "#EXTM3U\n") {
|
||||
t.Fatalf("export missing #EXTM3U header: %q", text)
|
||||
}
|
||||
wantPath := filepath.Join(folderPath, mf.RelativePath)
|
||||
if !strings.Contains(text, wantPath) {
|
||||
t.Fatalf("export = %q, want it to contain track path %q", text, wantPath)
|
||||
}
|
||||
if !strings.Contains(text, "Test Track") {
|
||||
t.Fatalf("export = %q, want it to contain track title", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistExportRequiresOwnershipOrPublic(t *testing.T) {
|
||||
h, db := newTestWeb(t)
|
||||
srv := httptest.NewServer(h.NewRouter())
|
||||
defer srv.Close()
|
||||
|
||||
adminJar := map[string]*http.Cookie{}
|
||||
doSetup(t, srv, adminJar, t.TempDir())
|
||||
seedPlaylistFixture(t, db)
|
||||
|
||||
owner, err := database.CreateUser(db, "owner", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
playlist, err := database.CreatePlaylist(db, owner.ID, "Private", "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp := doRequest(t, srv, adminJar, http.MethodGet, "/playlists/"+playlist.ID+"/export", nil)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("GET export of another user's private playlist = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistImportMatchesTracksByPath(t *testing.T) {
|
||||
h, db := newTestWeb(t)
|
||||
srv := httptest.NewServer(h.NewRouter())
|
||||
defer srv.Close()
|
||||
|
||||
jar := map[string]*http.Cookie{}
|
||||
doSetup(t, srv, jar, t.TempDir())
|
||||
|
||||
folderPath, mf, _ := seedPlaylistFixture(t, db)
|
||||
trackPath := filepath.Join(folderPath, mf.RelativePath)
|
||||
|
||||
m3u := "#EXTM3U\n#EXTINF:123,Some Artist - Test Track\n" + trackPath + "\nunmatched/nonexistent.mp3\n"
|
||||
|
||||
token := csrfToken(t, srv, jar, "/playlists/import")
|
||||
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
if err := mw.WriteField("csrf_token", token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fw, err := mw.CreateFormFile("file", "My Import.m3u8")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fw.Write([]byte(m3u)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mw.Close()
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, srv.URL+"/playlists/import", &body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
for _, c := range jar {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("POST import = %d, want 200; body: %s", resp.StatusCode, respBody)
|
||||
}
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
text := string(respBody)
|
||||
if !strings.Contains(text, "1") {
|
||||
t.Fatalf("import result page = %q, want it to mention 1 matched track", text)
|
||||
}
|
||||
|
||||
user, err := database.GetUserByUsername(db, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
playlists, err := database.ListPlaylistsByOwner(db, user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(playlists) != 1 {
|
||||
t.Fatalf("ListPlaylistsByOwner = %d playlists, want 1", len(playlists))
|
||||
}
|
||||
entries, err := database.ListPlaylistEntries(db, playlists[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("imported playlist has %d entries, want 1 (one unmatched line should be skipped)", len(entries))
|
||||
}
|
||||
}
|
||||
@@ -40,8 +40,11 @@ func (h *Web) NewRouter() http.Handler {
|
||||
|
||||
mux.HandleFunc("GET /playlists", h.requireUser(h.handlePlaylists))
|
||||
mux.HandleFunc("POST /playlists", h.requireUser(h.handlePlaylistCreate))
|
||||
mux.HandleFunc("GET /playlists/import", h.requireUser(h.handlePlaylistImportForm))
|
||||
mux.HandleFunc("POST /playlists/import", h.requireUser(h.handlePlaylistImport))
|
||||
mux.HandleFunc("GET /playlists/{id}", h.requireUser(h.handlePlaylist))
|
||||
mux.HandleFunc("POST /playlists/{id}/delete", h.requireUser(h.handlePlaylistDelete))
|
||||
mux.HandleFunc("GET /playlists/{id}/export", h.requireUser(h.handlePlaylistExport))
|
||||
|
||||
mux.HandleFunc("GET /favorites", h.requireUser(h.handleFavorites))
|
||||
mux.HandleFunc("POST /favorites/star", h.requireUser(h.handleFavoriteStar))
|
||||
|
||||
@@ -270,3 +270,71 @@ func TestLoginRejectsWrongPassword(t *testing.T) {
|
||||
t.Error("expected login failure message in response body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPermissionsCannotDemoteLastAdmin(t *testing.T) {
|
||||
h, db := newTestWeb(t)
|
||||
srv := httptest.NewServer(h.NewRouter())
|
||||
defer srv.Close()
|
||||
|
||||
adminJar := map[string]*http.Cookie{}
|
||||
doSetup(t, srv, adminJar, t.TempDir())
|
||||
|
||||
admin, err := database.GetUserByUsername(db, "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByUsername: %v", err)
|
||||
}
|
||||
|
||||
token := csrfToken(t, srv, adminJar, "/admin/users/"+admin.ID+"/permissions")
|
||||
resp := doRequest(t, srv, adminJar, http.MethodPost, "/admin/users/"+admin.ID+"/permissions", url.Values{
|
||||
"csrf_token": {token},
|
||||
// "admin" intentionally omitted: attempting to demote the only admin.
|
||||
"stream": {"1"},
|
||||
})
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("demote last admin = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
|
||||
got, err := database.GetUserByID(db, admin.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByID: %v", err)
|
||||
}
|
||||
if !got.IsAdmin {
|
||||
t.Fatal("last admin was demoted despite the guard")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPermissionsSetsMusicFolderRestriction(t *testing.T) {
|
||||
h, db := newTestWeb(t)
|
||||
srv := httptest.NewServer(h.NewRouter())
|
||||
defer srv.Close()
|
||||
|
||||
adminJar := map[string]*http.Cookie{}
|
||||
doSetup(t, srv, adminJar, t.TempDir())
|
||||
|
||||
user, err := database.CreateUser(db, "regular", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
folder, err := database.CreateMusicFolder(db, "Music", t.TempDir(), "music")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMusicFolder: %v", err)
|
||||
}
|
||||
|
||||
token := csrfToken(t, srv, adminJar, "/admin/users/"+user.ID+"/permissions")
|
||||
resp := doRequest(t, srv, adminJar, http.MethodPost, "/admin/users/"+user.ID+"/permissions", url.Values{
|
||||
"csrf_token": {token},
|
||||
"music_folders": {folder.ID},
|
||||
"stream": {"1"},
|
||||
})
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("save permissions = %d, want 302", resp.StatusCode)
|
||||
}
|
||||
|
||||
allowed, err := database.AllowedMusicFolderIDs(db, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("AllowedMusicFolderIDs: %v", err)
|
||||
}
|
||||
if len(allowed) != 1 || allowed[0] != folder.ID {
|
||||
t.Fatalf("AllowedMusicFolderIDs = %v, want [%s]", allowed, folder.ID)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user