feat: expose device pairing over HTTP

This commit is contained in:
DiheChen committed 2026-11-11 00:15:01 +08:00
1 parent e95476185b
commit 1157843f50
23 files changed
+587 -75

No files matched your search

+32 -1
View File
@@ -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
}
+6
View File
@@ -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) {
+48
View File
@@ -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)
}
}
+10 -3
View File
@@ -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}
}
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -106,4 +106,4 @@ Authorization: Bearer $guest
{"username": "guest", "password": "correct horse"}
-- response --
201
{"account": {"username": "guest"}}
{"account": {"username": "guest", "github": false}}
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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{
+20
View File
@@ -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 }),
}
}
+3 -2
View File
@@ -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)
+24 -24
View File
@@ -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([])
})
+5 -1
View File
@@ -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