feat: expose device pairing over HTTP
This commit is contained in:
1 parent
e95476185b
commit
1157843f50
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 --
|
||||
|
||||
Reference in new issue
Block a user