Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e539c7d78b | ||
|
|
d73a161e84 | ||
|
|
ffd2b390f0 | ||
|
|
ef4558bd65 | ||
|
|
c87b48f014 | ||
|
|
4a59c639cd | ||
|
|
a07ef81e3c | ||
|
|
ca6603a994 | ||
|
|
224ea02725 | ||
|
|
f4e4d23831 | ||
|
|
83afee3142 | ||
|
|
ec62be2d8d | ||
|
|
872904975d | ||
|
|
b50e645d30 | ||
|
|
7f67df3d41 | ||
|
|
a61df45c32 | ||
|
|
90a3b26c77 | ||
|
|
729e5861f5 | ||
|
|
d17477ba59 |
No files matched your search
@@ -39,6 +39,18 @@ vim .env
|
||||
# 然后使用原来的部署命令重新启动
|
||||
```
|
||||
|
||||
`proxy` 网络已经存在时,docker compose 不会按 `compose.prod.yaml` 修改它的网段。如果下面的命令输出的不是 `172.31.250.0/24`(比如网络由没有固定网段的旧版创建),serve 就不会信任 traefik 转发的客户端地址,所有客户端会被算进同一个限流额度。这时先停止服务、删除这个网络,再用原来的部署命令启动:
|
||||
|
||||
```bash
|
||||
docker network inspect proxy --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'
|
||||
docker compose \
|
||||
-f compose.yaml \
|
||||
-f compose.prod.yaml \
|
||||
down
|
||||
# down 已经删掉这个网络时,下面的命令会提示网络不存在,可以忽略
|
||||
docker network rm proxy
|
||||
```
|
||||
|
||||
修改源代码后,自行构建和启动:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -103,7 +103,7 @@ func (g *goDecoder) object(name string, s *schema) {
|
||||
decode := "decodeString"
|
||||
switch {
|
||||
case p.Type == "array":
|
||||
decode = fmt.Sprintf("decodeArray(decode%s)", refName(p.Items.Ref))
|
||||
decode = fmt.Sprintf("decodeArray(decode%s, %d)", refName(p.Items.Ref), *p.MaxItems)
|
||||
case p.Ref != "":
|
||||
decode = "decode" + refName(p.Ref)
|
||||
case p.Type == "integer":
|
||||
@@ -154,9 +154,6 @@ func (g *goDecoder) checks(value, at string, s *schema, patternName string) {
|
||||
if s.MinItems != nil {
|
||||
check("checkMinItems", fmt.Sprint(*s.MinItems))
|
||||
}
|
||||
if s.MaxItems != nil {
|
||||
check("checkMaxItems", fmt.Sprint(*s.MaxItems))
|
||||
}
|
||||
if s.Minimum != nil {
|
||||
check("checkMinimum", fmt.Sprint(*s.Minimum))
|
||||
}
|
||||
|
||||
@@ -470,10 +470,13 @@ func (d *idl) checkInboundArray(where string, s *schema) error {
|
||||
if s.Items.Ref == "" || d.Defs[refName(s.Items.Ref)] != nil && d.Defs[refName(s.Items.Ref)].scalar() {
|
||||
return fmt.Errorf("%s:请求中的数组元素只能引用对象或枚举类型", where)
|
||||
}
|
||||
if s.MinItems != nil && *s.MinItems < 0 || s.MaxItems != nil && *s.MaxItems < 0 {
|
||||
if s.MaxItems == nil {
|
||||
return fmt.Errorf("%s:请求中的数组必须写 maxItems,解码时先按它拒绝过长的数组", where)
|
||||
}
|
||||
if s.MinItems != nil && *s.MinItems < 0 || *s.MaxItems < 0 {
|
||||
return fmt.Errorf("%s:minItems 和 maxItems 不能是负数", where)
|
||||
}
|
||||
if s.MinItems != nil && s.MaxItems != nil && *s.MinItems > *s.MaxItems {
|
||||
if s.MinItems != nil && *s.MinItems > *s.MaxItems {
|
||||
return fmt.Errorf("%s:minItems 大于 maxItems", where)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -154,6 +154,11 @@ func TestParseIDLRejects(t *testing.T) {
|
||||
})
|
||||
d["x-requests"] = []any{roomRequest(object(map[string]any{"batch": ref("Batch")}, "batch"))}
|
||||
}, "$defs.Batch.properties.ids:数组必须是必填字段"},
|
||||
{"请求数组没有 maxItems", func(d map[string]any) {
|
||||
d["x-requests"] = []any{roomRequest(object(map[string]any{
|
||||
"ids": map[string]any{"type": "array", "items": ref("Empty"), "minItems": 1},
|
||||
}, "ids"))}
|
||||
}, "请求中的数组必须写 maxItems"},
|
||||
{"请求数组的 minItems 大于 maxItems", func(d map[string]any) {
|
||||
d["x-requests"] = []any{roomRequest(object(map[string]any{
|
||||
"ids": map[string]any{"type": "array", "items": ref("Empty"), "minItems": 2, "maxItems": 1},
|
||||
|
||||
+1
-4
@@ -270,15 +270,12 @@ func decodeRemoveTracksRequest(raw json.RawMessage, path string) (RemoveTracksRe
|
||||
if err = requireFields(m, path, "tracks"); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Tracks, err = decodeArray(decodeTrack)(m["tracks"], field(path, "tracks")); err != nil {
|
||||
if v.Tracks, err = decodeArray(decodeTrack, 3)(m["tracks"], field(path, "tracks")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMinItems(v.Tracks, field(path, "tracks"), 1); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaxItems(v.Tracks, field(path, "tracks"), 3); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,8 @@ networks:
|
||||
proxy:
|
||||
name: proxy
|
||||
# traefik 经这个网络转发请求,serve 默认信任这个网段传来的 X-Forwarded-For。与已有的 docker 网络冲突时改这里,并同步修改 ALISTEN_TRUSTED_PROXIES。
|
||||
# 网络已经存在时 docker compose 不会按这里改它的网段:proxy 网络不是这个网段时(比如由没有固定网段的旧版创建),
|
||||
# 先 down 停止服务,再 docker network rm proxy 删除这个网络,然后启动。
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.31.250.0/24
|
||||
|
||||
@@ -31,6 +31,13 @@ SELECT * FROM members WHERE username = @username;
|
||||
-- name: SetMemberPassword :execrows
|
||||
UPDATE members SET password_hash = @password_hash WHERE id = @id AND username IS NOT NULL;
|
||||
|
||||
-- name: LockAccountPassword :one
|
||||
-- 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE:同一个账号的修改和重置依次进行,后一个读到的是前一个写入的密码哈希。
|
||||
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = @id AND username IS NOT NULL FOR NO KEY UPDATE;
|
||||
|
||||
-- name: MemberHasSession :one
|
||||
SELECT EXISTS (SELECT FROM member_sessions WHERE token_hash = @token_hash AND member_id = @member_id);
|
||||
|
||||
-- name: LockMemberPassword :one
|
||||
-- FOR SHARE 让修改密码等到这次登录提交之后,它随后删除会话时会删掉这次登录签发的会话。
|
||||
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = @id FOR SHARE;
|
||||
|
||||
@@ -37,7 +37,7 @@ UPDATE playlists SET name = @name, updated_at = now() WHERE id = @id AND owner_i
|
||||
DELETE FROM playlists WHERE id = @id AND owner_id = @owner_id AND kind = 'custom';
|
||||
|
||||
-- name: LockPlaylist :one
|
||||
-- 加入曲目前锁住歌单:并发加入时依次检查上限、分配排序值。
|
||||
-- 改动曲目前锁住歌单:并发加入时依次检查上限、分配排序值。删除歌单先锁歌单行再级联删除曲目,其他操作也要先锁歌单再动曲目,否则会互相死锁。
|
||||
SELECT item_seq FROM playlists WHERE id = @id AND owner_id = @owner_id FOR UPDATE;
|
||||
|
||||
-- name: PlaylistHasItem :one
|
||||
|
||||
@@ -34,8 +34,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
maxBodyBytes = 64 << 10
|
||||
requestDeadline = 3 * time.Minute
|
||||
maxBodyBytes = 64 << 10
|
||||
// 歌单的批量接口一次最多 1000 首,链接曲目的 ID 最长 2048 字节,每首再留 256 字节给来源、字段名和 JSON 的标点。
|
||||
maxBatchBodyBytes = 1000 * (2048 + 256)
|
||||
requestDeadline = 3 * time.Minute
|
||||
)
|
||||
|
||||
type Services struct {
|
||||
@@ -118,10 +120,10 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
|
||||
srv.handle(mux, protocol.RouteDeleteMyPlaylist, srv.deleteMyPlaylist)
|
||||
srv.handle(mux, protocol.RouteListMyPlaylistItems, srv.listMyPlaylistItems)
|
||||
srv.handle(mux, protocol.RouteAddMyPlaylistItem, srv.addMyPlaylistItem)
|
||||
srv.handle(mux, protocol.RouteRemoveMyPlaylistItems, srv.removeMyPlaylistItems)
|
||||
srv.handleLimit(mux, protocol.RouteRemoveMyPlaylistItems, maxBatchBodyBytes, srv.removeMyPlaylistItems)
|
||||
srv.handle(mux, protocol.RouteListMyTrackPlaylists, srv.listMyTrackPlaylists)
|
||||
srv.handle(mux, protocol.RouteCopyMyPlaylistItems, srv.copyMyPlaylistItems)
|
||||
srv.handle(mux, protocol.RouteMoveMyPlaylistItems, srv.moveMyPlaylistItems)
|
||||
srv.handleLimit(mux, protocol.RouteCopyMyPlaylistItems, maxBatchBodyBytes, srv.copyMyPlaylistItems)
|
||||
srv.handleLimit(mux, protocol.RouteMoveMyPlaylistItems, maxBatchBodyBytes, srv.moveMyPlaylistItems)
|
||||
srv.handle(mux, protocol.RouteListRooms, srv.listRooms)
|
||||
srv.handle(mux, protocol.RouteGetRoom, srv.getRoom)
|
||||
srv.handle(mux, protocol.RouteCreateRoom, srv.createRoom)
|
||||
@@ -178,10 +180,14 @@ func SameOrigin(allowedOrigins []string, next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
func (s *server) handle(mux *http.ServeMux, route protocol.Route, fn handlerFunc) {
|
||||
s.handleLimit(mux, route, maxBodyBytes, fn)
|
||||
}
|
||||
|
||||
func (s *server) handleLimit(mux *http.ServeMux, route protocol.Route, maxBody int64, fn handlerFunc) {
|
||||
mux.HandleFunc(route.Pattern, func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), requestDeadline)
|
||||
defer cancel()
|
||||
ctx, resp, err := s.serve(ctx, w, r, route.Auth, func(ctx context.Context, c call) (any, error) {
|
||||
ctx, resp, err := s.serve(ctx, w, r, route.Auth, maxBody, func(ctx context.Context, c call) (any, error) {
|
||||
resp, err := fn(ctx, c)
|
||||
if err != nil || !route.Location {
|
||||
return resp, err
|
||||
@@ -204,7 +210,7 @@ func (s *server) handle(mux *http.ServeMux, route protocol.Route, fn handlerFunc
|
||||
|
||||
// 先鉴权再读请求体:后台没有启用时,无论请求是否合法都返回 FORBIDDEN。返回的 ctx 带有凭证所属的成员和房间,
|
||||
// 出错时的日志也要带上它们。
|
||||
func (s *server) serve(ctx context.Context, w http.ResponseWriter, r *http.Request, auth protocol.Auth, fn handlerFunc) (logCtx context.Context, _ any, err error) {
|
||||
func (s *server) serve(ctx context.Context, w http.ResponseWriter, r *http.Request, auth protocol.Auth, maxBody int64, fn handlerFunc) (logCtx context.Context, _ any, err error) {
|
||||
defer func() {
|
||||
if value := recover(); value != nil {
|
||||
logCtx, err = ctx, s.panicked(ctx, r, value)
|
||||
@@ -237,7 +243,7 @@ func (s *server) serve(ctx context.Context, w http.ResponseWriter, r *http.Reque
|
||||
return ctx, nil, fmt.Errorf("不支持的凭证类型 %d", auth)
|
||||
}
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodDelete {
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes))
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody))
|
||||
if err != nil {
|
||||
return ctx, nil, bodyError(r, err)
|
||||
}
|
||||
@@ -344,7 +350,7 @@ func errorInfo(err error) (info protocol.ErrorInfo, expected bool) {
|
||||
}
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
return protocol.ErrorInfo{Code: protocol.ErrorCodeInvalidArgument, Message: "请求体不能超过 64 KiB"}, true
|
||||
return protocol.ErrorInfo{Code: protocol.ErrorCodeInvalidArgument, Message: fmt.Sprintf("请求体不能超过 %d KiB", tooLarge.Limit>>10)}, true
|
||||
}
|
||||
var roomErr *room.Error
|
||||
if errors.As(err, &roomErr) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/dihemoe/alisten/internal/app/admin"
|
||||
"github.com/dihemoe/alisten/internal/app/identity"
|
||||
"github.com/dihemoe/alisten/internal/app/lobby"
|
||||
"github.com/dihemoe/alisten/internal/app/playlist"
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/app/port/porttest"
|
||||
"github.com/dihemoe/alisten/internal/app/realtime"
|
||||
@@ -154,6 +155,58 @@ func TestRequestBodyLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistBatchBodyLimit(t *testing.T) {
|
||||
store := porttest.New()
|
||||
s := services(store, store, "")
|
||||
s.Playlists = playlist.New(store, store, noTracks{}, s.Catalog, nil)
|
||||
server, _ := start(t, s)
|
||||
var created protocol.CreateMemberResponse
|
||||
if err := json.Unmarshal([]byte(send(t, server, http.MethodPost, "/api/members", "{}").body), &created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Register(t.Context(), created.Member.ID, "alice", "hash"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth := []string{"Authorization", "Bearer " + created.MemberToken}
|
||||
lists, err := store.MemberPlaylists(t.Context(), created.Member.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
favorites := lists[0].ID
|
||||
other, err := store.CreatePlaylist(t.Context(), created.Member.ID, "通勤", 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tracks := make([]string, 1000)
|
||||
for i := range tracks {
|
||||
tracks[i] = fmt.Sprintf(`{"source":"link","id":"https://www.youtube.com/watch?v=%011d&list=PL%032d"}`, i, i)
|
||||
}
|
||||
list := "[" + strings.Join(tracks, ",") + "]"
|
||||
if len(list) <= 64<<10 {
|
||||
t.Fatalf("1000 首链接只有 %d 字节,测不出 64 KiB 的上限", len(list))
|
||||
}
|
||||
if got := send(t, server, http.MethodPost, "/api/me/playlists/"+favorites+"/removals", `{"tracks":`+list+`}`, auth...); got.status != http.StatusNoContent {
|
||||
t.Errorf("移出 1000 首链接 = %#v,期望成功", got)
|
||||
}
|
||||
if got := send(t, server, http.MethodPost, "/api/me/playlists/"+favorites+"/moves", `{"tracks":`+list+`}`, auth...); got.status != http.StatusNoContent {
|
||||
t.Errorf("移动 1000 首链接 = %#v,期望成功", got)
|
||||
}
|
||||
if got := send(t, server, http.MethodPost, "/api/me/playlists/"+other.ID+"/copies", `{"from":"`+favorites+`","tracks":`+list+`}`, auth...); got != (response{status: http.StatusOK, body: `{"added":0}`}) {
|
||||
t.Errorf("复制 1000 首链接 = %#v,期望成功", got)
|
||||
}
|
||||
|
||||
tooLarge := `{"tracks":[]}` + strings.Repeat(" ", maxBatchBodyBytes)
|
||||
want := failure(protocol.ErrorCodeInvalidArgument, fmt.Sprintf("请求体不能超过 %d KiB", maxBatchBodyBytes>>10))
|
||||
if got := send(t, server, http.MethodPost, "/api/me/playlists/"+favorites+"/removals", tooLarge, auth...); got != want {
|
||||
t.Errorf("超过批量上限的请求体 = %#v,期望 %#v", got, want)
|
||||
}
|
||||
want = failure(protocol.ErrorCodeInvalidArgument, "请求体不能超过 64 KiB")
|
||||
if got := send(t, server, http.MethodPost, "/api/me/playlist-memberships", `{"tracks":`+list+`}`, auth...); got != want {
|
||||
t.Errorf("其他接口超过 64 KiB 的请求体 = %#v,期望 %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedPathParametersAreNotFound(t *testing.T) {
|
||||
store := porttest.New()
|
||||
server, logs := start(t, services(store, store, ""))
|
||||
|
||||
@@ -41,7 +41,7 @@ type streamClose struct {
|
||||
func (s *server) roomEvents(w http.ResponseWriter, r *http.Request) {
|
||||
// 打开事件流的期限与 WebSocket 的握手相同,打开之后的推送不设期限。
|
||||
setup, cancel := context.WithTimeout(r.Context(), helloTimeout)
|
||||
ctx, opened, err := s.serve(setup, w, r, protocol.AuthTicket, s.observe)
|
||||
ctx, opened, err := s.serve(setup, w, r, protocol.AuthTicket, maxBodyBytes, s.observe)
|
||||
cancel()
|
||||
if err != nil {
|
||||
s.fail(ctx, w, r, err)
|
||||
|
||||
@@ -105,6 +105,18 @@ func (q *Queries) DeleteSession(ctx context.Context, tokenHash []byte) (int64, e
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const lockAccountPassword = `-- name: LockAccountPassword :one
|
||||
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = $1 AND username IS NOT NULL FOR NO KEY UPDATE
|
||||
`
|
||||
|
||||
// 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE:同一个账号的修改和重置依次进行,后一个读到的是前一个写入的密码哈希。
|
||||
func (q *Queries) LockAccountPassword(ctx context.Context, id string) (string, error) {
|
||||
row := q.db.QueryRow(ctx, lockAccountPassword, id)
|
||||
var password_hash string
|
||||
err := row.Scan(&password_hash)
|
||||
return password_hash, err
|
||||
}
|
||||
|
||||
const lockMemberPassword = `-- name: LockMemberPassword :one
|
||||
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = $1 FOR SHARE
|
||||
`
|
||||
@@ -152,6 +164,22 @@ func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member,
|
||||
return i, err
|
||||
}
|
||||
|
||||
const memberHasSession = `-- name: MemberHasSession :one
|
||||
SELECT EXISTS (SELECT FROM member_sessions WHERE token_hash = $1 AND member_id = $2)
|
||||
`
|
||||
|
||||
type MemberHasSessionParams struct {
|
||||
TokenHash []byte
|
||||
MemberID string
|
||||
}
|
||||
|
||||
func (q *Queries) MemberHasSession(ctx context.Context, arg MemberHasSessionParams) (bool, error) {
|
||||
row := q.db.QueryRow(ctx, memberHasSession, arg.TokenHash, arg.MemberID)
|
||||
var exists bool
|
||||
err := row.Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
const registerMember = `-- name: RegisterMember :execrows
|
||||
UPDATE members SET username = $1, password_hash = $2
|
||||
WHERE id = $3 AND username IS NULL
|
||||
|
||||
@@ -201,7 +201,7 @@ type LockPlaylistParams struct {
|
||||
OwnerID string
|
||||
}
|
||||
|
||||
// 加入曲目前锁住歌单:并发加入时依次检查上限、分配排序值。
|
||||
// 改动曲目前锁住歌单:并发加入时依次检查上限、分配排序值。删除歌单先锁歌单行再级联删除曲目,其他操作也要先锁歌单再动曲目,否则会互相死锁。
|
||||
func (q *Queries) LockPlaylist(ctx context.Context, arg LockPlaylistParams) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, lockPlaylist, arg.ID, arg.OwnerID)
|
||||
var item_seq int64
|
||||
|
||||
@@ -138,18 +138,11 @@ func (s *Store) DeleteSession(ctx context.Context, tokenHash []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SetPassword(ctx context.Context, memberID, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
|
||||
func (s *Store) SetPassword(ctx context.Context, memberID, passwordHash string) ([][]byte, error) {
|
||||
var deleted [][]byte
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
q := s.q.WithTx(tx)
|
||||
n, err := q.SetMemberPassword(ctx, query.SetMemberPasswordParams{ID: memberID, PasswordHash: &passwordHash})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return port.ErrMemberNotFound
|
||||
}
|
||||
deleted, err = q.DeleteMemberSessions(ctx, query.DeleteMemberSessionsParams{MemberID: memberID, Keep: keepTokenHash})
|
||||
var err error
|
||||
deleted, err = setPassword(ctx, s.q.WithTx(tx), memberID, passwordHash, nil)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
@@ -157,3 +150,45 @@ func (s *Store) SetPassword(ctx context.Context, memberID, passwordHash string,
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func (s *Store) ChangePassword(ctx context.Context, memberID, currentPasswordHash, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
|
||||
var deleted [][]byte
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
q := s.q.WithTx(tx)
|
||||
// 先锁住成员行,再用新的语句查会话:并发的修改或重置提交之后才开始查,才能看到它删掉的会话。
|
||||
current, err := q.LockAccountPassword(ctx, memberID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return port.ErrMemberNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kept, err := q.MemberHasSession(ctx, query.MemberHasSessionParams{TokenHash: keepTokenHash, MemberID: memberID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !kept {
|
||||
return port.ErrSessionNotFound
|
||||
}
|
||||
if current != currentPasswordHash {
|
||||
return port.ErrAccountPasswordChanged
|
||||
}
|
||||
deleted, err = setPassword(ctx, q, memberID, passwordHash, keepTokenHash)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("修改成员 %s 的密码:%w", memberID, err)
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func setPassword(ctx context.Context, q *query.Queries, memberID, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
|
||||
n, err := q.SetMemberPassword(ctx, query.SetMemberPasswordParams{ID: memberID, PasswordHash: &passwordHash})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == 0 {
|
||||
return nil, port.ErrMemberNotFound
|
||||
}
|
||||
return q.DeleteMemberSessions(ctx, query.DeleteMemberSessionsParams{MemberID: memberID, Keep: keepTokenHash})
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/member"
|
||||
)
|
||||
@@ -226,12 +228,12 @@ func TestMemberSessions(t *testing.T) {
|
||||
if err := store.Register(t.Context(), alice.ID, "alice", "old"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deleted, err := store.SetPassword(t.Context(), alice.ID, "new", tokenHash("alice-1"))
|
||||
deleted, err := store.ChangePassword(t.Context(), alice.ID, "old", "new", tokenHash("alice-1"))
|
||||
if want := [][]byte{tokenHash("alice-2"), tokenHash("alice-3")}; err != nil || !sameHashes(deleted, want) {
|
||||
t.Errorf("SetPassword 返回 %x, %v,期望被删除的 alice-2、alice-3", deleted, err)
|
||||
t.Errorf("ChangePassword 返回 %x, %v,期望被删除的 alice-2、alice-3", deleted, err)
|
||||
}
|
||||
if ownerOf(t, store, "alice-1") != alice.ID || ownerOf(t, store, "alice-2") != "" || ownerOf(t, store, "alice-3") != "" {
|
||||
t.Error("SetPassword 应只留下指定的会话")
|
||||
t.Error("ChangePassword 应只留下发出请求的会话")
|
||||
}
|
||||
if err := store.DeleteSession(t.Context(), tokenHash("alice-1")); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -244,9 +246,12 @@ func TestMemberSessions(t *testing.T) {
|
||||
func TestSetPassword(t *testing.T) {
|
||||
store, _ := newStore(t)
|
||||
guest := createMember(t, store, "guest", "游客-1a2b")
|
||||
if _, err := store.SetPassword(t.Context(), guest.ID, "hash", nil); !errors.Is(err, port.ErrMemberNotFound) {
|
||||
if _, err := store.SetPassword(t.Context(), guest.ID, "hash"); !errors.Is(err, port.ErrMemberNotFound) {
|
||||
t.Errorf("给游客设置密码 = %v,期望 ErrMemberNotFound", err)
|
||||
}
|
||||
if _, err := store.ChangePassword(t.Context(), guest.ID, "", "hash", tokenHash("guest")); !errors.Is(err, port.ErrMemberNotFound) {
|
||||
t.Errorf("游客修改密码 = %v,期望 ErrMemberNotFound", err)
|
||||
}
|
||||
alice := createMember(t, store, "alice-1", "游客-3c4d")
|
||||
if err := store.Register(t.Context(), alice.ID, "alice", "old"); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -255,8 +260,8 @@ func TestSetPassword(t *testing.T) {
|
||||
if err := store.ReplaceSession(t.Context(), tokenHash("other"), alice.ID, tokenHash("alice-2"), "old"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deleted, err := store.SetPassword(t.Context(), alice.ID, "new", tokenHash("alice-1")); err != nil || !sameHashes(deleted, [][]byte{tokenHash("alice-2")}) {
|
||||
t.Errorf("SetPassword 返回 %x, %v,期望被删除的 alice-2", deleted, err)
|
||||
if deleted, err := store.ChangePassword(t.Context(), alice.ID, "old", "new", tokenHash("alice-1")); err != nil || !sameHashes(deleted, [][]byte{tokenHash("alice-2")}) {
|
||||
t.Errorf("ChangePassword 返回 %x, %v,期望被删除的 alice-2", deleted, err)
|
||||
}
|
||||
if account, err := store.AccountByUsername(t.Context(), "alice"); err != nil || account.PasswordHash != "new" {
|
||||
t.Errorf("修改之后的密码哈希 = %q, %v,期望 new", account.PasswordHash, err)
|
||||
@@ -264,11 +269,95 @@ func TestSetPassword(t *testing.T) {
|
||||
if ownerOf(t, store, "alice-1") != alice.ID || ownerOf(t, store, "alice-2") != "" {
|
||||
t.Error("修改密码应只留下指定的会话")
|
||||
}
|
||||
if deleted, err := store.SetPassword(t.Context(), alice.ID, "newer", nil); err != nil || !sameHashes(deleted, [][]byte{tokenHash("alice-1")}) {
|
||||
if deleted, err := store.SetPassword(t.Context(), alice.ID, "newer"); err != nil || !sameHashes(deleted, [][]byte{tokenHash("alice-1")}) {
|
||||
t.Errorf("SetPassword 返回 %x, %v,期望被删除的 alice-1", deleted, err)
|
||||
}
|
||||
if got := ownerOf(t, store, "alice-1"); got != "" {
|
||||
t.Errorf("不指定保留的会话时应删除全部会话,alice-1 仍属于 %s", got)
|
||||
t.Errorf("SetPassword 应删除全部会话,alice-1 仍属于 %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePasswordChecksSessionAndPassword(t *testing.T) {
|
||||
store, _ := newStore(t)
|
||||
alice := createMember(t, store, "alice-1", "游客-1a2b")
|
||||
if err := store.Register(t.Context(), alice.ID, "alice", "h1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
createMember(t, store, "guest", "游客-3c4d")
|
||||
createMember(t, store, "bob", "游客-5e6f")
|
||||
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "h1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name, current, keep string
|
||||
want error
|
||||
}{
|
||||
{"发出请求的会话已经不存在", "h1", "gone", port.ErrSessionNotFound},
|
||||
{"会话属于别的成员", "h1", "bob", port.ErrSessionNotFound},
|
||||
{"密码哈希已经变了", "h0", "alice-1", port.ErrAccountPasswordChanged},
|
||||
} {
|
||||
if _, err := store.ChangePassword(t.Context(), alice.ID, tc.current, "h2", tokenHash(tc.keep)); !errors.Is(err, tc.want) {
|
||||
t.Errorf("%s时 = %v,期望 %v", tc.name, err, tc.want)
|
||||
}
|
||||
}
|
||||
if account, err := store.AccountByUsername(t.Context(), "alice"); err != nil || account.PasswordHash != "h1" {
|
||||
t.Errorf("失败之后的密码哈希 = %q, %v,期望不变的 h1", account.PasswordHash, err)
|
||||
}
|
||||
if ownerOf(t, store, "alice-1") != alice.ID || ownerOf(t, store, "alice-2") != alice.ID {
|
||||
t.Error("失败时不应删除任何会话")
|
||||
}
|
||||
}
|
||||
|
||||
// 模拟 SetPassword 或另一次 ChangePassword 的事务,先不提交:改密码哈希,删除 keep 以外的会话,keep 为 nil 时全部删除。
|
||||
func changePasswordMeanwhile(t *testing.T, store *Store, pool *pgxpool.Pool, memberID string, keep []byte, want error) {
|
||||
t.Helper()
|
||||
tx, err := pool.Begin(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(t.Context()) }()
|
||||
if _, err := tx.Exec(t.Context(), "UPDATE members SET password_hash = 'h2' WHERE id = $1", memberID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tx.Exec(t.Context(), "DELETE FROM member_sessions WHERE member_id = $1 AND token_hash IS DISTINCT FROM $2", memberID, keep); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := store.ChangePassword(t.Context(), memberID, "h1", "h3", tokenHash("alice-1"))
|
||||
result <- err
|
||||
}()
|
||||
waitForLockWait(t, pool, 1)
|
||||
if err := tx.Commit(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := <-result; !errors.Is(err, want) {
|
||||
t.Errorf("另一次修改提交之后,ChangePassword = %v,期望 %v", err, want)
|
||||
}
|
||||
if account, err := store.AccountByUsername(t.Context(), "alice"); err != nil || account.PasswordHash != "h2" {
|
||||
t.Errorf("密码哈希 = %q, %v,期望先提交的那次写入的 h2", account.PasswordHash, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePasswordWaitsForReset(t *testing.T) {
|
||||
store, pool := newStore(t)
|
||||
alice := createMember(t, store, "alice-1", "游客-1a2b")
|
||||
if err := store.Register(t.Context(), alice.ID, "alice", "h1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changePasswordMeanwhile(t, store, pool, alice.ID, nil, port.ErrSessionNotFound)
|
||||
}
|
||||
|
||||
func TestChangePasswordWaitsForSameSessionChange(t *testing.T) {
|
||||
store, pool := newStore(t)
|
||||
alice := createMember(t, store, "alice-1", "游客-1a2b")
|
||||
if err := store.Register(t.Context(), alice.ID, "alice", "h1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changePasswordMeanwhile(t, store, pool, alice.ID, tokenHash("alice-1"), port.ErrAccountPasswordChanged)
|
||||
if ownerOf(t, store, "alice-1") != alice.ID {
|
||||
t.Error("同一个会话先改过密码,这个会话应仍然有效")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -199,11 +199,19 @@ func (s *Store) AddPlaylistItem(ctx context.Context, ownerID, id string, item po
|
||||
}
|
||||
|
||||
func (s *Store) RemovePlaylistItems(ctx context.Context, ownerID, id string, tracks []track.Track) error {
|
||||
err := s.owned(ctx, ownerID, id)
|
||||
if err == nil {
|
||||
sources, ids := splitTracks(tracks)
|
||||
err = s.q.DeletePlaylistItems(ctx, query.DeletePlaylistItemsParams{PlaylistID: id, Sources: sources, TrackIds: ids})
|
||||
if !isUUID(id) {
|
||||
return port.ErrPlaylistNotFound
|
||||
}
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
q := s.q.WithTx(tx)
|
||||
if _, err := q.LockPlaylist(ctx, query.LockPlaylistParams{ID: id, OwnerID: ownerID}); errors.Is(err, pgx.ErrNoRows) {
|
||||
return port.ErrPlaylistNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
sources, ids := splitTracks(tracks)
|
||||
return q.DeletePlaylistItems(ctx, query.DeletePlaylistItemsParams{PlaylistID: id, Sources: sources, TrackIds: ids})
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("从歌单 %s 移除 %d 首歌:%w", id, len(tracks), err)
|
||||
}
|
||||
|
||||
@@ -438,6 +438,71 @@ func TestConcurrentCrossCopiesDoNotDeadlock(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentRemovalsAndMovesDoNotDeadlock(t *testing.T) {
|
||||
store, _ := newStore(t)
|
||||
alice := registered(t, store, "alice")
|
||||
ids := make([]string, 40)
|
||||
for i := range ids {
|
||||
ids[i] = strconv.Itoa(i)
|
||||
}
|
||||
list := filled(t, store, alice.ID, "通勤", ids...)
|
||||
errs := make(chan error, 40)
|
||||
var wg sync.WaitGroup
|
||||
for i := range 20 {
|
||||
wg.Go(func() { errs <- store.RemovePlaylistItems(t.Context(), alice.ID, list, trackList(ids[i])) })
|
||||
wg.Go(func() { errs <- store.MovePlaylistItems(t.Context(), alice.ID, list, trackList(ids[20+i]), nil) })
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Errorf("并发移出和移动 = %v,期望都成功", err)
|
||||
}
|
||||
}
|
||||
got := displayIDs(t, store, alice.ID, list)
|
||||
slices.Sort(got)
|
||||
want := slices.Clone(ids[20:])
|
||||
slices.Sort(want)
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("并发移出和移动之后有 %v,期望只剩没有移出的 %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentRemovalsAndDeletesDoNotDeadlock(t *testing.T) {
|
||||
store, pool := newStore(t)
|
||||
alice := registered(t, store, "alice")
|
||||
lists := make([]string, 20)
|
||||
for i := range lists {
|
||||
lists[i] = filled(t, store, alice.ID, "歌单"+strconv.Itoa(i), "1", "2", "3")
|
||||
}
|
||||
removed, deleted := make(chan error, len(lists)), make(chan error, len(lists))
|
||||
var wg sync.WaitGroup
|
||||
for _, list := range lists {
|
||||
wg.Go(func() { removed <- store.RemovePlaylistItems(t.Context(), alice.ID, list, trackList("1", "2", "3")) })
|
||||
wg.Go(func() { deleted <- store.DeletePlaylist(t.Context(), alice.ID, list) })
|
||||
}
|
||||
wg.Wait()
|
||||
close(removed)
|
||||
close(deleted)
|
||||
for err := range removed {
|
||||
if err != nil && !errors.Is(err, port.ErrPlaylistNotFound) {
|
||||
t.Errorf("与删除歌单并发移出 = %v,期望成功或 ErrPlaylistNotFound", err)
|
||||
}
|
||||
}
|
||||
for err := range deleted {
|
||||
if err != nil {
|
||||
t.Errorf("与移出并发删除歌单 = %v,期望成功", err)
|
||||
}
|
||||
}
|
||||
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || len(lists) != 1 {
|
||||
t.Errorf("删除之后的歌单 = %#v, %v,期望只剩“我喜欢”", lists, err)
|
||||
}
|
||||
var count int
|
||||
if err := pool.QueryRow(t.Context(), `SELECT count(*) FROM playlist_items`).Scan(&count); err != nil || count != 0 {
|
||||
t.Errorf("删除歌单之后还有 %d 首曲目, %v,期望 0", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackPlaylistsOnlyListsOwnPlaylists(t *testing.T) {
|
||||
store, _ := newStore(t)
|
||||
alice := registered(t, store, "alice")
|
||||
|
||||
@@ -86,9 +86,9 @@ func TestTicketsBelongToSessions(t *testing.T) {
|
||||
if err := store.Register(t.Context(), alice.ID, "alice", "old"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deleted, err := store.SetPassword(t.Context(), alice.ID, "new", tokenHash("alice-1"))
|
||||
deleted, err := store.ChangePassword(t.Context(), alice.ID, "old", "new", tokenHash("alice-1"))
|
||||
if want := [][]byte{tokenHash("alice-2"), tokenHash("alice-3")}; err != nil || !sameHashes(deleted, want) {
|
||||
t.Errorf("SetPassword 返回 %x, %v,期望被删除的 alice-2、alice-3", deleted, err)
|
||||
t.Errorf("ChangePassword 返回 %x, %v,期望被删除的 alice-2、alice-3", deleted, err)
|
||||
}
|
||||
if !valid("alice-1") || valid("alice-2") || valid("alice-3") {
|
||||
t.Error("删除会话之后,只有保留的会话的票据仍然有效")
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
@@ -70,7 +71,7 @@ func (a *Accounts) Register(ctx context.Context, memberID, clientIP, username, p
|
||||
if !settings.RegistrationOpen {
|
||||
return Account{}, ErrRegistrationClosed
|
||||
}
|
||||
if err := a.take(ctx, "register:ip:"+clientIP, registerBurst, registerRefill); err != nil {
|
||||
if err := a.take(ctx, "register:ip:"+quotaAddr(clientIP), registerBurst, registerRefill); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
hash, err := HashPassword(ctx, password)
|
||||
@@ -91,7 +92,7 @@ func (a *Accounts) Login(ctx context.Context, current member.Member, currentToke
|
||||
return Session{}, Account{}, ErrAlreadyLoggedIn
|
||||
}
|
||||
// 按 IP 限制一个来源的尝试次数,按用户名限制一个账号被尝试的次数:换着 IP 猜同一个账号也会受限。
|
||||
if err := a.take(ctx, "login:ip:"+clientIP, loginIPBurst, loginIPRefill); err != nil {
|
||||
if err := a.take(ctx, "login:ip:"+quotaAddr(clientIP), loginIPBurst, loginIPRefill); err != nil {
|
||||
return Session{}, Account{}, err
|
||||
}
|
||||
if err := a.take(ctx, "login:user:"+username, loginUserBurst, loginUserRefill); err != nil {
|
||||
@@ -169,7 +170,8 @@ func (a *Accounts) ChangePassword(ctx context.Context, memberID, token, currentP
|
||||
if err := a.take(ctx, "password:member:"+memberID, passwordBurst, passwordRefill); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.verify(ctx, username, currentPassword); errors.Is(err, ErrInvalidCredentials) {
|
||||
account, err := a.verify(ctx, username, currentPassword)
|
||||
if errors.Is(err, ErrInvalidCredentials) {
|
||||
return ErrWrongCurrentPassword
|
||||
} else if err != nil {
|
||||
return err
|
||||
@@ -178,8 +180,14 @@ func (a *Accounts) ChangePassword(ctx context.Context, memberID, token, currentP
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deleted, err := a.members.SetPassword(ctx, memberID, hash, hashSecret(token))
|
||||
if err != nil {
|
||||
// 校验之后密码可能被重置或被别的设备改过:发出请求的会话已经被删掉时按 token 失效处理,同一个会话先改过时当前密码已经不对。
|
||||
deleted, err := a.members.ChangePassword(ctx, memberID, account.PasswordHash, hash, hashSecret(token))
|
||||
switch {
|
||||
case errors.Is(err, port.ErrSessionNotFound):
|
||||
return ErrInvalidMemberToken
|
||||
case errors.Is(err, port.ErrAccountPasswordChanged):
|
||||
return ErrWrongCurrentPassword
|
||||
case err != nil:
|
||||
return err
|
||||
}
|
||||
return a.gateway.RevokeSessions(ctx, deleted)
|
||||
@@ -205,7 +213,7 @@ func (a *Accounts) ResetPassword(ctx context.Context, username string) (string,
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
deleted, err := a.members.SetPassword(ctx, account.Member.ID, hash, nil)
|
||||
deleted, err := a.members.SetPassword(ctx, account.Member.ID, hash)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -215,6 +223,18 @@ func (a *Accounts) ResetPassword(ctx context.Context, username string) (string,
|
||||
return password, nil
|
||||
}
|
||||
|
||||
// IPv6 客户端通常分到整个 /64,可以在里面随意换地址,所以按 /64 计数。clientIP 由适配器给出,解析不了时原样使用。
|
||||
func quotaAddr(clientIP string) string {
|
||||
addr, err := netip.ParseAddr(clientIP)
|
||||
if err != nil {
|
||||
return clientIP
|
||||
}
|
||||
if addr = addr.Unmap(); addr.Is4() {
|
||||
return addr.String()
|
||||
}
|
||||
return netip.PrefixFrom(addr, 64).Masked().String()
|
||||
}
|
||||
|
||||
func (a *Accounts) take(ctx context.Context, key string, burst int, refill time.Duration) error {
|
||||
wait, err := a.quota.Take(ctx, key, burst, refill)
|
||||
if err != nil {
|
||||
|
||||
@@ -281,6 +281,39 @@ func TestLoginRejected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountQuotaCountsIPv6ByNetwork(t *testing.T) {
|
||||
a, s, _, q, _ := newAccounts(t)
|
||||
guest := createMember(t, s)
|
||||
q.exhausted["login:ip:2001:db8:1:2::/64"] = true
|
||||
for _, tc := range []struct {
|
||||
ip, key string
|
||||
want error
|
||||
}{
|
||||
{"2001:db8:1:2::1", "login:ip:2001:db8:1:2::/64", ErrTooManyAttempts},
|
||||
{"2001:db8:1:2:aaaa:bbbb:cccc:dddd", "login:ip:2001:db8:1:2::/64", ErrTooManyAttempts},
|
||||
{"2001:db8:1:3::1", "login:ip:2001:db8:1:3::/64", ErrInvalidCredentials},
|
||||
{"203.0.113.7", "login:ip:203.0.113.7", ErrInvalidCredentials},
|
||||
{"::ffff:203.0.113.7", "login:ip:203.0.113.7", ErrInvalidCredentials},
|
||||
} {
|
||||
q.taken = nil
|
||||
_, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, tc.ip, "nobody", "wrong horse")
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Errorf("从 %s 登录 = %v,期望 %v", tc.ip, err, tc.want)
|
||||
}
|
||||
if len(q.taken) == 0 || q.taken[0] != tc.key {
|
||||
t.Errorf("从 %s 登录取的额度 = %v,期望先取 %s", tc.ip, q.taken, tc.key)
|
||||
}
|
||||
}
|
||||
|
||||
q.taken = nil
|
||||
if _, err := a.Register(t.Context(), guest.Member.ID, "2001:db8:1:2:aaaa:bbbb:cccc:dddd", "alice", "correct horse"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []string{"register:ip:2001:db8:1:2::/64"}; !slices.Equal(q.taken, want) {
|
||||
t.Errorf("从 IPv6 地址注册取的额度 = %v,期望 %v", q.taken, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogout(t *testing.T) {
|
||||
a, s, store, _, revoked := newAccounts(t)
|
||||
alice := registerAccount(t, a, s, "alice", "correct horse")
|
||||
@@ -463,7 +496,7 @@ type passwordChangedMembers struct {
|
||||
}
|
||||
|
||||
func (s passwordChangedMembers) ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error {
|
||||
if _, err := s.SetPassword(ctx, memberID, "changed", nil); err != nil {
|
||||
if _, err := s.SetPassword(ctx, memberID, "changed"); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Store.ReplaceSession(ctx, oldTokenHash, memberID, newTokenHash, passwordHash)
|
||||
@@ -491,6 +524,56 @@ func TestLoginRejectedWhenGuestSessionOrPasswordChangesMeanwhile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 校验当前密码读到旧的密码哈希之后,先执行 meanwhile 再继续。
|
||||
type passwordReadMembers struct {
|
||||
*porttest.Store
|
||||
meanwhile func()
|
||||
}
|
||||
|
||||
func (s passwordReadMembers) AccountByUsername(ctx context.Context, username string) (port.Account, error) {
|
||||
account, err := s.Store.AccountByUsername(ctx, username)
|
||||
s.meanwhile()
|
||||
return account, err
|
||||
}
|
||||
|
||||
func TestChangePasswordRejectedWhenPasswordChangesMeanwhile(t *testing.T) {
|
||||
a, s, store, _, _ := newAccounts(t)
|
||||
alice := registerAccount(t, a, s, "alice", "correct horse")
|
||||
node := porttest.NewRedis().Node("a")
|
||||
racing := func(meanwhile func()) *Accounts {
|
||||
return NewAccounts(passwordReadMembers{store, meanwhile}, store, &fakeQuota{exhausted: map[string]bool{}}, newGateway(store, node, node), slog.New(slog.DiscardHandler))
|
||||
}
|
||||
|
||||
var reset string
|
||||
resetting := racing(func() {
|
||||
var err error
|
||||
if reset, err = a.ResetPassword(t.Context(), "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if err := resetting.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "battery staple"); !errors.Is(err, ErrInvalidMemberToken) {
|
||||
t.Errorf("校验当前密码之后后台重置了密码,ChangePassword = %v,期望 ErrInvalidMemberToken", err)
|
||||
}
|
||||
guest := createMember(t, s)
|
||||
session, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", reset)
|
||||
if err != nil {
|
||||
t.Fatalf("用重置出来的密码登录 = %v,期望成功", err)
|
||||
}
|
||||
|
||||
changing := racing(func() {
|
||||
if err := a.ChangePassword(t.Context(), session.Member.ID, session.MemberToken, reset, "first change"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if err := changing.ChangePassword(t.Context(), session.Member.ID, session.MemberToken, reset, "second change"); !errors.Is(err, ErrWrongCurrentPassword) {
|
||||
t.Errorf("校验当前密码之后同一个会话已经改了密码,ChangePassword = %v,期望 ErrWrongCurrentPassword", err)
|
||||
}
|
||||
next := createMember(t, s)
|
||||
if _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "first change"); err != nil {
|
||||
t.Errorf("用先完成的那次修改的密码登录 = %v,期望成功", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 名额占满、请求已经取消时,要做密码计算的请求以 context.Canceled 结束,不做的直接返回结果。
|
||||
func TestLoginDoesPasswordWorkForUnknownUsername(t *testing.T) {
|
||||
a, s, _, _, _ := newAccounts(t)
|
||||
|
||||
@@ -46,7 +46,9 @@ type Members interface {
|
||||
// 修改时对会话的删除已经执行,此时创建的会话不会再被删除。两种情况都不做任何修改。
|
||||
ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error
|
||||
DeleteSession(ctx context.Context, tokenHash []byte) error
|
||||
// 在同一个事务中修改已注册成员的密码哈希,并删除该成员除 keepTokenHash 以外的会话,keepTokenHash 为 nil 时全部删除。
|
||||
// 返回被删除的会话的 token 哈希。成员是游客时返回 ErrMemberNotFound。
|
||||
SetPassword(ctx context.Context, memberID, passwordHash string, keepTokenHash []byte) ([][]byte, error)
|
||||
// 在同一个事务中修改已注册成员的密码哈希,并删除该成员的全部会话。返回被删除的会话的 token 哈希。成员是游客时返回 ErrMemberNotFound。
|
||||
SetPassword(ctx context.Context, memberID, passwordHash string) ([][]byte, error)
|
||||
// 与 SetPassword 相同,但保留发出请求的 keepTokenHash 的会话。这个会话已经不存在时返回 ErrSessionNotFound;密码哈希已经不是
|
||||
// 校验当前密码时读到的 currentPasswordHash 时返回 ErrAccountPasswordChanged:密码在校验之后被修改或重置过。两种情况都不做任何修改。
|
||||
ChangePassword(ctx context.Context, memberID, currentPasswordHash, passwordHash string, keepTokenHash []byte) ([][]byte, error)
|
||||
}
|
||||
@@ -222,7 +222,7 @@ func (s *Store) DeleteSession(_ context.Context, tokenHash []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SetPassword(_ context.Context, memberID, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
|
||||
func (s *Store) SetPassword(_ context.Context, memberID, passwordHash string) ([][]byte, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
m := s.member(memberID)
|
||||
@@ -230,6 +230,23 @@ func (s *Store) SetPassword(_ context.Context, memberID, passwordHash string, ke
|
||||
return nil, port.ErrMemberNotFound
|
||||
}
|
||||
m.passwordHash = passwordHash
|
||||
return s.deleteSessions(memberID, nil), nil
|
||||
}
|
||||
|
||||
func (s *Store) ChangePassword(_ context.Context, memberID, currentPasswordHash, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
m := s.member(memberID)
|
||||
if m == nil || m.username == "" {
|
||||
return nil, port.ErrMemberNotFound
|
||||
}
|
||||
if owner, ok := s.sessions[string(keepTokenHash)]; !ok || owner != memberID {
|
||||
return nil, port.ErrSessionNotFound
|
||||
}
|
||||
if m.passwordHash != currentPasswordHash {
|
||||
return nil, port.ErrAccountPasswordChanged
|
||||
}
|
||||
m.passwordHash = passwordHash
|
||||
return s.deleteSessions(memberID, keepTokenHash), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -155,3 +157,98 @@ func TestRestoredLinksNeedTheirAudio(t *testing.T) {
|
||||
t.Errorf("音频还在的链接有 %d 个准备任务,期望没有", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 准备好的链接曲目进了歌单之后,三处封面地址都由对象存储的公开地址和封面的对象键拼出;换一种大小写写法也能找到它。
|
||||
func TestPlaylistLinkTracks(t *testing.T) {
|
||||
contractEnv(t, startPostgres(t)(t), startValkey(t)(t))
|
||||
svc := startService(t)
|
||||
dbConfig, err := loadDatabaseConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool, err := pgxpool.NewWithConfig(t.Context(), dbConfig)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
const link = "https://example.com/Song.mp3"
|
||||
cover := track.ObjectKey(media.CoverObject(media.Key(link), "jpg"))
|
||||
prepared := port.PreparedTrack{
|
||||
Meta: track.Meta{Title: "Song.mp3", CoverKey: cover, PageURL: link, Duration: 2 * time.Second},
|
||||
Result: track.Prepared{Duration: 2 * time.Second, Gain: 1},
|
||||
MediaKey: media.Key(link),
|
||||
MediaSize: 3,
|
||||
}
|
||||
if err := postgres.New(pool).SavePreparedTrack(t.Context(), track.Track{Source: track.Link, ID: link}, prepared); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
coverURL := "https://alisten.example/storage/alisten/" + string(cover)
|
||||
|
||||
call := func(request string, status int, resp any) {
|
||||
t.Helper()
|
||||
line, got, _, body := send(t, svc.server, request)
|
||||
if got != status {
|
||||
t.Fatalf("%s = %d %s,期望 %d", line, got, body, status)
|
||||
}
|
||||
if resp != nil {
|
||||
if err := json.Unmarshal(body, resp); err != nil {
|
||||
t.Fatalf("%s 的响应体 %s:%v", line, body, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
var session struct {
|
||||
MemberToken string `json:"memberToken"`
|
||||
}
|
||||
call("POST /api/members\n\n{}", http.StatusCreated, &session)
|
||||
auth := "\nAuthorization: Bearer " + session.MemberToken
|
||||
call("POST /api/account"+auth+"\n\n"+`{"username": "alice", "password": "correct horse"}`, http.StatusCreated, nil)
|
||||
|
||||
type item struct {
|
||||
Track struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"track"`
|
||||
Meta struct {
|
||||
CoverURL string `json:"coverUrl"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
var lists struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
CoverURL *string `json:"coverUrl"`
|
||||
} `json:"items"`
|
||||
}
|
||||
call("GET /api/me/playlists"+auth, http.StatusOK, &lists)
|
||||
favorites := lists.Items[0].ID
|
||||
|
||||
var added struct {
|
||||
Item item `json:"item"`
|
||||
}
|
||||
call("POST /api/me/playlists/"+favorites+"/items"+auth+"\n\n"+`{"track": {"source": "link", "id": "HTTPS://EXAMPLE.COM/Song.mp3"}}`, http.StatusCreated, &added)
|
||||
if added.Item.Track.ID != link || added.Item.Meta.CoverURL != coverURL {
|
||||
t.Errorf("加入的曲目 = %+v,期望 ID %s、封面 %s", added.Item, link, coverURL)
|
||||
}
|
||||
call("GET /api/me/playlists"+auth, http.StatusOK, &lists)
|
||||
if got := lists.Items[0].CoverURL; got == nil || *got != coverURL {
|
||||
t.Errorf("歌单的封面 = %v,期望 %s", got, coverURL)
|
||||
}
|
||||
var items struct {
|
||||
Items []item `json:"items"`
|
||||
}
|
||||
call("GET /api/me/playlists/"+favorites+"/items"+auth, http.StatusOK, &items)
|
||||
if len(items.Items) != 1 || items.Items[0].Track.ID != link || items.Items[0].Meta.CoverURL != coverURL {
|
||||
t.Errorf("歌单里的曲目 = %+v,期望一首 ID %s、封面 %s", items.Items, link, coverURL)
|
||||
}
|
||||
|
||||
var memberships struct {
|
||||
PlaylistIDs []string `json:"playlistIds"`
|
||||
}
|
||||
call("POST /api/me/playlist-memberships"+auth+"\n\n"+`{"track": {"source": "link", "id": "https://Example.com/Song.mp3"}}`, http.StatusOK, &memberships)
|
||||
if !slices.Equal(memberships.PlaylistIDs, []string{favorites}) {
|
||||
t.Errorf("换一种大小写查所在的歌单 = %v,期望 [%s]", memberships.PlaylistIDs, favorites)
|
||||
}
|
||||
call("POST /api/me/playlists/"+favorites+"/removals"+auth+"\n\n"+`{"tracks": [{"source": "link", "id": "hTTps://example.COM/Song.mp3"}]}`, http.StatusNoContent, nil)
|
||||
call("GET /api/me/playlists/"+favorites+"/items"+auth, http.StatusOK, &items)
|
||||
if len(items.Items) != 0 {
|
||||
t.Errorf("换一种大小写移出之后还有 %+v,期望没有", items.Items)
|
||||
}
|
||||
}
|
||||
@@ -318,6 +318,33 @@ Authorization: Bearer $bob
|
||||
404
|
||||
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
|
||||
|
||||
-- request --
|
||||
PATCH /api/me/playlists/$favorites
|
||||
Authorization: Bearer $bob
|
||||
|
||||
{"name": "别人的名字"}
|
||||
-- response --
|
||||
404
|
||||
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
|
||||
|
||||
-- request --
|
||||
POST /api/me/playlists/$favorites/items
|
||||
Authorization: Bearer $bob
|
||||
|
||||
{"track": {"source": "netease", "id": "186016"}}
|
||||
-- response --
|
||||
404
|
||||
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
|
||||
|
||||
-- request --
|
||||
POST /api/me/playlists/$favorites/removals
|
||||
Authorization: Bearer $bob
|
||||
|
||||
{"tracks": [{"source": "netease", "id": "185811"}]}
|
||||
-- response --
|
||||
404
|
||||
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
|
||||
|
||||
-- request --
|
||||
GET /api/me/playlists/$favorites/items
|
||||
Authorization: Bearer $bob
|
||||
|
||||
+3
-12
@@ -474,15 +474,12 @@ func decodeCopyMyPlaylistItemsRequest(raw json.RawMessage, path string) (CopyMyP
|
||||
if v.From, err = decodeString(m["from"], field(path, "from")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Tracks, err = decodeArray(decodeTrack)(m["tracks"], field(path, "tracks")); err != nil {
|
||||
if v.Tracks, err = decodeArray(decodeTrack, 1000)(m["tracks"], field(path, "tracks")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMinItems(v.Tracks, field(path, "tracks"), 1); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaxItems(v.Tracks, field(path, "tracks"), 1000); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
@@ -639,15 +636,12 @@ func decodeMoveMyPlaylistItemsRequest(raw json.RawMessage, path string) (MoveMyP
|
||||
}
|
||||
v.Before = &x
|
||||
}
|
||||
if v.Tracks, err = decodeArray(decodeTrack)(m["tracks"], field(path, "tracks")); err != nil {
|
||||
if v.Tracks, err = decodeArray(decodeTrack, 1000)(m["tracks"], field(path, "tracks")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMinItems(v.Tracks, field(path, "tracks"), 1); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaxItems(v.Tracks, field(path, "tracks"), 1000); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
@@ -838,15 +832,12 @@ func decodeRemoveMyPlaylistItemsRequest(raw json.RawMessage, path string) (Remov
|
||||
if err = requireFields(m, path, "tracks"); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Tracks, err = decodeArray(decodeTrack)(m["tracks"], field(path, "tracks")); err != nil {
|
||||
if v.Tracks, err = decodeArray(decodeTrack, 1000)(m["tracks"], field(path, "tracks")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMinItems(v.Tracks, field(path, "tracks"), 1); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaxItems(v.Tracks, field(path, "tracks"), 1000); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
|
||||
+5
-8
@@ -88,12 +88,16 @@ func decodeInteger(raw json.RawMessage, path string) (int64, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func decodeArray[T any](decode func(json.RawMessage, string) (T, error)) func(json.RawMessage, string) ([]T, error) {
|
||||
// 先数元素再逐个解码:过长的数组不必解码就能拒绝。
|
||||
func decodeArray[T any](decode func(json.RawMessage, string) (T, error), maxItems int) func(json.RawMessage, string) ([]T, error) {
|
||||
return func(raw json.RawMessage, path string) ([]T, error) {
|
||||
var elems []json.RawMessage
|
||||
if bytes.Equal(raw, null) || json.Unmarshal(raw, &elems) != nil {
|
||||
return nil, invalid(path, "应为数组")
|
||||
}
|
||||
if len(elems) > maxItems {
|
||||
return nil, invalid(path, fmt.Sprintf("元素不能超过 %d 个", maxItems))
|
||||
}
|
||||
out := make([]T, len(elems))
|
||||
for i, e := range elems {
|
||||
v, err := decode(e, fmt.Sprintf("%s[%d]", path, i))
|
||||
@@ -113,13 +117,6 @@ func checkMinItems[T any](a []T, path string, n int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkMaxItems[T any](a []T, path string, n int) error {
|
||||
if len(a) > n {
|
||||
return invalid(path, fmt.Sprintf("元素不能超过 %d 个", n))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkMinLength(s, path string, n int) error {
|
||||
if utf8.RuneCountInString(s) < n {
|
||||
return invalid(path, fmt.Sprintf("长度不能少于 %d 个字符", n))
|
||||
|
||||
@@ -87,8 +87,10 @@ func TestDecodeTrackArray(t *testing.T) {
|
||||
t.Errorf("%s = %q,期望 %q", tt.body, got, tt.want)
|
||||
}
|
||||
}
|
||||
tooMany := `{"tracks":[` + one + strings.Repeat(","+one, 1000) + `]}`
|
||||
if got := errText(func() error { _, err := DecodeRemoveMyPlaylistItemsRequest([]byte(tooMany)); return err }()); got != "tracks:元素不能超过 1000 个" {
|
||||
t.Errorf("1001 首 = %q", got)
|
||||
for _, elem := range []string{one, "{}"} {
|
||||
tooMany := `{"tracks":[` + elem + strings.Repeat(","+elem, 1000) + `]}`
|
||||
if got := errText(func() error { _, err := DecodeRemoveMyPlaylistItemsRequest([]byte(tooMany)); return err }()); got != "tracks:元素不能超过 1000 个" {
|
||||
t.Errorf("1001 个 %s = %q,期望先按个数拒绝、不再解码元素", elem, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -391,7 +391,7 @@
|
||||
{"name": "internal", "code": 1011, "description": "握手时发生内部错误"},
|
||||
{"name": "serviceRestart", "code": 1012, "description": "服务端实例退出,或实例与 Redis 的订阅连接中断"},
|
||||
{"name": "unavailable", "code": 1013, "description": "握手时服务暂时不可用"},
|
||||
{"name": "unauthorized", "code": 4401, "description": "票据无效、用 API Key 握手,或房间设置、修改了密码"},
|
||||
{"name": "unauthorized", "code": 4401, "description": "票据无效、用 API Key 握手、房间设置或修改了密码,或者票据所属的成员会话已被删除(退出登录、修改或重置账号密码)"},
|
||||
{"name": "notFound", "code": 4404, "description": "房间不存在,或房间已关闭、被删除"},
|
||||
{"name": "handshakeTimeout", "code": 4408, "description": "握手超时"},
|
||||
{"name": "clientOutdated", "code": 4426, "description": "前端与服务端的协议版本不一致"},
|
||||
|
||||
@@ -1741,7 +1741,7 @@
|
||||
},
|
||||
"info": {
|
||||
"title": "Alisten",
|
||||
"version": "12f9c11ba788ac55"
|
||||
"version": "5b9cd2cb39780e46"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
|
||||
@@ -4,7 +4,7 @@ package protocol
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const Version = "12f9c11ba788ac55"
|
||||
const Version = "5b9cd2cb39780e46"
|
||||
|
||||
type APIKey struct {
|
||||
Key string `json:"key"`
|
||||
|
||||
@@ -5,7 +5,7 @@ import { chatStore } from '@/features/chat'
|
||||
import { historyStore } from '@/features/history'
|
||||
import { lyricsStore } from '@/features/lyrics'
|
||||
import { playerStore, reminderStore, volumeStore } from '@/features/playback'
|
||||
import { playlistsStore } from '@/features/playlists'
|
||||
import { playlistsStore, setAccountSettingsOpener } from '@/features/playlists'
|
||||
import { roomEntryStore } from '@/features/rooms'
|
||||
import { searchStore } from '@/features/search'
|
||||
import { appearanceStore, performanceStore, profileStore } from '@/features/settings'
|
||||
@@ -28,6 +28,7 @@ volumeStore.start()
|
||||
brandStore.start()
|
||||
profileStore.start()
|
||||
playlistsStore.start()
|
||||
setAccountSettingsOpener(() => navigationStore.open('settings'))
|
||||
playerStore.start()
|
||||
reminderStore.start()
|
||||
historyStore.start()
|
||||
|
||||
@@ -222,6 +222,7 @@ describe('token 失效时更换身份', () => {
|
||||
|
||||
it('其他标签页已经换了 token 时不删除新的 token', async () => {
|
||||
local.setItem(memberTokenKey, 'expired')
|
||||
local.setItem('alisten.ticket.r1', 't1')
|
||||
routes.set('PATCH /api/me', () => {
|
||||
local.setItem(memberTokenKey, 'good')
|
||||
return unauthorized()
|
||||
@@ -230,6 +231,7 @@ describe('token 失效时更换身份', () => {
|
||||
await expect(api.updateMe({ name: 'a' })).rejects.toThrow('刚才的操作没有生效')
|
||||
|
||||
expect(local.getItem(memberTokenKey)).toBe('good')
|
||||
expect(local.getItem('alisten.ticket.r1')).toBe('t1')
|
||||
expect(callsTo('POST /api/members')).toHaveLength(0)
|
||||
expect(events).toEqual([{ kind: 'rejected' }, { kind: 'renewed', member }])
|
||||
})
|
||||
@@ -290,6 +292,19 @@ describe('token 失效时更换身份', () => {
|
||||
expect(local.getItem(memberTokenKey)).toBe('good')
|
||||
})
|
||||
|
||||
it('修改密码时当前密码错误返回 INVALID_CREDENTIALS,不更换身份', async () => {
|
||||
local.setItem(memberTokenKey, 'good')
|
||||
local.setItem('alisten.ticket.r1', 't1')
|
||||
routes.set('PUT /api/account/password', () => errorResponse(401, 'INVALID_CREDENTIALS', '当前密码错误'))
|
||||
const { api, events, IdentityRenewedError } = await load()
|
||||
const error: unknown = await api.changePassword({ currentPassword: 'wrong horse', newPassword: 'correct horse' }).catch((reason: unknown) => reason)
|
||||
expect(error).not.toBeInstanceOf(IdentityRenewedError)
|
||||
expect(error).toMatchObject({ code: 'INVALID_CREDENTIALS', status: 401 })
|
||||
expect(events).toEqual([])
|
||||
expect(local.getItem(memberTokenKey)).toBe('good')
|
||||
expect(local.getItem('alisten.ticket.r1')).toBe('t1')
|
||||
})
|
||||
|
||||
it('账号密码错误不更换身份', async () => {
|
||||
local.setItem(memberTokenKey, 'good')
|
||||
routes.set('POST /api/sessions', () => errorResponse(401, 'INVALID_CREDENTIALS', '用户名或密码错误'))
|
||||
@@ -300,6 +315,74 @@ describe('token 失效时更换身份', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('其他标签页更换身份', () => {
|
||||
let listeners: ((event: { key: string | null }) => void)[]
|
||||
let changed: ReturnType<typeof vi.fn<() => void>>
|
||||
|
||||
function storageEvent(key: string | null = memberTokenKey) {
|
||||
for (const listener of listeners)
|
||||
listener({ key })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
listeners = []
|
||||
changed = vi.fn<() => void>()
|
||||
vi.stubGlobal('window', {
|
||||
addEventListener: (type: string, listener: (event: { key: string | null }) => void) => {
|
||||
if (type === 'storage')
|
||||
listeners.push(listener)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('本标签页正在使用的 token 被换成另一个时通知切换', async () => {
|
||||
local.setItem(memberTokenKey, 'good')
|
||||
const { api, followMemberTokenChanges } = await load()
|
||||
followMemberTokenChanges(changed)
|
||||
await api.getMe()
|
||||
|
||||
local.setItem(memberTokenKey, 'other')
|
||||
storageEvent()
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('token 没有变化、无关的 key 或被删除时不切换', async () => {
|
||||
local.setItem(memberTokenKey, 'good')
|
||||
const { api, followMemberTokenChanges } = await load()
|
||||
followMemberTokenChanges(changed)
|
||||
await api.getMe()
|
||||
|
||||
storageEvent()
|
||||
local.setItem('alisten.volume', '80')
|
||||
storageEvent('alisten.volume')
|
||||
local.removeItem(memberTokenKey)
|
||||
storageEvent()
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('两个标签页都没有 token 各自创建游客时,先创建的另一个标签页不触发切换', async () => {
|
||||
const { api, followMemberTokenChanges } = await load()
|
||||
followMemberTokenChanges(changed)
|
||||
const pending = api.getMe()
|
||||
|
||||
local.setItem(memberTokenKey, 'other-tab')
|
||||
storageEvent()
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
await pending
|
||||
})
|
||||
|
||||
it('本标签页后写入的 token 覆盖了另一个标签页,迟到的事件不触发切换', async () => {
|
||||
const { api, followMemberTokenChanges } = await load()
|
||||
followMemberTokenChanges(changed)
|
||||
const pending = api.getMe()
|
||||
local.setItem(memberTokenKey, 'other-tab')
|
||||
await pending
|
||||
expect(local.getItem(memberTokenKey)).toBe('issued')
|
||||
storageEvent()
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('换取票据', () => {
|
||||
const route = 'POST /api/rooms/r1/tickets'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Auth, CreateTicketRequest, CreateTicketResponse, ErrorResponse, HttpRequest } from './http.gen'
|
||||
import type { ErrorCode, Member } from '@/core/realtime'
|
||||
import { Emitter } from '@/shared/lib/emitter'
|
||||
import { deleteAllTickets, deleteMemberToken, loadAdminToken, loadMemberToken, saveMemberToken } from './credentials'
|
||||
import { deleteAllTickets, deleteMemberToken, loadAdminToken, loadMemberToken, memberTokenKey, saveMemberToken } from './credentials'
|
||||
import { httpClient } from './http.gen'
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -134,13 +134,17 @@ export const api = {
|
||||
}
|
||||
|
||||
let creatingMember: Promise<string> | null = null
|
||||
let usedMemberToken: string | null = null
|
||||
|
||||
function memberToken(): Promise<string> {
|
||||
const stored = loadMemberToken()
|
||||
if (stored)
|
||||
if (stored) {
|
||||
usedMemberToken = stored
|
||||
return Promise.resolve(stored)
|
||||
}
|
||||
creatingMember ??= api.createMember().then(({ memberToken }) => {
|
||||
saveMemberToken(memberToken)
|
||||
usedMemberToken = memberToken
|
||||
return memberToken
|
||||
}).finally(() => {
|
||||
creatingMember = null
|
||||
@@ -175,3 +179,15 @@ async function replaceIdentity(rejected: string): Promise<void> {
|
||||
}
|
||||
identityEvents.emit({ kind: 'renewed', member })
|
||||
}
|
||||
|
||||
// 只有本标签页已经用过 token、并且保存的 token 换成了另一个时才算身份变了。
|
||||
// 两个标签页同时创建游客时,对方先写入的 token 会被后写入的覆盖,此时本标签页还没有用过 token,或用的就是最终保存的 token,都不触发,避免互相刷新。
|
||||
export function followMemberTokenChanges(onChange: () => void): void {
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key !== memberTokenKey)
|
||||
return
|
||||
const stored = loadMemberToken()
|
||||
if (usedMemberToken !== null && stored !== null && stored !== usedMemberToken)
|
||||
onChange()
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
const memberTokenKey = 'alisten.member-token'
|
||||
export const memberTokenKey = 'alisten.member-token'
|
||||
const adminTokenKey = 'alisten.admin-token'
|
||||
const ticketKeyPrefix = 'alisten.ticket.'
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { api, ApiError, identityEvents, IdentityRenewedError } from './client'
|
||||
export { api, ApiError, followMemberTokenChanges, identityEvents, IdentityRenewedError } from './client'
|
||||
export type { IdentityEvent } from './client'
|
||||
export { deleteAdminToken, deleteAllTickets, deleteMemberToken, deleteTicket, loadAdminToken, loadTicket, saveAdminToken, saveMemberToken, saveTicket } from './credentials'
|
||||
export type * from './http.gen'
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
|
||||
|
||||
export const PROTOCOL_VERSION = '12f9c11ba788ac55'
|
||||
export const PROTOCOL_VERSION = '5b9cd2cb39780e46'
|
||||
|
||||
export interface APIKey {
|
||||
key: string
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
.playback-progress { position: absolute; left: 0; right: 0; bottom: 0; margin-top: 0; }
|
||||
.player-center { flex-shrink: 0; }
|
||||
.player-track { position: static; }
|
||||
.player-time { top: 30px; right: 54px; }
|
||||
.player-time { top: 30px; right: 90px; }
|
||||
.progress-track { height: 2px; border-radius: 0 0 8px 8px; }
|
||||
.player-tools { display: none; }
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ListMusic, MessageCircle, SkipForward, Volume2, VolumeX } from 'lucide-react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { Track } from '@/core/realtime'
|
||||
import { ListMusic, ListPlus, MessageCircle, SkipForward, Volume2, VolumeX } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { roomStore } from '@/core/room'
|
||||
import { SaveToPlaylistButton } from '@/features/playlists'
|
||||
import { SaveToPlaylistDialog, saveToPlaylistLabel } from '@/features/playlists'
|
||||
import { sourceName } from '@/features/queue'
|
||||
import { formatDuration } from '@/shared/lib/time'
|
||||
import { useStore } from '@/shared/lib/use-store'
|
||||
@@ -70,6 +71,8 @@ export function PlayerBar({ onSkip, onLyrics, onQueue, onChat, queueOpen, chatOp
|
||||
const artist = meta?.artist || '未知艺术家'
|
||||
const source = nowPlaying ? sourceName(nowPlaying.entry.track) : undefined
|
||||
const note = nowPlaying?.entry.by ? `${nowPlaying.entry.by.name} 点的歌` : undefined
|
||||
// 记下打开时的歌:对话框开着时换了歌,或者这首播完了,勾选仍然作用于打开时的那首。
|
||||
const [saving, setSaving] = useState<{ track: Track, title: string } | null>(null)
|
||||
return (
|
||||
<section className="player-bar" aria-label="播放器">
|
||||
<button type="button" className="player-artwork" onClick={onLyrics} aria-label="查看歌词">
|
||||
@@ -88,10 +91,18 @@ export function PlayerBar({ onSkip, onLyrics, onQueue, onChat, queueOpen, chatOp
|
||||
</div>
|
||||
<div className="player-right">
|
||||
<div className="player-center">
|
||||
<IconButton
|
||||
icon={ListPlus}
|
||||
label={meta ? saveToPlaylistLabel(meta.title) : '保存到歌单'}
|
||||
disabled={!nowPlaying}
|
||||
onClick={() => {
|
||||
if (nowPlaying)
|
||||
setSaving({ track: nowPlaying.entry.track, title: nowPlaying.entry.meta.title })
|
||||
}}
|
||||
/>
|
||||
<AsyncIconButton icon={SkipForward} label="投票切歌" disabled={!nowPlaying || !connected} onAction={onSkip} />
|
||||
</div>
|
||||
<div className="player-tools">
|
||||
{nowPlaying && <SaveToPlaylistButton track={nowPlaying.entry.track} title={nowPlaying.entry.meta.title} />}
|
||||
<IconButton icon={muted ? VolumeX : Volume2} label={muted ? '取消静音' : '静音'} onClick={() => volumeStore.setMuted(!muted)} />
|
||||
<div className="volume-control">
|
||||
<input
|
||||
@@ -114,6 +125,7 @@ export function PlayerBar({ onSkip, onLyrics, onQueue, onChat, queueOpen, chatOp
|
||||
<IconButton icon={ListMusic} label="播放列表" className="player-panel-toggle" disabled={!canOpenPanels} aria-pressed={queueOpen} onClick={onQueue} />
|
||||
</div>
|
||||
</div>
|
||||
{saving && <SaveToPlaylistDialog track={saving.track} title={saving.title} onClose={() => setSaving(null)} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// 游客在保存对话框里点“去账号设置”时打开账号设置。设置页在哪里、怎样打开由应用壳决定,启动时设置。
|
||||
let open = (): void => {}
|
||||
|
||||
export function openAccountSettings(): void {
|
||||
open()
|
||||
}
|
||||
|
||||
export function setAccountSettingsOpener(opener: () => void): void {
|
||||
open = opener
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { lazyPage } from '@/shared/ui'
|
||||
|
||||
export { setAccountSettingsOpener } from './account-settings'
|
||||
export { playlistsStore } from './playlists-store'
|
||||
export { saveToPlaylistLabel } from './save-label'
|
||||
export { SaveToPlaylistButton, SaveToPlaylistDialog } from './save-to-playlist'
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { MovePlan } from './track-order'
|
||||
import type { MyPlaylist, MyPlaylistItem, Track } from '@/core/realtime'
|
||||
import type { SongAction } from '@/shared/ui'
|
||||
import { ArrowLeft, CirclePlus, ListMusic, ListPlus, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '@/core/api'
|
||||
import { errorMessage } from '@/core/errors'
|
||||
import { roomStore } from '@/core/room'
|
||||
@@ -18,6 +18,7 @@ import { playlistsStore, trackKey } from './playlists-store'
|
||||
import { saveToPlaylistLabel } from './save-label'
|
||||
import { SaveToPlaylistDialog } from './save-to-playlist'
|
||||
import { SelectionBar } from './selection-bar'
|
||||
import { TrackEdits } from './track-edits'
|
||||
import { dropAt } from './track-order'
|
||||
import { noSelection, retain, selectAll } from './track-selection'
|
||||
import './playlists.css'
|
||||
@@ -28,6 +29,7 @@ function keysOf(items: readonly MyPlaylistItem[]): string[] {
|
||||
|
||||
export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void }) {
|
||||
const playlist = useStore(playlistsStore, s => s.playlists?.find(p => p.id === id) ?? null)
|
||||
const listed = useStore(playlistsStore, s => s.playlists !== null)
|
||||
const connected = useStore(roomStore, s => s.status === 'open')
|
||||
const ephemeral = useStore(roomStore, s => s.room?.room.kind === 'ephemeral')
|
||||
const playing = useStore(roomStore, s => s.room?.nowPlaying?.entry.track)
|
||||
@@ -40,23 +42,25 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
|
||||
const [saving, setSaving] = useState<{ track: Track, title: string } | null>(null)
|
||||
const [savingSelected, setSavingSelected] = useState(false)
|
||||
const [confirmingRemoval, setConfirmingRemoval] = useState(false)
|
||||
// 移动按发起的先后逐个请求:每次的 before 都基于上一次移动之后的顺序。一次失败之后,排在它后面的不再发出。
|
||||
const movesRef = useRef({ tail: Promise.resolve(), generation: 0 })
|
||||
const [edits] = useState(() => new TrackEdits(() => setAttempt(n => n + 1)))
|
||||
// 歌单的更新时间变了就重新读取:加入、移出、移动和复制都会推进它,歌曲数不变时顺序也可能变了。
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const usable = edits.read()
|
||||
api.listMyPlaylistItems(id).then(({ items }) => {
|
||||
if (!active)
|
||||
if (!active || !usable())
|
||||
return
|
||||
setItems(items)
|
||||
setFailed(null)
|
||||
setSelection(selection => retain(selection, keysOf(items)))
|
||||
}, (error: unknown) => {
|
||||
if (active)
|
||||
if (active && usable())
|
||||
setFailed(errorMessage(error))
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [id, attempt, playlist?.trackCount])
|
||||
}, [id, attempt, playlist?.updatedAtMs, edits])
|
||||
useEffect(() => {
|
||||
const refresh = () => setAttempt(n => n + 1)
|
||||
window.addEventListener('focus', refresh)
|
||||
@@ -78,28 +82,23 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
|
||||
}, [selecting])
|
||||
const [now] = useState(Date.now)
|
||||
const name = playlist?.name ?? '歌单'
|
||||
// 读到了自己的歌单,里面却没有它:在别处删掉了,或者换了账号。
|
||||
const gone = listed && !playlist
|
||||
// 读不到时不再显示之前的曲目:它们可能已经不在了,也可能属于之前的账号。
|
||||
const shown = gone || failed ? null : items
|
||||
|
||||
const exitSelection = () => {
|
||||
setSelection(noSelection)
|
||||
setPicking(false)
|
||||
}
|
||||
|
||||
// 先在本地排好再请求;失败时回到原来的顺序,并重新读取:歌单可能在别处变过。
|
||||
const move = (target: MyPlaylist, current: MyPlaylistItem[], plan: MovePlan<string>) => {
|
||||
const byKey = new Map(current.map(item => [trackKey(item.track), item]))
|
||||
const trackOf = (key: string) => byKey.get(key)!.track
|
||||
const tracks = plan.moved.map(trackOf)
|
||||
const before = plan.before === undefined ? undefined : trackOf(plan.before)
|
||||
setItems(plan.order.map(key => byKey.get(key)!))
|
||||
const queue = movesRef.current
|
||||
const generation = queue.generation
|
||||
queue.tail = queue.tail.then(async () => {
|
||||
if (queue.generation !== generation || await playlistsStore.moveItems(target, tracks, before))
|
||||
return
|
||||
queue.generation++
|
||||
setItems(current)
|
||||
setAttempt(n => n + 1)
|
||||
})
|
||||
void edits.run(() => playlistsStore.moveItems(target, tracks, before), () => setItems(current))
|
||||
}
|
||||
|
||||
const remove = async (target: MyPlaylist, current: MyPlaylistItem[], tracks: Track[]) => {
|
||||
@@ -108,14 +107,23 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
|
||||
const previousSelection = selection
|
||||
setItems(rest)
|
||||
setSelection(retain(selection, keysOf(rest)))
|
||||
if (await playlistsStore.removeItems(target, tracks))
|
||||
return
|
||||
setItems(current)
|
||||
setSelection(previousSelection)
|
||||
await edits.run(() => playlistsStore.removeItems(target, tracks), () => {
|
||||
setItems(current)
|
||||
setSelection(previousSelection)
|
||||
})
|
||||
}
|
||||
|
||||
let body
|
||||
if (failed && items === null) {
|
||||
if (gone) {
|
||||
body = (
|
||||
<EmptyState
|
||||
icon={ListMusic}
|
||||
title="这个歌单不存在了"
|
||||
action={<button type="button" className="button-secondary" onClick={onBack}>返回我的歌单</button>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
else if (failed) {
|
||||
body = (
|
||||
<EmptyState
|
||||
icon={ListMusic}
|
||||
@@ -212,7 +220,7 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
|
||||
</p>
|
||||
)}
|
||||
<div className="my-playlist-actions">
|
||||
{items && items.length > 0 && (
|
||||
{shown && shown.length > 0 && (
|
||||
<button type="button" className="button-secondary my-playlist-select" onClick={() => selecting ? exitSelection() : setPicking(true)}>
|
||||
{selecting ? '完成' : '选择'}
|
||||
</button>
|
||||
@@ -222,35 +230,37 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
|
||||
</div>
|
||||
</header>
|
||||
{body}
|
||||
{!connected && items?.length ? <p className="text-ui-caption muted my-playlist-hint">进入房间之后才能点歌。</p> : null}
|
||||
{playlist && items && selected.length > 0 && (
|
||||
{/* 选中数的播报常驻在页面上:选择栏要等选中第一首才出现,跟着它出现的播报区域读屏软件不会播报。 */}
|
||||
<p className="sr-only" role="status">{shown && selected.length > 0 ? `已选 ${selected.length} 首` : ''}</p>
|
||||
{!connected && shown?.length ? <p className="text-ui-caption muted my-playlist-hint">进入房间之后才能点歌。</p> : null}
|
||||
{playlist && shown && selected.length > 0 && (
|
||||
<SelectionBar
|
||||
count={selected.length}
|
||||
allSelected={selected.length === items.length}
|
||||
allSelected={selected.length === shown.length}
|
||||
onSave={() => setSavingSelected(true)}
|
||||
onTop={() => {
|
||||
const plan = dropAt(keys, selection.keys, 0)
|
||||
if (plan)
|
||||
move(playlist, items, plan)
|
||||
move(playlist, shown, plan)
|
||||
}}
|
||||
onBottom={() => {
|
||||
const plan = dropAt(keys, selection.keys, keys.length)
|
||||
if (plan)
|
||||
move(playlist, items, plan)
|
||||
move(playlist, shown, plan)
|
||||
}}
|
||||
onRemove={() => {
|
||||
if (selected.length > 1)
|
||||
setConfirmingRemoval(true)
|
||||
else
|
||||
void remove(playlist, items, selected)
|
||||
void remove(playlist, shown, selected)
|
||||
}}
|
||||
onToggleAll={() => setSelection(selected.length === items.length ? noSelection : selectAll(keys))}
|
||||
onToggleAll={() => setSelection(selected.length === shown.length ? noSelection : selectAll(keys))}
|
||||
onClose={exitSelection}
|
||||
/>
|
||||
)}
|
||||
{saving && <SaveToPlaylistDialog track={saving.track} title={saving.title} onClose={() => setSaving(null)} />}
|
||||
{playlist && savingSelected && <SaveToPlaylistDialog from={playlist} tracks={selected} onClose={() => setSavingSelected(false)} />}
|
||||
{playlist && items && confirmingRemoval && (
|
||||
{playlist && shown && confirmingRemoval && (
|
||||
<Modal title="移出歌曲" onClose={() => setConfirmingRemoval(false)}>
|
||||
<p className="text-ui-base muted">{`把 ${selected.length} 首歌移出这个歌单?`}</p>
|
||||
<div className="dialog-actions">
|
||||
@@ -260,7 +270,7 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
|
||||
className="button-destructive"
|
||||
onClick={() => {
|
||||
setConfirmingRemoval(false)
|
||||
void remove(playlist, items, selected)
|
||||
void remove(playlist, shown, selected)
|
||||
}}
|
||||
>
|
||||
移出
|
||||
|
||||
@@ -16,6 +16,17 @@ import { moveByOne } from './track-order'
|
||||
import { selectAll, selectRange, toggle } from './track-selection'
|
||||
import { useTrackDrag } from './use-track-drag'
|
||||
|
||||
function rowOf(list: HTMLElement | null, key: string): Element | null {
|
||||
return list?.querySelector(`[data-key="${CSS.escape(key)}"]`) ?? null
|
||||
}
|
||||
|
||||
// 多选时焦点落在勾选框上,读屏软件才读得出这一行是否选中;平时落在行上。
|
||||
function focusRow(row: Element | null | undefined, selecting: boolean): void {
|
||||
const target = selecting ? row?.querySelector('.playlist-track-check') : row
|
||||
if (target instanceof HTMLElement)
|
||||
target.focus()
|
||||
}
|
||||
|
||||
export function PlaylistTracks({ items, playing, selection, selecting, actions, onSelect, onMove }: {
|
||||
items: MyPlaylistItem[]
|
||||
playing: Track | undefined
|
||||
@@ -29,6 +40,8 @@ export function PlaylistTracks({ items, playing, selection, selecting, actions,
|
||||
const lineRef = useRef<HTMLDivElement>(null)
|
||||
const labelRef = useRef<HTMLDivElement>(null)
|
||||
const focusAfterMoveRef = useRef<string | null>(null)
|
||||
// 最后获得焦点的行。勾选框随多选出现和消失,焦点在它上面时会丢。
|
||||
const focusedRef = useRef<string | null>(null)
|
||||
const keys = items.map(item => trackKey(item.track))
|
||||
const { dragging, onPointerDown } = useTrackDrag({
|
||||
listRef,
|
||||
@@ -48,8 +61,17 @@ export function PlaylistTracks({ items, playing, selection, selecting, actions,
|
||||
if (key === null)
|
||||
return
|
||||
focusAfterMoveRef.current = null
|
||||
listRef.current?.querySelector<HTMLElement>(`[data-key="${CSS.escape(key)}"]`)?.focus()
|
||||
}, [items])
|
||||
focusRow(rowOf(listRef.current, key), selecting)
|
||||
}, [items, selecting])
|
||||
|
||||
// 进出多选时,焦点留在原来那一行,落到这一行在新模式下的位置。
|
||||
useLayoutEffect(() => {
|
||||
const key = focusedRef.current
|
||||
const active = document.activeElement
|
||||
if (key === null || (active !== null && active !== document.body && !active.matches('.playlist-track')))
|
||||
return
|
||||
focusRow(rowOf(listRef.current, key), selecting)
|
||||
}, [selecting])
|
||||
|
||||
const onRowClick = (event: MouseEvent, key: string) => {
|
||||
if ((event.target as Element).closest('button, a'))
|
||||
@@ -66,12 +88,13 @@ export function PlaylistTracks({ items, playing, selection, selecting, actions,
|
||||
onSelect(selectAll(keys))
|
||||
return
|
||||
}
|
||||
// 其余按键只在焦点落在行本身时处理,行里的按钮保持原来的行为。
|
||||
const row = event.target as HTMLElement
|
||||
const key = row.dataset.key
|
||||
if (key === undefined)
|
||||
// 其余按键只在焦点落在行本身或它的勾选框上时处理,行里的按钮保持原来的行为。勾选框上的空格由浏览器切换。
|
||||
const target = event.target as HTMLElement
|
||||
const row = target.closest<HTMLElement>('[data-key]')
|
||||
const key = row?.dataset.key
|
||||
if (!row || key === undefined || (target !== row && !target.matches('.playlist-track-check')))
|
||||
return
|
||||
if (event.key === ' ' && selecting) {
|
||||
if (event.key === ' ' && selecting && target === row) {
|
||||
event.preventDefault()
|
||||
onSelect(toggle(selection, key))
|
||||
return
|
||||
@@ -81,9 +104,7 @@ export function PlaylistTracks({ items, playing, selection, selecting, actions,
|
||||
event.preventDefault()
|
||||
const delta = event.key === 'ArrowUp' ? -1 : 1
|
||||
if (!event.altKey) {
|
||||
const next = delta < 0 ? row.previousElementSibling : row.nextElementSibling
|
||||
if (next instanceof HTMLElement)
|
||||
next.focus()
|
||||
focusRow(delta < 0 ? row.previousElementSibling : row.nextElementSibling, selecting)
|
||||
return
|
||||
}
|
||||
const plan = moveByOne(keys, selection.keys.has(key) ? selection.keys : new Set([key]), delta)
|
||||
@@ -113,15 +134,20 @@ export function PlaylistTracks({ items, playing, selection, selecting, actions,
|
||||
key={key}
|
||||
data-key={key}
|
||||
className="song-row playlist-track"
|
||||
tabIndex={0}
|
||||
tabIndex={selecting ? -1 : 0}
|
||||
data-playing={isPlaying || undefined}
|
||||
data-selected={selected || undefined}
|
||||
data-dragging={dragging?.has(key) || undefined}
|
||||
onClick={event => onRowClick(event, key)}
|
||||
onFocus={() => {
|
||||
focusedRef.current = key
|
||||
}}
|
||||
onPointerDown={event => onPointerDown(event, key)}
|
||||
// 按住封面拖动时,浏览器会开始原生的图片拖拽并取消指针事件,排序的拖动随之中断。
|
||||
onDragStart={event => event.preventDefault()}
|
||||
>
|
||||
{selecting
|
||||
? <input type="checkbox" className="playlist-track-check" tabIndex={-1} checked={selected} readOnly aria-label={`选择${bookTitle(meta.title)}`} />
|
||||
? <input type="checkbox" className="playlist-track-check" checked={selected} readOnly aria-label={`选择${bookTitle(meta.title)}`} />
|
||||
: <span className="song-index text-ui-sm muted">{String(index + 1).padStart(2, '0')}</span>}
|
||||
{/* 多选时点封面也是切换选中,不打开音源页面。 */}
|
||||
<SongArtwork coverUrl={meta.coverUrl} title={meta.title} href={selecting ? undefined : meta.pageUrl} sourceName={source} />
|
||||
|
||||
@@ -42,6 +42,7 @@ let failCopy: boolean
|
||||
let failMove: boolean
|
||||
let bodies: unknown[]
|
||||
let holdReads: boolean
|
||||
let failReads: boolean
|
||||
let serverPlaylists: typeof favorites[]
|
||||
let server: Map<string, Map<string, Track>>
|
||||
|
||||
@@ -66,6 +67,8 @@ function items(playlist: string): Map<string, Track> {
|
||||
async function read(body: unknown): Promise<Response> {
|
||||
if (holdReads)
|
||||
await new Promise<void>(resolve => release.push(resolve))
|
||||
if (failReads)
|
||||
return json(503, { error: { code: 'UNAVAILABLE', message: '暂时不可用' } })
|
||||
return json(200, body)
|
||||
}
|
||||
|
||||
@@ -79,6 +82,7 @@ beforeEach(() => {
|
||||
failMove = false
|
||||
bodies = []
|
||||
holdReads = false
|
||||
failReads = false
|
||||
serverPlaylists = [favorites]
|
||||
server = new Map([['p1', new Map([[key(sunny), sunny]])]])
|
||||
vi.stubGlobal('window', new EventTarget())
|
||||
@@ -205,37 +209,69 @@ describe('playlistsStore', () => {
|
||||
expect(included(rain)).toBe(true)
|
||||
})
|
||||
|
||||
// 同步结束后会重新读取,读到的服务端状态也能把勾选改对。下面几个用例挡住这次读取,只看回滚本身。
|
||||
it('第一次请求失败时以服务端为准,提示服务端的文案', async () => {
|
||||
const { playlistsStore, included } = await loaded()
|
||||
const { notificationStore } = await import('@/shared/ui')
|
||||
failAdd = true
|
||||
void playlistsStore.setMembership(favorites, rain, true)
|
||||
holdReads = true
|
||||
const done = playlistsStore.setMembership(favorites, rain, true)
|
||||
void playlistsStore.setMembership(favorites, rain, false)
|
||||
void playlistsStore.setMembership(favorites, rain, true)
|
||||
await settle()
|
||||
await flush()
|
||||
release.shift()!()
|
||||
await done
|
||||
expect(writes()).toEqual(['POST /api/me/playlists/p1/items'])
|
||||
expect(included(rain)).toBe(false)
|
||||
expect(notificationStore.state.notifications.map(n => n.message)).toEqual([notReady])
|
||||
holdReads = false
|
||||
await settle()
|
||||
expect(included(rain)).toBe(false)
|
||||
})
|
||||
|
||||
it('移出失败时恢复勾选', async () => {
|
||||
const { playlistsStore, included } = await loaded()
|
||||
failRemove = true
|
||||
holdReads = true
|
||||
const done = playlistsStore.setMembership(favorites, sunny, false)
|
||||
expect(included(sunny)).toBe(false)
|
||||
await settle()
|
||||
await flush()
|
||||
release.shift()!()
|
||||
await done
|
||||
expect(included(sunny)).toBe(true)
|
||||
holdReads = false
|
||||
await settle()
|
||||
expect(included(sunny)).toBe(true)
|
||||
})
|
||||
|
||||
it('加入成功后移出失败,勾选恢复为已加入', async () => {
|
||||
const { playlistsStore, included } = await loaded()
|
||||
failRemove = true
|
||||
void playlistsStore.setMembership(favorites, rain, true)
|
||||
holdReads = true
|
||||
const done = playlistsStore.setMembership(favorites, rain, true)
|
||||
void playlistsStore.setMembership(favorites, rain, false)
|
||||
await settle()
|
||||
await flush()
|
||||
release.shift()!()
|
||||
await flush()
|
||||
release.shift()!()
|
||||
await done
|
||||
expect(writes()).toEqual(['POST /api/me/playlists/p1/items', 'POST /api/me/playlists/p1/removals'])
|
||||
expect(included(rain)).toBe(true)
|
||||
holdReads = false
|
||||
await settle()
|
||||
expect(included(rain)).toBe(true)
|
||||
})
|
||||
|
||||
it('移出失败后重新读取也失败,勾选保持回滚的结果', async () => {
|
||||
const { playlistsStore, included } = await loaded()
|
||||
failRemove = true
|
||||
failReads = true
|
||||
const done = playlistsStore.setMembership(favorites, sunny, false)
|
||||
await settle()
|
||||
await done
|
||||
expect(requests).toEqual(['POST /api/me/playlists/p1/removals', 'POST /api/me/playlist-memberships', 'GET /api/me/playlists'])
|
||||
expect(included(sunny)).toBe(true)
|
||||
expect(playlistsStore.state.failed).toBe(true)
|
||||
})
|
||||
|
||||
it('读取期间正在加入的歌以本地为准', async () => {
|
||||
@@ -346,6 +382,30 @@ describe('playlistsStore', () => {
|
||||
expect(notificationStore.state.notifications.map(n => n.message)).toEqual([changed])
|
||||
})
|
||||
|
||||
// 详情页的用法:移动经过 TrackEdits 排队。CONFLICT 说明歌单在别处变了,排在后面的移动基于过时的顺序。
|
||||
it('排队的移动遇到 CONFLICT:提示服务端的文案,回到这次移动之前,后面的不再发出,结束后重新读取', async () => {
|
||||
const { playlistsStore } = await loaded()
|
||||
const { notificationStore } = await import('@/shared/ui')
|
||||
const { TrackEdits } = await import('./track-edits')
|
||||
let rereads = 0
|
||||
const edits = new TrackEdits(() => rereads++)
|
||||
failMove = true
|
||||
let shown = '第一次移动之后'
|
||||
void edits.run(() => playlistsStore.moveItems(favorites, [rain], sunny), () => {
|
||||
shown = '移动之前'
|
||||
})
|
||||
shown = '第二次移动之后'
|
||||
const done = edits.run(() => playlistsStore.moveItems(favorites, [sunny]), () => {
|
||||
shown = '第一次移动之后'
|
||||
})
|
||||
await settle()
|
||||
await done
|
||||
expect(writes()).toEqual(['POST /api/me/playlists/p1/moves'])
|
||||
expect(shown).toBe('移动之前')
|
||||
expect(rereads).toBe(1)
|
||||
expect(notificationStore.state.notifications.map(n => n.message)).toEqual([changed])
|
||||
})
|
||||
|
||||
it('游客不读取歌单', async () => {
|
||||
const { playlistsStore } = await import('./playlists-store')
|
||||
playlistsStore.start()
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
|
||||
.playlist-tracks { position: relative; }
|
||||
.playlist-track { user-select: none; -webkit-user-select: none; }
|
||||
.playlist-track:focus-visible { outline: 2px solid var(--color-brand); outline-offset: -2px; }
|
||||
.playlist-track:is(:focus-visible, :has(> .playlist-track-check:focus-visible)) { outline: 2px solid var(--color-brand); outline-offset: -2px; }
|
||||
.playlist-track[data-selected], .playlist-track[data-selected]:hover { background: var(--color-selected); }
|
||||
.playlist-track[data-dragging] { opacity: .4; }
|
||||
.playlist-track-check { margin: 0 3px; }
|
||||
@@ -123,7 +123,7 @@
|
||||
.playlist-tracks[data-dragging] .playlist-track:not([data-selected]):hover { background: transparent; }
|
||||
@media (hover: hover) {
|
||||
.playlist-track-handle { opacity: 0; transition: opacity 120ms ease; }
|
||||
.playlist-tracks:not([data-dragging]) .playlist-track:is(:hover, :focus-visible) .playlist-track-handle { opacity: 1; }
|
||||
.playlist-tracks:not([data-dragging]) .playlist-track:is(:hover, :focus-visible, :has(> .playlist-track-check:focus-visible)) .playlist-track-handle { opacity: 1; }
|
||||
}
|
||||
.playlist-drop-line { position: absolute; top: 0; left: 8px; right: 8px; height: 2px; margin-top: -1px; border-radius: 1px; background: var(--color-brand); pointer-events: none; visibility: hidden; }
|
||||
.playlist-drag-label { position: fixed; top: 0; left: 0; z-index: 40; max-width: 240px; padding: 4px 10px; overflow: hidden; border: 1px solid var(--color-border); border-radius: 999px; background: var(--color-popover); color: var(--color-foreground); box-shadow: 0 8px 24px var(--color-shadow); text-overflow: ellipsis; white-space: nowrap; pointer-events: none; visibility: hidden; }
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { profileStore } from '@/features/settings'
|
||||
import { useStore } from '@/shared/lib/use-store'
|
||||
import { IconButton, Modal, notificationStore, Skeleton } from '@/shared/ui'
|
||||
import { openAccountSettings } from './account-settings'
|
||||
import { CreatePlaylistForm } from './create-playlist-form'
|
||||
import { FavoritesMark, PlaylistCover } from './playlist-cover'
|
||||
import { sortPlaylists } from './playlist-sort'
|
||||
@@ -103,7 +104,23 @@ export function SaveToPlaylistDialog(props: SaveToPlaylistProps) {
|
||||
|
||||
let body
|
||||
if (!account) {
|
||||
body = <p className="text-ui-base muted">注册账号之后就能建自己的歌单。</p>
|
||||
body = (
|
||||
<>
|
||||
<p className="text-ui-base muted">注册或登录之后就能把歌保存到歌单。</p>
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button-primary"
|
||||
onClick={() => {
|
||||
onClose()
|
||||
openAccountSettings()
|
||||
}}
|
||||
>
|
||||
去账号设置
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
else if (readFailed || (playlists === null && failed)) {
|
||||
body = (
|
||||
|
||||
@@ -24,7 +24,7 @@ export function SelectionBar({ count, allSelected, onSave, onTop, onBottom, onRe
|
||||
}) {
|
||||
return (
|
||||
<div className="playlist-selection-bar" role="toolbar" aria-label="选中的歌">
|
||||
<span className="playlist-selection-count text-ui-sm" aria-live="polite">{`已选 ${count} 首`}</span>
|
||||
<span className="playlist-selection-count text-ui-sm">{`已选 ${count} 首`}</span>
|
||||
<BarButton icon={ListPlus} text="保存到歌单" label="把选中的歌保存到歌单" onClick={onSave} />
|
||||
<BarButton icon={ArrowUpToLine} text="移到最前" label="把选中的歌移到最前" onClick={onTop} />
|
||||
<BarButton icon={ArrowDownToLine} text="移到最后" label="把选中的歌移到最后" onClick={onBottom} />
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { TrackEdits } from './track-edits'
|
||||
|
||||
// 由测试决定每个请求何时完成、成功与否。
|
||||
function requests() {
|
||||
const pending: ((ok: boolean) => void)[] = []
|
||||
const sent: string[] = []
|
||||
const request = (name: string) => () => {
|
||||
sent.push(name)
|
||||
return new Promise<boolean>(resolve => pending.push(resolve))
|
||||
}
|
||||
const settle = async (ok: boolean) => {
|
||||
await flush()
|
||||
pending.shift()!(ok)
|
||||
await flush()
|
||||
}
|
||||
return { request, sent, settle }
|
||||
}
|
||||
|
||||
function flush() {
|
||||
return new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function setup() {
|
||||
let rereads = 0
|
||||
const edits = new TrackEdits(() => rereads++)
|
||||
return { edits, rereads: () => rereads }
|
||||
}
|
||||
|
||||
describe('trackEdits', () => {
|
||||
it('没有修改时读到的结果可以用', () => {
|
||||
const { edits } = setup()
|
||||
const usable = edits.read()
|
||||
expect(usable()).toBe(true)
|
||||
})
|
||||
|
||||
it('修改进行中返回的读取不能用', async () => {
|
||||
const { edits } = setup()
|
||||
const { request, settle } = requests()
|
||||
void edits.run(request('移出'), () => {})
|
||||
const usable = edits.read()
|
||||
expect(usable()).toBe(false)
|
||||
await settle(true)
|
||||
expect(usable()).toBe(false)
|
||||
})
|
||||
|
||||
it('读取开始之后发起的修改,即使在读取返回前已经完成,读到的也不能用', async () => {
|
||||
const { edits } = setup()
|
||||
const { request, settle } = requests()
|
||||
const usable = edits.read()
|
||||
void edits.run(request('移动'), () => {})
|
||||
await settle(true)
|
||||
expect(usable()).toBe(false)
|
||||
})
|
||||
|
||||
it('最后一个修改完成之后重新读取一次', async () => {
|
||||
const { edits, rereads } = setup()
|
||||
const { request, settle } = requests()
|
||||
void edits.run(request('移出'), () => {})
|
||||
void edits.run(request('移动'), () => {})
|
||||
await settle(true)
|
||||
expect(rereads()).toBe(0)
|
||||
await settle(true)
|
||||
expect(rereads()).toBe(1)
|
||||
expect(edits.read()()).toBe(true)
|
||||
})
|
||||
|
||||
it('修改按发起的先后逐个请求,前一个完成之后才发下一个', async () => {
|
||||
const { edits } = setup()
|
||||
const { request, sent, settle } = requests()
|
||||
void edits.run(request('移到最前'), () => {})
|
||||
void edits.run(request('移到最后'), () => {})
|
||||
await flush()
|
||||
expect(sent).toEqual(['移到最前'])
|
||||
await settle(true)
|
||||
expect(sent).toEqual(['移到最前', '移到最后'])
|
||||
})
|
||||
|
||||
it('一次失败之后,排在后面的不再发出,列表回到失败的那次之前,结束后重新读取一次', async () => {
|
||||
const { edits, rereads } = setup()
|
||||
const { request, sent, settle } = requests()
|
||||
const rolledBack: string[] = []
|
||||
let shown = '第一次移动之后'
|
||||
void edits.run(request('第一次移动'), () => {
|
||||
rolledBack.push('第一次移动')
|
||||
shown = '移动之前'
|
||||
})
|
||||
shown = '第二次移动之后'
|
||||
const done = edits.run(request('第二次移动'), () => {
|
||||
rolledBack.push('第二次移动')
|
||||
shown = '第一次移动之后'
|
||||
})
|
||||
await settle(false)
|
||||
await done
|
||||
expect(sent).toEqual(['第一次移动'])
|
||||
expect(rolledBack).toEqual(['第一次移动'])
|
||||
expect(shown).toBe('移动之前')
|
||||
expect(rereads()).toBe(1)
|
||||
})
|
||||
|
||||
it('失败之后新发起的修改照常请求', async () => {
|
||||
const { edits } = setup()
|
||||
const { request, sent, settle } = requests()
|
||||
void edits.run(request('移动'), () => {})
|
||||
await settle(false)
|
||||
void edits.run(request('移出'), () => {})
|
||||
await flush()
|
||||
expect(sent).toEqual(['移动', '移出'])
|
||||
})
|
||||
|
||||
it('移出失败时回到移出之前,全部完成之后重新读取', async () => {
|
||||
const { edits, rereads } = setup()
|
||||
const { request, settle } = requests()
|
||||
let items = ['晴天', '七里香']
|
||||
const before = items
|
||||
items = ['七里香']
|
||||
const done = edits.run(request('移出'), () => {
|
||||
items = before
|
||||
})
|
||||
await settle(false)
|
||||
await done
|
||||
expect(items).toEqual(['晴天', '七里香'])
|
||||
expect(rereads()).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
// 详情页对曲目的修改(移动、移出)先在本地改好再请求。请求按发起的先后逐个发出,每次都基于上一次修改之后的顺序;
|
||||
// 一次失败之后,排在它后面的不再发出,由失败的那次回到它之前的样子。
|
||||
// 修改进行中读到的列表可能是修改之前的,不能用;最后一个修改结束之后再读一次,以服务端为准。
|
||||
export class TrackEdits {
|
||||
readonly #reread: () => void
|
||||
#tail: Promise<void> = Promise.resolve()
|
||||
#generation = 0
|
||||
#pending = 0
|
||||
#started = 0
|
||||
|
||||
constructor(reread: () => void) {
|
||||
this.#reread = reread
|
||||
}
|
||||
|
||||
// request 失败时返回 false,不抛出。
|
||||
run(request: () => Promise<boolean>, rollback: () => void): Promise<void> {
|
||||
const generation = this.#generation
|
||||
this.#pending++
|
||||
this.#started++
|
||||
const done = this.#tail.then(async () => {
|
||||
if (this.#generation !== generation || await request())
|
||||
return
|
||||
this.#generation++
|
||||
rollback()
|
||||
}).finally(() => {
|
||||
if (--this.#pending === 0)
|
||||
this.#reread()
|
||||
})
|
||||
this.#tail = done
|
||||
return done
|
||||
}
|
||||
|
||||
// 读取开始时调用,返回的函数在读取返回时判断读到的列表能不能用。
|
||||
read(): () => boolean {
|
||||
const idle = this.#pending === 0
|
||||
const started = this.#started
|
||||
return () => idle && this.#started === started
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { roomStore } from '@/core/room'
|
||||
import { SaveToPlaylistDialog, saveToPlaylistLabel } from '@/features/playlists'
|
||||
import { bookTitle } from '@/shared/lib/book-title'
|
||||
import { useStore } from '@/shared/lib/use-store'
|
||||
import { EmptyState, PlayingBars, Select, SongArtwork, SongList } from '@/shared/ui'
|
||||
import { EmptyState, IconButton, PlayingBars, Select, SongArtwork, SongList } from '@/shared/ui'
|
||||
import { likeEntry, removeEntry, setPlayMode } from './actions'
|
||||
import { sourceName } from './source'
|
||||
import './queue.css'
|
||||
@@ -28,7 +28,7 @@ function songItem(entry: Entry): SongItem {
|
||||
}
|
||||
}
|
||||
|
||||
function QueueCard({ label, entry, className = '', playing = false }: { label: string, entry: Entry, className?: string, playing?: boolean }) {
|
||||
function QueueCard({ label, entry, className = '', playing = false, onSave }: { label: string, entry: Entry, className?: string, playing?: boolean, onSave: () => void }) {
|
||||
const song = songItem(entry)
|
||||
return (
|
||||
<div className={`queue-current ${className}`}>
|
||||
@@ -39,6 +39,7 @@ function QueueCard({ label, entry, className = '', playing = false }: { label: s
|
||||
<span className="text-ui-sm muted truncate">{[song.artist, song.sourceName, song.note].filter(Boolean).join(' · ')}</span>
|
||||
</div>
|
||||
{playing && <PlayingBars />}
|
||||
<IconButton icon={ListPlus} label={saveToPlaylistLabel(song.title)} onClick={onSave} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -50,6 +51,7 @@ export function QueuePage({ compact = false }: { compact?: boolean }) {
|
||||
const playMode = useStore(roomStore, s => s.room?.playMode ?? 'sequential')
|
||||
const connected = useStore(roomStore, s => s.status === 'open')
|
||||
const [saving, setSaving] = useState<{ track: Track, title: string } | null>(null)
|
||||
const save = (entry: Entry) => setSaving({ track: entry.track, title: entry.meta.title })
|
||||
return (
|
||||
<section className={compact ? 'queue-panel' : 'page-content queue-page'} aria-label="播放列表">
|
||||
<div className="section-heading">
|
||||
@@ -70,8 +72,8 @@ export function QueuePage({ compact = false }: { compact?: boolean }) {
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{nowPlaying && <QueueCard label="正在播放" entry={nowPlaying.entry} playing />}
|
||||
{pending && <QueueCard label="下一首" entry={pending} className="queue-pending" />}
|
||||
{nowPlaying && <QueueCard label="正在播放" entry={nowPlaying.entry} playing onSave={() => save(nowPlaying.entry)} />}
|
||||
{pending && <QueueCard label="下一首" entry={pending} className="queue-pending" onSave={() => save(pending)} />}
|
||||
{items.length
|
||||
? (
|
||||
<SongList
|
||||
|
||||
@@ -36,19 +36,8 @@ export function AccountRow() {
|
||||
}, [])
|
||||
const logout = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.logout()
|
||||
switchIdentity(null)
|
||||
}
|
||||
catch (error) {
|
||||
// 账号在这台设备上的会话已经失效,客户端也已经换成新的游客,退出登录的目的已经达到。
|
||||
if (error instanceof IdentityRenewedError) {
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
notificationStore.error(errorMessage(error))
|
||||
if (!await profileStore.logout())
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
const description = !member
|
||||
? (failed ? '暂时无法获取你的资料。' : '正在获取你的资料…')
|
||||
|
||||
@@ -30,9 +30,13 @@ const member = { id: 'm1', name: 'alice', avatarHash: '' }
|
||||
const renewed = { id: 'm2', name: '游客-1a2b', avatarHash: '' }
|
||||
|
||||
let local: FakeStorage
|
||||
let storageListeners: ((event: { key: string }) => void)[]
|
||||
let reload: ReturnType<typeof vi.fn>
|
||||
let me: unknown
|
||||
let updates: unknown[]
|
||||
let patchFails: boolean
|
||||
let patchError: { status: number, code: string } | null
|
||||
let extraRoutes: Map<string, () => Response>
|
||||
|
||||
async function load() {
|
||||
const [{ profileStore, switchIdentity }, { identityEvents }] = await Promise.all([import('./profile-store'), import('@/core/api')])
|
||||
@@ -47,15 +51,26 @@ beforeEach(() => {
|
||||
reload = vi.fn()
|
||||
me = { member }
|
||||
updates = []
|
||||
patchFails = false
|
||||
patchError = null
|
||||
extraRoutes = new Map()
|
||||
vi.stubGlobal('localStorage', local)
|
||||
vi.stubGlobal('sessionStorage', new FakeStorage())
|
||||
vi.stubGlobal('window', { location: { reload } })
|
||||
storageListeners = []
|
||||
vi.stubGlobal('window', { location: { reload }, addEventListener: (_type: string, listener: (event: { key: string }) => void) => storageListeners.push(listener) })
|
||||
vi.stubGlobal('fetch', (path: string, init: RequestInit) => {
|
||||
const request = `${init.method} ${path}`
|
||||
if (request === 'PATCH /api/me') {
|
||||
if (patchFails)
|
||||
return Promise.reject(new TypeError('offline'))
|
||||
updates.push(JSON.parse(String(init.body)))
|
||||
if (patchError)
|
||||
return Promise.resolve(new Response(JSON.stringify({ error: { code: patchError.code, message: '邮箱格式不对' } }), { status: patchError.status }))
|
||||
return Promise.resolve(new Response(JSON.stringify({ member }), { status: 200 }))
|
||||
}
|
||||
const extra = extraRoutes.get(request)
|
||||
if (extra)
|
||||
return Promise.resolve(extra())
|
||||
if (request !== 'GET /api/me')
|
||||
throw new Error(`没有预期的请求 ${request}`)
|
||||
return Promise.resolve(new Response(JSON.stringify(me), { status: 200 }))
|
||||
@@ -99,6 +114,50 @@ describe('switchIdentity', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('其他标签页更换身份', () => {
|
||||
it('其他标签页换了 token 之后重新加载页面', async () => {
|
||||
local.setItem(memberTokenKey, 'good')
|
||||
const { profileStore } = await load()
|
||||
profileStore.start()
|
||||
await profileStore.load()
|
||||
local.setItem(memberTokenKey, 'other')
|
||||
for (const listener of storageListeners) listener({ key: memberTokenKey })
|
||||
expect(reload).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('退出登录', () => {
|
||||
beforeEach(() => {
|
||||
local.setItem(memberTokenKey, 'account')
|
||||
})
|
||||
|
||||
it('成功后删除 token 并重新加载页面', async () => {
|
||||
extraRoutes.set('DELETE /api/sessions/current', () => new Response(null, { status: 204 }))
|
||||
const { profileStore } = await load()
|
||||
await profileStore.logout()
|
||||
expect(local.getItem(memberTokenKey)).toBeNull()
|
||||
expect(reload).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('会话已经失效、客户端换成了新的游客时,重新加载页面', async () => {
|
||||
extraRoutes.set('DELETE /api/sessions/current', () => new Response(JSON.stringify({ error: { code: 'UNAUTHORIZED', message: '身份已失效' } }), { status: 401 }))
|
||||
extraRoutes.set('POST /api/members', () => new Response(JSON.stringify({ member: renewed, memberToken: 'guest' }), { status: 200 }))
|
||||
me = { member: renewed }
|
||||
const { profileStore } = await load()
|
||||
await profileStore.logout()
|
||||
expect(local.getItem(memberTokenKey)).toBe('guest')
|
||||
expect(reload).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('其他错误不重新加载页面,token 保留', async () => {
|
||||
extraRoutes.set('DELETE /api/sessions/current', () => new Response(JSON.stringify({ error: { code: 'INTERNAL', message: '出错了' } }), { status: 500 }))
|
||||
const { profileStore } = await load()
|
||||
expect(await profileStore.logout()).toBe(false)
|
||||
expect(local.getItem(memberTokenKey)).toBe('account')
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('账号状态', () => {
|
||||
beforeEach(() => {
|
||||
local.setItem(memberTokenKey, 'good')
|
||||
@@ -151,6 +210,7 @@ describe('邮箱', () => {
|
||||
await profileStore.load()
|
||||
expect(profileStore.state.email).toBe('Alice@Example.COM')
|
||||
|
||||
local.removeItem(emailKey)
|
||||
me = { member, account: { username: 'alice' } }
|
||||
await profileStore.load()
|
||||
expect(profileStore.state.email).toBe('')
|
||||
@@ -205,4 +265,72 @@ describe('邮箱', () => {
|
||||
await profileStore.registered({ username: 'alice' })
|
||||
expect(updates).toEqual([])
|
||||
})
|
||||
|
||||
it('注册时转存失败,本机的副本保留,下次加载时重试并删除副本', async () => {
|
||||
local.setItem(emailKey, 'guest@example.com')
|
||||
patchFails = true
|
||||
const { profileStore } = await load()
|
||||
profileStore.start()
|
||||
await profileStore.registered({ username: 'alice' })
|
||||
expect(local.getItem(emailKey)).toBe('guest@example.com')
|
||||
|
||||
patchFails = false
|
||||
me = { member, account: { username: 'alice' } }
|
||||
await profileStore.load()
|
||||
expect(updates).toEqual([{ email: 'guest@example.com' }])
|
||||
expect(local.getItem(emailKey)).toBeNull()
|
||||
expect(profileStore.state).toMatchObject({ email: 'guest@example.com', account: { username: 'alice', email: 'guest@example.com' } })
|
||||
})
|
||||
|
||||
it('服务端判定本机的邮箱不合格时删除副本,之后加载不再转存', async () => {
|
||||
local.setItem(emailKey, 'legacy')
|
||||
patchError = { status: 400, code: 'INVALID_ARGUMENT' }
|
||||
me = { member, account: { username: 'alice' } }
|
||||
const { profileStore } = await load()
|
||||
await profileStore.load()
|
||||
expect(local.getItem(emailKey)).toBeNull()
|
||||
expect(updates).toEqual([{ email: 'legacy' }])
|
||||
|
||||
await profileStore.load()
|
||||
expect(updates).toEqual([{ email: 'legacy' }])
|
||||
})
|
||||
|
||||
it('重试仍失败时保留本机的副本,下次加载再试', async () => {
|
||||
local.setItem(emailKey, 'guest@example.com')
|
||||
patchFails = true
|
||||
me = { member, account: { username: 'alice' } }
|
||||
const { profileStore } = await load()
|
||||
await profileStore.load()
|
||||
expect(local.getItem(emailKey)).toBe('guest@example.com')
|
||||
|
||||
patchFails = false
|
||||
await profileStore.load()
|
||||
expect(local.getItem(emailKey)).toBeNull()
|
||||
expect(updates).toEqual([{ email: 'guest@example.com' }])
|
||||
})
|
||||
|
||||
it('已注册且本机没有副本,或账号已有邮箱时,加载不发更新请求', async () => {
|
||||
me = { member, account: { username: 'alice' } }
|
||||
const { profileStore } = await load()
|
||||
await profileStore.load()
|
||||
|
||||
local.setItem(emailKey, 'guest@example.com')
|
||||
me = { member, account: { username: 'alice', email: 'alice@example.com' } }
|
||||
await profileStore.load()
|
||||
expect(updates).toEqual([])
|
||||
})
|
||||
|
||||
it('用户清空账号的邮箱时一并删除本机残留的副本,之后加载不再转存', async () => {
|
||||
local.setItem(emailKey, 'guest@example.com')
|
||||
me = { member, account: { username: 'alice', email: 'alice@example.com' } }
|
||||
const { profileStore } = await load()
|
||||
await profileStore.load()
|
||||
expect(await profileStore.save({ email: '' })).toBe(true)
|
||||
expect(local.getItem(emailKey)).toBeNull()
|
||||
|
||||
updates.length = 0
|
||||
me = { member, account: { username: 'alice' } }
|
||||
await profileStore.load()
|
||||
expect(updates).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IdentityEvent } from '@/core/api'
|
||||
import type { Account, Member } from '@/core/realtime'
|
||||
import { api, deleteAllTickets, deleteMemberToken, identityEvents, IdentityRenewedError, saveMemberToken } from '@/core/api'
|
||||
import { api, ApiError, deleteAllTickets, deleteMemberToken, followMemberTokenChanges, identityEvents, IdentityRenewedError, saveMemberToken } from '@/core/api'
|
||||
import { errorMessage } from '@/core/errors'
|
||||
import { Store } from '@/shared/lib/store'
|
||||
import { notificationStore } from '@/shared/ui'
|
||||
@@ -18,6 +18,8 @@ class ProfileStore extends Store<{ member: Member | null, account: Account | nul
|
||||
start(): void {
|
||||
this.setState({ email: localStorage.getItem(emailKey) ?? '' })
|
||||
identityEvents.on(this.#onIdentity)
|
||||
// 其他标签页登录或退出之后,本标签页的资料、房间连接都属于旧身份,重新加载页面换成新身份。
|
||||
followMemberTokenChanges(() => window.location.reload())
|
||||
}
|
||||
|
||||
load(): Promise<void> {
|
||||
@@ -27,21 +29,45 @@ class ProfileStore extends Store<{ member: Member | null, account: Account | nul
|
||||
return this.#loading
|
||||
}
|
||||
|
||||
// 本机已有的邮箱存进新账号,存失败只提示,不影响注册。
|
||||
// 本机已有的邮箱存进新账号,存失败只提示,不影响注册;本机的副本保留,下次加载时重试。
|
||||
async registered(account: Account): Promise<void> {
|
||||
this.setState({ account })
|
||||
const email = localStorage.getItem(emailKey)
|
||||
if (email && await this.save({ email }))
|
||||
localStorage.removeItem(emailKey)
|
||||
await this.#moveLocalEmail()
|
||||
}
|
||||
|
||||
// 返回 false 时,失败原因已经通过通知显示。
|
||||
async logout(): Promise<boolean> {
|
||||
try {
|
||||
await api.logout()
|
||||
switchIdentity(null)
|
||||
}
|
||||
catch (error) {
|
||||
// 账号在这台设备上的会话已经失效,客户端也已经换成新的游客,退出登录的目的已经达到。
|
||||
if (error instanceof IdentityRenewedError) {
|
||||
window.location.reload()
|
||||
return true
|
||||
}
|
||||
notificationStore.error(errorMessage(error))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 返回 false 时,失败原因已经通过通知显示。
|
||||
async save(changes: { name?: string, email?: string }): Promise<boolean> {
|
||||
return await this.#update(changes) === null
|
||||
}
|
||||
|
||||
// 失败时返回错误,失败原因已经通过通知显示。
|
||||
async #update(changes: { name?: string, email?: string }): Promise<unknown> {
|
||||
try {
|
||||
const { member } = await api.updateMe(changes)
|
||||
const { account } = this.state
|
||||
if (account) {
|
||||
const email = changes.email?.trim() ?? this.state.email
|
||||
// 用户对账号邮箱的修改是最终决定,本机残留的副本不能再覆盖它。
|
||||
if (changes.email !== undefined)
|
||||
localStorage.removeItem(emailKey)
|
||||
this.setState({ member, email, account: { username: account.username, ...(email && { email }) }, failed: false })
|
||||
}
|
||||
else {
|
||||
@@ -49,12 +75,12 @@ class ProfileStore extends Store<{ member: Member | null, account: Account | nul
|
||||
localStorage.setItem(emailKey, email)
|
||||
this.setState({ member, email, failed: false })
|
||||
}
|
||||
return true
|
||||
return null
|
||||
}
|
||||
catch (error) {
|
||||
// 客户端不能按服务端的 message 区分是哪个字段不合格。
|
||||
notificationStore.error(error instanceof IdentityRenewedError ? '个人资料没有保存,再保存一次。' : errorMessage(error, { INVALID_ARGUMENT: '昵称不能为空;邮箱要写成 name@example.com 这样的格式。' }))
|
||||
return false
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +89,8 @@ class ProfileStore extends Store<{ member: Member | null, account: Account | nul
|
||||
try {
|
||||
const { member, account } = await api.getMe()
|
||||
this.setState({ member, account: account ?? null, email: account ? account.email ?? '' : localStorage.getItem(emailKey) ?? '', failed: false })
|
||||
if (account && !account.email)
|
||||
await this.#moveLocalEmail()
|
||||
}
|
||||
catch (error) {
|
||||
// 成员 token 被拒绝时身份已经更换,新的成员由身份事件设置。
|
||||
@@ -73,6 +101,16 @@ class ProfileStore extends Store<{ member: Member | null, account: Account | nul
|
||||
}
|
||||
}
|
||||
|
||||
async #moveLocalEmail(): Promise<void> {
|
||||
const email = localStorage.getItem(emailKey)
|
||||
if (!email)
|
||||
return
|
||||
const error = await this.#update({ email })
|
||||
// 服务端不接受的副本重试也不会成功,删掉免得每次加载都报错;其他错误(网络、服务端故障)留到下次再试。
|
||||
if (error instanceof ApiError && error.code === 'INVALID_ARGUMENT')
|
||||
localStorage.removeItem(emailKey)
|
||||
}
|
||||
|
||||
#onIdentity = (event: IdentityEvent): void => {
|
||||
switch (event.kind) {
|
||||
case 'rejected':
|
||||
|
||||
@@ -108,3 +108,4 @@
|
||||
.modal-body { padding: 20px 20px calc(20px + env(safe-area-inset-bottom)); }
|
||||
.notifications { --notification-top: calc(76px + env(safe-area-inset-top)); }
|
||||
}
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
||||
Reference in new issue
Block a user