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
195 lines
5.5 KiB
Go
195 lines
5.5 KiB
Go
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
|
|
}
|