package web import ( "database/sql" "io" "net/http" "strconv" "strings" "git.noroutes.net/NoRoutes/vibetunes/internal/database" "git.noroutes.net/NoRoutes/vibetunes/internal/netguard" ) // --- Artists --- func (h *Web) handleAdminArtistEdit(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") artist, err := database.GetArtist(h.DB, id) if err != nil { http.NotFound(w, r) return } h.page(w, r, "admin_artist_edit", "artists", "Edit artist: "+artist.Name, artist) } func (h *Web) handleAdminArtistUpdate(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if _, err := database.GetArtist(h.DB, id); err != nil { http.NotFound(w, r) return } if err := r.ParseMultipartForm(maxArtistPhotoBytes); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } if err := database.UpdateArtist(h.DB, id, r.FormValue("name"), r.FormValue("sort_name"), r.FormValue("bio")); err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } switch { case r.FormValue("remove_photo") == "1": if err := database.SetArtistCoverArt(h.DB, id, sql.NullString{}, false); err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } default: if !h.applyPhotoOverride(w, r, "photo_file", "photo_url", func(localPath, contentHash, mimeType string) error { _, err := database.UpsertArtistCoverArt(h.DB, id, "external", localPath, mimeType, contentHash, 0, 0, true) return err }) { return } } http.Redirect(w, r, "/artists/"+id, http.StatusFound) } // --- Albums --- func (h *Web) handleAdminAlbumEdit(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") album, err := database.GetAlbum(h.DB, id) if err != nil { http.NotFound(w, r) return } h.page(w, r, "admin_album_edit", "albums", "Edit album: "+album.Title, album) } func (h *Web) handleAdminAlbumUpdate(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if _, err := database.GetAlbum(h.DB, id); err != nil { http.NotFound(w, r) return } if err := r.ParseMultipartForm(maxArtistPhotoBytes); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } if err := database.UpdateAlbum(h.DB, id, r.FormValue("title"), r.FormValue("sort_title"), formNullInt(r, "year"), formNullInt(r, "original_year"), r.FormValue("record_label")); err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } if !h.applyPhotoOverride(w, r, "cover_file", "cover_url", func(localPath, contentHash, mimeType string) error { _, err := database.UpsertAlbumCoverArt(h.DB, id, "external", localPath, "", mimeType, contentHash, 0, 0) return err }) { return } http.Redirect(w, r, "/albums/"+id, http.StatusFound) } // --- Songs --- func (h *Web) handleAdminSongEdit(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") song, err := database.GetSong(h.DB, id) if err != nil { http.NotFound(w, r) return } h.page(w, r, "admin_song_edit", "songs", "Edit song: "+song.Title, song) } func (h *Web) handleAdminSongUpdate(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if _, err := database.GetSong(h.DB, id); err != nil { http.NotFound(w, r) return } if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } locked := r.FormValue("metadata_locked") == "1" if err := database.UpdateSongMetadata(h.DB, id, r.FormValue("title"), r.FormValue("sort_title"), formNullInt(r, "track_number"), formNullInt(r, "disc_number"), r.FormValue("composer"), r.FormValue("conductor"), locked); err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } http.Redirect(w, r, "/songs", http.StatusFound) } // --- shared helpers --- // formNullInt parses a form field as an integer, returning an invalid // sql.NullInt64 when the field is blank or unparsable rather than // erroring the request — the same tolerant behavior as the rest of // VibeSonic's admin forms. func formNullInt(r *http.Request, name string) sql.NullInt64 { raw := strings.TrimSpace(r.FormValue(name)) if raw == "" { return sql.NullInt64{} } n, err := strconv.ParseInt(raw, 10, 64) if err != nil { return sql.NullInt64{} } return sql.NullInt64{Int64: n, Valid: true} } // applyPhotoOverride implements the common "upload a file, or give a // URL, or leave the existing photo/cover alone" logic shared by the // artist and album edit forms. store is called with the resulting local // file's path/hash/mime once one is available. Returns false (having // already written an HTTP error response) on failure. func (h *Web) applyPhotoOverride(w http.ResponseWriter, r *http.Request, fileField, urlField string, store func(localPath, contentHash, mimeType string) error) bool { if file, _, err := r.FormFile(fileField); err == nil { defer file.Close() body, err := io.ReadAll(io.LimitReader(file, maxArtistPhotoBytes+1)) if err != nil { http.Error(w, "could not read uploaded image", http.StatusBadRequest) return false } if len(body) > maxArtistPhotoBytes { http.Error(w, "uploaded image exceeds maximum allowed size", http.StatusBadRequest) return false } localPath, contentHash, mimeType, err := h.storeImageBytes(body) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return false } if err := store(localPath, contentHash, mimeType); err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return false } return true } photoURL := strings.TrimSpace(r.FormValue(urlField)) if photoURL == "" { return true } if err := netguard.ValidateURL(photoURL); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return false } localPath, contentHash, mimeType, err := h.downloadImage(r.Context(), photoURL) if err != nil { http.Error(w, "could not download image: "+err.Error(), http.StatusBadRequest) return false } if err := store(localPath, contentHash, mimeType); err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return false } return true }