package web import ( "context" "fmt" "net/http" "net/url" "time" "git.noroutes.net/NoRoutes/vibetunes/internal/auth" "git.noroutes.net/NoRoutes/vibetunes/internal/database" ) func (h *Web) handleAdminRoot(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/users", http.StatusFound) } // --- Users --- func (h *Web) handleAdminUsers(w http.ResponseWriter, r *http.Request) { users, err := database.ListUsers(h.DB) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } h.page(w, r, "admin_users", "admin_users", "Users", users) } func (h *Web) handleAdminUserCreate(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } username := r.FormValue("username") password := r.FormValue("password") isAdmin := r.FormValue("is_admin") == "1" if username == "" || len(password) < 8 { http.Redirect(w, r, "/admin/users", http.StatusFound) return } user, err := database.CreateUser(h.DB, username, "", "", isAdmin) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } if err := auth.SetUserPassword(h.DB, h.EncryptionKey, user.ID, password); err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } 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) if err != nil { http.NotFound(w, r) return } 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") target, err := database.GetUserByID(h.DB, id) if err != nil { http.NotFound(w, r) return } if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } get := func(name string) bool { return r.FormValue(name) == "1" } perms := database.Permissions{ Settings: get("settings"), Stream: get("stream"), Download: get("download"), Upload: get("upload"), Playlist: get("playlist"), CoverArt: get("cover_art"), Comment: get("comment"), Podcast: get("podcast"), Share: get("share"), Jukebox: get("jukebox"), VideoConversion: get("video_conversion"), } if err := database.SetUserPermissions(h.DB, id, perms); err != nil { 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) } func (h *Web) handleAdminUserToggleDisabled(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") target, err := database.GetUserByID(h.DB, id) if err != nil { http.NotFound(w, r) return } if err := database.SetUserDisabled(h.DB, id, !target.Disabled); err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } if !target.Disabled { // just disabled it: revoke sessions _ = database.DeleteUserSessions(h.DB, id) } http.Redirect(w, r, "/admin/users", http.StatusFound) } func (h *Web) handleAdminUserDelete(w http.ResponseWriter, r *http.Request) { current := auth.UserFromContext(r.Context()) id := r.PathValue("id") if id == current.ID { http.Error(w, "cannot delete your own account", http.StatusBadRequest) return } if err := database.DeleteUser(h.DB, id); err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } http.Redirect(w, r, "/admin/users", http.StatusFound) } // --- Music folders --- func (h *Web) handleAdminFolders(w http.ResponseWriter, r *http.Request) { folders, err := database.ListMusicFolders(h.DB) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } h.page(w, r, "admin_folders", "admin_folders", "Music Folders", folders) } func (h *Web) handleAdminFolderCreate(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } name := r.FormValue("name") path := r.FormValue("path") kind := r.FormValue("kind") if kind == "" { kind = "music" } if name == "" || path == "" { http.Redirect(w, r, "/admin/folders", http.StatusFound) return } folder, err := database.CreateMusicFolder(h.DB, name, path, kind) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } if h.Scanner != nil { go func() { _ = h.Scanner.ScanFolder(context.Background(), folder.ID) }() } http.Redirect(w, r, "/admin/folders", http.StatusFound) } func (h *Web) handleAdminFolderToggle(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") folder, err := database.GetMusicFolder(h.DB, id) if err != nil { http.NotFound(w, r) return } if err := database.SetMusicFolderEnabled(h.DB, id, !folder.Enabled); err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } http.Redirect(w, r, "/admin/folders", http.StatusFound) } func (h *Web) handleAdminFolderDelete(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if err := database.DeleteMusicFolder(h.DB, id); err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } http.Redirect(w, r, "/admin/folders", http.StatusFound) } func (h *Web) handleAdminFolderScan(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if _, err := database.GetMusicFolder(h.DB, id); err != nil { http.NotFound(w, r) return } if h.Scanner != nil { go func() { _ = h.Scanner.ScanFolder(context.Background(), id) }() } 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, dest, http.StatusFound) } // --- Scanner status --- type folderScanStatus struct { Folder *database.MusicFolder State *database.ScanState } func (h *Web) handleAdminScanner(w http.ResponseWriter, r *http.Request) { folders, err := database.ListMusicFolders(h.DB) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } var out []folderScanStatus for _, f := range folders { state, err := database.GetScanState(h.DB, f.ID) if err != nil { continue } out = append(out, folderScanStatus{Folder: f, State: state}) } h.page(w, r, "admin_scanner", "admin_scanner", "Scanner", out) } // --- System settings --- func (h *Web) handleAdminSettings(w http.ResponseWriter, r *http.Request) { h.renderAdminSettings(w, r, "", "", nil) } func (h *Web) renderAdminSettings(w http.ResponseWriter, r *http.Request, flash, kind string, photoResults []ArtistPhotoOutcome) { trusted := "" for i, p := range h.Config.ExtAuth.TrustedSources { if i > 0 { trusted += ", " } trusted += p.String() } h.flash(w, r, "admin_settings", "admin_settings", "System Settings", flash, kind, struct { ScanInterval string FFmpegPath string FFprobePath string ExtAuthEnabled bool ExtAuthTrustedSources string ExtAuthHeader string ArtistPhotosEnabled bool ArtistPhotoResults []ArtistPhotoOutcome }{ ScanInterval: database.GetSetting(h.DB, "scan_interval", h.Config.ScanInterval.String()), FFmpegPath: database.GetSetting(h.DB, "ffmpeg_path", h.Config.FFmpegPath), FFprobePath: database.GetSetting(h.DB, "ffprobe_path", h.Config.FFprobePath), ExtAuthEnabled: h.Config.ExtAuth.Enabled, ExtAuthTrustedSources: trusted, ExtAuthHeader: h.Config.ExtAuth.UserHeader, ArtistPhotosEnabled: h.Config.EnableArtistPhotos, ArtistPhotoResults: photoResults, }) } func (h *Web) handleAdminSettingsUpdate(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } interval := r.FormValue("scan_interval") if _, err := time.ParseDuration(interval); err != nil { h.renderAdminSettings(w, r, "Invalid scan interval: "+err.Error(), "danger", nil) return } _ = database.SetSetting(h.DB, "scan_interval", interval) _ = database.SetSetting(h.DB, "ffmpeg_path", r.FormValue("ffmpeg_path")) _ = database.SetSetting(h.DB, "ffprobe_path", r.FormValue("ffprobe_path")) h.renderAdminSettings(w, r, "Settings saved. Some changes take effect after a restart.", "success", nil) } // handleAdminArtistPhotosRefresh runs a synchronous, admin-triggered // fetch of every eligible artist's photo and reports the outcome for // each one — including failures — rather than the silent best-effort // background fetch normally used when an artist's page is viewed. func (h *Web) handleAdminArtistPhotosRefresh(w http.ResponseWriter, r *http.Request) { // Unlike the best-effort background fetch on an artist's page (gated // by EnableArtistPhotos, since that one runs unattended on every // visit), this is an explicit, one-off admin action — it runs // regardless of the config toggle so it doubles as a way to check // what's actually happening (bad network, no confident name match, // an API error) without editing the environment first. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() results, err := h.RefreshAllArtistPhotos(ctx) if err != nil { h.renderAdminSettings(w, r, "Artist photo refresh failed: "+err.Error(), "danger", nil) return } var fetched, notFound, skipped, failed int for _, res := range results { switch { case res.Error != "": failed++ case res.Status == "fetched": fetched++ case res.Status == "no photo found": notFound++ default: skipped++ } } kind := "success" if failed > 0 { kind = "warning" } flash := fmt.Sprintf("Artist photo refresh complete: %d fetched, %d no photo found, %d skipped, %d failed (see below).", fetched, notFound, skipped, failed) h.renderAdminSettings(w, r, flash, kind, results) }