Subsonic: sharing (getShares, createShare, updateShare, deleteShare) plus real public share web pages and stream route
This commit is contained in:
@@ -2,6 +2,7 @@ package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
assets "git.noroutes.net/NoRoutes/vibetunes/web"
|
||||
@@ -24,6 +25,9 @@ func (h *Web) NewRouter() http.Handler {
|
||||
mux.HandleFunc("POST /login", h.handleLogin)
|
||||
mux.HandleFunc("POST /logout", h.handleLogout)
|
||||
|
||||
mux.HandleFunc("GET /share/{token}", h.handleSharePage)
|
||||
mux.HandleFunc("GET /share/{token}/stream/{songID}", h.handleShareStream)
|
||||
|
||||
mux.HandleFunc("GET /{$}", h.requireUser(h.handleHome))
|
||||
mux.HandleFunc("GET /search", h.requireUser(h.handleSearch))
|
||||
|
||||
@@ -91,7 +95,7 @@ func (h *Web) requireAdmin(fn http.HandlerFunc) http.HandlerFunc {
|
||||
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/" {
|
||||
strings.HasPrefix(r.URL.Path, "/static/") || strings.HasPrefix(r.URL.Path, "/share/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
func TestPublicShareRequiresNoAuth(t *testing.T) {
|
||||
h, db := newTestWeb(t)
|
||||
seedShareFixture(t, h, db)
|
||||
srv := httptest.NewServer(h.NewRouter())
|
||||
defer srv.Close()
|
||||
|
||||
share, err := firstShare(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := http.Get(srv.URL + "/share/" + share.Token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("public share page status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if len(body) == 0 {
|
||||
t.Fatal("empty share page body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicShareUnknownToken404s(t *testing.T) {
|
||||
h, _ := newTestWeb(t)
|
||||
srv := httptest.NewServer(h.NewRouter())
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + "/share/does-not-exist")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareStreamRejectsSongNotInShare(t *testing.T) {
|
||||
h, db := newTestWeb(t)
|
||||
seedShareFixture(t, h, db)
|
||||
srv := httptest.NewServer(h.NewRouter())
|
||||
defer srv.Close()
|
||||
|
||||
share, err := firstShare(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := http.Get(srv.URL + "/share/" + share.Token + "/stream/sg_not_in_this_share")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for a song not part of the share", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func firstShare(db *sql.DB) (*database.Share, error) {
|
||||
shares, err := database.ListAllShares(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return shares[0], nil
|
||||
}
|
||||
|
||||
// seedShareFixture creates a user, a music folder/song row, and a share
|
||||
// containing that song, directly via the database (no scanner needed —
|
||||
// this package's tests don't exercise ffmpeg).
|
||||
func seedShareFixture(t *testing.T, h *Web, db *sql.DB) {
|
||||
t.Helper()
|
||||
owner, err := database.CreateUser(db, "shareowner", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
folder, err := database.CreateMusicFolder(db, "Music", t.TempDir(), "music")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mf, err := database.CreateMediaFile(db, folder.ID, "song.mp3", 100, 0, "hash", "mp3", "song", 10, 128, 44100, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
song, err := database.UpsertSong(db, database.UpsertSongInput{MediaFileID: mf.ID, Title: "Shared Song"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
share, err := database.CreateShare(db, owner.ID, "test-token-123", "A test share", sql.NullString{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.AddShareItem(db, share.ID, sql.NullString{String: song.ID, Valid: true}, sql.NullString{}, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user