Subsonic: sharing (getShares, createShare, updateShare, deleteShare) plus real public share web pages and stream route

This commit is contained in:
2026-08-23 00:18:51 -05:00
parent b77ccd242a
commit c6e0131984
9 changed files with 734 additions and 14 deletions
+109
View File
@@ -0,0 +1,109 @@
package web
import (
"net/http"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/streaming"
)
// handleSharePage serves a public, unauthenticated page for a share
// token — the whole point of a share link is that the recipient doesn't
// need a VibeSonic account. Expired or unknown tokens 404 rather than
// leaking which is which.
func (h *Web) handleSharePage(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
share, err := database.GetShareByToken(h.DB, token)
if err != nil || share.IsExpired() {
http.NotFound(w, r)
return
}
_ = database.RecordShareVisit(h.DB, share.ID)
songIDs, err := database.ShareSongIDs(h.DB, share.ID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
var rows []SongRow
for _, id := range songIDs {
song, serr := database.GetSong(h.DB, id)
if serr != nil {
continue
}
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), ""))
}
owner, _ := database.GetUserByID(h.DB, share.OwnerID)
ownerName := ""
if owner != nil {
ownerName = owner.Username
}
render(w, "share", PageData{
Title: "Shared music",
Data: struct {
Description string
OwnerName string
Songs []SongRow
Token string
}{share.Description, ownerName, rows, token},
})
}
// handleShareStream serves one song from a share, publicly and without
// a session — but only a song that is actually part of that share, and
// only through the same path-validated streaming.ServeFile used
// everywhere else, so a share link can never be used to fetch arbitrary
// filesystem paths.
func (h *Web) handleShareStream(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
songID := r.PathValue("songID")
share, err := database.GetShareByToken(h.DB, token)
if err != nil || share.IsExpired() {
http.NotFound(w, r)
return
}
songIDs, err := database.ShareSongIDs(h.DB, share.ID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
allowed := false
for _, id := range songIDs {
if id == songID {
allowed = true
break
}
}
if !allowed {
http.NotFound(w, r)
return
}
song, err := database.GetSong(h.DB, songID)
if err != nil {
http.NotFound(w, r)
return
}
mf, err := database.GetMediaFile(h.DB, song.MediaFileID)
if err != nil {
http.NotFound(w, r)
return
}
folder, err := database.GetMusicFolder(h.DB, mf.MusicFolderID)
if err != nil {
http.NotFound(w, r)
return
}
absPath, perr := streaming.ResolvePath(folder.Path, mf.RelativePath)
if perr != nil {
http.Error(w, "403 Forbidden", http.StatusForbidden)
return
}
if err := streaming.ServeFile(w, r, absPath, mf.RelativePath); err != nil {
http.Error(w, "could not read media file", http.StatusInternalServerError)
}
}