Subsonic: sharing (getShares, createShare, updateShare, deleteShare) plus real public share web pages and stream route
This commit is contained in:
+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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user