feat: expose device pairing over HTTP
This commit is contained in:
1 parent
50a865e2a3
commit
88f8e2f957
23 files changed
+587
-75
No files matched your search
@@ -70,8 +70,39 @@ func (s *server) adminResetAccountPassword(ctx context.Context, c call) (any, er
|
||||
return protocol.AdminResetAccountPasswordResponse{Password: password}, nil
|
||||
}
|
||||
|
||||
func (s *server) createPairingCode(ctx context.Context, c call) (any, error) {
|
||||
if _, err := protocol.DecodeCreatePairingCodeRequest(c.body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code, err := s.Accounts.CreatePairingCode(ctx, c.member.ID, bearer(c.r))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 配对码放在片段里,不会出现在请求和 Referer 中。
|
||||
image, err := qrDataURI(s.PublicURL + "/pair#" + code.Code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return protocol.CreatePairingCodeResponse{Code: code.Code, QrImage: image, ExpiresAtMs: code.ExpiresAt.UnixMilli()}, nil
|
||||
}
|
||||
|
||||
func (s *server) redeemPairingCode(ctx context.Context, c call) (any, error) {
|
||||
req, err := protocol.DecodeRedeemPairingCodeRequest(c.body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session, account, err := s.Accounts.RedeemPairingCode(ctx, c.member, bearer(c.r), c.clientIP, req.Code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return protocol.RedeemPairingCodeResponse{MemberToken: session.MemberToken, Member: memberDTO(session.Member), Account: accountDTO(account)}, nil
|
||||
}
|
||||
|
||||
func accountDTO(a identity.Account) protocol.Account {
|
||||
dto := protocol.Account{Username: a.Username}
|
||||
dto := protocol.Account{Github: a.GitHub}
|
||||
if a.Username != "" {
|
||||
dto.Username = &a.Username
|
||||
}
|
||||
if a.Email != "" {
|
||||
dto.Email = &a.Email
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ type Services struct {
|
||||
|
||||
// 浏览器访问对象存储时使用的地址前缀,结尾没有 /。
|
||||
StorageURL string
|
||||
// 网页的 origin,结尾没有 /。
|
||||
PublicURL string
|
||||
}
|
||||
|
||||
type server struct {
|
||||
@@ -169,6 +171,8 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
|
||||
srv.handle(mux, protocol.RouteUnshareMyPlaylist, srv.unshareMyPlaylist)
|
||||
srv.handle(mux, protocol.RouteGetSharedPlaylist, srv.getSharedPlaylist)
|
||||
srv.handle(mux, protocol.RouteCopySharedPlaylist, srv.copySharedPlaylist)
|
||||
srv.handle(mux, protocol.RouteCreatePairingCode, srv.createPairingCode)
|
||||
srv.handle(mux, protocol.RouteRedeemPairingCode, srv.redeemPairingCode)
|
||||
mux.HandleFunc("GET /ws", srv.websocket)
|
||||
mux.HandleFunc("GET /api/room/events", srv.roomEvents)
|
||||
mux.HandleFunc("GET /media/{source}/{id}", srv.redirectMedia)
|
||||
@@ -357,6 +361,8 @@ var knownErrors = []struct {
|
||||
{errClientOutdated, protocol.ErrorCodeClientOutdated},
|
||||
{errPanicked, protocol.ErrorCodeInternal},
|
||||
{port.ErrShareNotFound, protocol.ErrorCodeNotFound},
|
||||
{identity.ErrInvalidPairingCode, protocol.ErrorCodeInvalidCredentials},
|
||||
{port.ErrNoPassword, protocol.ErrorCodeConflict},
|
||||
}
|
||||
|
||||
func errorInfo(err error) (info protocol.ErrorInfo, expected bool) {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"rsc.io/qr"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/identity"
|
||||
"github.com/dihemoe/alisten/internal/app/port/porttest"
|
||||
"github.com/dihemoe/alisten/protocol"
|
||||
)
|
||||
|
||||
type unlimited struct{}
|
||||
|
||||
func (unlimited) Take(context.Context, string, int, time.Duration) (time.Duration, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// 二维码的内容是网页上兑换配对码的地址,配对码放在片段里。
|
||||
func TestPairingCodeQRImage(t *testing.T) {
|
||||
store := porttest.New()
|
||||
s := services(store, store, "")
|
||||
s.Accounts = identity.NewAccounts(store, store, unlimited{}, porttest.NewCodes(), porttest.NewCodes(), porttest.NewDevices(), s.Gateway)
|
||||
s.PublicURL = "https://alisten.example"
|
||||
server, _ := start(t, s)
|
||||
token := memberToken(t, server)
|
||||
auth := []string{"Authorization", "Bearer " + token}
|
||||
if got := send(t, server, http.MethodPost, "/api/account", `{"username":"alice","password":"correct horse"}`, auth...); got.status != http.StatusCreated {
|
||||
t.Fatalf("注册 = %#v", got)
|
||||
}
|
||||
got := send(t, server, http.MethodPost, "/api/account/pairing-codes", "{}", auth...)
|
||||
var created protocol.CreatePairingCodeResponse
|
||||
if err := json.Unmarshal([]byte(got.body), &created); err != nil || got.status != http.StatusCreated {
|
||||
t.Fatalf("生成配对码 = %#v", got)
|
||||
}
|
||||
code, err := qr.Encode("https://alisten.example/pair#"+created.Code, qr.M)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG()); created.QrImage != want {
|
||||
t.Errorf("二维码 = %.60q…,期望内容为 https://alisten.example/pair#%s 的 PNG", created.QrImage, created.Code)
|
||||
}
|
||||
}
|
||||
@@ -87,15 +87,22 @@ func qrLogin(session port.QRLoginSession) (protocol.QRLogin, error) {
|
||||
if session.LoginURL == "" {
|
||||
return protocol.QRLogin{}, fmt.Errorf("音源没有给出二维码图片和登录链接:%w", port.ErrProviderFailed)
|
||||
}
|
||||
code, err := qr.Encode(session.LoginURL, qr.M)
|
||||
if err != nil {
|
||||
var err error
|
||||
if image, err = qrDataURI(session.LoginURL); err != nil {
|
||||
return protocol.QRLogin{}, fmt.Errorf("把登录链接编码成二维码:%w:%w", err, port.ErrProviderFailed)
|
||||
}
|
||||
image = "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG())
|
||||
}
|
||||
return protocol.QRLogin{Key: session.Key, QrImage: image, ExpiresAtMs: session.ExpiresAt.UnixMilli()}, nil
|
||||
}
|
||||
|
||||
func qrDataURI(text string) (string, error) {
|
||||
code, err := qr.Encode(text, qr.M)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG()), nil
|
||||
}
|
||||
|
||||
func qrLoginResult(r port.QRLoginResult) protocol.QRLoginResult {
|
||||
return protocol.QRLoginResult{Status: protocol.QRLoginStatus(r.Status), Message: r.Message}
|
||||
}
|
||||
@@ -95,6 +95,7 @@ func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instanc
|
||||
Listens: listen.New(store),
|
||||
|
||||
StorageURL: cfg.storageURL,
|
||||
PublicURL: cfg.publicOrigin,
|
||||
}
|
||||
httpLogger := base.With("component", "http")
|
||||
return public{
|
||||
|
||||
+7
-7
@@ -15,14 +15,14 @@ Authorization: Bearer $alice
|
||||
{"username": "alice", "password": "correct horse"}
|
||||
-- response --
|
||||
201
|
||||
{"account": {"username": "alice"}}
|
||||
{"account": {"username": "alice", "github": false}}
|
||||
|
||||
-- request --
|
||||
GET /api/me
|
||||
Authorization: Bearer $alice
|
||||
-- response --
|
||||
200
|
||||
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}, "recordListens": true}
|
||||
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}, "recordListens": true}
|
||||
|
||||
-- request --
|
||||
PATCH /api/me
|
||||
@@ -38,7 +38,7 @@ GET /api/me
|
||||
Authorization: Bearer $alice
|
||||
-- response --
|
||||
200
|
||||
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}, "recordListens": true}
|
||||
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM", "github": false}, "recordListens": true}
|
||||
|
||||
-- request --
|
||||
POST /api/account
|
||||
@@ -132,14 +132,14 @@ Authorization: Bearer $bob
|
||||
{"username": "alice", "password": "correct horse"}
|
||||
-- response --
|
||||
201
|
||||
{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}, "device": "$bobDevice"}
|
||||
{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM", "github": false}, "device": "$bobDevice"}
|
||||
|
||||
-- request --
|
||||
GET /api/me
|
||||
Authorization: Bearer $alice2
|
||||
-- response --
|
||||
200
|
||||
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}, "recordListens": true}
|
||||
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM", "github": false}, "recordListens": true}
|
||||
|
||||
-- request --
|
||||
PATCH /api/me
|
||||
@@ -155,7 +155,7 @@ GET /api/me
|
||||
Authorization: Bearer $alice
|
||||
-- response --
|
||||
200
|
||||
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}, "recordListens": true}
|
||||
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}, "recordListens": true}
|
||||
|
||||
-- request --
|
||||
GET /api/me
|
||||
@@ -255,4 +255,4 @@ Authorization: Bearer $dave
|
||||
{"username": "alice", "password": "$resetPassword"}
|
||||
-- response --
|
||||
201
|
||||
{"memberToken": "$alice4", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}, "device": "$daveDevice"}
|
||||
{"memberToken": "$alice4", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}, "device": "$daveDevice"}
|
||||
+3
-3
@@ -16,7 +16,7 @@ Authorization: Bearer $alice
|
||||
{"username": "alice", "password": "correct horse"}
|
||||
-- response --
|
||||
201
|
||||
{"account": {"username": "alice"}}
|
||||
{"account": {"username": "alice", "github": false}}
|
||||
|
||||
-- request --
|
||||
POST /api/members
|
||||
@@ -33,7 +33,7 @@ Authorization: Bearer $laptop
|
||||
{"username": "alice", "password": "correct horse"}
|
||||
-- response --
|
||||
201
|
||||
{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}, "device": "$device"}
|
||||
{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}, "device": "$device"}
|
||||
|
||||
-- request --
|
||||
DELETE /api/sessions/current
|
||||
@@ -127,7 +127,7 @@ Authorization: Bearer $laptop2
|
||||
{"username": "alice", "password": "correct horse", "device": "$device"}
|
||||
-- response --
|
||||
201
|
||||
{"memberToken": "$alice3", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}, "device": "$device"}
|
||||
{"memberToken": "$alice3", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}, "device": "$device"}
|
||||
|
||||
-- request --
|
||||
PUT /api/account/password
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
设备配对:已注册的账号生成配对码,游客兑换之后登录这个账号,原来的游客 token 随即失效;配对码只能兑换一次,游客不能生成,生成它的会话退出之后配对码作废。
|
||||
|
||||
-- request --
|
||||
POST /api/members
|
||||
|
||||
{}
|
||||
-- response --
|
||||
201
|
||||
{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
|
||||
|
||||
-- request --
|
||||
POST /api/account/pairing-codes
|
||||
Authorization: Bearer $alice
|
||||
|
||||
{}
|
||||
-- response --
|
||||
403
|
||||
{"error": {"code": "FORBIDDEN", "message": "还没有注册账号"}}
|
||||
|
||||
-- request --
|
||||
POST /api/account
|
||||
Authorization: Bearer $alice
|
||||
|
||||
{"username": "alice", "password": "correct horse"}
|
||||
-- response --
|
||||
201
|
||||
{"account": {"username": "alice", "github": false}}
|
||||
|
||||
-- request --
|
||||
POST /api/account/pairing-codes
|
||||
Authorization: Bearer $alice
|
||||
|
||||
{}
|
||||
-- response --
|
||||
201
|
||||
{"code": "$code", "qrImage": "$qr", "expiresAtMs": 1}
|
||||
|
||||
-- request --
|
||||
POST /api/members
|
||||
|
||||
{}
|
||||
-- response --
|
||||
201
|
||||
{"memberToken": "$bob", "member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}}
|
||||
|
||||
-- request --
|
||||
POST /api/sessions/pairing
|
||||
Authorization: Bearer $bob
|
||||
|
||||
{"code": "abcd2345"}
|
||||
-- response --
|
||||
400
|
||||
{"error": {"code": "INVALID_ARGUMENT", "message": "code:格式不正确"}}
|
||||
|
||||
-- request --
|
||||
POST /api/sessions/pairing
|
||||
Authorization: Bearer $bob
|
||||
|
||||
{"code": "$code"}
|
||||
-- response --
|
||||
201
|
||||
{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}}
|
||||
|
||||
-- request --
|
||||
GET /api/me
|
||||
Authorization: Bearer $bob
|
||||
-- response --
|
||||
401
|
||||
{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
|
||||
|
||||
-- request --
|
||||
GET /api/me
|
||||
Authorization: Bearer $alice2
|
||||
-- response --
|
||||
200
|
||||
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}, "recordListens": true}
|
||||
|
||||
-- request --
|
||||
POST /api/members
|
||||
|
||||
{}
|
||||
-- response --
|
||||
201
|
||||
{"memberToken": "$carol", "member": {"id": "$carolId", "name": "$carolName", "avatarHash": ""}}
|
||||
|
||||
-- request --
|
||||
POST /api/sessions/pairing
|
||||
Authorization: Bearer $carol
|
||||
|
||||
{"code": "$code"}
|
||||
-- response --
|
||||
401
|
||||
{"error": {"code": "INVALID_CREDENTIALS", "message": "配对码不对或已经过期"}}
|
||||
|
||||
-- request --
|
||||
POST /api/account/pairing-codes
|
||||
Authorization: Bearer $alice2
|
||||
|
||||
{}
|
||||
-- response --
|
||||
201
|
||||
{"code": "$code2", "qrImage": "$qr2", "expiresAtMs": 1}
|
||||
|
||||
-- request --
|
||||
POST /api/sessions/pairing
|
||||
Authorization: Bearer $alice
|
||||
|
||||
{"code": "$code2"}
|
||||
-- response --
|
||||
409
|
||||
{"error": {"code": "CONFLICT", "message": "已经登录了账号,先退出再登录其他账号"}}
|
||||
|
||||
-- request --
|
||||
DELETE /api/sessions/current
|
||||
Authorization: Bearer $alice2
|
||||
-- response --
|
||||
204
|
||||
|
||||
-- request --
|
||||
POST /api/sessions/pairing
|
||||
Authorization: Bearer $carol
|
||||
|
||||
{"code": "$code2"}
|
||||
-- response --
|
||||
401
|
||||
{"error": {"code": "INVALID_CREDENTIALS", "message": "配对码不对或已经过期"}}
|
||||
|
||||
-- request --
|
||||
GET /api/me
|
||||
Authorization: Bearer $carol
|
||||
-- response --
|
||||
200
|
||||
{"member": {"id": "$carolId", "name": "$carolName", "avatarHash": ""}, "recordListens": true}
|
||||
+1
-1
@@ -42,7 +42,7 @@ Authorization: Bearer $alice
|
||||
{"username": "alice", "password": "correct horse"}
|
||||
-- response --
|
||||
201
|
||||
{"account": {"username": "alice"}}
|
||||
{"account": {"username": "alice", "github": false}}
|
||||
|
||||
-- request --
|
||||
POST /api/rooms
|
||||
|
||||
+2
-2
@@ -15,7 +15,7 @@ Authorization: Bearer $alice
|
||||
{"username": "alice", "password": "correct horse"}
|
||||
-- response --
|
||||
201
|
||||
{"account": {"username": "alice"}}
|
||||
{"account": {"username": "alice", "github": false}}
|
||||
|
||||
-- request --
|
||||
PATCH /api/me
|
||||
@@ -165,7 +165,7 @@ Authorization: Bearer $bob
|
||||
{"username": "bob", "password": "correct horse"}
|
||||
-- response --
|
||||
201
|
||||
{"account": {"username": "bob"}}
|
||||
{"account": {"username": "bob", "github": false}}
|
||||
|
||||
-- request --
|
||||
POST /api/me/playlists/$favorites/share
|
||||
|
||||
+2
-2
@@ -31,7 +31,7 @@ Authorization: Bearer $alice
|
||||
{"username": "alice", "password": "correct horse"}
|
||||
-- response --
|
||||
201
|
||||
{"account": {"username": "alice"}}
|
||||
{"account": {"username": "alice", "github": false}}
|
||||
|
||||
-- request --
|
||||
GET /api/me/playlists
|
||||
@@ -282,7 +282,7 @@ Authorization: Bearer $bob
|
||||
{"username": "bob", "password": "correct horse"}
|
||||
-- response --
|
||||
201
|
||||
{"account": {"username": "bob"}}
|
||||
{"account": {"username": "bob", "github": false}}
|
||||
|
||||
-- request --
|
||||
GET /api/me/playlists
|
||||
|
||||
+1
-1
@@ -106,4 +106,4 @@ Authorization: Bearer $guest
|
||||
{"username": "guest", "password": "correct horse"}
|
||||
-- response --
|
||||
201
|
||||
{"account": {"username": "guest"}}
|
||||
{"account": {"username": "guest", "github": false}}
|
||||
+2
-2
@@ -16,7 +16,7 @@ Authorization: Bearer $alice
|
||||
{"username": "alice", "password": "correct horse"}
|
||||
-- response --
|
||||
201
|
||||
{"account": {"username": "alice"}}
|
||||
{"account": {"username": "alice", "github": false}}
|
||||
|
||||
-- request --
|
||||
POST /api/members
|
||||
@@ -33,7 +33,7 @@ Authorization: Bearer $guest
|
||||
{"username": "alice", "password": "correct horse"}
|
||||
-- response --
|
||||
201
|
||||
{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}, "device": "$guestDevice"}
|
||||
{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}, "device": "$guestDevice"}
|
||||
|
||||
-- request --
|
||||
POST /api/rooms
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@ Authorization: Bearer $alice
|
||||
{"username": "alice", "password": "correct horse"}
|
||||
-- response --
|
||||
201
|
||||
{"account": {"username": "alice"}}
|
||||
{"account": {"username": "alice", "github": false}}
|
||||
|
||||
-- request --
|
||||
GET /api/me/listens
|
||||
@@ -215,7 +215,7 @@ Authorization: Bearer $bob
|
||||
{"username": "alice", "password": "correct horse"}
|
||||
-- response --
|
||||
201
|
||||
{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}, "device": "$bobDevice"}
|
||||
{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}, "device": "$bobDevice"}
|
||||
-- closed again --
|
||||
4401
|
||||
-- receive alice --
|
||||
|
||||
+39
-6
@@ -8,12 +8,13 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
patternAdminCreateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{1,32}$`)
|
||||
patternAdminUpdateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{0,32}$`)
|
||||
patternLoginRequestDevice = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`)
|
||||
patternLoginRequestUsername = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
|
||||
patternRegisterRequestUsername = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
|
||||
patternTrackIDWhenBilibili = regexp.MustCompile(`^BV[1-9A-HJ-NP-Za-km-z]{10}(?:/([2-9]|[1-9][0-9]{1,3}))?$`)
|
||||
patternAdminCreateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{1,32}$`)
|
||||
patternAdminUpdateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{0,32}$`)
|
||||
patternLoginRequestDevice = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`)
|
||||
patternLoginRequestUsername = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
|
||||
patternRedeemPairingCodeRequestCode = regexp.MustCompile(`^[2-9A-HJ-NP-Z]{8}$`)
|
||||
patternRegisterRequestUsername = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
|
||||
patternTrackIDWhenBilibili = regexp.MustCompile(`^BV[1-9A-HJ-NP-Za-km-z]{10}(?:/([2-9]|[1-9][0-9]{1,3}))?$`)
|
||||
)
|
||||
|
||||
func decodeConnectionRequest(typ string, raw json.RawMessage, path string) (any, bool, error) {
|
||||
@@ -203,6 +204,14 @@ func DecodeCopySharedPlaylistRequest(body []byte) (CopySharedPlaylistRequest, er
|
||||
return decodeCopySharedPlaylistRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeCreatePairingCodeRequest(body []byte) (CreatePairingCodeRequest, error) {
|
||||
return decodeCreatePairingCodeRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeRedeemPairingCodeRequest(body []byte) (RedeemPairingCodeRequest, error) {
|
||||
return decodeRedeemPairingCodeRequest(body, "")
|
||||
}
|
||||
|
||||
func decodeAddMyPlaylistItemRequest(raw json.RawMessage, path string) (AddMyPlaylistItemRequest, error) {
|
||||
var v AddMyPlaylistItemRequest
|
||||
m, err := decodeObject(raw, path, "track")
|
||||
@@ -554,6 +563,12 @@ func decodeCreateMyPlaylistRequest(raw json.RawMessage, path string) (CreateMyPl
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeCreatePairingCodeRequest(raw json.RawMessage, path string) (CreatePairingCodeRequest, error) {
|
||||
var v CreatePairingCodeRequest
|
||||
_, err := decodeObject(raw, path)
|
||||
return v, err
|
||||
}
|
||||
|
||||
func decodeCreateRoomRequest(raw json.RawMessage, path string) (CreateRoomRequest, error) {
|
||||
var v CreateRoomRequest
|
||||
m, err := decodeObject(raw, path, "description", "name", "password")
|
||||
@@ -980,6 +995,24 @@ func decodeRecommendListRequest(raw json.RawMessage, path string) (RecommendList
|
||||
return v, err
|
||||
}
|
||||
|
||||
func decodeRedeemPairingCodeRequest(raw json.RawMessage, path string) (RedeemPairingCodeRequest, error) {
|
||||
var v RedeemPairingCodeRequest
|
||||
m, err := decodeObject(raw, path, "code")
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = requireFields(m, path, "code"); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Code, err = decodeString(m["code"], field(path, "code")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkPattern(v.Code, field(path, "code"), patternRedeemPairingCodeRequestCode); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeRegisterRequest(raw json.RawMessage, path string) (RegisterRequest, error) {
|
||||
var v RegisterRequest
|
||||
m, err := decodeObject(raw, path, "password", "username")
|
||||
|
||||
+60
-5
@@ -161,10 +161,11 @@
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"username": {"type": "string"},
|
||||
"email": {"type": "string", "description": "账号设置了邮箱时才有"}
|
||||
"username": {"type": "string", "description": "设置了用户名和密码时才有,只绑定了 GitHub 的账号没有"},
|
||||
"email": {"type": "string", "description": "账号设置了邮箱时才有"},
|
||||
"github": {"type": "boolean", "description": "绑定了 GitHub"}
|
||||
},
|
||||
"required": ["username"]
|
||||
"required": ["github"]
|
||||
},
|
||||
"AdminAccount": {
|
||||
"type": "object",
|
||||
@@ -375,6 +376,11 @@
|
||||
"items": {"type": "array", "items": {"$ref": "#/$defs/MyPlaylistItem"}, "description": "新加入的在前"}
|
||||
},
|
||||
"required": ["name", "favorites", "ownerName", "ownerAvatarHash", "items"]
|
||||
},
|
||||
"PairingCode": {
|
||||
"type": "string",
|
||||
"description": "配对码:8 位,由数字 2–9 和除 I、O 以外的大写字母组成",
|
||||
"pattern": "^[2-9A-HJ-NP-Z]{8}$"
|
||||
}
|
||||
},
|
||||
"x-ws": {
|
||||
@@ -795,7 +801,7 @@
|
||||
},
|
||||
{
|
||||
"name": "register",
|
||||
"description": "游客注册账号:设置用户名和密码,成员和成员 token 都不变;站点关闭注册时返回 FORBIDDEN",
|
||||
"description": "游客注册账号:设置用户名和密码,成员和成员 token 都不变;站点关闭注册时返回 FORBIDDEN。只绑定了 GitHub 的账号也用它补设用户名和密码,不受注册开关限制",
|
||||
"method": "POST",
|
||||
"path": "/api/account",
|
||||
"auth": "member",
|
||||
@@ -820,7 +826,7 @@
|
||||
},
|
||||
{
|
||||
"name": "changePassword",
|
||||
"description": "修改密码:账号在其他设备上的成员 token 随即失效,发出请求的这个不变;当前密码错误时返回 INVALID_CREDENTIALS",
|
||||
"description": "修改密码:账号在其他设备上的成员 token 随即失效,发出请求的这个不变;当前密码错误时返回 INVALID_CREDENTIALS,账号还没有设置密码时返回 CONFLICT",
|
||||
"method": "PUT",
|
||||
"path": "/api/account/password",
|
||||
"auth": "member",
|
||||
@@ -1686,6 +1692,55 @@
|
||||
"required": ["name"]
|
||||
},
|
||||
"response": {"$ref": "#/$defs/MyPlaylist"}
|
||||
},
|
||||
{
|
||||
"name": "createPairingCode",
|
||||
"description": "已注册的账号生成配对码,新设备兑换之后登录这个账号:5 分钟内有效,只能兑换一次,发出请求的成员 token 失效时随之作废;游客调用时返回 FORBIDDEN",
|
||||
"method": "POST",
|
||||
"path": "/api/account/pairing-codes",
|
||||
"auth": "member",
|
||||
"status": 201,
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"code": {"$ref": "#/$defs/PairingCode"},
|
||||
"qrImage": {"type": "string", "description": "内容为 ALISTEN_PUBLIC_URL/pair#<配对码> 的二维码,PNG 的 data: URI"},
|
||||
"expiresAtMs": {"type": "integer"}
|
||||
},
|
||||
"required": ["code", "qrImage", "expiresAtMs"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "redeemPairingCode",
|
||||
"description": "游客兑换配对码,登录生成它的账号:返回账号的新成员 token,发出请求的游客 token 随即失效;配对码不对或已过期时返回 INVALID_CREDENTIALS,已经登录了账号时返回 CONFLICT",
|
||||
"method": "POST",
|
||||
"path": "/api/sessions/pairing",
|
||||
"auth": "member",
|
||||
"status": 201,
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"code": {"$ref": "#/$defs/PairingCode"}
|
||||
},
|
||||
"required": ["code"]
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"memberToken": {"type": "string"},
|
||||
"member": {"$ref": "#/$defs/Member"},
|
||||
"account": {"$ref": "#/$defs/Account"}
|
||||
},
|
||||
"required": ["memberToken", "member", "account"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+156
-4
@@ -21,12 +21,17 @@
|
||||
"description": "账号设置了邮箱时才有",
|
||||
"type": "string"
|
||||
},
|
||||
"github": {
|
||||
"description": "绑定了 GitHub",
|
||||
"type": "boolean"
|
||||
},
|
||||
"username": {
|
||||
"description": "设置了用户名和密码时才有,只绑定了 GitHub 的账号没有",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"username"
|
||||
"github"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -523,6 +528,33 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"CreatePairingCodeRequest": {
|
||||
"additionalProperties": false,
|
||||
"type": "object"
|
||||
},
|
||||
"CreatePairingCodeResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"code": {
|
||||
"description": "配对码:8 位,由数字 2–9 和除 I、O 以外的大写字母组成",
|
||||
"pattern": "^[2-9A-HJ-NP-Z]{8}$",
|
||||
"type": "string"
|
||||
},
|
||||
"expiresAtMs": {
|
||||
"type": "integer"
|
||||
},
|
||||
"qrImage": {
|
||||
"description": "内容为 ALISTEN_PUBLIC_URL/pair#<配对码> 的二维码,PNG 的 data: URI",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"qrImage",
|
||||
"expiresAtMs"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"CreateRoomRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -1425,6 +1457,40 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RedeemPairingCodeRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"code": {
|
||||
"description": "配对码:8 位,由数字 2–9 和除 I、O 以外的大写字母组成",
|
||||
"pattern": "^[2-9A-HJ-NP-Z]{8}$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RedeemPairingCodeResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"account": {
|
||||
"$ref": "#/components/schemas/Account"
|
||||
},
|
||||
"member": {
|
||||
"$ref": "#/components/schemas/Member"
|
||||
},
|
||||
"memberToken": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"memberToken",
|
||||
"member",
|
||||
"account"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RegisterRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -2033,7 +2099,7 @@
|
||||
},
|
||||
"info": {
|
||||
"title": "Alisten",
|
||||
"version": "cbfa923a9cbe9f0a"
|
||||
"version": "0023496d94e1b43f"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
@@ -2077,7 +2143,50 @@
|
||||
"memberToken": []
|
||||
}
|
||||
],
|
||||
"summary": "游客注册账号:设置用户名和密码,成员和成员 token 都不变;站点关闭注册时返回 FORBIDDEN"
|
||||
"summary": "游客注册账号:设置用户名和密码,成员和成员 token 都不变;站点关闭注册时返回 FORBIDDEN。只绑定了 GitHub 的账号也用它补设用户名和密码,不受注册开关限制"
|
||||
}
|
||||
},
|
||||
"/api/account/pairing-codes": {
|
||||
"post": {
|
||||
"operationId": "createPairingCode",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreatePairingCodeRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreatePairingCodeResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "成功"
|
||||
},
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "失败"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"memberToken": []
|
||||
}
|
||||
],
|
||||
"summary": "已注册的账号生成配对码,新设备兑换之后登录这个账号:5 分钟内有效,只能兑换一次,发出请求的成员 token 失效时随之作废;游客调用时返回 FORBIDDEN"
|
||||
}
|
||||
},
|
||||
"/api/account/password": {
|
||||
@@ -2113,7 +2222,7 @@
|
||||
"memberToken": []
|
||||
}
|
||||
],
|
||||
"summary": "修改密码:账号在其他设备上的成员 token 随即失效,发出请求的这个不变;当前密码错误时返回 INVALID_CREDENTIALS"
|
||||
"summary": "修改密码:账号在其他设备上的成员 token 随即失效,发出请求的这个不变;当前密码错误时返回 INVALID_CREDENTIALS,账号还没有设置密码时返回 CONFLICT"
|
||||
}
|
||||
},
|
||||
"/api/admin/accounts/{username}": {
|
||||
@@ -4772,6 +4881,49 @@
|
||||
"summary": "退出登录:发出请求的成员 token 随即失效"
|
||||
}
|
||||
},
|
||||
"/api/sessions/pairing": {
|
||||
"post": {
|
||||
"operationId": "redeemPairingCode",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RedeemPairingCodeRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RedeemPairingCodeResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "成功"
|
||||
},
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "失败"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"memberToken": []
|
||||
}
|
||||
],
|
||||
"summary": "游客兑换配对码,登录生成它的账号:返回账号的新成员 token,发出请求的游客 token 随即失效;配对码不对或已过期时返回 INVALID_CREDENTIALS,已经登录了账号时返回 CONFLICT"
|
||||
}
|
||||
},
|
||||
"/api/shared-playlists/{shareId}": {
|
||||
"get": {
|
||||
"operationId": "getSharedPlaylist",
|
||||
|
||||
+23
-2
@@ -4,7 +4,7 @@ package protocol
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const Version = "cbfa923a9cbe9f0a"
|
||||
const Version = "0023496d94e1b43f"
|
||||
|
||||
type APIKey struct {
|
||||
Key string `json:"key"`
|
||||
@@ -12,7 +12,8 @@ type APIKey struct {
|
||||
|
||||
type Account struct {
|
||||
Email *string `json:"email,omitempty"`
|
||||
Username string `json:"username"`
|
||||
Github bool `json:"github"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
}
|
||||
|
||||
type ActivityKind string
|
||||
@@ -196,6 +197,14 @@ type CreateMyPlaylistRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type CreatePairingCodeRequest struct{}
|
||||
|
||||
type CreatePairingCodeResponse struct {
|
||||
Code string `json:"code"`
|
||||
ExpiresAtMs int64 `json:"expiresAtMs"`
|
||||
QrImage string `json:"qrImage"`
|
||||
}
|
||||
|
||||
type CreateRoomRequest struct {
|
||||
Description string `json:"description"`
|
||||
Name string `json:"name"`
|
||||
@@ -598,6 +607,16 @@ func (v RecommendListResponse) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(plain(v))
|
||||
}
|
||||
|
||||
type RedeemPairingCodeRequest struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type RedeemPairingCodeResponse struct {
|
||||
Account Account `json:"account"`
|
||||
Member Member `json:"member"`
|
||||
MemberToken string `json:"memberToken"`
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Password string `json:"password"`
|
||||
Username string `json:"username"`
|
||||
@@ -926,6 +945,8 @@ var (
|
||||
RouteUnshareMyPlaylist = Route{Pattern: "DELETE /api/me/playlists/{id}/share", Auth: AuthMember, Status: 204}
|
||||
RouteGetSharedPlaylist = Route{Pattern: "GET /api/shared-playlists/{shareId}", Auth: AuthNone, Status: 200}
|
||||
RouteCopySharedPlaylist = Route{Pattern: "POST /api/shared-playlists/{shareId}/copies", Auth: AuthMember, Status: 201}
|
||||
RouteCreatePairingCode = Route{Pattern: "POST /api/account/pairing-codes", Auth: AuthMember, Status: 201}
|
||||
RouteRedeemPairingCode = Route{Pattern: "POST /api/sessions/pairing", Auth: AuthMember, Status: 201}
|
||||
)
|
||||
|
||||
var RoomRoutes = []RoomRoute{
|
||||
|
||||
@@ -133,6 +133,14 @@ export interface CreateMyPlaylistRequest {
|
||||
name: string
|
||||
}
|
||||
|
||||
export type CreatePairingCodeRequest = Record<string, never>
|
||||
|
||||
export interface CreatePairingCodeResponse {
|
||||
code: string
|
||||
expiresAtMs: number
|
||||
qrImage: string
|
||||
}
|
||||
|
||||
export interface CreateRoomRequest {
|
||||
description: string
|
||||
name: string
|
||||
@@ -227,6 +235,16 @@ export interface MoveMyPlaylistItemsRequest {
|
||||
tracks: Track[]
|
||||
}
|
||||
|
||||
export interface RedeemPairingCodeRequest {
|
||||
code: string
|
||||
}
|
||||
|
||||
export interface RedeemPairingCodeResponse {
|
||||
account: Account
|
||||
member: Member
|
||||
memberToken: string
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
password: string
|
||||
username: string
|
||||
@@ -348,5 +366,7 @@ export function httpClient(send: HttpSend) {
|
||||
unshareMyPlaylist: (id: string) => send<void>({ method: 'DELETE', path: `/api/me/playlists/${encodeURIComponent(id)}/share`, auth: 'member' }),
|
||||
getSharedPlaylist: (shareId: string) => send<SharedPlaylist>({ method: 'GET', path: `/api/shared-playlists/${encodeURIComponent(shareId)}`, auth: 'none' }),
|
||||
copySharedPlaylist: (shareId: string, body: CopySharedPlaylistRequest) => send<MyPlaylist>({ method: 'POST', path: `/api/shared-playlists/${encodeURIComponent(shareId)}/copies`, auth: 'member', body }),
|
||||
createPairingCode: () => send<CreatePairingCodeResponse>({ method: 'POST', path: '/api/account/pairing-codes', auth: 'member', body: {} }),
|
||||
redeemPairingCode: (body: RedeemPairingCodeRequest) => send<RedeemPairingCodeResponse>({ method: 'POST', path: '/api/sessions/pairing', auth: 'member', body }),
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
|
||||
|
||||
export const PROTOCOL_VERSION = 'cbfa923a9cbe9f0a'
|
||||
export const PROTOCOL_VERSION = '0023496d94e1b43f'
|
||||
|
||||
export interface APIKey {
|
||||
key: string
|
||||
@@ -8,7 +8,8 @@ export interface APIKey {
|
||||
|
||||
export interface Account {
|
||||
email?: string
|
||||
username: string
|
||||
github: boolean
|
||||
username?: string
|
||||
}
|
||||
|
||||
export type ActivityKind = 'added' | 'removed' | 'liked' | 'voted' | 'skipped' | 'failed'
|
||||
|
||||
@@ -464,7 +464,7 @@ describe('playlistsStore', () => {
|
||||
const { playlistsStore } = await import('./playlists-store')
|
||||
const { profileStore } = await import('@/features/settings')
|
||||
playlistsStore.start()
|
||||
await profileStore.registered({ username: 'alice' })
|
||||
await profileStore.registered({ username: 'alice', github: false })
|
||||
await flush()
|
||||
expect(playlistsStore.state.playlists).toEqual([favorites])
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
@@ -477,8 +477,8 @@ describe('playlistsStore', () => {
|
||||
const { profileStore } = await import('@/features/settings')
|
||||
holdReads = true
|
||||
playlistsStore.start()
|
||||
await profileStore.registered({ username: 'alice' })
|
||||
await profileStore.registered({ username: 'bob' })
|
||||
await profileStore.registered({ username: 'alice', github: false })
|
||||
await profileStore.registered({ username: 'bob', github: false })
|
||||
await flush()
|
||||
release.shift()!()
|
||||
await flush()
|
||||
@@ -493,12 +493,12 @@ describe('playlistsStore', () => {
|
||||
const { profileStore } = await import('@/features/settings')
|
||||
const { notificationStore } = await import('@/shared/ui')
|
||||
playlistsStore.start()
|
||||
await profileStore.registered({ username: 'alice' })
|
||||
await profileStore.registered({ username: 'alice', github: false })
|
||||
await flush()
|
||||
failAdd = true
|
||||
const done = playlistsStore.setMembership(favorites, rain, true)
|
||||
await flush()
|
||||
await profileStore.registered({ username: 'bob' })
|
||||
await profileStore.registered({ username: 'bob', github: false })
|
||||
release.shift()!()
|
||||
await done
|
||||
expect(notificationStore.state.notifications).toEqual([])
|
||||
@@ -508,12 +508,12 @@ describe('playlistsStore', () => {
|
||||
const { playlistsStore } = await import('./playlists-store')
|
||||
const { profileStore } = await import('@/features/settings')
|
||||
playlistsStore.start()
|
||||
await profileStore.registered({ username: 'alice' })
|
||||
await profileStore.registered({ username: 'alice', github: false })
|
||||
await flush()
|
||||
holdReads = true
|
||||
const reading = playlistsStore.containing(sunny)
|
||||
await flush()
|
||||
await profileStore.registered({ username: 'bob' })
|
||||
await profileStore.registered({ username: 'bob', github: false })
|
||||
release.shift()!()
|
||||
await reading
|
||||
expect(playlistsStore.state.memberships.size).toBe(0)
|
||||
|
||||
@@ -167,15 +167,15 @@ describe('账号状态', () => {
|
||||
})
|
||||
|
||||
it('取回资料时一并取回账号', async () => {
|
||||
me = { member, account: { username: 'alice' } }
|
||||
me = { member, account: { username: 'alice', github: false } }
|
||||
const { profileStore } = await load()
|
||||
await profileStore.load()
|
||||
expect(profileStore.state).toMatchObject({ member, account: { username: 'alice' } })
|
||||
expect(profileStore.state).toMatchObject({ member, account: { username: 'alice', github: false } })
|
||||
})
|
||||
|
||||
it('游客没有账号', async () => {
|
||||
const { profileStore } = await load()
|
||||
await profileStore.registered({ username: 'alice' })
|
||||
await profileStore.registered({ username: 'alice', github: false })
|
||||
await profileStore.load()
|
||||
expect(profileStore.state).toMatchObject({ member, account: null })
|
||||
})
|
||||
@@ -183,18 +183,18 @@ describe('账号状态', () => {
|
||||
it('注册之后设置账号', async () => {
|
||||
const { profileStore } = await load()
|
||||
await profileStore.load()
|
||||
await profileStore.registered({ username: 'alice' })
|
||||
expect(profileStore.state).toMatchObject({ member, account: { username: 'alice' } })
|
||||
await profileStore.registered({ username: 'alice', github: false })
|
||||
expect(profileStore.state).toMatchObject({ member, account: { username: 'alice', github: false } })
|
||||
})
|
||||
|
||||
it('身份被拒绝或更换之后没有账号', async () => {
|
||||
const { profileStore, identityEvents } = await load()
|
||||
profileStore.start()
|
||||
await profileStore.registered({ username: 'alice' })
|
||||
await profileStore.registered({ username: 'alice', github: false })
|
||||
identityEvents.emit({ kind: 'rejected' })
|
||||
expect(profileStore.state).toMatchObject({ member: null, account: null })
|
||||
|
||||
await profileStore.registered({ username: 'alice' })
|
||||
await profileStore.registered({ username: 'alice', github: false })
|
||||
identityEvents.emit({ kind: 'renewed', member: renewed })
|
||||
expect(profileStore.state).toMatchObject({ member: renewed, account: null })
|
||||
})
|
||||
@@ -207,14 +207,14 @@ describe('邮箱', () => {
|
||||
|
||||
it('已注册时取自账号,不读本机', async () => {
|
||||
local.setItem(emailKey, 'local@example.com')
|
||||
me = { member, account: { username: 'alice', email: 'Alice@Example.COM' } }
|
||||
me = { member, account: { username: 'alice', email: 'Alice@Example.COM', github: false } }
|
||||
const { profileStore } = await load()
|
||||
profileStore.start()
|
||||
await profileStore.load()
|
||||
expect(profileStore.state.email).toBe('Alice@Example.COM')
|
||||
|
||||
local.removeItem(emailKey)
|
||||
me = { member, account: { username: 'alice' } }
|
||||
me = { member, account: { username: 'alice', github: false } }
|
||||
await profileStore.load()
|
||||
expect(profileStore.state.email).toBe('')
|
||||
})
|
||||
@@ -228,18 +228,18 @@ describe('邮箱', () => {
|
||||
})
|
||||
|
||||
it('已注册时保存到账号,不写本机', async () => {
|
||||
me = { member, account: { username: 'alice' } }
|
||||
me = { member, account: { username: 'alice', github: false } }
|
||||
const { profileStore } = await load()
|
||||
await profileStore.load()
|
||||
|
||||
expect(await profileStore.save({ email: ' Alice@Example.COM ' })).toBe(true)
|
||||
expect(updates).toEqual([{ email: ' Alice@Example.COM ' }])
|
||||
expect(profileStore.state).toMatchObject({ email: 'Alice@Example.COM', account: { username: 'alice', email: 'Alice@Example.COM' } })
|
||||
expect(profileStore.state).toMatchObject({ email: 'Alice@Example.COM', account: { username: 'alice', email: 'Alice@Example.COM', github: false } })
|
||||
expect(local.getItem(emailKey)).toBeNull()
|
||||
|
||||
expect(await profileStore.save({ email: '' })).toBe(true)
|
||||
expect(profileStore.state.email).toBe('')
|
||||
expect(profileStore.state.account).toEqual({ username: 'alice' })
|
||||
expect(profileStore.state.account).toEqual({ username: 'alice', github: false })
|
||||
})
|
||||
|
||||
it('游客保存到本机', async () => {
|
||||
@@ -255,17 +255,17 @@ describe('邮箱', () => {
|
||||
const { profileStore } = await load()
|
||||
profileStore.start()
|
||||
await profileStore.load()
|
||||
await profileStore.registered({ username: 'alice' })
|
||||
await profileStore.registered({ username: 'alice', github: false })
|
||||
|
||||
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' } })
|
||||
expect(profileStore.state).toMatchObject({ email: 'guest@example.com', account: { username: 'alice', email: 'guest@example.com', github: false } })
|
||||
})
|
||||
|
||||
it('注册时本机没有邮箱就不发请求', async () => {
|
||||
const { profileStore } = await load()
|
||||
await profileStore.load()
|
||||
await profileStore.registered({ username: 'alice' })
|
||||
await profileStore.registered({ username: 'alice', github: false })
|
||||
expect(updates).toEqual([])
|
||||
})
|
||||
|
||||
@@ -274,21 +274,21 @@ describe('邮箱', () => {
|
||||
patchFails = true
|
||||
const { profileStore } = await load()
|
||||
profileStore.start()
|
||||
await profileStore.registered({ username: 'alice' })
|
||||
await profileStore.registered({ username: 'alice', github: false })
|
||||
expect(local.getItem(emailKey)).toBe('guest@example.com')
|
||||
|
||||
patchFails = false
|
||||
me = { member, account: { username: 'alice' } }
|
||||
me = { member, account: { username: 'alice', github: false } }
|
||||
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' } })
|
||||
expect(profileStore.state).toMatchObject({ email: 'guest@example.com', account: { username: 'alice', email: 'guest@example.com', github: false } })
|
||||
})
|
||||
|
||||
it('服务端判定本机的邮箱不合格时删除副本,之后加载不再转存', async () => {
|
||||
local.setItem(emailKey, 'legacy')
|
||||
patchError = { status: 400, code: 'INVALID_ARGUMENT' }
|
||||
me = { member, account: { username: 'alice' } }
|
||||
me = { member, account: { username: 'alice', github: false } }
|
||||
const { profileStore } = await load()
|
||||
await profileStore.load()
|
||||
expect(local.getItem(emailKey)).toBeNull()
|
||||
@@ -301,7 +301,7 @@ describe('邮箱', () => {
|
||||
it('重试仍失败时保留本机的副本,下次加载再试', async () => {
|
||||
local.setItem(emailKey, 'guest@example.com')
|
||||
patchFails = true
|
||||
me = { member, account: { username: 'alice' } }
|
||||
me = { member, account: { username: 'alice', github: false } }
|
||||
const { profileStore } = await load()
|
||||
await profileStore.load()
|
||||
expect(local.getItem(emailKey)).toBe('guest@example.com')
|
||||
@@ -313,26 +313,26 @@ describe('邮箱', () => {
|
||||
})
|
||||
|
||||
it('已注册且本机没有副本,或账号已有邮箱时,加载不发更新请求', async () => {
|
||||
me = { member, account: { username: 'alice' } }
|
||||
me = { member, account: { username: 'alice', github: false } }
|
||||
const { profileStore } = await load()
|
||||
await profileStore.load()
|
||||
|
||||
local.setItem(emailKey, 'guest@example.com')
|
||||
me = { member, account: { username: 'alice', email: 'alice@example.com' } }
|
||||
me = { member, account: { username: 'alice', email: 'alice@example.com', github: false } }
|
||||
await profileStore.load()
|
||||
expect(updates).toEqual([])
|
||||
})
|
||||
|
||||
it('用户清空账号的邮箱时一并删除本机残留的副本,之后加载不再转存', async () => {
|
||||
local.setItem(emailKey, 'guest@example.com')
|
||||
me = { member, account: { username: 'alice', email: 'alice@example.com' } }
|
||||
me = { member, account: { username: 'alice', email: 'alice@example.com', github: false } }
|
||||
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' } }
|
||||
me = { member, account: { username: 'alice', github: false } }
|
||||
await profileStore.load()
|
||||
expect(updates).toEqual([])
|
||||
})
|
||||
|
||||
@@ -90,7 +90,11 @@ class ProfileStore extends Store<ProfileState> {
|
||||
// 用户对账号邮箱的修改是最终决定,本机残留的副本不能再覆盖它。
|
||||
if (changes.email !== undefined)
|
||||
localStorage.removeItem(emailKey)
|
||||
this.setState({ member, email, account: { username: account.username, ...(email && { email }) }, failed: false })
|
||||
const next: Account = { ...account }
|
||||
delete next.email
|
||||
if (email)
|
||||
next.email = email
|
||||
this.setState({ member, email, account: next, failed: false })
|
||||
}
|
||||
else {
|
||||
const email = changes.email ?? this.state.email
|
||||
|
||||
Reference in new issue
Block a user