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:
@@ -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 ---
|
||||
|
||||
Reference in New Issue
Block a user