diff --git a/internal/database/shares.go b/internal/database/shares.go index a80d9b6..1ed90fd 100644 --- a/internal/database/shares.go +++ b/internal/database/shares.go @@ -3,39 +3,184 @@ package database import ( "database/sql" "errors" + "time" "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 { - ID string - OwnerID string - Token string - Description string - ExpiresAt sql.NullString + ID string + OwnerID string + Token string + Description string + 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") - _, 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 { 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) { + return scanShare(db.QueryRow(shareSelect+` WHERE token = ?`, token)) +} + +func scanShare(row *sql.Row) (*Share, error) { var s Share - err := db.QueryRow(`SELECT id, owner_id, token, description, expires_at FROM shares WHERE token = ?`, token). - Scan(&s.ID, &s.OwnerID, &s.Token, &s.Description, &s.ExpiresAt) + var createdAt string + err := row.Scan(&s.ID, &s.OwnerID, &s.Token, &s.Description, &s.ExpiresAt, &createdAt, &s.LastVisitedAt, &s.VisitCount) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } + s.CreatedAt, _ = parseTime(createdAt) 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 +} diff --git a/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist1641501891_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/index.m3u8 b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist1641501891_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/index.m3u8 new file mode 100644 index 0000000..7017fd6 --- /dev/null +++ b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist1641501891_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/index.m3u8 @@ -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 diff --git a/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist1641501891_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/seg00000.ts b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist1641501891_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/seg00000.ts new file mode 100644 index 0000000..7ae6f6a Binary files /dev/null and b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist1641501891_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/seg00000.ts differ diff --git a/internal/subsonic/sharing.go b/internal/subsonic/sharing.go new file mode 100644 index 0000000..5d259a3 --- /dev/null +++ b/internal/subsonic/sharing.go @@ -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 +} diff --git a/internal/subsonic/sharing_test.go b/internal/subsonic/sharing_test.go new file mode 100644 index 0000000..26f3dae --- /dev/null +++ b/internal/subsonic/sharing_test.go @@ -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) + } +} diff --git a/internal/web/router.go b/internal/web/router.go index 21418fe..4c00c58 100644 --- a/internal/web/router.go +++ b/internal/web/router.go @@ -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 } diff --git a/internal/web/share_handlers.go b/internal/web/share_handlers.go new file mode 100644 index 0000000..0e474aa --- /dev/null +++ b/internal/web/share_handlers.go @@ -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) + } +} diff --git a/internal/web/share_handlers_test.go b/internal/web/share_handlers_test.go new file mode 100644 index 0000000..6e0552e --- /dev/null +++ b/internal/web/share_handlers_test.go @@ -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) + } +} diff --git a/web/templates/share.html b/web/templates/share.html new file mode 100644 index 0000000..6b9f36a --- /dev/null +++ b/web/templates/share.html @@ -0,0 +1,54 @@ +{{define "share"}} + +
+ + +Shared by {{.Data.OwnerName}}
{{end}} + {{if .Data.Description}}{{.Data.Description}}
{{end}} + + {{if .Data.Songs}} +| {{add $i 1}} | +
+ {{$s.Title}}
+ {{$s.ArtistName}}
+ |
+ + |
This share has no items.
+ {{end}} +