feat(playlists): share playlists with revocable read-only links
ci / api / api (push) Successful in 55s
release / release (версия и заметки) (push) Successful in 5s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m21s
ci / ts / ts (packages + web) (push) Successful in 2m19s
release / android (APK в релиз) (push) Successful in 4m45s
release / desktop (Linux и Windows в релиз) (push) Successful in 20m58s
release / prune (старые файлы и прогоны) (push) Successful in 9s
ci / module boundaries / boundaries (push) Successful in 7s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m35s
ci / db / db (push) Successful in 59s
ci / api / api (push) Successful in 55s
release / release (версия и заметки) (push) Successful in 5s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m21s
ci / ts / ts (packages + web) (push) Successful in 2m19s
release / android (APK в релиз) (push) Successful in 4m45s
release / desktop (Linux и Windows в релиз) (push) Successful in 20m58s
release / prune (старые файлы и прогоны) (push) Successful in 9s
ci / module boundaries / boundaries (push) Successful in 7s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m35s
ci / db / db (push) Successful in 59s
This commit is contained in:
1 parent
bfcf6e48b2
commit
0a59059738
23 files changed
+1164
-319
No files matched your search
@@ -0,0 +1,69 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"pipu/api/internal/api/gen"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
)
|
||||
|
||||
// Repeated sharing preserves the existing URL. After revocation a fresh UUID is allocated.
|
||||
func (st *Store) sharePlaylist(ctx context.Context, owner, id uuid.UUID) (uuid.UUID, error) {
|
||||
var shareID uuid.UUID
|
||||
err := st.pool.QueryRow(ctx, `UPDATE playlists SET share_id=COALESCE(share_id,$3)
|
||||
WHERE id=$1 AND user_id=$2 RETURNING share_id`, id, owner, uuid.New()).Scan(&shareID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return uuid.Nil, apperr.NotFound("playlist.not_found", "playlist not found")
|
||||
}
|
||||
return shareID, err
|
||||
}
|
||||
|
||||
func (st *Store) revokePlaylistShare(ctx context.Context, owner, id uuid.UUID) error {
|
||||
result, err := st.pool.Exec(ctx, `UPDATE playlists SET share_id=NULL WHERE id=$1 AND user_id=$2`, id, owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return apperr.NotFound("playlist.not_found", "playlist not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) SharePlaylist(ctx context.Context, req gen.SharePlaylistRequestObject) (gen.SharePlaylistResponseObject, error) {
|
||||
user, err := principal(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shareID, err := h.store.sharePlaylist(ctx, user.UserID, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.SharePlaylist200JSONResponse{ShareId: shareID}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) RevokePlaylistShare(ctx context.Context, req gen.RevokePlaylistShareRequestObject) (gen.RevokePlaylistShareResponseObject, error) {
|
||||
user, err := principal(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = h.store.revokePlaylistShare(ctx, user.UserID, req.Id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.RevokePlaylistShare204Response{}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) GetSharedPlaylist(ctx context.Context, req gen.GetSharedPlaylistRequestObject) (gen.GetSharedPlaylistResponseObject, error) {
|
||||
user, err := principal(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := h.readPlaylist(ctx, user.UserID, req.ShareId, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.GetSharedPlaylist200JSONResponse(p), nil
|
||||
}
|
||||
@@ -67,17 +67,33 @@ func (h *Handler) ListPlaylists(ctx context.Context, _ gen.ListPlaylistsRequestO
|
||||
}
|
||||
|
||||
func (h *Handler) playlist(ctx context.Context, user, id uuid.UUID) (gen.Playlist, error) {
|
||||
return h.readPlaylist(ctx, user, id, false)
|
||||
}
|
||||
|
||||
func (h *Handler) readPlaylist(ctx context.Context, user, id uuid.UUID, shared bool) (gen.Playlist, error) {
|
||||
var out gen.Playlist
|
||||
tx, err := h.store.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
p, err := scanPlaylist(tx.QueryRow(ctx, playlistSelect+` WHERE p.user_id=$1 AND p.id=$2`, user, id))
|
||||
filter := ` WHERE p.user_id=$1 AND p.id=$2`
|
||||
args := []any{user, id}
|
||||
if shared {
|
||||
filter = ` WHERE p.share_id=$1 AND EXISTS (SELECT 1 FROM users u WHERE u.id=p.user_id AND NOT u.is_blocked)`
|
||||
args = []any{id}
|
||||
}
|
||||
p, err := scanPlaylist(tx.QueryRow(ctx, playlistSelect+filter, args...))
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
rows, err := tx.Query(ctx, `SELECT track_id FROM playlist_tracks WHERE playlist_id=$1 ORDER BY position`, id)
|
||||
var shareID *uuid.UUID
|
||||
if !shared {
|
||||
if err = tx.QueryRow(ctx, `SELECT share_id FROM playlists WHERE id=$1`, p.Id).Scan(&shareID); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
rows, err := tx.Query(ctx, `SELECT track_id FROM playlist_tracks WHERE playlist_id=$1 ORDER BY position`, p.Id)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -99,7 +115,7 @@ func (h *Handler) playlist(ctx context.Context, user, id uuid.UUID) (gen.Playlis
|
||||
if dtos == nil {
|
||||
dtos = []gen.Track{}
|
||||
}
|
||||
out = gen.Playlist{Id: p.Id, Name: p.Name, Description: p.Description, Revision: p.Revision, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, TrackCount: len(dtos), Tracks: dtos}
|
||||
out = gen.Playlist{ShareId: shareID, Id: p.Id, Name: p.Name, Description: p.Description, Revision: p.Revision, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, TrackCount: len(dtos), Tracks: dtos}
|
||||
for _, t := range dtos {
|
||||
out.DurationMs += int64(t.DurationMs)
|
||||
}
|
||||
|
||||
@@ -54,6 +54,40 @@ func TestPlaylistPersistenceAndIsolation(t *testing.T) {
|
||||
require.ErrorIs(t, st.savePlaylist(ctx, other, id, "stolen", "", nil, 1, false), apperr.NotFound("playlist.not_found", ""))
|
||||
_, err = h.DeletePlaylist(auth.WithPrincipal(ctx, auth.Principal{UserID: other}), gen.DeletePlaylistRequestObject{Id: id})
|
||||
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
|
||||
|
||||
// Sharing grants read access through a separate opaque ID, never through the private owner endpoint.
|
||||
ownerCtx := auth.WithPrincipal(ctx, auth.Principal{UserID: owner})
|
||||
otherCtx := auth.WithPrincipal(ctx, auth.Principal{UserID: other})
|
||||
_, err = h.GetSharedPlaylist(otherCtx, gen.GetSharedPlaylistRequestObject{ShareId: id})
|
||||
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
|
||||
_, err = st.sharePlaylist(ctx, other, id)
|
||||
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
|
||||
shareID, err := st.sharePlaylist(ctx, owner, id)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, id, shareID)
|
||||
sameID, err := st.sharePlaylist(ctx, owner, id)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, shareID, sameID)
|
||||
response, err := h.GetSharedPlaylist(otherCtx, gen.GetSharedPlaylistRequestObject{ShareId: shareID})
|
||||
require.NoError(t, err)
|
||||
shared := gen.Playlist(response.(gen.GetSharedPlaylist200JSONResponse))
|
||||
require.Equal(t, []uuid.UUID{first, second}, []uuid.UUID{shared.Tracks[0].Id, shared.Tracks[1].Id})
|
||||
require.Nil(t, shared.ShareId, "the public DTO does not expose owner sharing controls")
|
||||
got, err = h.playlist(ctx, owner, id)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, shareID, *got.ShareId)
|
||||
require.Equal(t, 1, got.Revision, "sharing does not conflict with track editing")
|
||||
_, err = h.GetPlaylist(otherCtx, gen.GetPlaylistRequestObject{Id: id})
|
||||
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
|
||||
_, err = h.RevokePlaylistShare(otherCtx, gen.RevokePlaylistShareRequestObject{Id: id})
|
||||
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
|
||||
_, err = h.RevokePlaylistShare(ownerCtx, gen.RevokePlaylistShareRequestObject{Id: id})
|
||||
require.NoError(t, err)
|
||||
_, err = h.GetSharedPlaylist(otherCtx, gen.GetSharedPlaylistRequestObject{ShareId: shareID})
|
||||
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
|
||||
freshID, err := st.sharePlaylist(ctx, owner, id)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, shareID, freshID, "revoked links never revive")
|
||||
require.NoError(t, st.savePlaylist(ctx, owner, id, "Mix", "", []uuid.UUID{second, first}, 1, false))
|
||||
require.ErrorIs(t, st.savePlaylist(ctx, owner, id, "stale", "", nil, 1, false), apperr.Conflict("playlist.conflict", ""))
|
||||
// A missing track rolls back both metadata and membership.
|
||||
@@ -63,6 +97,16 @@ func TestPlaylistPersistenceAndIsolation(t *testing.T) {
|
||||
require.Equal(t, 2, got.Revision)
|
||||
require.Equal(t, "Mix", got.Name)
|
||||
require.Equal(t, second, got.Tracks[0].Id)
|
||||
response, err = h.GetSharedPlaylist(otherCtx, gen.GetSharedPlaylistRequestObject{ShareId: freshID})
|
||||
require.NoError(t, err)
|
||||
shared = gen.Playlist(response.(gen.GetSharedPlaylist200JSONResponse))
|
||||
require.Equal(t, second, shared.Tracks[0].Id, "shared pages follow edits to the live playlist")
|
||||
_, err = pool.Exec(ctx, `UPDATE users SET is_blocked=true WHERE id=$1`, owner)
|
||||
require.NoError(t, err)
|
||||
_, err = h.GetSharedPlaylist(otherCtx, gen.GetSharedPlaylistRequestObject{ShareId: freshID})
|
||||
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
|
||||
_, err = pool.Exec(ctx, `UPDATE users SET is_blocked=false WHERE id=$1`, owner)
|
||||
require.NoError(t, err)
|
||||
// Exactly one concurrent write may consume a revision.
|
||||
results := make(chan error, 2)
|
||||
var wg sync.WaitGroup
|
||||
@@ -86,6 +130,8 @@ func TestPlaylistPersistenceAndIsolation(t *testing.T) {
|
||||
require.Equal(t, 1, successes)
|
||||
_, err = h.DeletePlaylist(auth.WithPrincipal(ctx, auth.Principal{UserID: owner}), gen.DeletePlaylistRequestObject{Id: id})
|
||||
require.NoError(t, err)
|
||||
_, err = h.GetSharedPlaylist(otherCtx, gen.GetSharedPlaylistRequestObject{ShareId: freshID})
|
||||
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
|
||||
var remaining int
|
||||
require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM playlist_tracks WHERE playlist_id=$1`, id).Scan(&remaining))
|
||||
require.Zero(t, remaining)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
"pipu/api/internal/api/gen"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -19,3 +22,14 @@ func TestValidatePlaylist(t *testing.T) {
|
||||
require.Error(t, validatePlaylist("Mix", "", []uuid.UUID{uuid.Nil}))
|
||||
require.Error(t, validatePlaylist("Mix", "", make([]uuid.UUID, 2001)))
|
||||
}
|
||||
|
||||
func TestPlaylistSharingRequiresAuthentication(t *testing.T) {
|
||||
h := &Handler{}
|
||||
ctx := context.Background()
|
||||
_, err := h.SharePlaylist(ctx, gen.SharePlaylistRequestObject{Id: uuid.New()})
|
||||
require.ErrorIs(t, err, apperr.Unauthorized("auth.unauthorized", ""))
|
||||
_, err = h.RevokePlaylistShare(ctx, gen.RevokePlaylistShareRequestObject{Id: uuid.New()})
|
||||
require.ErrorIs(t, err, apperr.Unauthorized("auth.unauthorized", ""))
|
||||
_, err = h.GetSharedPlaylist(ctx, gen.GetSharedPlaylistRequestObject{ShareId: uuid.New()})
|
||||
require.ErrorIs(t, err, apperr.Unauthorized("auth.unauthorized", ""))
|
||||
}
|
||||
Reference in new issue
Block a user