feat: expose account registration, login and admin recovery over HTTP

This commit is contained in:
DiheChen committed 2026-11-09 21:21:23 +08:00
1 parent 62d99221fe
commit 246baf5a0e
17 files changed
+1643 -53

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
package api
import (
"context"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/protocol"
)
func (s *server) register(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeRegisterRequest(c.body)
if err != nil {
return nil, err
}
account, err := s.Accounts.Register(ctx, c.member.ID, c.clientIP, req.Username, req.Password)
if err != nil {
return nil, err
}
return protocol.RegisterResponse{Account: accountDTO(account)}, nil
}
func (s *server) login(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeLoginRequest(c.body)
if err != nil {
return nil, err
}
session, account, device, err := s.Accounts.Login(ctx, c.member, bearer(c.r), c.clientIP, req.Username, req.Password, deref(req.Device))
if err != nil {
return nil, err
}
return protocol.LoginResponse{MemberToken: session.MemberToken, Member: memberDTO(session.Member), Account: accountDTO(account), Device: device}, nil
}
func (s *server) logout(ctx context.Context, c call) (any, error) {
return nil, s.Accounts.Logout(ctx, bearer(c.r))
}
func (s *server) changePassword(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeChangePasswordRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.Accounts.ChangePassword(ctx, c.member.ID, bearer(c.r), req.CurrentPassword, req.NewPassword)
}
func (s *server) adminGetAccount(ctx context.Context, c call) (any, error) {
username := c.r.PathValue("username")
if !usernamePattern.MatchString(username) {
return nil, identity.ErrAccountNotFound
}
account, err := s.Accounts.Find(ctx, username)
if err != nil {
return nil, err
}
return protocol.AdminAccount{Member: memberDTO(account.Member), Username: account.Username}, nil
}
func (s *server) adminResetAccountPassword(ctx context.Context, c call) (any, error) {
if _, err := protocol.DecodeAdminResetAccountPasswordRequest(c.body); err != nil {
return nil, err
}
username := c.r.PathValue("username")
if !usernamePattern.MatchString(username) {
return nil, identity.ErrAccountNotFound
}
password, err := s.Accounts.ResetPassword(ctx, username)
if err != nil {
return nil, err
}
return protocol.AdminResetAccountPasswordResponse{Password: password}, nil
}
func accountDTO(a identity.Account) protocol.Account {
dto := protocol.Account{Username: a.Username}
if a.Email != "" {
dto.Email = &a.Email
}
return dto
}
+18
View File
@@ -37,6 +37,7 @@ const (
type Services struct {
Identity *identity.Service
Accounts *identity.Accounts
Lobby *lobby.Service
Admin *admin.Service
Gateway *realtime.Gateway
@@ -98,6 +99,10 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
srv.handle(mux, protocol.RouteCreateMember, srv.createMember)
srv.handle(mux, protocol.RouteGetMe, srv.getMe)
srv.handle(mux, protocol.RouteUpdateMe, srv.updateMe)
srv.handle(mux, protocol.RouteRegister, srv.register)
srv.handle(mux, protocol.RouteChangePassword, srv.changePassword)
srv.handle(mux, protocol.RouteLogin, srv.login)
srv.handle(mux, protocol.RouteLogout, srv.logout)
srv.handle(mux, protocol.RouteListRooms, srv.listRooms)
srv.handle(mux, protocol.RouteGetRoom, srv.getRoom)
srv.handle(mux, protocol.RouteCreateRoom, srv.createRoom)
@@ -127,6 +132,10 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
srv.handle(mux, protocol.RouteAdminListMedia, srv.adminListMedia)
srv.handle(mux, protocol.RouteAdminDeleteMedia, srv.adminDeleteMedia)
srv.handle(mux, protocol.RouteAdminUpdateSite, srv.adminUpdateSite)
srv.handle(mux, protocol.RouteAdminOpenRegistration, srv.adminOpenRegistration)
srv.handle(mux, protocol.RouteAdminCloseRegistration, srv.adminCloseRegistration)
srv.handle(mux, protocol.RouteAdminGetAccount, srv.adminGetAccount)
srv.handle(mux, protocol.RouteAdminResetAccountPassword, srv.adminResetAccountPassword)
mux.HandleFunc("GET /ws", srv.websocket)
mux.HandleFunc("GET /api/room/events", srv.roomEvents)
mux.HandleFunc("GET /media/{source}/{id}", srv.redirectMedia)
@@ -279,6 +288,15 @@ var knownErrors = []struct {
{identity.ErrInvalidTicket, protocol.ErrorCodeUnauthorized},
{identity.ErrBotSession, protocol.ErrorCodeUnauthorized},
{identity.ErrInvalidEmail, protocol.ErrorCodeInvalidArgument},
{identity.ErrInvalidCredentials, protocol.ErrorCodeInvalidCredentials},
{identity.ErrWrongCurrentPassword, protocol.ErrorCodeInvalidCredentials},
{identity.ErrAlreadyLoggedIn, protocol.ErrorCodeConflict},
{identity.ErrRegistrationClosed, protocol.ErrorCodeForbidden},
{identity.ErrNotRegistered, protocol.ErrorCodeForbidden},
{identity.ErrTooManyAttempts, protocol.ErrorCodeRateLimited},
{identity.ErrAccountNotFound, protocol.ErrorCodeNotFound},
{port.ErrUsernameTaken, protocol.ErrorCodeConflict},
{port.ErrAlreadyRegistered, protocol.ErrorCodeConflict},
{realtime.ErrRequesterNotAllowed, protocol.ErrorCodeInvalidArgument},
{site.ErrInvalidBrandName, protocol.ErrorCodeInvalidArgument},
{realtime.ErrUnavailable, protocol.ErrorCodeUnavailable},
+1
View File
@@ -42,6 +42,7 @@ func services(store *porttest.Store, rooms port.RoomDirectory, adminToken string
l := lobby.New(rooms, node)
return Services{
Identity: identity.New(store, rooms, store, gateway),
Accounts: identity.NewAccounts(store, store, nil, nil),
Lobby: l,
Admin: admin.New(adminToken, l, rooms, store, store, porttest.NewNetEaseQRLogin(), porttest.NewQQQRLogin(), porttest.NewBilibiliQRLogin(), porttest.NewYouTubeCookies(), gateway),
Gateway: gateway,
+15 -5
View File
@@ -13,11 +13,12 @@ import (
"github.com/dihemoe/alisten/protocol"
)
// 路径中的房间 ID 和别名不经过协议解码。格式不正确的房间 ID 或别名一定不存在,其中含有 U+0000 等字符的还会让
// 路径中的房间 ID、别名和用户名不经过协议解码。格式不正确的房间 ID、别名或用户名一定不存在,其中含有 U+0000 等字符的还会让
// PostgreSQL 报错,所以直接按不存在处理。
var (
uuidPattern = regexp.MustCompile(`^(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
slugPattern = regexp.MustCompile(`^[a-z0-9-]{1,32}$`)
uuidPattern = regexp.MustCompile(`^(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
slugPattern = regexp.MustCompile(`^[a-z0-9-]{1,32}$`)
usernamePattern = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
)
func (s *server) createMember(ctx context.Context, c call) (any, error) {
@@ -31,8 +32,17 @@ func (s *server) createMember(ctx context.Context, c call) (any, error) {
return protocol.CreateMemberResponse{MemberToken: session.MemberToken, Member: memberDTO(session.Member)}, nil
}
func (s *server) getMe(_ context.Context, c call) (any, error) {
return protocol.GetMeResponse{Member: memberDTO(c.member)}, nil
func (s *server) getMe(ctx context.Context, c call) (any, error) {
resp := protocol.GetMeResponse{Member: memberDTO(c.member)}
account, registered, err := s.Accounts.Account(ctx, c.member.ID)
if err != nil {
return nil, err
}
if registered {
dto := accountDTO(account)
resp.Account = &dto
}
return resp, nil
}
func (s *server) updateMe(ctx context.Context, c call) (any, error) {
+12 -1
View File
@@ -11,7 +11,7 @@ func (s *server) getSite(ctx context.Context, _ call) (any, error) {
if err != nil {
return nil, err
}
return protocol.GetSiteResponse{BrandName: settings.BrandName}, nil
return protocol.GetSiteResponse{BrandName: settings.BrandName, RegistrationOpen: settings.RegistrationOpen}, nil
}
func (s *server) adminUpdateSite(ctx context.Context, c call) (any, error) {
@@ -25,3 +25,14 @@ func (s *server) adminUpdateSite(ctx context.Context, c call) (any, error) {
}
return protocol.AdminUpdateSiteResponse{BrandName: name}, nil
}
func (s *server) adminOpenRegistration(ctx context.Context, c call) (any, error) {
if _, err := protocol.DecodeAdminOpenRegistrationRequest(c.body); err != nil {
return nil, err
}
return nil, s.Site.SetRegistrationOpen(ctx, true)
}
func (s *server) adminCloseRegistration(ctx context.Context, _ call) (any, error) {
return nil, s.Site.SetRegistrationOpen(ctx, false)
}
+6 -3
View File
@@ -15,14 +15,17 @@ func TestSite(t *testing.T) {
brandName := func(name string) response {
return response{status: http.StatusOK, body: `{"brandName":"` + name + `"}`}
}
site := func(name string) response {
return response{status: http.StatusOK, body: `{"brandName":"` + name + `","registrationOpen":true}`}
}
if got := send(t, server, http.MethodGet, "/api/site", ""); got != brandName("Alisten") {
if got := send(t, server, http.MethodGet, "/api/site", ""); got != site("Alisten") {
t.Errorf("没有设置过时 GET /api/site = %#v,期望默认的 Alisten", got)
}
if got := send(t, server, http.MethodPatch, "/api/admin/site", `{"brandName":" 周末听歌会 "}`, adminHeader...); got != brandName("周末听歌会") {
t.Errorf("PATCH /api/admin/site = %#v,期望返回去掉首尾空白之后存下的名字", got)
}
if got := send(t, server, http.MethodGet, "/api/site", ""); got != brandName("周末听歌会") {
if got := send(t, server, http.MethodGet, "/api/site", ""); got != site("周末听歌会") {
t.Errorf("修改之后 GET /api/site = %#v,期望去掉首尾空白的新名字", got)
}
@@ -36,7 +39,7 @@ func TestSite(t *testing.T) {
if got := send(t, server, http.MethodPatch, "/api/admin/site", `{"brandName":"别的名字"}`); got != unauthorized {
t.Errorf("不带管理 token 的 PATCH = %#v,期望 %#v", got, unauthorized)
}
if got := send(t, server, http.MethodGet, "/api/site", ""); got != brandName("周末听歌会") {
if got := send(t, server, http.MethodGet, "/api/site", ""); got != site("周末听歌会") {
t.Errorf("被拒绝的修改之后 GET /api/site = %#v,期望不变", got)
}
if logs.Len() != 0 {
+1
View File
@@ -78,6 +78,7 @@ func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instanc
rooms := lobby.New(store, leases)
services := api.Services{
Identity: identity.New(store, store, store, gateway),
Accounts: identity.NewAccounts(store, store, redis.NewAccountQuota(rdb), redis.NewDevices(rdb)),
Lobby: rooms,
Admin: admin.New(cfg.adminToken, rooms, store, store, store, src.neteaseLogin, src.qqLogin, src.bilibiliLogin, store, gateway),
Gateway: gateway,
+129
View File
@@ -0,0 +1,129 @@
登录按客户端 IP 限流:同一个 IP 1 分钟内最多尝试 10 次,换一个用户名也算在内;请求经受信任的代理转发时,按 X-Forwarded-For 中的客户端地址计数,另一个客户端不受影响。
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$guest", "member": {"id": "$guestId", "name": "$guestName", "avatarHash": ""}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user01", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user02", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user03", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user04", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user05", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user06", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user07", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user08", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user09", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user10", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user11", "password": "wrong horse"}
-- response --
429
{"error": {"code": "RATE_LIMITED", "message": "尝试次数太多,请稍后再试。"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.8
{"username": "user12", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
+258
View File
@@ -0,0 +1,258 @@
账号:游客注册之后成员和成员 token 都不变;其他游客用账号登录,得到新的成员 token 和这台设备的登录设备凭证,自己原来的 token 随即失效;修改密码、退出登录让对应的成员 token 失效;后台可以查找账号、重置密码。
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
-- request --
POST /api/account
Authorization: Bearer $alice
{"username": "alice", "password": "correct horse"}
-- response --
201
{"account": {"username": "alice"}}
-- request --
GET /api/me
Authorization: Bearer $alice
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}}
-- request --
PATCH /api/me
Authorization: Bearer $alice
{"email": " Alice@Example.COM "}
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}}
-- request --
GET /api/me
Authorization: Bearer $alice
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}}
-- request --
POST /api/account
Authorization: Bearer $alice
{"username": "alice2", "password": "correct horse"}
-- response --
409
{"error": {"code": "CONFLICT", "message": "已经注册过了"}}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$bob", "member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}}
-- request --
GET /api/me
Authorization: Bearer $bob
-- response --
200
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}}
-- request --
PATCH /api/me
Authorization: Bearer $bob
{"email": "Alice@Example.COM"}
-- response --
200
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}}
-- request --
GET /api/me
Authorization: Bearer $bob
-- response --
200
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}}
-- request --
PATCH /api/me
Authorization: Bearer $bob
{"email": ""}
-- response --
200
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}}
-- request --
POST /api/account
Authorization: Bearer $bob
{"username": "alice", "password": "another horse"}
-- response --
409
{"error": {"code": "CONFLICT", "message": "用户名已被占用"}}
-- request --
POST /api/account
Authorization: Bearer $bob
{"username": "Bob", "password": "another horse"}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "username:格式不正确"}}
-- request --
POST /api/account
Authorization: Bearer $bob
{"username": "bob", "password": "short"}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "password:长度不能少于 8 个字符"}}
-- request --
POST /api/sessions
Authorization: Bearer $bob
{"username": "alice", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
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"}
-- request --
GET /api/me
Authorization: Bearer $alice2
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}}
-- request --
PATCH /api/me
Authorization: Bearer $alice2
{"email": ""}
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
-- request --
GET /api/me
Authorization: Bearer $alice
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}}
-- request --
GET /api/me
Authorization: Bearer $bob
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
-- request --
POST /api/sessions
Authorization: Bearer $alice2
{"username": "alice", "password": "correct horse"}
-- response --
409
{"error": {"code": "CONFLICT", "message": "已经登录了账号,先退出再登录其他账号"}}
-- request --
PUT /api/account/password
Authorization: Bearer $alice
{"currentPassword": "wrong horse", "newPassword": "battery staple"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "当前密码错误"}}
-- request --
PUT /api/account/password
Authorization: Bearer $alice
{"currentPassword": "correct horse", "newPassword": "battery staple"}
-- response --
204
-- request --
GET /api/me
Authorization: Bearer $alice2
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
-- request --
DELETE /api/sessions/current
Authorization: Bearer $alice
-- response --
204
-- request --
GET /api/me
Authorization: Bearer $alice
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
-- request --
GET /api/admin/accounts/alice
Authorization: Bearer admin-secret
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "username": "alice"}
-- request --
GET /api/admin/accounts/nobody
Authorization: Bearer admin-secret
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "没有这个用户名的账号"}}
-- request --
GET /api/admin/accounts/Not%20Valid
Authorization: Bearer admin-secret
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "没有这个用户名的账号"}}
-- request --
POST /api/admin/accounts/alice/password
Authorization: Bearer admin-secret
{}
-- response --
200
{"password": "$resetPassword"}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$dave", "member": {"id": "$daveId", "name": "$daveName", "avatarHash": ""}}
-- request --
POST /api/sessions
Authorization: Bearer $dave
{"username": "alice", "password": "$resetPassword"}
-- response --
201
{"memberToken": "$alice4", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}, "device": "$daveDevice"}
+155
View File
@@ -0,0 +1,155 @@
登录设备凭证:账号密码登录成功时返回这台设备的凭证,退出登录之后设备留着它。按用户名计数的额度 1 分钟内最多 5 次,被别人猜密码用完之后,
不带凭证的登录被限流,带着这个账号的凭证仍然能登录,拿到同一个凭证。改密码之后,此前签发的凭证不再被认出,同样被限流。
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
-- request --
POST /api/account
Authorization: Bearer $alice
{"username": "alice", "password": "correct horse"}
-- response --
201
{"account": {"username": "alice"}}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$laptop", "member": {"id": "$laptopId", "name": "$laptopName", "avatarHash": ""}}
-- request --
POST /api/sessions
Authorization: Bearer $laptop
{"username": "alice", "password": "correct horse"}
-- response --
201
{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}, "device": "$device"}
-- request --
DELETE /api/sessions/current
Authorization: Bearer $alice2
-- response --
204
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$attacker", "member": {"id": "$attackerId", "name": "$attackerName", "avatarHash": ""}}
-- request --
POST /api/sessions
Authorization: Bearer $attacker
{"username": "alice", "password": "wrong horse 1"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $attacker
{"username": "alice", "password": "wrong horse 2"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $attacker
{"username": "alice", "password": "wrong horse 3"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $attacker
{"username": "alice", "password": "wrong horse 4"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $attacker
{"username": "alice", "password": "wrong horse 5"}
-- response --
429
{"error": {"code": "RATE_LIMITED", "message": "尝试次数太多,请稍后再试。"}}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$laptop2", "member": {"id": "$laptop2Id", "name": "$laptop2Name", "avatarHash": ""}}
-- request --
POST /api/sessions
Authorization: Bearer $laptop2
{"username": "alice", "password": "correct horse"}
-- response --
429
{"error": {"code": "RATE_LIMITED", "message": "尝试次数太多,请稍后再试。"}}
-- request --
POST /api/sessions
Authorization: Bearer $laptop2
{"username": "alice", "password": "correct horse", "device": "not a device"}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "device:格式不正确"}}
-- request --
POST /api/sessions
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"}
-- request --
PUT /api/account/password
Authorization: Bearer $alice3
{"currentPassword": "correct horse", "newPassword": "battery staple"}
-- response --
204
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$laptop3", "member": {"id": "$laptop3Id", "name": "$laptop3Name", "avatarHash": ""}}
-- request --
POST /api/sessions
Authorization: Bearer $laptop3
{"username": "alice", "password": "battery staple", "device": "$device"}
-- response --
429
{"error": {"code": "RATE_LIMITED", "message": "尝试次数太多,请稍后再试。"}}
+50 -4
View File
@@ -1,10 +1,10 @@
站点:品牌名默认是 Alisten;后台修改时返回存下的名字,首尾的空白已经去掉,之后读到的也是它;空的、超过 20 个字符的和看不见的名字不能保存;修改品牌名要管理 token。
站点:品牌名默认是 Alisten,默认开放注册;后台修改品牌名时返回存下的名字,首尾的空白已经去掉,之后读到的也是它;空的、超过 20 个字符的和看不见的名字不能保存;后台可以关闭和重新开放注册,关闭之后游客不能注册;修改站点设置要管理 token。
-- request --
GET /api/site
-- response --
200
{"brandName": "Alisten"}
{"brandName": "Alisten", "registrationOpen": true}
-- request --
PATCH /api/admin/site
@@ -19,7 +19,7 @@ Authorization: Bearer admin-secret
GET /api/site
-- response --
200
{"brandName": "周末听歌会"}
{"brandName": "周末听歌会", "registrationOpen": true}
-- request --
PATCH /api/admin/site
@@ -56,8 +56,54 @@ PATCH /api/admin/site
401
{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
-- request --
DELETE /api/admin/registration
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
-- request --
DELETE /api/admin/registration
Authorization: Bearer admin-secret
-- response --
204
-- request --
GET /api/site
-- response --
200
{"brandName": "周末听歌会"}
{"brandName": "周末听歌会", "registrationOpen": false}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$guest", "member": {"id": "$guestId", "name": "$guestName", "avatarHash": ""}}
-- request --
POST /api/account
Authorization: Bearer $guest
{"username": "guest", "password": "correct horse"}
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "注册已关闭"}}
-- request --
PUT /api/admin/registration
Authorization: Bearer admin-secret
{}
-- response --
204
-- request --
POST /api/account
Authorization: Bearer $guest
{"username": "guest", "password": "correct horse"}
-- response --
201
{"account": {"username": "guest"}}
+129
View File
@@ -10,6 +10,9 @@ 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}))?$`)
)
@@ -72,6 +75,18 @@ func DecodeUpdateMeRequest(body []byte) (UpdateMeRequest, error) {
return decodeUpdateMeRequest(body, "")
}
func DecodeRegisterRequest(body []byte) (RegisterRequest, error) {
return decodeRegisterRequest(body, "")
}
func DecodeChangePasswordRequest(body []byte) (ChangePasswordRequest, error) {
return decodeChangePasswordRequest(body, "")
}
func DecodeLoginRequest(body []byte) (LoginRequest, error) {
return decodeLoginRequest(body, "")
}
func DecodeCreateRoomRequest(body []byte) (CreateRoomRequest, error) {
return decodeCreateRoomRequest(body, "")
}
@@ -124,6 +139,14 @@ func DecodeAdminUpdateSiteRequest(body []byte) (AdminUpdateSiteRequest, error) {
return decodeAdminUpdateSiteRequest(body, "")
}
func DecodeAdminOpenRegistrationRequest(body []byte) (AdminOpenRegistrationRequest, error) {
return decodeAdminOpenRegistrationRequest(body, "")
}
func DecodeAdminResetAccountPasswordRequest(body []byte) (AdminResetAccountPasswordRequest, error) {
return decodeAdminResetAccountPasswordRequest(body, "")
}
func decodeAdminCheckBilibiliLoginRequest(raw json.RawMessage, path string) (AdminCheckBilibiliLoginRequest, error) {
var v AdminCheckBilibiliLoginRequest
m, err := decodeObject(raw, path, "key")
@@ -225,6 +248,18 @@ func decodeAdminCreateRoomRequest(raw json.RawMessage, path string) (AdminCreate
return v, nil
}
func decodeAdminOpenRegistrationRequest(raw json.RawMessage, path string) (AdminOpenRegistrationRequest, error) {
var v AdminOpenRegistrationRequest
_, err := decodeObject(raw, path)
return v, err
}
func decodeAdminResetAccountPasswordRequest(raw json.RawMessage, path string) (AdminResetAccountPasswordRequest, error) {
var v AdminResetAccountPasswordRequest
_, err := decodeObject(raw, path)
return v, err
}
func decodeAdminResetRoomApiKeyRequest(raw json.RawMessage, path string) (AdminResetRoomApiKeyRequest, error) {
var v AdminResetRoomApiKeyRequest
_, err := decodeObject(raw, path)
@@ -340,6 +375,36 @@ func decodeAdminUpdateSiteRequest(raw json.RawMessage, path string) (AdminUpdate
return v, nil
}
func decodeChangePasswordRequest(raw json.RawMessage, path string) (ChangePasswordRequest, error) {
var v ChangePasswordRequest
m, err := decodeObject(raw, path, "currentPassword", "newPassword")
if err != nil {
return v, err
}
if err = requireFields(m, path, "currentPassword", "newPassword"); err != nil {
return v, err
}
if v.CurrentPassword, err = decodeString(m["currentPassword"], field(path, "currentPassword")); err != nil {
return v, err
}
if err = checkMinLength(v.CurrentPassword, field(path, "currentPassword"), 8); err != nil {
return v, err
}
if err = checkMaxLength(v.CurrentPassword, field(path, "currentPassword"), 128); err != nil {
return v, err
}
if v.NewPassword, err = decodeString(m["newPassword"], field(path, "newPassword")); err != nil {
return v, err
}
if err = checkMinLength(v.NewPassword, field(path, "newPassword"), 8); err != nil {
return v, err
}
if err = checkMaxLength(v.NewPassword, field(path, "newPassword"), 128); err != nil {
return v, err
}
return v, nil
}
func decodeChatSendRequest(raw json.RawMessage, path string) (ChatSendRequest, error) {
var v ChatSendRequest
m, err := decodeObject(raw, path, "content")
@@ -432,6 +497,43 @@ func decodeHelloRequest(raw json.RawMessage, path string) (HelloRequest, error)
return v, nil
}
func decodeLoginRequest(raw json.RawMessage, path string) (LoginRequest, error) {
var v LoginRequest
m, err := decodeObject(raw, path, "device", "password", "username")
if err != nil {
return v, err
}
if err = requireFields(m, path, "password", "username"); err != nil {
return v, err
}
if r, ok := m["device"]; ok {
x, err := decodeString(r, field(path, "device"))
if err != nil {
return v, err
}
if err = checkPattern(x, field(path, "device"), patternLoginRequestDevice); err != nil {
return v, err
}
v.Device = &x
}
if v.Password, err = decodeString(m["password"], field(path, "password")); err != nil {
return v, err
}
if err = checkMinLength(v.Password, field(path, "password"), 8); err != nil {
return v, err
}
if err = checkMaxLength(v.Password, field(path, "password"), 128); err != nil {
return v, err
}
if v.Username, err = decodeString(m["username"], field(path, "username")); err != nil {
return v, err
}
if err = checkPattern(v.Username, field(path, "username"), patternLoginRequestUsername); err != nil {
return v, err
}
return v, nil
}
func decodePingRequest(raw json.RawMessage, path string) (PingRequest, error) {
var v PingRequest
m, err := decodeObject(raw, path, "clientTimeMs")
@@ -583,6 +685,33 @@ func decodeRecommendListRequest(raw json.RawMessage, path string) (RecommendList
return v, err
}
func decodeRegisterRequest(raw json.RawMessage, path string) (RegisterRequest, error) {
var v RegisterRequest
m, err := decodeObject(raw, path, "password", "username")
if err != nil {
return v, err
}
if err = requireFields(m, path, "password", "username"); err != nil {
return v, err
}
if v.Password, err = decodeString(m["password"], field(path, "password")); err != nil {
return v, err
}
if err = checkMinLength(v.Password, field(path, "password"), 8); err != nil {
return v, err
}
if err = checkMaxLength(v.Password, field(path, "password"), 128); err != nil {
return v, err
}
if v.Username, err = decodeString(m["username"], field(path, "username")); err != nil {
return v, err
}
if err = checkPattern(v.Username, field(path, "username"), patternRegisterRequestUsername); err != nil {
return v, err
}
return v, nil
}
func decodeRoomSyncRequest(raw json.RawMessage, path string) (RoomSyncRequest, error) {
var v RoomSyncRequest
_, err := decodeObject(raw, path)
+172 -6
View File
@@ -12,6 +12,7 @@
"enum": [
"INVALID_ARGUMENT",
"UNAUTHORIZED",
"INVALID_CREDENTIALS",
"FORBIDDEN",
"NOT_FOUND",
"CONFLICT",
@@ -26,6 +27,7 @@
"x-httpStatus": {
"INVALID_ARGUMENT": 400,
"UNAUTHORIZED": 401,
"INVALID_CREDENTIALS": 401,
"FORBIDDEN": 403,
"NOT_FOUND": 404,
"CONFLICT": 409,
@@ -139,6 +141,40 @@
},
"required": ["id", "name", "avatarHash"]
},
"Username": {
"type": "string",
"description": "用户名:3 到 32 个小写字母、数字、下划线或连字符",
"pattern": "^[a-z0-9_-]{3,32}$"
},
"AccountPassword": {
"type": "string",
"description": "账号密码",
"minLength": 8,
"maxLength": 128
},
"LoginDevice": {
"type": "string",
"description": "登录设备凭证:用账号密码登录成功时发给这台设备,43 个 base64url 字符",
"pattern": "^[A-Za-z0-9_-]{43}$"
},
"Account": {
"type": "object",
"additionalProperties": false,
"properties": {
"username": {"type": "string"},
"email": {"type": "string", "description": "账号设置了邮箱时才有"}
},
"required": ["username"]
},
"AdminAccount": {
"type": "object",
"additionalProperties": false,
"properties": {
"member": {"$ref": "#/$defs/Member"},
"username": {"type": "string"}
},
"required": ["member", "username"]
},
"Members": {
"type": "object",
"additionalProperties": false,
@@ -658,7 +694,7 @@
},
{
"name": "getMe",
"description": "当前成员的资料",
"description": "当前成员的资料;已注册的成员带上账号",
"method": "GET",
"path": "/api/me",
"auth": "member",
@@ -666,14 +702,15 @@
"type": "object",
"additionalProperties": false,
"properties": {
"member": {"$ref": "#/$defs/Member"}
"member": {"$ref": "#/$defs/Member"},
"account": {"$ref": "#/$defs/Account"}
},
"required": ["member"]
}
},
{
"name": "updateMe",
"description": "修改昵称和头像;email 只用来计算头像哈希,空字符串表示清除头像",
"description": "修改昵称和头像;email 用来计算头像哈希,已注册的账号同时保存它,去掉首尾空白后应形如 name@example.com,空字符串表示清除",
"method": "PATCH",
"path": "/api/me",
"auth": "member",
@@ -694,6 +731,85 @@
"required": ["member"]
}
},
{
"name": "register",
"description": "游客注册账号:设置用户名和密码,成员和成员 token 都不变;站点关闭注册时返回 FORBIDDEN",
"method": "POST",
"path": "/api/account",
"auth": "member",
"status": 201,
"request": {
"type": "object",
"additionalProperties": false,
"properties": {
"username": {"$ref": "#/$defs/Username"},
"password": {"$ref": "#/$defs/AccountPassword"}
},
"required": ["username", "password"]
},
"response": {
"type": "object",
"additionalProperties": false,
"properties": {
"account": {"$ref": "#/$defs/Account"}
},
"required": ["account"]
}
},
{
"name": "changePassword",
"description": "修改密码:账号在其他设备上的成员 token 随即失效,发出请求的这个不变;当前密码错误时返回 INVALID_CREDENTIALS",
"method": "PUT",
"path": "/api/account/password",
"auth": "member",
"status": 204,
"request": {
"type": "object",
"additionalProperties": false,
"properties": {
"currentPassword": {"$ref": "#/$defs/AccountPassword"},
"newPassword": {"$ref": "#/$defs/AccountPassword"}
},
"required": ["currentPassword", "newPassword"]
}
},
{
"name": "login",
"description": "游客登录账号:返回账号的新成员 token,发出请求的游客 token 随即失效;用户名或密码错误时返回 INVALID_CREDENTIALS,已经登录了账号时返回 CONFLICT",
"method": "POST",
"path": "/api/sessions",
"auth": "member",
"status": 201,
"request": {
"type": "object",
"additionalProperties": false,
"properties": {
"username": {"$ref": "#/$defs/Username"},
"password": {"$ref": "#/$defs/AccountPassword"},
"device": {"$ref": "#/$defs/LoginDevice", "description": "本机此前登录这个账号时拿到的凭证:有人反复猜这个账号的密码时,带上它本机仍然能登录"}
},
"required": ["username", "password"]
},
"response": {
"type": "object",
"additionalProperties": false,
"properties": {
"memberToken": {"type": "string"},
"member": {"$ref": "#/$defs/Member"},
"account": {"$ref": "#/$defs/Account"},
"device": {"$ref": "#/$defs/LoginDevice", "description": "本机以后登录这个账号时要带上的凭证,退出登录之后也要保留:带来的凭证有效时原样返回,否则是新签发的"}
},
"required": ["memberToken", "member", "account", "device"]
}
},
{
"name": "logout",
"description": "退出登录:发出请求的成员 token 随即失效",
"method": "DELETE",
"path": "/api/sessions/current",
"auth": "member",
"status": 204
},
{
"name": "listRooms",
"description": "房间列表",
@@ -769,7 +885,7 @@
},
{
"name": "getSite",
"description": "品牌名;没有设置过时是 Alisten",
"description": "品牌名和是否开放注册;没有设置过品牌名时是 Alisten",
"method": "GET",
"path": "/api/site",
"auth": "none",
@@ -777,9 +893,10 @@
"type": "object",
"additionalProperties": false,
"properties": {
"brandName": {"type": "string"}
"brandName": {"type": "string"},
"registrationOpen": {"type": "boolean"}
},
"required": ["brandName"]
"required": ["brandName", "registrationOpen"]
}
},
{
@@ -1097,6 +1214,55 @@
},
"required": ["brandName"]
}
},
{
"name": "adminOpenRegistration",
"description": "开放注册",
"method": "PUT",
"path": "/api/admin/registration",
"auth": "admin",
"status": 204,
"request": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
},
{
"name": "adminCloseRegistration",
"description": "关闭注册:不能再注册新账号,已有账号照常登录,游客照常使用",
"method": "DELETE",
"path": "/api/admin/registration",
"auth": "admin",
"status": 204
},
{
"name": "adminGetAccount",
"description": "按用户名查找账号",
"method": "GET",
"path": "/api/admin/accounts/{username}",
"auth": "admin",
"response": {"$ref": "#/$defs/AdminAccount"}
},
{
"name": "adminResetAccountPassword",
"description": "重置账号的密码:生成随机的新密码,只在这次响应中返回;账号在所有设备上的成员 token 随即失效",
"method": "POST",
"path": "/api/admin/accounts/{username}/password",
"auth": "admin",
"request": {
"type": "object",
"additionalProperties": false,
"properties": {}
},
"response": {
"type": "object",
"additionalProperties": false,
"properties": {
"password": {"type": "string"}
},
"required": ["password"]
}
}
]
}
+477 -5
View File
@@ -14,6 +14,22 @@
],
"type": "object"
},
"Account": {
"additionalProperties": false,
"properties": {
"email": {
"description": "账号设置了邮箱时才有",
"type": "string"
},
"username": {
"type": "string"
}
},
"required": [
"username"
],
"type": "object"
},
"ActivityKind": {
"enum": [
"added",
@@ -25,6 +41,22 @@
],
"type": "string"
},
"AdminAccount": {
"additionalProperties": false,
"properties": {
"member": {
"$ref": "#/components/schemas/Member"
},
"username": {
"type": "string"
}
},
"required": [
"member",
"username"
],
"type": "object"
},
"AdminCheckBilibiliLoginRequest": {
"additionalProperties": false,
"properties": {
@@ -246,6 +278,26 @@
],
"type": "object"
},
"AdminOpenRegistrationRequest": {
"additionalProperties": false,
"type": "object"
},
"AdminResetAccountPasswordRequest": {
"additionalProperties": false,
"type": "object"
},
"AdminResetAccountPasswordResponse": {
"additionalProperties": false,
"properties": {
"password": {
"type": "string"
}
},
"required": [
"password"
],
"type": "object"
},
"AdminResetRoomApiKeyRequest": {
"additionalProperties": false,
"type": "object"
@@ -331,6 +383,28 @@
],
"type": "object"
},
"ChangePasswordRequest": {
"additionalProperties": false,
"properties": {
"currentPassword": {
"description": "账号密码",
"maxLength": 128,
"minLength": 8,
"type": "string"
},
"newPassword": {
"description": "账号密码",
"maxLength": 128,
"minLength": 8,
"type": "string"
}
},
"required": [
"currentPassword",
"newPassword"
],
"type": "object"
},
"ChatSendRequest": {
"additionalProperties": false,
"properties": {
@@ -479,6 +553,7 @@
"enum": [
"INVALID_ARGUMENT",
"UNAUTHORIZED",
"INVALID_CREDENTIALS",
"FORBIDDEN",
"NOT_FOUND",
"CONFLICT",
@@ -496,6 +571,7 @@
"FORBIDDEN": 403,
"INTERNAL": 500,
"INVALID_ARGUMENT": 400,
"INVALID_CREDENTIALS": 401,
"NOTHING_PLAYING": 409,
"NOT_FOUND": 404,
"PROVIDER_ERROR": 502,
@@ -537,6 +613,9 @@
"GetMeResponse": {
"additionalProperties": false,
"properties": {
"account": {
"$ref": "#/components/schemas/Account"
},
"member": {
"$ref": "#/components/schemas/Member"
}
@@ -551,10 +630,14 @@
"properties": {
"brandName": {
"type": "string"
},
"registrationOpen": {
"type": "boolean"
}
},
"required": [
"brandName"
"brandName",
"registrationOpen"
],
"type": "object"
},
@@ -573,6 +656,58 @@
],
"type": "object"
},
"LoginRequest": {
"additionalProperties": false,
"properties": {
"device": {
"description": "本机此前登录这个账号时拿到的凭证:有人反复猜这个账号的密码时,带上它本机仍然能登录",
"pattern": "^[A-Za-z0-9_-]{43}$",
"type": "string"
},
"password": {
"description": "账号密码",
"maxLength": 128,
"minLength": 8,
"type": "string"
},
"username": {
"description": "用户名:3 到 32 个小写字母、数字、下划线或连字符",
"pattern": "^[a-z0-9_-]{3,32}$",
"type": "string"
}
},
"required": [
"username",
"password"
],
"type": "object"
},
"LoginResponse": {
"additionalProperties": false,
"properties": {
"account": {
"$ref": "#/components/schemas/Account"
},
"device": {
"description": "本机以后登录这个账号时要带上的凭证,退出登录之后也要保留:带来的凭证有效时原样返回,否则是新签发的",
"pattern": "^[A-Za-z0-9_-]{43}$",
"type": "string"
},
"member": {
"$ref": "#/components/schemas/Member"
},
"memberToken": {
"type": "string"
}
},
"required": [
"memberToken",
"member",
"account",
"device"
],
"type": "object"
},
"Member": {
"additionalProperties": false,
"properties": {
@@ -892,6 +1027,39 @@
],
"type": "object"
},
"RegisterRequest": {
"additionalProperties": false,
"properties": {
"password": {
"description": "账号密码",
"maxLength": 128,
"minLength": 8,
"type": "string"
},
"username": {
"description": "用户名:3 到 32 个小写字母、数字、下划线或连字符",
"pattern": "^[a-z0-9_-]{3,32}$",
"type": "string"
}
},
"required": [
"username",
"password"
],
"type": "object"
},
"RegisterResponse": {
"additionalProperties": false,
"properties": {
"account": {
"$ref": "#/components/schemas/Account"
}
},
"required": [
"account"
],
"type": "object"
},
"RoomInfo": {
"additionalProperties": false,
"properties": {
@@ -1343,10 +1511,185 @@
},
"info": {
"title": "Alisten",
"version": "473ee31f5ef912b4"
"version": "bb812527ea1133df"
},
"openapi": "3.1.0",
"paths": {
"/api/account": {
"post": {
"operationId": "register",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RegisterRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RegisterResponse"
}
}
},
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"memberToken": []
}
],
"summary": "游客注册账号:设置用户名和密码,成员和成员 token 都不变;站点关闭注册时返回 FORBIDDEN"
}
},
"/api/account/password": {
"put": {
"operationId": "changePassword",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ChangePasswordRequest"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"memberToken": []
}
],
"summary": "修改密码:账号在其他设备上的成员 token 随即失效,发出请求的这个不变;当前密码错误时返回 INVALID_CREDENTIALS"
}
},
"/api/admin/accounts/{username}": {
"get": {
"operationId": "adminGetAccount",
"parameters": [
{
"in": "path",
"name": "username",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminAccount"
}
}
},
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"adminToken": []
}
],
"summary": "按用户名查找账号"
}
},
"/api/admin/accounts/{username}/password": {
"post": {
"operationId": "adminResetAccountPassword",
"parameters": [
{
"in": "path",
"name": "username",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminResetAccountPasswordRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminResetAccountPasswordResponse"
}
}
},
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"adminToken": []
}
],
"summary": "重置账号的密码:生成随机的新密码,只在这次响应中返回;账号在所有设备上的成员 token 随即失效"
}
},
"/api/admin/credentials/bilibili": {
"get": {
"operationId": "adminGetBilibiliCredential",
@@ -1864,6 +2207,66 @@
"summary": "删除一首已存储的歌的音频和封面,并清除它的准备结果"
}
},
"/api/admin/registration": {
"delete": {
"operationId": "adminCloseRegistration",
"responses": {
"204": {
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"adminToken": []
}
],
"summary": "关闭注册:不能再注册新账号,已有账号照常登录,游客照常使用"
},
"put": {
"operationId": "adminOpenRegistration",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminOpenRegistrationRequest"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"adminToken": []
}
],
"summary": "开放注册"
}
},
"/api/admin/rooms": {
"get": {
"operationId": "adminListRooms",
@@ -2201,7 +2604,7 @@
"memberToken": []
}
],
"summary": "当前成员的资料"
"summary": "当前成员的资料;已注册的成员带上账号"
},
"patch": {
"operationId": "updateMe",
@@ -2242,7 +2645,7 @@
"memberToken": []
}
],
"summary": "修改昵称和头像;email 只用来计算头像哈希,空字符串表示清除头像"
"summary": "修改昵称和头像;email 用来计算头像哈希,已注册的账号同时保存它,去掉首尾空白后应形如 name@example.com,空字符串表示清除"
}
},
"/api/members": {
@@ -2973,6 +3376,75 @@
"summary": "按 UUID 或别名查找房间"
}
},
"/api/sessions": {
"post": {
"operationId": "login",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LoginRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LoginResponse"
}
}
},
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"memberToken": []
}
],
"summary": "游客登录账号:返回账号的新成员 token,发出请求的游客 token 随即失效;用户名或密码错误时返回 INVALID_CREDENTIALS,已经登录了账号时返回 CONFLICT"
}
},
"/api/sessions/current": {
"delete": {
"operationId": "logout",
"responses": {
"204": {
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"memberToken": []
}
],
"summary": "退出登录:发出请求的成员 token 随即失效"
}
},
"/api/site": {
"get": {
"operationId": "getSite",
@@ -2999,7 +3471,7 @@
}
},
"security": [],
"summary": "品牌名;没有设置过时是 Alisten"
"summary": "品牌名和是否开放注册;没有设置过品牌名时是 Alisten"
}
}
}
+83 -26
View File
@@ -4,12 +4,17 @@ package protocol
import "encoding/json"
const Version = "473ee31f5ef912b4"
const Version = "bb812527ea1133df"
type APIKey struct {
Key string `json:"key"`
}
type Account struct {
Email *string `json:"email,omitempty"`
Username string `json:"username"`
}
type ActivityKind string
const (
@@ -21,6 +26,11 @@ const (
ActivityKindFailed ActivityKind = "failed"
)
type AdminAccount struct {
Member Member `json:"member"`
Username string `json:"username"`
}
type AdminCheckBilibiliLoginRequest struct {
Key string `json:"key"`
}
@@ -94,6 +104,14 @@ func (v AdminListRoomsResponse) MarshalJSON() ([]byte, error) {
return json.Marshal(plain(v))
}
type AdminOpenRegistrationRequest struct{}
type AdminResetAccountPasswordRequest struct{}
type AdminResetAccountPasswordResponse struct {
Password string `json:"password"`
}
type AdminResetRoomApiKeyRequest struct{}
type AdminSetYoutubeCredentialRequest struct {
@@ -123,6 +141,11 @@ type AdminUpdateSiteResponse struct {
BrandName string `json:"brandName"`
}
type ChangePasswordRequest struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
type ChatMessageEvent struct {
Content string `json:"content"`
Member Member `json:"member"`
@@ -180,18 +203,19 @@ const (
type ErrorCode string
const (
ErrorCodeInvalidArgument ErrorCode = "INVALID_ARGUMENT"
ErrorCodeUnauthorized ErrorCode = "UNAUTHORIZED"
ErrorCodeForbidden ErrorCode = "FORBIDDEN"
ErrorCodeNotFound ErrorCode = "NOT_FOUND"
ErrorCodeConflict ErrorCode = "CONFLICT"
ErrorCodeQueueFull ErrorCode = "QUEUE_FULL"
ErrorCodeNothingPlaying ErrorCode = "NOTHING_PLAYING"
ErrorCodeRateLimited ErrorCode = "RATE_LIMITED"
ErrorCodeProviderError ErrorCode = "PROVIDER_ERROR"
ErrorCodeUnavailable ErrorCode = "UNAVAILABLE"
ErrorCodeClientOutdated ErrorCode = "CLIENT_OUTDATED"
ErrorCodeInternal ErrorCode = "INTERNAL"
ErrorCodeInvalidArgument ErrorCode = "INVALID_ARGUMENT"
ErrorCodeUnauthorized ErrorCode = "UNAUTHORIZED"
ErrorCodeInvalidCredentials ErrorCode = "INVALID_CREDENTIALS"
ErrorCodeForbidden ErrorCode = "FORBIDDEN"
ErrorCodeNotFound ErrorCode = "NOT_FOUND"
ErrorCodeConflict ErrorCode = "CONFLICT"
ErrorCodeQueueFull ErrorCode = "QUEUE_FULL"
ErrorCodeNothingPlaying ErrorCode = "NOTHING_PLAYING"
ErrorCodeRateLimited ErrorCode = "RATE_LIMITED"
ErrorCodeProviderError ErrorCode = "PROVIDER_ERROR"
ErrorCodeUnavailable ErrorCode = "UNAVAILABLE"
ErrorCodeClientOutdated ErrorCode = "CLIENT_OUTDATED"
ErrorCodeInternal ErrorCode = "INTERNAL"
)
type ErrorInfo struct {
@@ -200,11 +224,13 @@ type ErrorInfo struct {
}
type GetMeResponse struct {
Member Member `json:"member"`
Account *Account `json:"account,omitempty"`
Member Member `json:"member"`
}
type GetSiteResponse struct {
BrandName string `json:"brandName"`
BrandName string `json:"brandName"`
RegistrationOpen bool `json:"registrationOpen"`
}
type HelloRequest struct {
@@ -231,6 +257,19 @@ func (v ListRoomsResponse) MarshalJSON() ([]byte, error) {
return json.Marshal(plain(v))
}
type LoginRequest struct {
Device *string `json:"device,omitempty"`
Password string `json:"password"`
Username string `json:"username"`
}
type LoginResponse struct {
Account Account `json:"account"`
Device string `json:"device"`
Member Member `json:"member"`
MemberToken string `json:"memberToken"`
}
type Member struct {
AvatarHash string `json:"avatarHash"`
ID string `json:"id"`
@@ -397,6 +436,15 @@ func (v RecommendListResponse) MarshalJSON() ([]byte, error) {
return json.Marshal(plain(v))
}
type RegisterRequest struct {
Password string `json:"password"`
Username string `json:"username"`
}
type RegisterResponse struct {
Account Account `json:"account"`
}
type RoomActivityEvent struct {
Entry *Entry `json:"entry,omitempty"`
Kind ActivityKind `json:"kind"`
@@ -609,6 +657,10 @@ var (
RouteCreateMember = Route{Pattern: "POST /api/members", Auth: AuthNone, Status: 201}
RouteGetMe = Route{Pattern: "GET /api/me", Auth: AuthMember, Status: 200}
RouteUpdateMe = Route{Pattern: "PATCH /api/me", Auth: AuthMember, Status: 200}
RouteRegister = Route{Pattern: "POST /api/account", Auth: AuthMember, Status: 201}
RouteChangePassword = Route{Pattern: "PUT /api/account/password", Auth: AuthMember, Status: 204}
RouteLogin = Route{Pattern: "POST /api/sessions", Auth: AuthMember, Status: 201}
RouteLogout = Route{Pattern: "DELETE /api/sessions/current", Auth: AuthMember, Status: 204}
RouteListRooms = Route{Pattern: "GET /api/rooms", Auth: AuthNone, Status: 200}
RouteGetRoom = Route{Pattern: "GET /api/rooms/{ref}", Auth: AuthNone, Status: 200}
RouteCreateRoom = Route{Pattern: "POST /api/rooms", Auth: AuthNone, Status: 201, Location: true}
@@ -635,6 +687,10 @@ var (
RouteAdminListMedia = Route{Pattern: "GET /api/admin/media", Auth: AuthAdmin, Status: 200}
RouteAdminDeleteMedia = Route{Pattern: "DELETE /api/admin/media/{key}", Auth: AuthAdmin, Status: 204}
RouteAdminUpdateSite = Route{Pattern: "PATCH /api/admin/site", Auth: AuthAdmin, Status: 200}
RouteAdminOpenRegistration = Route{Pattern: "PUT /api/admin/registration", Auth: AuthAdmin, Status: 204}
RouteAdminCloseRegistration = Route{Pattern: "DELETE /api/admin/registration", Auth: AuthAdmin, Status: 204}
RouteAdminGetAccount = Route{Pattern: "GET /api/admin/accounts/{username}", Auth: AuthAdmin, Status: 200}
RouteAdminResetAccountPassword = Route{Pattern: "POST /api/admin/accounts/{username}/password", Auth: AuthAdmin, Status: 200}
)
var RoomRoutes = []RoomRoute{
@@ -652,17 +708,18 @@ var RoomRoutes = []RoomRoute{
}
var errorCodeHTTPStatus = map[ErrorCode]int{
ErrorCodeInvalidArgument: 400,
ErrorCodeUnauthorized: 401,
ErrorCodeForbidden: 403,
ErrorCodeNotFound: 404,
ErrorCodeConflict: 409,
ErrorCodeQueueFull: 409,
ErrorCodeNothingPlaying: 409,
ErrorCodeRateLimited: 429,
ErrorCodeProviderError: 502,
ErrorCodeUnavailable: 503,
ErrorCodeInternal: 500,
ErrorCodeInvalidArgument: 400,
ErrorCodeUnauthorized: 401,
ErrorCodeInvalidCredentials: 401,
ErrorCodeForbidden: 403,
ErrorCodeNotFound: 404,
ErrorCodeConflict: 409,
ErrorCodeQueueFull: 409,
ErrorCodeNothingPlaying: 409,
ErrorCodeRateLimited: 429,
ErrorCodeProviderError: 502,
ErrorCodeUnavailable: 503,
ErrorCodeInternal: 500,
}
// IDL 没有为某个取值指定 HTTP 状态码时返回 0。
+46 -1
View File
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
import type { APIKey, ErrorInfo, Member, QQLoginType, QRLogin, QRLoginResult, RoomListing, SourceAccount, StoredMedia } from '../realtime/protocol.gen'
import type { APIKey, Account, AdminAccount, ErrorInfo, Member, QQLoginType, QRLogin, QRLoginResult, RoomListing, SourceAccount, StoredMedia } from '../realtime/protocol.gen'
export interface AdminCheckBilibiliLoginRequest {
key: string
@@ -59,6 +59,14 @@ export interface AdminListRoomsResponse {
items: RoomListing[]
}
export type AdminOpenRegistrationRequest = Record<string, never>
export type AdminResetAccountPasswordRequest = Record<string, never>
export interface AdminResetAccountPasswordResponse {
password: string
}
export type AdminResetRoomApiKeyRequest = Record<string, never>
export interface AdminSetYoutubeCredentialRequest {
@@ -88,6 +96,11 @@ export interface AdminUpdateSiteResponse {
brandName: string
}
export interface ChangePasswordRequest {
currentPassword: string
newPassword: string
}
export type CreateMemberRequest = Record<string, never>
export interface CreateMemberResponse {
@@ -114,17 +127,41 @@ export interface CreateTicketResponse {
}
export interface GetMeResponse {
account?: Account
member: Member
}
export interface GetSiteResponse {
brandName: string
registrationOpen: boolean
}
export interface ListRoomsResponse {
items: RoomListing[]
}
export interface LoginRequest {
device?: string
password: string
username: string
}
export interface LoginResponse {
account: Account
device: string
member: Member
memberToken: string
}
export interface RegisterRequest {
password: string
username: string
}
export interface RegisterResponse {
account: Account
}
export interface UpdateMeRequest {
email?: string
name?: string
@@ -154,6 +191,10 @@ export function httpClient(send: HttpSend) {
createMember: () => send<CreateMemberResponse>({ method: 'POST', path: '/api/members', auth: 'none', body: {} }),
getMe: () => send<GetMeResponse>({ method: 'GET', path: '/api/me', auth: 'member' }),
updateMe: (body: UpdateMeRequest) => send<UpdateMeResponse>({ method: 'PATCH', path: '/api/me', auth: 'member', body }),
register: (body: RegisterRequest) => send<RegisterResponse>({ method: 'POST', path: '/api/account', auth: 'member', body }),
changePassword: (body: ChangePasswordRequest) => send<void>({ method: 'PUT', path: '/api/account/password', auth: 'member', body }),
login: (body: LoginRequest) => send<LoginResponse>({ method: 'POST', path: '/api/sessions', auth: 'member', body }),
logout: () => send<void>({ method: 'DELETE', path: '/api/sessions/current', auth: 'member' }),
listRooms: () => send<ListRoomsResponse>({ method: 'GET', path: '/api/rooms', auth: 'none' }),
getRoom: (ref: string) => send<RoomListing>({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}`, auth: 'none' }),
createRoom: (body: CreateRoomRequest) => send<CreateRoomResponse>({ method: 'POST', path: '/api/rooms', auth: 'none', body }),
@@ -180,5 +221,9 @@ export function httpClient(send: HttpSend) {
adminListMedia: () => send<AdminListMediaResponse>({ method: 'GET', path: '/api/admin/media', auth: 'admin' }),
adminDeleteMedia: (key: string) => send<void>({ method: 'DELETE', path: `/api/admin/media/${encodeURIComponent(key)}`, auth: 'admin' }),
adminUpdateSite: (body: AdminUpdateSiteRequest) => send<AdminUpdateSiteResponse>({ method: 'PATCH', path: '/api/admin/site', auth: 'admin', body }),
adminOpenRegistration: () => send<void>({ method: 'PUT', path: '/api/admin/registration', auth: 'admin', body: {} }),
adminCloseRegistration: () => send<void>({ method: 'DELETE', path: '/api/admin/registration', auth: 'admin' }),
adminGetAccount: (username: string) => send<AdminAccount>({ method: 'GET', path: `/api/admin/accounts/${encodeURIComponent(username)}`, auth: 'admin' }),
adminResetAccountPassword: (username: string) => send<AdminResetAccountPasswordResponse>({ method: 'POST', path: `/api/admin/accounts/${encodeURIComponent(username)}/password`, auth: 'admin', body: {} }),
}
}
+12 -2
View File
@@ -1,13 +1,23 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
export const PROTOCOL_VERSION = '473ee31f5ef912b4'
export const PROTOCOL_VERSION = 'bb812527ea1133df'
export interface APIKey {
key: string
}
export interface Account {
email?: string
username: string
}
export type ActivityKind = 'added' | 'removed' | 'liked' | 'voted' | 'skipped' | 'failed'
export interface AdminAccount {
member: Member
username: string
}
export interface ChatMessageEvent {
content: string
member: Member
@@ -32,7 +42,7 @@ export interface Entry {
export type EntryStatus = 'preparing' | 'ready'
export type ErrorCode = 'INVALID_ARGUMENT' | 'UNAUTHORIZED' | 'FORBIDDEN' | 'NOT_FOUND' | 'CONFLICT' | 'QUEUE_FULL' | 'NOTHING_PLAYING' | 'RATE_LIMITED' | 'PROVIDER_ERROR' | 'UNAVAILABLE' | 'CLIENT_OUTDATED' | 'INTERNAL'
export type ErrorCode = 'INVALID_ARGUMENT' | 'UNAUTHORIZED' | 'INVALID_CREDENTIALS' | 'FORBIDDEN' | 'NOT_FOUND' | 'CONFLICT' | 'QUEUE_FULL' | 'NOTHING_PLAYING' | 'RATE_LIMITED' | 'PROVIDER_ERROR' | 'UNAVAILABLE' | 'CLIENT_OUTDATED' | 'INTERNAL'
export interface ErrorInfo {
code: ErrorCode