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 --