Subsonic: sharing (getShares, createShare, updateShare, deleteShare) plus real public share web pages and stream route
This commit is contained in:
+153
-8
@@ -3,39 +3,184 @@ package database
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Shares are a schema foundation for Phase 2's Subsonic share endpoints;
|
|
||||||
// Phase 1 doesn't yet expose a sharing UI.
|
|
||||||
|
|
||||||
type Share struct {
|
type Share struct {
|
||||||
ID string
|
ID string
|
||||||
OwnerID string
|
OwnerID string
|
||||||
Token string
|
Token string
|
||||||
Description string
|
Description string
|
||||||
ExpiresAt sql.NullString
|
ExpiresAt sql.NullString
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastVisitedAt sql.NullString
|
||||||
|
VisitCount int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateShare(db *sql.DB, ownerID, token, description string) (*Share, error) {
|
const shareSelect = `SELECT id, owner_id, token, description, expires_at, created_at, last_visited_at, visit_count FROM shares`
|
||||||
|
|
||||||
|
func CreateShare(db *sql.DB, ownerID, token, description string, expiresAt sql.NullString) (*Share, error) {
|
||||||
id := idgen.New("sh")
|
id := idgen.New("sh")
|
||||||
_, err := db.Exec(`INSERT INTO shares (id, owner_id, token, description) VALUES (?, ?, ?, ?)`, id, ownerID, token, description)
|
var exp any
|
||||||
|
if expiresAt.Valid {
|
||||||
|
exp = expiresAt.String
|
||||||
|
}
|
||||||
|
_, err := db.Exec(`INSERT INTO shares (id, owner_id, token, description, expires_at) VALUES (?, ?, ?, ?, ?)`, id, ownerID, token, description, exp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return GetShareByToken(db, token)
|
return GetShare(db, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetShare(db *sql.DB, id string) (*Share, error) {
|
||||||
|
return scanShare(db.QueryRow(shareSelect+` WHERE id = ?`, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetShareByToken(db *sql.DB, token string) (*Share, error) {
|
func GetShareByToken(db *sql.DB, token string) (*Share, error) {
|
||||||
|
return scanShare(db.QueryRow(shareSelect+` WHERE token = ?`, token))
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanShare(row *sql.Row) (*Share, error) {
|
||||||
var s Share
|
var s Share
|
||||||
err := db.QueryRow(`SELECT id, owner_id, token, description, expires_at FROM shares WHERE token = ?`, token).
|
var createdAt string
|
||||||
Scan(&s.ID, &s.OwnerID, &s.Token, &s.Description, &s.ExpiresAt)
|
err := row.Scan(&s.ID, &s.OwnerID, &s.Token, &s.Description, &s.ExpiresAt, &createdAt, &s.LastVisitedAt, &s.VisitCount)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
s.CreatedAt, _ = parseTime(createdAt)
|
||||||
return &s, nil
|
return &s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ListSharesByOwner(db *sql.DB, ownerID string) ([]*Share, error) {
|
||||||
|
rows, err := db.Query(shareSelect+` WHERE owner_id = ? ORDER BY created_at DESC`, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanShares(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListAllShares(db *sql.DB) ([]*Share, error) {
|
||||||
|
rows, err := db.Query(shareSelect + ` ORDER BY created_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanShares(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanShares(rows *sql.Rows) ([]*Share, error) {
|
||||||
|
var out []*Share
|
||||||
|
for rows.Next() {
|
||||||
|
var s Share
|
||||||
|
var createdAt string
|
||||||
|
if err := rows.Scan(&s.ID, &s.OwnerID, &s.Token, &s.Description, &s.ExpiresAt, &createdAt, &s.LastVisitedAt, &s.VisitCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.CreatedAt, _ = parseTime(createdAt)
|
||||||
|
out = append(out, &s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateShare(db *sql.DB, id, description string, expiresAt sql.NullString) error {
|
||||||
|
var exp any
|
||||||
|
if expiresAt.Valid {
|
||||||
|
exp = expiresAt.String
|
||||||
|
}
|
||||||
|
_, err := db.Exec(`UPDATE shares SET description = ?, expires_at = ? WHERE id = ?`, description, exp, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteShare(db *sql.DB, id string) error {
|
||||||
|
_, err := db.Exec(`DELETE FROM shares WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func RecordShareVisit(db *sql.DB, id string) error {
|
||||||
|
_, err := db.Exec(`UPDATE shares SET last_visited_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), visit_count = visit_count + 1 WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsExpired reports whether a share's expiry has passed.
|
||||||
|
func (s *Share) IsExpired() bool {
|
||||||
|
if !s.ExpiresAt.Valid {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
t, err := parseTime(s.ExpiresAt.String)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return time.Now().After(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Share items ---
|
||||||
|
|
||||||
|
type ShareItem struct {
|
||||||
|
ID string
|
||||||
|
ShareID string
|
||||||
|
SongID sql.NullString
|
||||||
|
AlbumID sql.NullString
|
||||||
|
Position int
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddShareItem(db *sql.DB, shareID string, songID, albumID sql.NullString, position int) error {
|
||||||
|
var song, album any
|
||||||
|
if songID.Valid {
|
||||||
|
song = songID.String
|
||||||
|
}
|
||||||
|
if albumID.Valid {
|
||||||
|
album = albumID.String
|
||||||
|
}
|
||||||
|
_, err := db.Exec(`INSERT INTO share_items (id, share_id, song_id, album_id, position) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
idgen.New("shi"), shareID, song, album, position)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListShareItems(db *sql.DB, shareID string) ([]*ShareItem, error) {
|
||||||
|
rows, err := db.Query(`SELECT id, share_id, song_id, album_id, position FROM share_items WHERE share_id = ? ORDER BY position`, shareID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []*ShareItem
|
||||||
|
for rows.Next() {
|
||||||
|
var si ShareItem
|
||||||
|
if err := rows.Scan(&si.ID, &si.ShareID, &si.SongID, &si.AlbumID, &si.Position); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, &si)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShareSongIDs resolves a share's items to a flat list of song IDs,
|
||||||
|
// expanding any shared albums into their songs.
|
||||||
|
func ShareSongIDs(db *sql.DB, shareID string) ([]string, error) {
|
||||||
|
items, err := ListShareItems(db, shareID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, item := range items {
|
||||||
|
if item.SongID.Valid {
|
||||||
|
out = append(out, item.SongID.String)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if item.AlbumID.Valid {
|
||||||
|
songs, err := ListSongsByAlbum(db, item.AlbumID.String)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, s := range songs {
|
||||||
|
out = append(out, s.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#EXTM3U
|
||||||
|
#EXT-X-VERSION:3
|
||||||
|
#EXT-X-TARGETDURATION:1
|
||||||
|
#EXT-X-MEDIA-SEQUENCE:0
|
||||||
|
#EXTINF:1.023322,
|
||||||
|
seg00000.ts
|
||||||
|
#EXT-X-ENDLIST
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1,198 @@
|
|||||||
|
package subsonic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||||
|
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
register("getShares", handleGetShares)
|
||||||
|
register("createShare", handleCreateShare)
|
||||||
|
register("updateShare", handleUpdateShare)
|
||||||
|
register("deleteShare", handleDeleteShare)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shareURL(rc *Context, token string) string {
|
||||||
|
scheme := "http"
|
||||||
|
if rc.R.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
return scheme + "://" + rc.R.Host + "/share/" + token
|
||||||
|
}
|
||||||
|
|
||||||
|
func shareToModel(db *sql.DB, rc *Context, s *database.Share) (Share, error) {
|
||||||
|
owner, _ := database.GetUserByID(db, s.OwnerID)
|
||||||
|
ownerName := ""
|
||||||
|
if owner != nil {
|
||||||
|
ownerName = owner.Username
|
||||||
|
}
|
||||||
|
out := Share{
|
||||||
|
ID: s.ID,
|
||||||
|
URL: shareURL(rc, s.Token),
|
||||||
|
Description: s.Description,
|
||||||
|
Username: ownerName,
|
||||||
|
Created: isoTime(s.CreatedAt),
|
||||||
|
VisitCount: s.VisitCount,
|
||||||
|
}
|
||||||
|
if s.ExpiresAt.Valid {
|
||||||
|
if t, err := parseISOTime(s.ExpiresAt.String); err == nil {
|
||||||
|
out.Expires = isoTime(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.LastVisitedAt.Valid {
|
||||||
|
if t, err := parseISOTime(s.LastVisitedAt.String); err == nil {
|
||||||
|
out.LastVisited = isoTime(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
songIDs, err := database.ShareSongIDs(db, s.ID)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
for _, id := range songIDs {
|
||||||
|
song, serr := database.GetSong(db, id)
|
||||||
|
if serr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mf, merr := database.GetMediaFile(db, song.MediaFileID)
|
||||||
|
if merr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Entry = append(out.Entry, childFromSong(db, song, mf, albumOfSong(db, song), ""))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetShares(rc *Context) *apiError {
|
||||||
|
if !rc.User.Allows(rc.User.Permissions.Share) {
|
||||||
|
return errUnauthorized("sharing")
|
||||||
|
}
|
||||||
|
db := rc.Server.DB
|
||||||
|
var shares []*database.Share
|
||||||
|
var err error
|
||||||
|
if rc.User.IsAdmin {
|
||||||
|
shares, err = database.ListAllShares(db)
|
||||||
|
} else {
|
||||||
|
shares, err = database.ListSharesByOwner(db, rc.User.ID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return newError(ErrGeneric, err.Error())
|
||||||
|
}
|
||||||
|
out := &Shares{}
|
||||||
|
for _, s := range shares {
|
||||||
|
model, merr := shareToModel(db, rc, s)
|
||||||
|
if merr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Share = append(out.Share, model)
|
||||||
|
}
|
||||||
|
rc.Response = newResponse()
|
||||||
|
rc.Response.Shares = out
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreateShare(rc *Context) *apiError {
|
||||||
|
if !rc.User.Allows(rc.User.Permissions.Share) {
|
||||||
|
return errUnauthorized("sharing")
|
||||||
|
}
|
||||||
|
ids := rc.Params.GetAll("id")
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return errMissingParameter("id")
|
||||||
|
}
|
||||||
|
db := rc.Server.DB
|
||||||
|
description := rc.Params.Get("description")
|
||||||
|
|
||||||
|
var expiresAt sql.NullString
|
||||||
|
if v := rc.Params.Get("expires"); v != "" {
|
||||||
|
millis, perr := strconv.ParseInt(v, 10, 64)
|
||||||
|
if perr == nil {
|
||||||
|
expiresAt = sql.NullString{String: isoTime(time.UnixMilli(millis)), Valid: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
token := idgen.New("")
|
||||||
|
share, err := database.CreateShare(db, rc.User.ID, token, description, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
return newError(ErrGeneric, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
pos := 0
|
||||||
|
for _, id := range ids {
|
||||||
|
var songID, albumID sql.NullString
|
||||||
|
if _, err := database.GetSong(db, id); err == nil {
|
||||||
|
songID = sql.NullString{String: id, Valid: true}
|
||||||
|
} else if _, err := database.GetAlbum(db, id); err == nil {
|
||||||
|
albumID = sql.NullString{String: id, Valid: true}
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := database.AddShareItem(db, share.ID, songID, albumID, pos); err != nil {
|
||||||
|
return newError(ErrGeneric, err.Error())
|
||||||
|
}
|
||||||
|
pos++
|
||||||
|
}
|
||||||
|
|
||||||
|
model, merr := shareToModel(db, rc, share)
|
||||||
|
if merr != nil {
|
||||||
|
return newError(ErrGeneric, merr.Error())
|
||||||
|
}
|
||||||
|
rc.Response = newResponse()
|
||||||
|
rc.Response.Shares = &Shares{Share: []Share{model}}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUpdateShare(rc *Context) *apiError {
|
||||||
|
id, aerr := rc.Params.Required("id")
|
||||||
|
if aerr != nil {
|
||||||
|
return aerr
|
||||||
|
}
|
||||||
|
db := rc.Server.DB
|
||||||
|
share, err := database.GetShare(db, id)
|
||||||
|
if err != nil {
|
||||||
|
return errNotFound("Share")
|
||||||
|
}
|
||||||
|
if share.OwnerID != rc.User.ID && !rc.User.IsAdmin {
|
||||||
|
return errUnauthorized("modifying this share")
|
||||||
|
}
|
||||||
|
|
||||||
|
description := share.Description
|
||||||
|
if rc.Params.Has("description") {
|
||||||
|
description = rc.Params.Get("description")
|
||||||
|
}
|
||||||
|
expiresAt := share.ExpiresAt
|
||||||
|
if v := rc.Params.Get("expires"); v != "" {
|
||||||
|
millis, perr := strconv.ParseInt(v, 10, 64)
|
||||||
|
if perr == nil {
|
||||||
|
expiresAt = sql.NullString{String: isoTime(time.UnixMilli(millis)), Valid: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := database.UpdateShare(db, id, description, expiresAt); err != nil {
|
||||||
|
return newError(ErrGeneric, err.Error())
|
||||||
|
}
|
||||||
|
rc.Response = newResponse()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDeleteShare(rc *Context) *apiError {
|
||||||
|
id, aerr := rc.Params.Required("id")
|
||||||
|
if aerr != nil {
|
||||||
|
return aerr
|
||||||
|
}
|
||||||
|
db := rc.Server.DB
|
||||||
|
share, err := database.GetShare(db, id)
|
||||||
|
if err != nil {
|
||||||
|
return errNotFound("Share")
|
||||||
|
}
|
||||||
|
if share.OwnerID != rc.User.ID && !rc.User.IsAdmin {
|
||||||
|
return errUnauthorized("deleting this share")
|
||||||
|
}
|
||||||
|
if err := database.DeleteShare(db, id); err != nil {
|
||||||
|
return newError(ErrGeneric, err.Error())
|
||||||
|
}
|
||||||
|
rc.Response = newResponse()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package subsonic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShareLifecycle(t *testing.T) {
|
||||||
|
s, srv := newTestRecorderServer(t)
|
||||||
|
createTestUser(t, s, "alice", "password123", false)
|
||||||
|
seedLibrary(t, s)
|
||||||
|
song := firstSong(t, s)
|
||||||
|
|
||||||
|
q := baseParams("alice", "password123")
|
||||||
|
q.Add("id", song.ID)
|
||||||
|
q.Set("description", "Check this out")
|
||||||
|
q.Set("f", "json")
|
||||||
|
resp := getPath(t, srv.URL, "/rest/createShare.view", q)
|
||||||
|
j := decodeJSON(t, resp)
|
||||||
|
shares := j["shares"].(map[string]any)["share"].([]any)
|
||||||
|
if len(shares) != 1 {
|
||||||
|
t.Fatalf("createShare shares = %v, want 1", shares)
|
||||||
|
}
|
||||||
|
share := shares[0].(map[string]any)
|
||||||
|
if share["url"] == nil || share["url"] == "" {
|
||||||
|
t.Fatalf("share missing url: %v", share)
|
||||||
|
}
|
||||||
|
shareID := share["id"].(string)
|
||||||
|
entries, ok := share["entry"].([]any)
|
||||||
|
if !ok || len(entries) != 1 {
|
||||||
|
t.Fatalf("share entries = %v, want 1", share["entry"])
|
||||||
|
}
|
||||||
|
|
||||||
|
q2 := baseParams("alice", "password123")
|
||||||
|
q2.Set("f", "json")
|
||||||
|
resp = getPath(t, srv.URL, "/rest/getShares.view", q2)
|
||||||
|
j = decodeJSON(t, resp)
|
||||||
|
shares = j["shares"].(map[string]any)["share"].([]any)
|
||||||
|
if len(shares) != 1 {
|
||||||
|
t.Fatalf("getShares = %v, want 1", shares)
|
||||||
|
}
|
||||||
|
|
||||||
|
q3 := baseParams("alice", "password123")
|
||||||
|
q3.Set("id", shareID)
|
||||||
|
q3.Set("description", "Updated description")
|
||||||
|
resp = getPath(t, srv.URL, "/rest/updateShare.view", q3)
|
||||||
|
x := decodeXML(t, resp)
|
||||||
|
if x.Status != "ok" {
|
||||||
|
t.Fatalf("updateShare failed: %+v", x)
|
||||||
|
}
|
||||||
|
|
||||||
|
q4 := baseParams("alice", "password123")
|
||||||
|
q4.Set("id", shareID)
|
||||||
|
resp = getPath(t, srv.URL, "/rest/deleteShare.view", q4)
|
||||||
|
x = decodeXML(t, resp)
|
||||||
|
if x.Status != "ok" {
|
||||||
|
t.Fatalf("deleteShare failed: %+v", x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShareOtherUserCannotModify(t *testing.T) {
|
||||||
|
s, _ := newTestServer(t)
|
||||||
|
createTestUser(t, s, "owner", "password123", false)
|
||||||
|
createTestUser(t, s, "other", "password123", false)
|
||||||
|
seedLibrary(t, s)
|
||||||
|
srv := httptest.NewServer(s.Router())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
q := baseParams("owner", "password123")
|
||||||
|
q.Add("id", firstSong(t, s).ID)
|
||||||
|
q.Set("f", "json")
|
||||||
|
resp := getPath(t, srv.URL, "/rest/createShare.view", q)
|
||||||
|
j := decodeJSON(t, resp)
|
||||||
|
shares := j["shares"].(map[string]any)["share"].([]any)
|
||||||
|
shareID := shares[0].(map[string]any)["id"].(string)
|
||||||
|
|
||||||
|
q2 := baseParams("other", "password123")
|
||||||
|
q2.Set("id", shareID)
|
||||||
|
resp = getPath(t, srv.URL, "/rest/deleteShare.view", q2)
|
||||||
|
x := decodeXML(t, resp)
|
||||||
|
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrUnauthorized {
|
||||||
|
t.Fatalf("expected error 50 deleting another user's share, got %+v", x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateShareMissingID(t *testing.T) {
|
||||||
|
s, srv := newTestRecorderServer(t)
|
||||||
|
createTestUser(t, s, "alice", "password123", false)
|
||||||
|
|
||||||
|
resp := getPath(t, srv.URL, "/rest/createShare.view", baseParams("alice", "password123"))
|
||||||
|
x := decodeXML(t, resp)
|
||||||
|
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrMissingParameter {
|
||||||
|
t.Fatalf("expected error 10, got %+v", x)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||||
assets "git.noroutes.net/NoRoutes/vibetunes/web"
|
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 /login", h.handleLogin)
|
||||||
mux.HandleFunc("POST /logout", h.handleLogout)
|
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 /{$}", h.requireUser(h.handleHome))
|
||||||
mux.HandleFunc("GET /search", h.requireUser(h.handleSearch))
|
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 {
|
func (h *Web) setupGate(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path == "/setup" || r.URL.Path == "/healthz" || r.URL.Path == "/readyz" ||
|
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)
|
next.ServeHTTP(w, r)
|
||||||
return
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{{define "share"}}<!doctype html>
|
||||||
|
<html lang="en" data-bs-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{.Title}} · VibeSonic</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/bootstrap-icons.min.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container py-4" style="max-width: 700px;">
|
||||||
|
<h1 class="h4 mb-1"><i class="bi bi-vinyl-fill"></i> Shared music</h1>
|
||||||
|
{{if .Data.OwnerName}}<p class="text-secondary small">Shared by {{.Data.OwnerName}}</p>{{end}}
|
||||||
|
{{if .Data.Description}}<p>{{.Data.Description}}</p>{{end}}
|
||||||
|
|
||||||
|
{{if .Data.Songs}}
|
||||||
|
<div class="table-responsive" data-playable-list>
|
||||||
|
<table class="vs-table vs-table-hover table mb-0">
|
||||||
|
<tbody>
|
||||||
|
{{range $i, $s := .Data.Songs}}
|
||||||
|
<tr class="vs-playable" data-song-id="{{$s.ID}}" data-title="{{$s.Title}}" data-artist="{{$s.ArtistName}}" data-album="{{$s.AlbumTitle}}">
|
||||||
|
<td class="vs-track-num">{{add $i 1}}</td>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold">{{$s.Title}}</div>
|
||||||
|
<div class="text-secondary small">{{$s.ArtistName}}</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-end"><a class="btn btn-sm btn-outline-secondary" href="/share/{{$.Data.Token}}/stream/{{$s.ID}}" download><i class="bi bi-download"></i></a></td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-secondary">This share has no items.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<audio id="vs-share-audio" controls style="position:fixed; bottom:0; left:0; right:0; width:100%;"></audio>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var audio = document.getElementById("vs-share-audio");
|
||||||
|
document.addEventListener("click", function (ev) {
|
||||||
|
var el = ev.target.closest(".vs-playable");
|
||||||
|
if (!el) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
audio.src = "/share/{{.Data.Token}}/stream/" + encodeURIComponent(el.dataset.songId);
|
||||||
|
audio.play().catch(function () {});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user