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

This commit is contained in:
mrleo1nid committed 2026-10-22 14:57:14 +03:00
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
}
+19 -3
View File
@@ -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", ""))
}