feat: share playlists through unguessable links and copy shared playlists

This commit is contained in:
DiheChen committed 2026-11-11 00:08:02 +08:00
1 parent 9f23e97652
commit eed25578c7
2 files changed
+259

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
package playlist
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"github.com/dihemoe/alisten/internal/app/port"
)
// 分享 ID 是 128 位随机值,拿到链接的人才能打开,无法猜出别人的分享。
const shareIDBytes = 16
var shareIDLength = base64.RawURLEncoding.EncodedLen(shareIDBytes)
// 开启分享和重新生成是同一个操作:每次都换一个新的分享 ID,旧链接随即失效。
func (s *Service) Share(ctx context.Context, memberID, id string) (string, error) {
if err := s.registered(ctx, memberID); err != nil {
return "", err
}
b := make([]byte, shareIDBytes)
// crypto/rand.Read 不会返回错误。
rand.Read(b)
shareID := base64.RawURLEncoding.EncodeToString(b)
if err := s.playlists.SetPlaylistShare(ctx, memberID, id, shareID); err != nil {
return "", err
}
return shareID, nil
}
func (s *Service) Unshare(ctx context.Context, memberID, id string) error {
if err := s.registered(ctx, memberID); err != nil {
return err
}
return s.playlists.SetPlaylistShare(ctx, memberID, id, "")
}
// 不需要身份。
func (s *Service) Shared(ctx context.Context, shareID string) (port.SharedPlaylist, error) {
if !validShareID(shareID) {
return port.SharedPlaylist{}, port.ErrShareNotFound
}
return s.playlists.SharedPlaylist(ctx, shareID)
}
func (s *Service) CopyShared(ctx context.Context, memberID, shareID, name string) (port.PlaylistRecord, error) {
if err := s.registered(ctx, memberID); err != nil {
return port.PlaylistRecord{}, err
}
name, err := validName(name)
if err != nil {
return port.PlaylistRecord{}, err
}
if !validShareID(shareID) {
return port.PlaylistRecord{}, port.ErrShareNotFound
}
// 来源歌单本身不超过曲目上限,复制出的也不会超过。
p, err := s.playlists.CopySharedPlaylist(ctx, memberID, shareID, name, maxPlaylists)
if errors.Is(err, port.ErrPlaylistLimit) {
return port.PlaylistRecord{}, ErrTooManyPlaylists
}
return p, err
}
// 分享 ID 取自地址,可能是任意文本:不像生成出来的就不必查存储,NUL 之类的字符也就不会到达数据库。
func validShareID(id string) bool {
if len(id) != shareIDLength {
return false
}
_, err := base64.RawURLEncoding.DecodeString(id)
return err == nil
}
+186
View File
@@ -0,0 +1,186 @@
package playlist
import (
"context"
"errors"
"reflect"
"regexp"
"slices"
"strconv"
"strings"
"testing"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/port/porttest"
"github.com/dihemoe/alisten/internal/domain/track"
)
var shareIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{22}$`)
func TestGuestCannotShareOrCopy(t *testing.T) {
f := newFixture(t)
guest := f.member(t, "")
alice := f.member(t, "alice")
favorites := f.favorites(t, alice.ID)
if _, err := f.svc.Share(t.Context(), guest.ID, favorites); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客开启分享 = %v,期望 ErrNotRegistered", err)
}
if err := f.svc.Unshare(t.Context(), guest.ID, favorites); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客停止分享 = %v,期望 ErrNotRegistered", err)
}
shareID, err := f.svc.Share(t.Context(), alice.ID, favorites)
if err != nil {
t.Fatal(err)
}
if _, err := f.svc.CopyShared(t.Context(), guest.ID, shareID, "抄来的"); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客复制 = %v,期望 ErrNotRegistered", err)
}
if _, err := f.svc.Shared(t.Context(), shareID); err != nil {
t.Errorf("不需要身份就能读取:%v", err)
}
if len(f.quota.keys) != 0 {
t.Errorf("分享和复制不应消耗额度:%v", f.quota.keys)
}
}
func TestShareGeneratesANewLinkEachTime(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
bob := f.member(t, "bob")
favorites := f.favorites(t, alice.ID)
item, err := f.svc.Add(t.Context(), alice.ID, favorites, sunny)
if err != nil {
t.Fatal(err)
}
first, err := f.svc.Share(t.Context(), alice.ID, favorites)
if err != nil || !shareIDPattern.MatchString(first) {
t.Fatalf("分享 ID = %q, %v,期望 22 个 base64url 字符", first, err)
}
shared, err := f.svc.Shared(t.Context(), first)
want := port.SharedPlaylist{Name: "我喜欢", Favorites: true, OwnerName: alice.Name, Items: []port.PlaylistItem{item}}
if err != nil || !reflect.DeepEqual(shared, want) {
t.Errorf("分享的歌单 = %#v, %v,期望 %#v", shared, err, want)
}
if lists, err := f.svc.List(t.Context(), alice.ID); err != nil || lists[0].ShareID != first {
t.Errorf("歌单 = %#v, %v,期望带上分享 ID", lists, err)
}
second, err := f.svc.Share(t.Context(), alice.ID, favorites)
if err != nil || !shareIDPattern.MatchString(second) || second == first {
t.Fatalf("重新生成 = %q, %v,期望换一个新的分享 ID", second, err)
}
if _, err := f.svc.Shared(t.Context(), first); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("旧链接 = %v,期望 ErrShareNotFound", err)
}
if _, err := f.svc.Shared(t.Context(), second); err != nil {
t.Errorf("新链接 = %v", err)
}
if _, err := f.svc.Share(t.Context(), bob.ID, favorites); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("分享别人的歌单 = %v,期望 ErrPlaylistNotFound", err)
}
if err := f.svc.Unshare(t.Context(), bob.ID, favorites); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("停止别人的分享 = %v,期望 ErrPlaylistNotFound", err)
}
if err := f.svc.Unshare(t.Context(), alice.ID, favorites); err != nil {
t.Fatal(err)
}
if _, err := f.svc.Shared(t.Context(), second); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("停止分享之后 = %v,期望 ErrShareNotFound", err)
}
}
type shareLookups struct {
*porttest.Store
lookups int
}
func (s *shareLookups) SharedPlaylist(ctx context.Context, shareID string) (port.SharedPlaylist, error) {
s.lookups++
return s.Store.SharedPlaylist(ctx, shareID)
}
func (s *shareLookups) CopySharedPlaylist(ctx context.Context, ownerID, shareID, name string, limit int) (port.PlaylistRecord, error) {
s.lookups++
return s.Store.CopySharedPlaylist(ctx, ownerID, shareID, name, limit)
}
func TestMalformedShareIDsAreNotLookedUp(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
lookups := &shareLookups{Store: f.store}
f.svc.playlists = lookups
valid := strings.Repeat("a", 21)
for _, id := range []string{"", "abc", valid, valid + "aa", valid + "\x00", valid + "=", valid + "+", valid + "/", "\xff" + valid} {
if _, err := f.svc.Shared(t.Context(), id); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("读取 %q = %v,期望 ErrShareNotFound", id, err)
}
if _, err := f.svc.CopyShared(t.Context(), alice.ID, id, "抄来的"); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("复制 %q = %v,期望 ErrShareNotFound", id, err)
}
}
if lookups.lookups != 0 {
t.Errorf("格式不对的分享 ID 查了 %d 次存储,期望不查", lookups.lookups)
}
if _, err := f.svc.Shared(t.Context(), valid+"a"); !errors.Is(err, port.ErrShareNotFound) || lookups.lookups != 1 {
t.Errorf("格式正确却不存在的分享 = %v,查了 %d 次,期望查一次后 ErrShareNotFound", err, lookups.lookups)
}
}
func TestCopySharedPlaylist(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
bob := f.member(t, "bob")
rain := netease("185811")
f.netease.Add(rain.ID, track.Meta{Title: "七里香", Artist: "周杰伦", PageURL: "https://music.163.com/#/song?id=185811"}, "", nil)
commute, err := f.svc.Create(t.Context(), alice.ID, "通勤")
if err != nil {
t.Fatal(err)
}
for _, song := range []track.Track{sunny, rain} {
if _, err := f.svc.Add(t.Context(), alice.ID, commute.ID, song); err != nil {
t.Fatal(err)
}
}
shareID, err := f.svc.Share(t.Context(), alice.ID, commute.ID)
if err != nil {
t.Fatal(err)
}
keys := len(f.quota.keys)
copied, err := f.svc.CopyShared(t.Context(), bob.ID, shareID, " 抄来的 ")
if err != nil || copied.Name != "抄来的" || copied.TrackCount != 2 || copied.ShareID != "" {
t.Fatalf("复制 = %#v, %v,期望去掉名称首尾的空白、带 2 首、没有分享", copied, err)
}
got, err := f.svc.Items(t.Context(), bob.ID, copied.ID)
original, _ := f.svc.Items(t.Context(), alice.ID, commute.ID)
if err != nil || !slices.Equal(got, original) {
t.Errorf("复制的曲目 = %#v, %v,期望与原歌单相同 %#v", got, err, original)
}
if len(f.quota.keys) != keys {
t.Errorf("复制不应消耗额度:%v", f.quota.keys[keys:])
}
if _, err := f.svc.CopyShared(t.Context(), bob.ID, shareID, " "); !errors.Is(err, ErrInvalidName) {
t.Errorf("名称为空白 = %v,期望 ErrInvalidName", err)
}
if err := f.svc.Unshare(t.Context(), alice.ID, commute.ID); err != nil {
t.Fatal(err)
}
if _, err := f.svc.CopyShared(t.Context(), bob.ID, shareID, "抄来的"); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("停止分享之后复制 = %v,期望 ErrShareNotFound", err)
}
shareID, err = f.svc.Share(t.Context(), alice.ID, commute.ID)
if err != nil {
t.Fatal(err)
}
for i := range 48 {
if _, err := f.svc.Create(t.Context(), bob.ID, "歌单"+strconv.Itoa(i)); err != nil {
t.Fatal(err)
}
}
if _, err := f.svc.CopyShared(t.Context(), bob.ID, shareID, "第 51 个"); !errors.Is(err, ErrTooManyPlaylists) {
t.Errorf("第 51 个歌单 = %v,期望 ErrTooManyPlaylists", err)
}
}