Web UI: setup wizard, auth pages, library browsing, player, admin, streaming, cover art
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"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
|
||||
}
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := database.CreateUser(h.DB, username, "", hash, isAdmin); 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) }()
|
||||
}
|
||||
referer := r.Header.Get("Referer")
|
||||
if referer == "" {
|
||||
referer = "/admin/scanner"
|
||||
}
|
||||
http.Redirect(w, r, referer, 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, "", "")
|
||||
}
|
||||
|
||||
func (h *Web) renderAdminSettings(w http.ResponseWriter, r *http.Request, flash, kind string) {
|
||||
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
|
||||
}{
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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")
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
func (h *Web) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
if h.Sessions.CurrentUser(r) != nil {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
render(w, "login", PageData{
|
||||
Title: "Log in",
|
||||
CSRFToken: h.CSRF.Token(w, r),
|
||||
Data: struct{ Next string }{Next: r.URL.Query().Get("next")},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Web) handleLogin(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")
|
||||
next := r.FormValue("next")
|
||||
|
||||
user, err := database.GetUserByUsername(h.DB, username)
|
||||
if err != nil || user.Disabled || !user.PasswordHash.Valid {
|
||||
h.loginFailed(w, r, next)
|
||||
return
|
||||
}
|
||||
ok, err := auth.VerifyPassword(password, user.PasswordHash.String)
|
||||
if err != nil || !ok {
|
||||
h.loginFailed(w, r, next)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Sessions.Issue(w, r, user.ID); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = database.TouchLastLogin(h.DB, user.ID)
|
||||
|
||||
if next == "" || next[0] != '/' {
|
||||
next = "/"
|
||||
}
|
||||
http.Redirect(w, r, next, http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Web) loginFailed(w http.ResponseWriter, r *http.Request, next string) {
|
||||
render(w, "login", PageData{
|
||||
Title: "Log in",
|
||||
CSRFToken: h.CSRF.Token(w, r),
|
||||
Flash: "Invalid username or password",
|
||||
FlashKind: "danger",
|
||||
Data: struct{ Next string }{Next: next},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Web) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
h.Sessions.Clear(w, r)
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
|
||||
// --- First-run setup ---
|
||||
|
||||
func (h *Web) handleSetupPage(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := database.CountUsers(h.DB)
|
||||
if err == nil && n > 0 {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
render(w, "setup", PageData{
|
||||
Title: "Setup",
|
||||
CSRFToken: h.CSRF.Token(w, r),
|
||||
Data: h.setupData(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Web) setupData() any {
|
||||
ffmpegPath := resolveOr(h.Config.FFmpegPath, "ffmpeg")
|
||||
ffprobePath := resolveOr(h.Config.FFprobePath, "ffprobe")
|
||||
_, ffmpegErr := exec.LookPath(ffmpegPath)
|
||||
_, ffprobeErr := exec.LookPath(ffprobePath)
|
||||
return struct {
|
||||
FFmpegPath string
|
||||
FFmpegFound bool
|
||||
FFprobePath string
|
||||
FFprobeFound bool
|
||||
}{
|
||||
FFmpegPath: ffmpegPath,
|
||||
FFmpegFound: ffmpegErr == nil,
|
||||
FFprobePath: ffprobePath,
|
||||
FFprobeFound: ffprobeErr == nil,
|
||||
}
|
||||
}
|
||||
|
||||
func resolveOr(configured, def string) string {
|
||||
if configured != "" {
|
||||
return configured
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func (h *Web) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := database.CountUsers(h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
folderName := r.FormValue("folder_name")
|
||||
folderPath := r.FormValue("folder_path")
|
||||
|
||||
if username == "" || len(password) < 8 || folderPath == "" {
|
||||
render(w, "setup", PageData{
|
||||
Title: "Setup", CSRFToken: h.CSRF.Token(w, r),
|
||||
Flash: "Please fill in all fields (password must be at least 8 characters)", FlashKind: "danger",
|
||||
Data: h.setupData(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
user, err := database.CreateUser(h.DB, username, "", hash, true)
|
||||
if err != nil {
|
||||
render(w, "setup", PageData{
|
||||
Title: "Setup", CSRFToken: h.CSRF.Token(w, r),
|
||||
Flash: "Could not create the administrator account: " + err.Error(), FlashKind: "danger",
|
||||
Data: h.setupData(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
folder, err := database.CreateMusicFolder(h.DB, folderName, folderPath, "music")
|
||||
if err != nil {
|
||||
render(w, "setup", PageData{
|
||||
Title: "Setup", CSRFToken: h.CSRF.Token(w, r),
|
||||
Flash: "Administrator account created, but the music folder could not be added: " + err.Error(), FlashKind: "warning",
|
||||
Data: h.setupData(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Sessions.Issue(w, r, user.ID); 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, "/", http.StatusFound)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/metadata"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/streaming"
|
||||
)
|
||||
|
||||
const maxCoverArtSize = 1200
|
||||
|
||||
// handleCoverArt serves a resized cover image, generating and caching it
|
||||
// on first request. Authorization: the caller must already be signed in
|
||||
// (enforced by the router's RequireUser wrapper on this route).
|
||||
func (h *Web) handleCoverArt(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
size := clampSize(r.URL.Query().Get("size"))
|
||||
|
||||
art, err := database.GetCoverArt(h.DB, id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
sourcePath, contentHash, isTemp, err := h.resolveCoverArtSource(r.Context(), art)
|
||||
if err != nil {
|
||||
http.Error(w, "cover art unavailable", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if isTemp {
|
||||
defer os.Remove(sourcePath)
|
||||
}
|
||||
|
||||
cachedPath, err := h.CoverArt.GetOrCreate(sourcePath, contentHash, size)
|
||||
if err != nil {
|
||||
http.Error(w, "could not generate cover art", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
|
||||
if err := streaming.ServeFile(w, r, cachedPath, "cover.jpg"); err != nil {
|
||||
http.Error(w, "could not read cover art", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveCoverArtSource returns a plain image file path for a cover_art
|
||||
// row, extracting embedded artwork to a temp file on demand. isTemp
|
||||
// tells the caller whether it must remove the returned path afterward.
|
||||
func (h *Web) resolveCoverArtSource(ctx context.Context, art *database.CoverArt) (path, contentHash string, isTemp bool, err error) {
|
||||
if art.Source == "folder" {
|
||||
return art.SourcePath, art.ContentHash, false, nil
|
||||
}
|
||||
|
||||
// Embedded: extract the attached-picture stream from the source
|
||||
// media file each time it's needed for resizing (the resize cache
|
||||
// below avoids repeating this on every request).
|
||||
mf, err := database.GetMediaFile(h.DB, art.MediaFileID.String)
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
folder, err := database.GetMusicFolder(h.DB, mf.MusicFolderID)
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
absPath, err := streaming.ResolvePath(folder.Path, mf.RelativePath)
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
|
||||
prober := &metadata.Prober{FFprobePath: h.Config.FFprobePath}
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
tags, err := prober.Probe(ctx, absPath)
|
||||
if err != nil || !tags.HasEmbeddedArt {
|
||||
return "", "", false, err
|
||||
}
|
||||
|
||||
extracted, err := h.CoverArt.ExtractEmbedded(ctx, absPath, tags.EmbeddedArtStream)
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
return extracted, art.ContentHash, true, nil
|
||||
}
|
||||
|
||||
func clampSize(raw string) int {
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n <= 0 {
|
||||
return 300
|
||||
}
|
||||
if n > maxCoverArtSize {
|
||||
return maxCoverArtSize
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package web
|
||||
|
||||
import "net/http"
|
||||
|
||||
func (h *Web) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
func (h *Web) handleReadyz(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.DB.PingContext(r.Context()); err != nil {
|
||||
http.Error(w, "database unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
const pageSize = 60
|
||||
|
||||
func (h *Web) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
|
||||
albums, err := database.ListAlbums(h.DB, 12, 0)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var recentlyAdded []AlbumView
|
||||
for _, a := range albums {
|
||||
recentlyAdded = append(recentlyAdded, toAlbumView(h.DB, a))
|
||||
}
|
||||
|
||||
var recentlyPlayedAlbums []AlbumView
|
||||
if user != nil {
|
||||
history, err := database.ListRecentlyPlayed(h.DB, user.ID, 30)
|
||||
if err == nil {
|
||||
seen := map[string]bool{}
|
||||
for _, entry := range history {
|
||||
song, err := database.GetSong(h.DB, entry.SongID)
|
||||
if err != nil || !song.AlbumID.Valid || seen[song.AlbumID.String] {
|
||||
continue
|
||||
}
|
||||
seen[song.AlbumID.String] = true
|
||||
album, err := database.GetAlbum(h.DB, song.AlbumID.String)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
recentlyPlayedAlbums = append(recentlyPlayedAlbums, toAlbumView(h.DB, album))
|
||||
if len(recentlyPlayedAlbums) >= 12 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasLibrary := len(recentlyAdded) > 0
|
||||
h.page(w, r, "home", "home", "Home", struct {
|
||||
HasLibrary bool
|
||||
RecentlyAdded []AlbumView
|
||||
RecentlyPlayedAlbums []AlbumView
|
||||
}{hasLibrary, recentlyAdded, recentlyPlayedAlbums})
|
||||
}
|
||||
|
||||
func (h *Web) handleArtists(w http.ResponseWriter, r *http.Request) {
|
||||
artists, err := database.ListArtists(h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var views []ArtistView
|
||||
for _, a := range artists {
|
||||
views = append(views, toArtistView(a))
|
||||
}
|
||||
h.page(w, r, "artists", "artists", "Artists", views)
|
||||
}
|
||||
|
||||
func (h *Web) handleArtist(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
|
||||
}
|
||||
albums, err := database.ListAlbumsByArtist(h.DB, id)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var views []AlbumView
|
||||
for _, a := range albums {
|
||||
views = append(views, toAlbumView(h.DB, a))
|
||||
}
|
||||
h.page(w, r, "artist", "artists", artist.Name, struct {
|
||||
Artist *database.Artist
|
||||
Albums []AlbumView
|
||||
}{artist, views})
|
||||
}
|
||||
|
||||
func (h *Web) handleAlbums(w http.ResponseWriter, r *http.Request) {
|
||||
offset := parseOffset(r)
|
||||
albums, err := database.ListAlbums(h.DB, pageSize+1, offset)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
hasMore := len(albums) > pageSize
|
||||
if hasMore {
|
||||
albums = albums[:pageSize]
|
||||
}
|
||||
var views []AlbumView
|
||||
for _, a := range albums {
|
||||
views = append(views, toAlbumView(h.DB, a))
|
||||
}
|
||||
h.page(w, r, "albums", "albums", "Albums", struct {
|
||||
Albums []AlbumView
|
||||
HasMore bool
|
||||
NextOffset int
|
||||
}{views, hasMore, offset + pageSize})
|
||||
}
|
||||
|
||||
func (h *Web) handleAlbum(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id := r.PathValue("id")
|
||||
album, err := database.GetAlbum(h.DB, id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
songs, err := database.ListSongsByAlbum(h.DB, id)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
userID := ""
|
||||
if user != nil {
|
||||
userID = user.ID
|
||||
}
|
||||
var rows []SongRow
|
||||
for _, s := range songs {
|
||||
rows = append(rows, toSongRow(h.DB, s, album, userID))
|
||||
}
|
||||
starred := false
|
||||
if user != nil {
|
||||
starred, _ = database.IsStarred(h.DB, user.ID, "album", album.ID)
|
||||
}
|
||||
h.page(w, r, "album", "albums", album.Title, struct {
|
||||
Album AlbumView
|
||||
Songs []SongRow
|
||||
Starred bool
|
||||
}{toAlbumView(h.DB, album), rows, starred})
|
||||
}
|
||||
|
||||
func (h *Web) handleSongs(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
offset := parseOffset(r)
|
||||
songs, err := database.ListSongs(h.DB, pageSize+1, offset)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
hasMore := len(songs) > pageSize
|
||||
if hasMore {
|
||||
songs = songs[:pageSize]
|
||||
}
|
||||
userID := ""
|
||||
if user != nil {
|
||||
userID = user.ID
|
||||
}
|
||||
var rows []SongRow
|
||||
for _, s := range songs {
|
||||
rows = append(rows, toSongRow(h.DB, s, albumOf(h.DB, s), userID))
|
||||
}
|
||||
h.page(w, r, "songs", "songs", "Songs", struct {
|
||||
Songs []SongRow
|
||||
HasMore bool
|
||||
NextOffset int
|
||||
}{rows, hasMore, offset + pageSize})
|
||||
}
|
||||
|
||||
func (h *Web) handleGenres(w http.ResponseWriter, r *http.Request) {
|
||||
genres, err := database.ListGenres(h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.page(w, r, "genres", "genres", "Genres", genres)
|
||||
}
|
||||
|
||||
func (h *Web) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
|
||||
var artistViews []ArtistView
|
||||
var albumViews []AlbumView
|
||||
var songRows []SongRow
|
||||
|
||||
if query != "" {
|
||||
like := "%" + query + "%"
|
||||
|
||||
artistRows, _ := h.DB.Query(`SELECT DISTINCT a.id, a.name FROM artists a JOIN album_artists aa ON aa.artist_id = a.id WHERE a.name LIKE ? ORDER BY a.name LIMIT 20`, like)
|
||||
if artistRows != nil {
|
||||
defer artistRows.Close()
|
||||
for artistRows.Next() {
|
||||
var id, name string
|
||||
if artistRows.Scan(&id, &name) == nil {
|
||||
artistViews = append(artistViews, ArtistView{ID: id, Name: name})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
albumRows, _ := h.DB.Query(`SELECT id FROM albums WHERE title LIKE ? ORDER BY title LIMIT 20`, like)
|
||||
if albumRows != nil {
|
||||
defer albumRows.Close()
|
||||
for albumRows.Next() {
|
||||
var id string
|
||||
if albumRows.Scan(&id) == nil {
|
||||
if album, err := database.GetAlbum(h.DB, id); err == nil {
|
||||
albumViews = append(albumViews, toAlbumView(h.DB, album))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
songIDRows, _ := h.DB.Query(`SELECT id FROM songs WHERE title LIKE ? ORDER BY title LIMIT 40`, like)
|
||||
if songIDRows != nil {
|
||||
defer songIDRows.Close()
|
||||
userID := ""
|
||||
if user != nil {
|
||||
userID = user.ID
|
||||
}
|
||||
for songIDRows.Next() {
|
||||
var id string
|
||||
if songIDRows.Scan(&id) == nil {
|
||||
if song, err := database.GetSong(h.DB, id); err == nil {
|
||||
songRows = append(songRows, toSongRow(h.DB, song, albumOf(h.DB, song), userID))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h.page(w, r, "search", "search", "Search", struct {
|
||||
Query string
|
||||
Artists []ArtistView
|
||||
Albums []AlbumView
|
||||
Songs []SongRow
|
||||
}{query, artistViews, albumViews, songRows})
|
||||
}
|
||||
|
||||
func parseOffset(r *http.Request) int {
|
||||
n, err := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
if err != nil || n < 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
func (h *Web) handlePlaylists(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
playlists, err := database.ListPlaylistsVisibleToUser(h.DB, user.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.page(w, r, "playlists", "playlists", "Playlists", playlists)
|
||||
}
|
||||
|
||||
func (h *Web) handlePlaylistCreate(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
if name == "" {
|
||||
http.Redirect(w, r, "/playlists", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if _, err := database.CreatePlaylist(h.DB, user.ID, name, "", false); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/playlists", http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Web) handlePlaylist(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
|
||||
}
|
||||
var rows []SongRow
|
||||
for _, e := range entries {
|
||||
song, err := database.GetSong(h.DB, e.SongID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), user.ID))
|
||||
}
|
||||
h.page(w, r, "playlist", "playlists", playlist.Name, struct {
|
||||
Playlist *database.Playlist
|
||||
Songs []SongRow
|
||||
Owned bool
|
||||
}{playlist, rows, playlist.OwnerID == user.ID})
|
||||
}
|
||||
|
||||
func (h *Web) handlePlaylistDelete(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 {
|
||||
http.Error(w, "403 Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := database.DeletePlaylist(h.DB, id); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/playlists", http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Web) handleFavorites(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
|
||||
artistIDs, _ := database.ListStarredIDs(h.DB, user.ID, "artist")
|
||||
var artists []ArtistView
|
||||
for _, id := range artistIDs {
|
||||
if a, err := database.GetArtist(h.DB, id); err == nil {
|
||||
artists = append(artists, toArtistView(a))
|
||||
}
|
||||
}
|
||||
|
||||
albumIDs, _ := database.ListStarredIDs(h.DB, user.ID, "album")
|
||||
var albums []AlbumView
|
||||
for _, id := range albumIDs {
|
||||
if a, err := database.GetAlbum(h.DB, id); err == nil {
|
||||
albums = append(albums, toAlbumView(h.DB, a))
|
||||
}
|
||||
}
|
||||
|
||||
songIDs, _ := database.ListStarredIDs(h.DB, user.ID, "song")
|
||||
var songs []SongRow
|
||||
for _, id := range songIDs {
|
||||
if s, err := database.GetSong(h.DB, id); err == nil {
|
||||
songs = append(songs, toSongRow(h.DB, s, albumOf(h.DB, s), user.ID))
|
||||
}
|
||||
}
|
||||
|
||||
h.page(w, r, "favorites", "favorites", "Favorites", struct {
|
||||
Artists []ArtistView
|
||||
Albums []AlbumView
|
||||
Songs []SongRow
|
||||
}{artists, albums, songs})
|
||||
}
|
||||
|
||||
func (h *Web) handleFavoriteStar(w http.ResponseWriter, r *http.Request) { h.setFavorite(w, r, true) }
|
||||
func (h *Web) handleFavoriteUnstar(w http.ResponseWriter, r *http.Request) {
|
||||
h.setFavorite(w, r, false)
|
||||
}
|
||||
|
||||
func (h *Web) setFavorite(w http.ResponseWriter, r *http.Request, star bool) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
itemType := r.FormValue("item_type")
|
||||
itemID := r.FormValue("item_id")
|
||||
next := r.FormValue("next")
|
||||
if next == "" || next[0] != '/' {
|
||||
next = "/"
|
||||
}
|
||||
switch itemType {
|
||||
case "song", "album", "artist":
|
||||
default:
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var err error
|
||||
if star {
|
||||
err = database.Star(h.DB, user.ID, itemType, itemID)
|
||||
} else {
|
||||
err = database.Unstar(h.DB, user.ID, itemType, itemID)
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, next, http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Web) handleRecent(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
history, err := database.ListRecentlyPlayed(h.DB, user.ID, 100)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var rows []SongRow
|
||||
seen := map[string]bool{}
|
||||
for _, entry := range history {
|
||||
if seen[entry.SongID] {
|
||||
continue
|
||||
}
|
||||
seen[entry.SongID] = true
|
||||
song, err := database.GetSong(h.DB, entry.SongID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), user.ID))
|
||||
}
|
||||
h.page(w, r, "recent", "recent", "Recently Played", rows)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
assets "git.noroutes.net/NoRoutes/vibetunes/web"
|
||||
)
|
||||
|
||||
var funcMap = template.FuncMap{
|
||||
"fmtDuration": func(seconds float64) string {
|
||||
if seconds <= 0 {
|
||||
return "-"
|
||||
}
|
||||
d := time.Duration(seconds) * time.Second
|
||||
m := int(d.Minutes())
|
||||
s := int(d.Seconds()) % 60
|
||||
return fmt.Sprintf("%d:%02d", m, s)
|
||||
},
|
||||
"fmtTime": func(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "-"
|
||||
}
|
||||
return t.Format("2006-01-02 15:04")
|
||||
},
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
}
|
||||
|
||||
var templates *template.Template
|
||||
|
||||
func init() {
|
||||
t, err := template.New("").Funcs(funcMap).ParseFS(assets.FS, "templates/*.html", "templates/partials/*.html")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
templates = t
|
||||
}
|
||||
|
||||
// PageData is the common envelope passed to every page template.
|
||||
type PageData struct {
|
||||
Active string // nav item key for sidebar highlighting
|
||||
Title string
|
||||
User *database.User
|
||||
CSRFToken string
|
||||
Flash string
|
||||
FlashKind string // success|danger|warning
|
||||
Data any
|
||||
}
|
||||
|
||||
func render(w http.ResponseWriter, name string, data PageData) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := templates.ExecuteTemplate(w, name, data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
assets "git.noroutes.net/NoRoutes/vibetunes/web"
|
||||
)
|
||||
|
||||
// NewRouter builds VibeSonic's full HTTP handler: static assets, health
|
||||
// checks, first-run setup, authentication, and the authenticated
|
||||
// application (library browsing, playback, playlists, favorites,
|
||||
// settings, admin).
|
||||
func (h *Web) NewRouter() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.Handle("GET /static/", http.FileServer(http.FS(assets.FS)))
|
||||
mux.HandleFunc("GET /healthz", h.handleHealthz)
|
||||
mux.HandleFunc("GET /readyz", h.handleReadyz)
|
||||
|
||||
mux.HandleFunc("GET /setup", h.handleSetupPage)
|
||||
mux.HandleFunc("POST /setup", h.handleSetup)
|
||||
mux.HandleFunc("GET /login", h.handleLoginPage)
|
||||
mux.HandleFunc("POST /login", h.handleLogin)
|
||||
mux.HandleFunc("POST /logout", h.handleLogout)
|
||||
|
||||
mux.HandleFunc("GET /{$}", h.requireUser(h.handleHome))
|
||||
mux.HandleFunc("GET /search", h.requireUser(h.handleSearch))
|
||||
|
||||
mux.HandleFunc("GET /artists", h.requireUser(h.handleArtists))
|
||||
mux.HandleFunc("GET /artists/{id}", h.requireUser(h.handleArtist))
|
||||
mux.HandleFunc("GET /albums", h.requireUser(h.handleAlbums))
|
||||
mux.HandleFunc("GET /albums/{id}", h.requireUser(h.handleAlbum))
|
||||
mux.HandleFunc("GET /songs", h.requireUser(h.handleSongs))
|
||||
mux.HandleFunc("GET /genres", h.requireUser(h.handleGenres))
|
||||
|
||||
mux.HandleFunc("GET /playlists", h.requireUser(h.handlePlaylists))
|
||||
mux.HandleFunc("POST /playlists", h.requireUser(h.handlePlaylistCreate))
|
||||
mux.HandleFunc("GET /playlists/{id}", h.requireUser(h.handlePlaylist))
|
||||
mux.HandleFunc("POST /playlists/{id}/delete", h.requireUser(h.handlePlaylistDelete))
|
||||
|
||||
mux.HandleFunc("GET /favorites", h.requireUser(h.handleFavorites))
|
||||
mux.HandleFunc("POST /favorites/star", h.requireUser(h.handleFavoriteStar))
|
||||
mux.HandleFunc("POST /favorites/unstar", h.requireUser(h.handleFavoriteUnstar))
|
||||
mux.HandleFunc("GET /recent", h.requireUser(h.handleRecent))
|
||||
|
||||
mux.HandleFunc("GET /settings", h.requireUser(h.handleSettings))
|
||||
mux.HandleFunc("POST /settings/password", h.requireUser(h.handlePasswordChange))
|
||||
mux.HandleFunc("POST /settings/api-keys", h.requireUser(h.handleAPIKeyCreate))
|
||||
mux.HandleFunc("POST /settings/api-keys/{id}/revoke", h.requireUser(h.handleAPIKeyRevoke))
|
||||
mux.HandleFunc("POST /settings/sessions/{id}/revoke", h.requireUser(h.handleSessionRevoke))
|
||||
|
||||
mux.HandleFunc("GET /stream/{id}", h.requireUser(h.handleStream))
|
||||
mux.HandleFunc("GET /cover-art/{id}", h.requireUser(h.handleCoverArt))
|
||||
|
||||
mux.HandleFunc("GET /admin", h.requireAdmin(h.handleAdminRoot))
|
||||
mux.HandleFunc("GET /admin/users", h.requireAdmin(h.handleAdminUsers))
|
||||
mux.HandleFunc("POST /admin/users", h.requireAdmin(h.handleAdminUserCreate))
|
||||
mux.HandleFunc("POST /admin/users/{id}/toggle-disabled", h.requireAdmin(h.handleAdminUserToggleDisabled))
|
||||
mux.HandleFunc("POST /admin/users/{id}/delete", h.requireAdmin(h.handleAdminUserDelete))
|
||||
|
||||
mux.HandleFunc("GET /admin/folders", h.requireAdmin(h.handleAdminFolders))
|
||||
mux.HandleFunc("POST /admin/folders", h.requireAdmin(h.handleAdminFolderCreate))
|
||||
mux.HandleFunc("POST /admin/folders/{id}/toggle", h.requireAdmin(h.handleAdminFolderToggle))
|
||||
mux.HandleFunc("POST /admin/folders/{id}/delete", h.requireAdmin(h.handleAdminFolderDelete))
|
||||
mux.HandleFunc("POST /admin/folders/{id}/scan", h.requireAdmin(h.handleAdminFolderScan))
|
||||
|
||||
mux.HandleFunc("GET /admin/scanner", h.requireAdmin(h.handleAdminScanner))
|
||||
mux.HandleFunc("GET /admin/settings", h.requireAdmin(h.handleAdminSettings))
|
||||
mux.HandleFunc("POST /admin/settings", h.requireAdmin(h.handleAdminSettingsUpdate))
|
||||
|
||||
var handler http.Handler = mux
|
||||
handler = h.CSRF.Protect(handler)
|
||||
handler = h.setupGate(handler)
|
||||
handler = h.Middleware.Authenticate(handler)
|
||||
return handler
|
||||
}
|
||||
|
||||
func (h *Web) requireUser(fn http.HandlerFunc) http.HandlerFunc {
|
||||
wrapped := h.Middleware.RequireUser(fn)
|
||||
return wrapped.ServeHTTP
|
||||
}
|
||||
|
||||
func (h *Web) requireAdmin(fn http.HandlerFunc) http.HandlerFunc {
|
||||
wrapped := h.Middleware.RequireAdmin(fn)
|
||||
return wrapped.ServeHTTP
|
||||
}
|
||||
|
||||
// setupGate sends every request to /setup until an administrator
|
||||
// account exists, so a fresh install can never be used half-configured.
|
||||
func (h *Web) setupGate(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/setup" || r.URL.Path == "/healthz" || r.URL.Path == "/readyz" ||
|
||||
len(r.URL.Path) >= 8 && r.URL.Path[:8] == "/static/" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
n, err := database.CountUsers(h.DB)
|
||||
if err == nil && n == 0 {
|
||||
http.Redirect(w, r, "/setup", http.StatusFound)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
func (h *Web) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderSettings(w, r, "", "", "")
|
||||
}
|
||||
|
||||
func (h *Web) renderSettings(w http.ResponseWriter, r *http.Request, newAPIKey, flash, flashKind string) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
keys, err := database.ListAPIKeysForUser(h.DB, user.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sessions, err := database.ListUserSessions(h.DB, user.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.flash(w, r, "settings", "", "Settings", flash, flashKind, struct {
|
||||
APIKeys []*database.APIKey
|
||||
Sessions []*database.Session
|
||||
NewAPIKey string
|
||||
}{keys, sessions, newAPIKey})
|
||||
}
|
||||
|
||||
func (h *Web) handlePasswordChange(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
current := r.FormValue("current_password")
|
||||
newPassword := r.FormValue("new_password")
|
||||
|
||||
if !user.PasswordHash.Valid {
|
||||
h.renderSettings(w, r, "", "This account has no local password to change (it signs in via external authentication).", "danger")
|
||||
return
|
||||
}
|
||||
ok, err := auth.VerifyPassword(current, user.PasswordHash.String)
|
||||
if err != nil || !ok {
|
||||
h.renderSettings(w, r, "", "Current password is incorrect.", "danger")
|
||||
return
|
||||
}
|
||||
if len(newPassword) < 8 {
|
||||
h.renderSettings(w, r, "", "New password must be at least 8 characters.", "danger")
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := database.SetUserPassword(h.DB, user.ID, hash); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = database.DeleteUserSessions(h.DB, user.ID)
|
||||
if err := h.Sessions.Issue(w, r, user.ID); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.renderSettings(w, r, "", "Password updated.", "success")
|
||||
}
|
||||
|
||||
func (h *Web) handleAPIKeyCreate(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
rawKey, err := auth.GenerateAPIKey()
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := database.CreateAPIKey(h.DB, user.ID, name, auth.HashAPIKey(rawKey)); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.renderSettings(w, r, rawKey, "", "")
|
||||
}
|
||||
|
||||
func (h *Web) handleAPIKeyRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id := r.PathValue("id")
|
||||
key, err := database.GetAPIKey(h.DB, id)
|
||||
if err != nil || key.UserID != user.ID {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := database.RevokeAPIKey(h.DB, id); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Web) handleSessionRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id := r.PathValue("id")
|
||||
sess, err := database.GetSession(h.DB, id)
|
||||
if err != nil || sess.UserID != user.ID {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := database.DeleteSession(h.DB, id); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusFound)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/streaming"
|
||||
)
|
||||
|
||||
// handleStream serves a song's audio bytes directly (no transcoding in
|
||||
// Phase 1). Authorization happens here, before any file is opened: the
|
||||
// caller must be a signed-in, non-disabled user (enforced by the
|
||||
// RequireUser middleware wrapping this route), and the resolved path is
|
||||
// always re-validated against its music folder root.
|
||||
func (h *Web) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
songID := r.PathValue("id")
|
||||
|
||||
song, err := database.GetSong(h.DB, songID)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
mediaFile, err := database.GetMediaFile(h.DB, song.MediaFileID)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
folder, err := database.GetMusicFolder(h.DB, mediaFile.MusicFolderID)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
absPath, err := streaming.ResolvePath(folder.Path, mediaFile.RelativePath)
|
||||
if err != nil {
|
||||
http.Error(w, "403 Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Best-effort playback tracking: only record once per playback (on
|
||||
// the request for the start of the file, not every range chunk a
|
||||
// browser issues while seeking/buffering).
|
||||
rangeHeader := r.Header.Get("Range")
|
||||
if rangeHeader == "" || strings.HasPrefix(rangeHeader, "bytes=0-") {
|
||||
_ = database.SetNowPlaying(h.DB, user.ID, song.ID, "web", 0)
|
||||
_ = database.RecordPlayback(h.DB, user.ID, song.ID, 0)
|
||||
}
|
||||
|
||||
if err := streaming.ServeFile(w, r, absPath, mediaFile.RelativePath); err != nil {
|
||||
http.Error(w, "could not read media file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
// View-model structs shape database rows for templates, keeping SQL
|
||||
// scanning details (sql.Null*) out of the .html files.
|
||||
|
||||
type ArtistView struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
type AlbumView struct {
|
||||
ID string
|
||||
Title string
|
||||
ArtistName string
|
||||
Year int64
|
||||
CoverArtID string
|
||||
}
|
||||
|
||||
type SongRow struct {
|
||||
ID string
|
||||
Title string
|
||||
ArtistName string
|
||||
AlbumTitle string
|
||||
AlbumID string
|
||||
CoverArtID string
|
||||
TrackNumber int64
|
||||
DurationSeconds float64
|
||||
Starred bool
|
||||
}
|
||||
|
||||
func toArtistView(a *database.Artist) ArtistView {
|
||||
return ArtistView{ID: a.ID, Name: a.Name}
|
||||
}
|
||||
|
||||
func toAlbumView(db *sql.DB, a *database.Album) AlbumView {
|
||||
v := AlbumView{ID: a.ID, Title: a.Title}
|
||||
if a.Year.Valid {
|
||||
v.Year = a.Year.Int64
|
||||
}
|
||||
if a.CoverArtID.Valid {
|
||||
v.CoverArtID = a.CoverArtID.String
|
||||
}
|
||||
if a.PrimaryAlbumArtistID.Valid {
|
||||
if artist, err := database.GetArtist(db, a.PrimaryAlbumArtistID.String); err == nil {
|
||||
v.ArtistName = artist.Name
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// toSongRow builds a display row for a song, given its owning album (may
|
||||
// be nil for albumless tracks) and the current user (for the starred
|
||||
// flag; pass "" userID to skip that lookup).
|
||||
func toSongRow(db *sql.DB, s *database.Song, album *database.Album, userID string) SongRow {
|
||||
row := SongRow{
|
||||
ID: s.ID,
|
||||
Title: s.Title,
|
||||
DurationSeconds: 0,
|
||||
}
|
||||
if s.TrackNumber.Valid {
|
||||
row.TrackNumber = s.TrackNumber.Int64
|
||||
}
|
||||
if names, err := database.ListSongArtistNames(db, s.ID); err == nil && len(names) > 0 {
|
||||
row.ArtistName = names[0]
|
||||
for _, n := range names[1:] {
|
||||
row.ArtistName += ", " + n
|
||||
}
|
||||
}
|
||||
if album != nil {
|
||||
row.AlbumTitle = album.Title
|
||||
row.AlbumID = album.ID
|
||||
if album.CoverArtID.Valid {
|
||||
row.CoverArtID = album.CoverArtID.String
|
||||
}
|
||||
}
|
||||
if mf, err := database.GetMediaFile(db, s.MediaFileID); err == nil {
|
||||
row.DurationSeconds = mf.DurationSeconds
|
||||
}
|
||||
if userID != "" {
|
||||
if starred, err := database.IsStarred(db, userID, "song", s.ID); err == nil {
|
||||
row.Starred = starred
|
||||
}
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// albumOf loads a song's album, tolerating albumless songs.
|
||||
func albumOf(db *sql.DB, s *database.Song) *database.Album {
|
||||
if !s.AlbumID.Valid {
|
||||
return nil
|
||||
}
|
||||
album, err := database.GetAlbum(db, s.AlbumID.String)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return album
|
||||
}
|
||||
|
||||
type SessionView struct {
|
||||
ID string
|
||||
UserAgent string
|
||||
IPAddress string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Package web implements VibeSonic's server-rendered Bootstrap 5 UI:
|
||||
// setup, authentication, library browsing, playback, and admin pages.
|
||||
package web
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"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/scanner"
|
||||
)
|
||||
|
||||
type Web struct {
|
||||
DB *sql.DB
|
||||
Config config.Config
|
||||
Sessions *auth.Sessions
|
||||
CSRF *auth.CSRF
|
||||
Middleware *auth.Middleware
|
||||
Scanner *scanner.Scanner
|
||||
CoverArt *coverart.Store
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
// page renders a template with the standard envelope (current user, CSRF
|
||||
// token, active nav item) filled in.
|
||||
func (h *Web) page(w http.ResponseWriter, r *http.Request, name, active, title string, data any) {
|
||||
render(w, name, PageData{
|
||||
Active: active,
|
||||
Title: title,
|
||||
User: auth.UserFromContext(r.Context()),
|
||||
CSRFToken: h.CSRF.Token(w, r),
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Web) flash(w http.ResponseWriter, r *http.Request, name, active, title, flash, kind string, data any) {
|
||||
render(w, name, PageData{
|
||||
Active: active,
|
||||
Title: title,
|
||||
User: auth.UserFromContext(r.Context()),
|
||||
CSRFToken: h.CSRF.Token(w, r),
|
||||
Flash: flash,
|
||||
FlashKind: kind,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user