Compare commits

...
26 Commits
Author SHA1 Message Date
DiheChen 29bfda6011 feat: let the Android app open links to this site
CI / go (push) Canceled after 0s
CI / web (push) Canceled after 0s
CI / images (Dockerfile, server, alisten) (push) Canceled after 0s
CI / images (web/Dockerfile, web, ) (push) Canceled after 0s
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Canceled after 0s
CI / helm-release (push) Canceled after 0s
CI / deploy (push) Canceled after 0s
2026-11-10 14:14:04 +08:00
DiheChen 6b9e3a0a24 feat: sign in with GitHub and manage the GitHub link in settings 2026-11-10 13:04:09 +08:00
DiheChen dd18ea7e2c feat: pair a new device from settings with a code or QR code 2026-11-10 12:59:53 +08:00
DiheChen 63279125a5 feat: offer GitHub login over HTTP when an OAuth app is configured 2026-11-10 12:55:58 +08:00
DiheChen 88f8e2f957 feat: expose device pairing over HTTP 2026-11-10 12:50:18 +08:00
DiheChen 50a865e2a3 feat: sign in with GitHub and link GitHub to accounts 2026-11-10 12:46:53 +08:00
DiheChen dc9852c0fe feat: pair new devices with one-time codes 2026-11-10 12:44:56 +08:00
DiheChen 5414520881 feat: exchange GitHub OAuth codes for user IDs 2026-11-10 12:42:48 +08:00
DiheChen c89866618b feat: keep pairing codes and GitHub login states in Redis 2026-11-10 12:41:38 +08:00
DiheChen e6edc02451 feat: store GitHub links on accounts that may have no password 2026-11-10 12:40:03 +08:00
DiheChen 9e4b8fa0c9 feat: open shared playlist links to request, save and copy their songs 2026-11-10 12:39:53 +08:00
DiheChen ed6d55ec29 feat: share my playlists from the playlist menu 2026-11-10 12:37:00 +08:00
DiheChen 6a5d8b4ebd feat: expose playlist sharing over HTTP 2026-11-10 12:34:57 +08:00
DiheChen 805db62264 feat: share playlists through unguessable links and copy shared playlists 2026-11-10 12:30:39 +08:00
DiheChen e61fab83ab feat: store playlist share links and copy shared playlists 2026-11-10 12:29:28 +08:00
DiheChen 6e71dc1b15 feat: show listening history from the server and sync the recording switch across devices 2026-11-10 12:28:52 +08:00
DiheChen fe05ac5486 feat: read, remove and clear listening history and switch recording over HTTP 2026-11-10 12:24:22 +08:00
DiheChen c6f53953b4 feat: write listening history as songs start and members join 2026-11-10 12:18:15 +08:00
DiheChen 4cb342d562 feat: keep each member's listening history and move a guest's history into the account on login 2026-11-10 12:14:24 +08:00
DiheChen 6bd8a1aea8 feat: tell which members hear each song from the room domain 2026-11-10 12:08:12 +08:00
DiheChen 110ec4c486 feat: import playlists from files, links and search results 2026-11-10 10:33:17 +08:00
DiheChen 3186fca62c feat: export playlists to files and import them in the web client 2026-11-10 10:15:11 +08:00
DiheChen c09f42a2dd feat: expose playlist imports over HTTP 2026-11-10 10:05:38 +08:00
DiheChen 759494be04 feat: import playlists from files and from NetEase links 2026-11-10 09:53:38 +08:00
DiheChen a034fbfe31 feat: look up prepared tracks in bulk and create a playlist with its songs at once 2026-11-10 09:50:31 +08:00
DiheChen a2bb542b9a feat: recognize NetEase and QQ Music playlist links and resolve their short links 2026-11-10 09:18:05 +08:00
169 changed files with 13919 additions and 635 deletions

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
-- +goose Up
-- 关闭之后不再记录新的播放历史,已有的保留。
ALTER TABLE members ADD COLUMN record_listens boolean NOT NULL DEFAULT true;
-- 成员在房间里听到过的歌,每首只留最近一次。元数据存一份快照,不关联 tracks:管理员删除已存储的媒体时会连带删掉 tracks 中对应的行。
CREATE TABLE listens (
member_id uuid NOT NULL REFERENCES members ON DELETE CASCADE,
source text NOT NULL,
track_id text NOT NULL,
title text NOT NULL,
artist text,
album text,
cover_url text,
cover_key text,
duration_ms bigint NOT NULL,
page_url text NOT NULL,
heard_at timestamptz NOT NULL,
PRIMARY KEY (member_id, source, track_id)
);
CREATE INDEX ON listens (member_id, heard_at DESC);
+3
View File
@@ -0,0 +1,3 @@
-- +goose Up
-- 分享链接中的随机串,NULL 表示没有分享。明文保存:所有者随时可以再复制一次链接。
ALTER TABLE playlists ADD COLUMN share_id text UNIQUE;
+6
View File
@@ -0,0 +1,6 @@
-- +goose Up
-- 绑定了 GitHub 的成员也是已注册的账号,可以没有用户名和密码;账号的邮箱因此也可以属于只绑定了 GitHub 的成员。
ALTER TABLE members
ADD COLUMN github_id bigint UNIQUE,
DROP CONSTRAINT members_email_check,
ADD CONSTRAINT members_email_check CHECK (email IS NULL OR username IS NOT NULL OR github_id IS NOT NULL);
+57
View File
@@ -0,0 +1,57 @@
-- name: LockListeners :many
-- 按 ID 的顺序锁住开启了记录的成员:同一个成员的历史依次写入,删掉超出上限的部分时不会多删或少删;
-- 加锁的顺序固定,同时写几个成员的事务之间不会死锁。
SELECT id FROM members WHERE id = ANY(@ids::uuid[]) AND record_listens ORDER BY id FOR NO KEY UPDATE;
-- name: UpsertListens :exec
INSERT INTO listens (member_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at)
SELECT m, @source::text, @track_id::text, @title::text, sqlc.narg(artist)::text, sqlc.narg(album)::text, sqlc.narg(cover_url)::text,
sqlc.narg(cover_key)::text, @duration_ms::bigint, @page_url::text, @heard_at::timestamptz
FROM unnest(@member_ids::uuid[]) AS m
ON CONFLICT (member_id, source, track_id) DO UPDATE
SET title = excluded.title, artist = excluded.artist, album = excluded.album, cover_url = excluded.cover_url,
cover_key = excluded.cover_key, duration_ms = excluded.duration_ms, page_url = excluded.page_url,
heard_at = greatest(listens.heard_at, excluded.heard_at);
-- name: MoveListens :exec
-- 并入的规则与 UpsertListens 相同,并入的那份算新写入的;to_id 关闭了记录时只删不并。
WITH moved AS (
DELETE FROM listens WHERE member_id = sqlc.arg(from_id)::uuid
RETURNING source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
)
INSERT INTO listens (member_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at)
SELECT sqlc.arg(to_id)::uuid, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
FROM moved
WHERE EXISTS (SELECT FROM members WHERE id = sqlc.arg(to_id)::uuid AND record_listens)
ON CONFLICT (member_id, source, track_id) DO UPDATE
SET title = excluded.title, artist = excluded.artist, album = excluded.album, cover_url = excluded.cover_url,
cover_key = excluded.cover_key, duration_ms = excluded.duration_ms, page_url = excluded.page_url,
heard_at = greatest(listens.heard_at, excluded.heard_at);
-- name: TrimListens :exec
-- 排序与 MemberListens 相同:留下的正是读取时返回的那些。
DELETE FROM listens l
USING (
SELECT member_id, source, track_id,
row_number() OVER (PARTITION BY member_id ORDER BY heard_at DESC, source, track_id) AS n
FROM listens WHERE member_id = ANY(@member_ids::uuid[])
) ranked
WHERE l.member_id = ranked.member_id AND l.source = ranked.source AND l.track_id = ranked.track_id AND ranked.n > @keep::bigint;
-- name: MemberListens :many
SELECT source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
FROM listens WHERE member_id = @member_id
ORDER BY heard_at DESC, source, track_id
LIMIT @max;
-- name: DeleteListen :exec
DELETE FROM listens WHERE member_id = @member_id AND source = @source AND track_id = @track_id;
-- name: ClearListens :exec
DELETE FROM listens WHERE member_id = @member_id;
-- name: ListenRecording :one
SELECT record_listens FROM members WHERE id = @id;
-- name: SetListenRecording :execrows
UPDATE members SET record_listens = @record_listens WHERE id = @id;
+29 -4
View File
@@ -13,21 +13,42 @@ SELECT * FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE to
UPDATE members
SET name = coalesce(sqlc.narg(name), name),
avatar_hash = CASE WHEN sqlc.narg(avatar_hash)::text IS NULL THEN avatar_hash ELSE NULLIF(sqlc.narg(avatar_hash)::text, '') END,
email = CASE WHEN username IS NULL THEN NULL WHEN sqlc.narg(email)::text IS NULL THEN email ELSE NULLIF(sqlc.narg(email)::text, '') END
email = CASE WHEN username IS NULL AND github_id IS NULL THEN NULL WHEN sqlc.narg(email)::text IS NULL THEN email ELSE NULLIF(sqlc.narg(email)::text, '') END
WHERE id = @id
RETURNING *;
-- name: MemberAccountProfile :one
SELECT username, email FROM members WHERE id = @id;
SELECT username, email, github_id FROM members WHERE id = @id;
-- name: RegisterMember :execrows
-- 只给游客设置:已经注册过的成员不受影响,影响的行数为 0。
-- 只给游客设置。只绑定了 GitHub 的账号补设用户名和密码要删除其他会话,用 AddMemberCredentials。
UPDATE members SET username = @username, password_hash = @password_hash
WHERE id = @id AND username IS NULL;
WHERE id = @id AND username IS NULL AND github_id IS NULL;
-- name: AddMemberCredentials :execrows
-- 只给只绑定了 GitHub 的账号设置,它的“我喜欢”已经在绑定时创建了。
UPDATE members SET username = @username, password_hash = @password_hash
WHERE id = @id AND username IS NULL AND github_id IS NOT NULL;
-- name: AccountByUsername :one
SELECT * FROM members WHERE username = @username;
-- name: AccountByID :one
SELECT * FROM members WHERE id = @id AND (username IS NOT NULL OR github_id IS NOT NULL);
-- name: AccountByGitHub :one
SELECT * FROM members WHERE github_id = @github_id;
-- name: BindGitHub :one
-- 只给还没有绑定 GitHub 的成员绑定,已经绑定过的成员不受影响,没有返回的行。was_guest 表示成员原来是游客:这次绑定就是注册。
UPDATE members SET github_id = @github_id
WHERE id = @id AND github_id IS NULL
RETURNING (username IS NULL)::boolean AS was_guest;
-- name: UnbindGitHub :execrows
-- 没有密码的账号解绑之后就再也无法登录,所以只给设置了密码的账号解绑。
UPDATE members SET github_id = NULL WHERE id = @id AND password_hash IS NOT NULL;
-- name: SetMemberPassword :execrows
UPDATE members SET password_hash = @password_hash WHERE id = @id AND username IS NOT NULL;
@@ -40,11 +61,15 @@ SELECT EXISTS (SELECT FROM member_sessions WHERE token_hash = @token_hash AND me
-- name: LockMemberPassword :one
-- 让修改密码等到这次登录提交之后,它随后删除会话时会删掉这次登录签发的会话。
-- 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE:同一个账号的几次登录依次并入游客的历史,否则两边会按相反的顺序抢同一批历史行而死锁。
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = @id FOR NO KEY UPDATE;
-- name: DeleteSession :execrows
DELETE FROM member_sessions WHERE token_hash = @token_hash;
-- name: TakeSession :one
DELETE FROM member_sessions WHERE token_hash = @token_hash RETURNING member_id;
-- name: DeleteMemberSessions :many
-- keep 为 NULL 时删除这个成员的全部会话。
DELETE FROM member_sessions WHERE member_id = @member_id AND token_hash IS DISTINCT FROM sqlc.narg(keep)::bytea
+35 -1
View File
@@ -2,7 +2,7 @@
INSERT INTO playlists (owner_id, kind, name) VALUES (@owner_id, 'favorites', '我喜欢');
-- name: MemberPlaylists :many
SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, count(i.track_id)::bigint AS track_count
SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, p.share_id, count(i.track_id)::bigint AS track_count
FROM playlists p
LEFT JOIN playlist_items i ON i.playlist_id = p.id
WHERE p.owner_id = @owner_id
@@ -50,6 +50,10 @@ SELECT count(*) FROM playlist_items WHERE playlist_id = @playlist_id;
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
VALUES (@playlist_id, @source, @track_id, @title, @artist, @album, @cover_url, @cover_key, @duration_ms, @page_url, @position);
-- name: InsertPlaylistItems :copyfrom
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
VALUES (@playlist_id, @source, @track_id, @title, @artist, @album, @cover_url, @cover_key, @duration_ms, @page_url, @position);
-- name: SetPlaylistSeq :exec
UPDATE playlists SET item_seq = @item_seq, updated_at = now() WHERE id = @id;
@@ -104,3 +108,33 @@ SELECT p.id FROM playlists p
JOIN playlist_items i ON i.playlist_id = p.id
WHERE p.owner_id = @owner_id AND i.source = @source AND i.track_id = @track_id
ORDER BY p.id;
-- name: SetPlaylistShare :execrows
UPDATE playlists SET share_id = @share_id WHERE id = @id AND owner_id = @owner_id;
-- name: SharedPlaylist :one
SELECT p.id, p.kind, p.name, m.name AS owner_name, m.avatar_hash AS owner_avatar_hash
FROM playlists p
JOIN members m ON m.id = p.owner_id
WHERE p.share_id = sqlc.arg(share_id)::text;
-- name: CopySharedPlaylistItems :one
-- 确认分享还在和复制曲目在同一条语句里,看到的是同一个快照:要么完整复制,要么读不到分享。不锁来源歌单:两个人同时复制
-- 对方分享的“我喜欢”时,各自已经锁住了自己的“我喜欢”,再去锁对方的就会互相等待。
WITH shared AS (
SELECT id FROM playlists WHERE share_id = sqlc.arg(share_id)::text
), copied AS (
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
SELECT sqlc.arg(to_id), i.source, i.track_id, i.title, i.artist, i.album, i.cover_url, i.cover_key, i.duration_ms, i.page_url,
row_number() OVER (ORDER BY i.position)
FROM playlist_items i
JOIN shared s ON s.id = i.playlist_id
RETURNING 1
)
SELECT EXISTS (SELECT FROM shared) AS found, (SELECT count(*) FROM copied)::bigint AS copied;
-- name: PlaylistCover :one
SELECT cover_url, cover_key FROM playlist_items
WHERE playlist_id = @playlist_id AND (cover_url IS NOT NULL OR cover_key IS NOT NULL)
ORDER BY position DESC
LIMIT 1;
+6
View File
@@ -12,3 +12,9 @@ ON CONFLICT (source, track_id) DO UPDATE SET
lyric = excluded.lyric, lyric_words = excluded.lyric_words, lyric_trans = excluded.lyric_trans,
media_key = excluded.media_key, media_size = excluded.media_size,
prepared_at = now();
-- name: TrackMetas :many
SELECT t.source, t.track_id, t.title, t.artist, t.album, t.cover_url, t.cover_key, t.page_url, t.duration_ms
FROM unnest(@sources::text[]) WITH ORDINALITY AS s(source, ord)
JOIN unnest(@track_ids::text[]) WITH ORDINALITY AS k(track_id, ord) USING (ord)
JOIN tracks t ON t.source = s.source AND t.track_id = k.track_id;
+33 -2
View File
@@ -12,7 +12,7 @@ func (s *server) register(ctx context.Context, c call) (any, error) {
if err != nil {
return nil, err
}
account, err := s.Accounts.Register(ctx, c.member.ID, c.clientIP, req.Username, req.Password)
account, err := s.Accounts.Register(ctx, c.member.ID, bearer(c.r), c.clientIP, req.Username, req.Password)
if err != nil {
return nil, err
}
@@ -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
}
+41 -1
View File
@@ -19,6 +19,7 @@ import (
"github.com/dihemoe/alisten/internal/app/admin"
"github.com/dihemoe/alisten/internal/app/catalog"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/listen"
"github.com/dihemoe/alisten/internal/app/lobby"
"github.com/dihemoe/alisten/internal/app/media"
"github.com/dihemoe/alisten/internal/app/playlist"
@@ -36,7 +37,9 @@ const (
maxBodyBytes = 64 << 10
// 歌单的批量接口一次最多 1000 首,链接曲目的 ID 最长 2048 字节,每首再留 256 字节给来源、字段名和 JSON 的标点。
maxBatchBodyBytes = 1000 * (2048 + 256)
requestDeadline = 3 * time.Minute
// 导入文件一次最多 1000 首,每首除了曲目还带标题、歌手、专辑和两个地址。nginx 中 /api/me/playlists/imports 的上限要与它保持一致。
maxImportBodyBytes = 1 << 20
requestDeadline = 3 * time.Minute
)
type Services struct {
@@ -50,9 +53,13 @@ type Services struct {
Media *media.Library
Playback *media.Playback
Site *site.Service
Listens *listen.Service
GitHub *identity.GitHub
// 浏览器访问对象存储时使用的地址前缀,结尾没有 /。
StorageURL string
// 网页的 origin,结尾没有 /。
PublicURL string
}
type server struct {
@@ -120,6 +127,9 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
srv.handle(mux, protocol.RouteListMyTrackPlaylists, srv.listMyTrackPlaylists)
srv.handleLimit(mux, protocol.RouteCopyMyPlaylistItems, maxBatchBodyBytes, srv.copyMyPlaylistItems)
srv.handleLimit(mux, protocol.RouteMoveMyPlaylistItems, maxBatchBodyBytes, srv.moveMyPlaylistItems)
srv.handleLimit(mux, protocol.RouteImportMyPlaylist, maxImportBodyBytes, srv.importMyPlaylist)
srv.handle(mux, protocol.RouteResolveMyPlaylistLink, srv.resolveMyPlaylistLink)
srv.handle(mux, protocol.RouteImportMyPlatformPlaylist, srv.importMyPlatformPlaylist)
srv.handle(mux, protocol.RouteListRooms, srv.listRooms)
srv.handle(mux, protocol.RouteGetRoom, srv.getRoom)
srv.handle(mux, protocol.RouteCreateRoom, srv.createRoom)
@@ -146,6 +156,9 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
srv.handle(mux, protocol.RouteAdminGetYoutubeCredential, srv.adminGetYoutubeCredential)
srv.handle(mux, protocol.RouteAdminSetYoutubeCredential, srv.adminSetYoutubeCredential)
srv.handle(mux, protocol.RouteAdminDeleteYoutubeCredential, srv.adminDeleteYoutubeCredential)
srv.handle(mux, protocol.RouteAdminGetGithubCredential, srv.adminGetGithubCredential)
srv.handle(mux, protocol.RouteAdminSetGithubCredential, srv.adminSetGithubCredential)
srv.handle(mux, protocol.RouteAdminDeleteGithubCredential, srv.adminDeleteGithubCredential)
srv.handle(mux, protocol.RouteAdminListMedia, srv.adminListMedia)
srv.handle(mux, protocol.RouteAdminDeleteMedia, srv.adminDeleteMedia)
srv.handle(mux, protocol.RouteAdminUpdateSite, srv.adminUpdateSite)
@@ -153,6 +166,21 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
srv.handle(mux, protocol.RouteAdminCloseRegistration, srv.adminCloseRegistration)
srv.handle(mux, protocol.RouteAdminGetAccount, srv.adminGetAccount)
srv.handle(mux, protocol.RouteAdminResetAccountPassword, srv.adminResetAccountPassword)
srv.handle(mux, protocol.RouteListMyListens, srv.listMyListens)
srv.handle(mux, protocol.RouteRemoveMyListen, srv.removeMyListen)
srv.handle(mux, protocol.RouteClearMyListens, srv.clearMyListens)
srv.handle(mux, protocol.RouteEnableListenRecording, srv.enableListenRecording)
srv.handle(mux, protocol.RouteDisableListenRecording, srv.disableListenRecording)
srv.handle(mux, protocol.RouteShareMyPlaylist, srv.shareMyPlaylist)
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)
srv.handle(mux, protocol.RouteStartGithubLogin, srv.startGithubLogin)
srv.handle(mux, protocol.RouteFinishGithubLogin, srv.finishGithubLogin)
srv.handle(mux, protocol.RouteUnbindGithub, srv.unbindGithub)
mux.HandleFunc("GET "+GitHubCallbackPath, srv.githubCallback)
mux.HandleFunc("GET /ws", srv.websocket)
mux.HandleFunc("GET /api/room/events", srv.roomEvents)
mux.HandleFunc("GET /media/{source}/{id}", srv.redirectMedia)
@@ -326,6 +354,9 @@ var knownErrors = []struct {
{playlist.ErrPlaylistFull, protocol.ErrorCodeConflict},
{playlist.ErrTrackNotReady, protocol.ErrorCodeConflict},
{playlist.ErrTooFast, protocol.ErrorCodeRateLimited},
{playlist.ErrInvalidPlaylistLink, protocol.ErrorCodeInvalidArgument},
{playlist.ErrUnsupportedPlaylist, protocol.ErrorCodeInvalidArgument},
{playlist.ErrShortLinkFailed, protocol.ErrorCodeProviderError},
{playlistorder.ErrBeforeMoved, protocol.ErrorCodeInvalidArgument},
{playlistorder.ErrBeforeMissing, protocol.ErrorCodeConflict},
{realtime.ErrRequesterNotAllowed, protocol.ErrorCodeInvalidArgument},
@@ -337,6 +368,15 @@ var knownErrors = []struct {
{catalog.ErrRateLimited, protocol.ErrorCodeRateLimited},
{errClientOutdated, protocol.ErrorCodeClientOutdated},
{errPanicked, protocol.ErrorCodeInternal},
{port.ErrShareNotFound, protocol.ErrorCodeNotFound},
{identity.ErrInvalidPairingCode, protocol.ErrorCodeInvalidCredentials},
{port.ErrNoPassword, protocol.ErrorCodeConflict},
{identity.ErrGitHubLoginExpired, protocol.ErrorCodeInvalidCredentials},
{identity.ErrGitHubMismatch, protocol.ErrorCodeInvalidCredentials},
{identity.ErrGitHubUnverified, protocol.ErrorCodeForbidden},
{identity.ErrGitHubDisabled, protocol.ErrorCodeNotFound},
{port.ErrGitHubTaken, protocol.ErrorCodeConflict},
{port.ErrGitHubAlreadyBound, protocol.ErrorCodeConflict},
}
func errorInfo(err error) (info protocol.ErrorInfo, expected bool) {
+6 -3
View File
@@ -39,16 +39,19 @@ const allowedOrigin = "https://allowed.example"
func services(store *porttest.Store, rooms port.RoomDirectory, adminToken string) Services {
music := newCatalog(rooms, 10)
node := porttest.NewRedis().Node("a")
_, gateway := realtime.New(store, rooms, store, music, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
_, gateway := realtime.New(store, rooms, store, store, music, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
l := lobby.New(rooms, node)
accounts := identity.NewAccounts(store, store, nil, nil, nil, nil, gateway)
return Services{
Identity: identity.New(store, rooms, store, gateway),
Accounts: identity.NewAccounts(store, store, nil, nil, gateway),
Accounts: accounts,
Lobby: l,
Admin: admin.New(adminToken, l, rooms, store, store, porttest.NewNetEaseQRLogin(), porttest.NewQQQRLogin(), porttest.NewBilibiliQRLogin(), porttest.NewYouTubeCookies(), gateway),
Gateway: gateway,
Catalog: music,
Site: site.New(store),
// 这些测试不发起 GitHub 授权,只读取后台是否配置了 OAuth App。
GitHub: identity.NewGitHub(accounts, store, nil, nil),
}
}
@@ -158,7 +161,7 @@ func TestRequestBodyLimit(t *testing.T) {
func TestPlaylistBatchBodyLimit(t *testing.T) {
store := porttest.New()
s := services(store, store, "")
s.Playlists = playlist.New(store, store, noTracks{}, s.Catalog, nil)
s.Playlists = playlist.New(store, store, noTracks{}, s.Catalog, nil, nil)
server, _ := start(t, s)
var created protocol.CreateMemberResponse
if err := json.Unmarshal([]byte(send(t, server, http.MethodPost, "/api/members", "{}").body), &created); err != nil {
+4
View File
@@ -17,6 +17,10 @@ func (noTracks) PreparedTrack(context.Context, track.Track) (port.PreparedTrack,
return port.PreparedTrack{}, false, nil
}
func (noTracks) TrackMetas(context.Context, []track.Track) (map[track.Track]track.Meta, error) {
return nil, nil
}
func (noTracks) SavePreparedTrack(context.Context, track.Track, port.PreparedTrack) error {
return nil
}
+140
View File
@@ -0,0 +1,140 @@
package api
import (
"context"
"errors"
"net/http"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/protocol"
)
// 用户在 GitHub 上授权之后,浏览器跳到这里:网页的 origin 加上它,就是 OAuth App 中登记的回调地址。
const GitHubCallbackPath = "/api/auth/github/callback"
// 回调处理完之后的落地页,结果放在片段里。App 用系统浏览器授权,跳到 App 的自定义 scheme 才能回到 App。
// 自定义 scheme 可能被别的 App 抢注,截获的兑换码却换不到身份:它 1 分钟内有效、只能用一次,兑换时要带上发起授权的客户端本机保存的
// nonce,并且必须由发起授权的同一个成员调用。
const (
githubWebLanding = "/auth/github"
githubAppLanding = "moe.dihe.alisten:/auth/github"
)
func (s *server) startGithubLogin(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeStartGithubLoginRequest(c.body)
if err != nil {
return nil, err
}
intent := port.GitHubLogin
switch req.Intent {
case protocol.GitHubIntentBind:
intent = port.GitHubBind
case protocol.GitHubIntentVerify:
intent = port.GitHubVerify
}
client := port.GitHubWeb
if req.Client != nil && *req.Client == protocol.GitHubClientApp {
client = port.GitHubApp
}
url, err := s.GitHub.Authorize(ctx, c.member.ID, c.clientIP, intent, client, req.Nonce, deref(req.CurrentPassword))
if err != nil {
return nil, err
}
return protocol.StartGithubLoginResponse{URL: url}, nil
}
func (s *server) finishGithubLogin(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeFinishGithubLoginRequest(c.body)
if err != nil {
return nil, err
}
result, err := s.GitHub.Exchange(ctx, c.member, bearer(c.r), req.Code, req.Nonce)
if err != nil {
return nil, err
}
resp := protocol.FinishGithubLoginResponse{Member: memberDTO(result.Member), Account: accountDTO(result.Account)}
if result.Session != nil {
resp.MemberToken = &result.Session.MemberToken
}
return resp, nil
}
func (s *server) unbindGithub(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeUnbindGithubRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.GitHub.Unbind(ctx, c.member.ID, req.CurrentPassword)
}
func (s *server) adminGetGithubCredential(ctx context.Context, _ call) (any, error) {
clientID, err := s.GitHub.ClientID(ctx)
if err != nil {
return nil, err
}
return protocol.AdminGetGithubCredentialResponse{ClientID: optional(clientID), ClientSecretSet: clientID != "", CallbackURL: s.PublicURL + GitHubCallbackPath}, nil
}
func (s *server) adminSetGithubCredential(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeAdminSetGithubCredentialRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.GitHub.SetOAuthApp(ctx, port.GitHubOAuthApp{ClientID: req.ClientID, ClientSecret: req.ClientSecret})
}
func (s *server) adminDeleteGithubCredential(ctx context.Context, _ call) (any, error) {
return nil, s.GitHub.DeleteOAuthApp(ctx)
}
// 浏览器从 GitHub 跳回来,换到兑换码之后跳到发起授权的客户端的落地页。结果放在片段里,不会出现在之后的请求和 Referer 中;
// 这个地址的查询串里有授权码,所以响应也不让浏览器把它作为 Referer 发出去。跳转目标只看 state 对应的记录,不看回调地址中的参数,
// 否则就成了开放重定向。
func (s *server) githubCallback(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), requestDeadline)
defer cancel()
q := r.URL.Query()
var (
client port.GitHubClient
fragment string
err error
)
if reason := q.Get("error"); reason != "" {
fragment = "error=denied"
if reason != "access_denied" {
s.logger.WarnContext(ctx, "GitHub 拒绝了授权请求", "reason", reason, "description", q.Get("error_description"))
fragment = "error=failed"
}
client, err = s.GitHub.Cancel(ctx, q.Get("state"))
if err != nil && !errors.Is(err, identity.ErrGitHubRequestExpired) {
s.logger.ErrorContext(ctx, "作废 GitHub 授权的 state 失败", "error", err)
}
} else {
var code string
client, code, err = s.GitHub.Callback(ctx, q.Get("state"), q.Get("code"))
switch {
case err == nil:
fragment = "code=" + code
case errors.Is(err, identity.ErrGitHubRequestExpired):
fragment = "error=expired"
case errors.Is(err, identity.ErrGitHubDisabled):
fragment = "error=failed"
case errors.Is(err, identity.ErrGitHubFailed):
s.logger.WarnContext(ctx, "没有从 GitHub 换到用户", "error", err)
fragment = "error=failed"
default:
s.logger.ErrorContext(ctx, "处理 GitHub 的回调失败", "error", err)
fragment = "error=failed"
}
}
landing := githubWebLanding
if client == port.GitHubApp {
landing = githubAppLanding
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
// 不用 http.Redirect:它会把片段当作路径的一部分清理。
w.Header().Set("Location", landing+"#"+fragment)
w.WriteHeader(http.StatusFound)
}
+3
View File
@@ -42,6 +42,9 @@ func (s *server) getMe(ctx context.Context, c call) (any, error) {
dto := accountDTO(account)
resp.Account = &dto
}
if resp.RecordListens, err = s.Listens.Recording(ctx, c.member.ID); err != nil {
return nil, err
}
return resp, nil
}
+42
View File
@@ -0,0 +1,42 @@
package api
import (
"context"
"github.com/dihemoe/alisten/protocol"
)
func (s *server) listMyListens(ctx context.Context, c call) (any, error) {
listens, err := s.Listens.List(ctx, c.member.ID)
if err != nil {
return nil, err
}
items := make([]protocol.MyListen, len(listens))
for i, l := range listens {
items[i] = protocol.MyListen{Track: trackDTO(l.Track), Meta: s.trackMeta(l.Meta), HeardAtMs: l.HeardAt.UnixMilli()}
}
return protocol.ListMyListensResponse{Items: items}, nil
}
func (s *server) removeMyListen(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeRemoveMyListenRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.Listens.Remove(ctx, c.member.ID, trackOf(req.Track))
}
func (s *server) clearMyListens(ctx context.Context, c call) (any, error) {
return nil, s.Listens.Clear(ctx, c.member.ID)
}
func (s *server) enableListenRecording(ctx context.Context, c call) (any, error) {
if _, err := protocol.DecodeEnableListenRecordingRequest(c.body); err != nil {
return nil, err
}
return nil, s.Listens.SetRecording(ctx, c.member.ID, true)
}
func (s *server) disableListenRecording(ctx context.Context, c call) (any, error) {
return nil, s.Listens.SetRecording(ctx, c.member.ID, false)
}
+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)
}
}
+96
View File
@@ -2,7 +2,9 @@ package api
import (
"context"
"time"
"github.com/dihemoe/alisten/internal/app/playlist"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
"github.com/dihemoe/alisten/protocol"
@@ -22,6 +24,7 @@ func (s *server) playlistDTO(p port.PlaylistRecord) protocol.MyPlaylist {
CreatedAtMs: p.CreatedAt.UnixMilli(),
UpdatedAtMs: p.UpdatedAt.UnixMilli(),
CoverURL: cover,
ShareID: optional(p.ShareID),
}
}
@@ -144,3 +147,96 @@ func (s *server) moveMyPlaylistItems(ctx context.Context, c call) (any, error) {
}
return nil, s.Playlists.Move(ctx, c.member.ID, c.r.PathValue("id"), tracksOf(req.Tracks), before)
}
func (s *server) importMyPlaylist(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeImportMyPlaylistRequest(c.body)
if err != nil {
return nil, err
}
tracks := make([]playlist.ImportedTrack, len(req.Tracks))
for i, t := range req.Tracks {
tracks[i] = playlist.ImportedTrack{
Track: trackOf(t.Track),
Title: t.Title,
Artist: deref(t.Artist),
Album: deref(t.Album),
CoverURL: deref(t.CoverURL),
PageURL: deref(t.PageURL),
Duration: time.Duration(t.DurationMs) * time.Millisecond,
}
}
p, n, err := s.Playlists.ImportFile(ctx, c.member.ID, req.Name, tracks)
if err != nil {
return nil, err
}
return protocol.ImportMyPlaylistResponse{Playlist: s.playlistDTO(p), Imported: int64(n)}, nil
}
func (s *server) resolveMyPlaylistLink(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeResolveMyPlaylistLinkRequest(c.body)
if err != nil {
return nil, err
}
source, id, err := s.Playlists.ResolveLink(ctx, c.member.ID, req.Link)
if err != nil {
return nil, err
}
return protocol.ResolveMyPlaylistLinkResponse{Source: protocol.Source(source), PlaylistID: id}, nil
}
func (s *server) importMyPlatformPlaylist(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeImportMyPlatformPlaylistRequest(c.body)
if err != nil {
return nil, err
}
p, n, err := s.Playlists.ImportPlatform(ctx, c.member.ID, req.Name, track.Source(req.Source), req.PlaylistID)
if err != nil {
return nil, err
}
return protocol.ImportMyPlatformPlaylistResponse{Playlist: s.playlistDTO(p), Imported: int64(n)}, nil
}
func (s *server) shareMyPlaylist(ctx context.Context, c call) (any, error) {
if _, err := protocol.DecodeShareMyPlaylistRequest(c.body); err != nil {
return nil, err
}
shareID, err := s.Playlists.Share(ctx, c.member.ID, c.r.PathValue("id"))
if err != nil {
return nil, err
}
return protocol.ShareMyPlaylistResponse{ShareID: shareID}, nil
}
func (s *server) unshareMyPlaylist(ctx context.Context, c call) (any, error) {
return nil, s.Playlists.Unshare(ctx, c.member.ID, c.r.PathValue("id"))
}
func (s *server) getSharedPlaylist(ctx context.Context, c call) (any, error) {
p, err := s.Playlists.Shared(ctx, c.r.PathValue("shareId"))
if err != nil {
return nil, err
}
items := make([]protocol.MyPlaylistItem, len(p.Items))
for i, item := range p.Items {
items[i] = s.playlistItemDTO(item)
}
return protocol.SharedPlaylist{
Name: p.Name,
Favorites: p.Favorites,
OwnerName: p.OwnerName,
OwnerAvatarHash: p.OwnerAvatarHash,
Items: items,
}, nil
}
func (s *server) copySharedPlaylist(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeCopySharedPlaylistRequest(c.body)
if err != nil {
return nil, err
}
p, err := s.Playlists.CopyShared(ctx, c.member.ID, c.r.PathValue("shareId"), req.Name)
if err != nil {
return nil, err
}
return s.playlistDTO(p), nil
}
+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 -1
View File
@@ -43,7 +43,7 @@ func newRoomFixture(t *testing.T) roomFixture {
store := porttest.New()
music := newCatalog(store, 10)
node := porttest.NewRedis().Node("a")
host, gateway := realtime.New(store, store, store, music, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
host, gateway := realtime.New(store, store, store, store, music, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(context.Background())
wait, err := realtime.Start(ctx, host, gateway)
if err != nil {
+5 -1
View File
@@ -11,7 +11,11 @@ func (s *server) getSite(ctx context.Context, _ call) (any, error) {
if err != nil {
return nil, err
}
return protocol.GetSiteResponse{BrandName: settings.BrandName, RegistrationOpen: settings.RegistrationOpen}, nil
githubClientID, err := s.GitHub.ClientID(ctx)
if err != nil {
return nil, err
}
return protocol.GetSiteResponse{BrandName: settings.BrandName, RegistrationOpen: settings.RegistrationOpen, GithubLogin: githubClientID != ""}, nil
}
func (s *server) adminUpdateSite(ctx context.Context, c call) (any, error) {
+1 -1
View File
@@ -16,7 +16,7 @@ func TestSite(t *testing.T) {
return response{status: http.StatusOK, body: `{"brandName":"` + name + `"}`}
}
site := func(name string) response {
return response{status: http.StatusOK, body: `{"brandName":"` + name + `","registrationOpen":true}`}
return response{status: http.StatusOK, body: `{"brandName":"` + name + `","githubLogin":false,"registrationOpen":true}`}
}
if got := send(t, server, http.MethodGet, "/api/site", ""); got != site("Alisten") {
+1 -1
View File
@@ -134,7 +134,7 @@ func newWSFixtureWithTickets(t *testing.T, store *porttest.Store, tickets port.T
music := newCatalog(directory, 10)
node := porttest.NewRedis().Node("a")
preparer := porttest.NewPreparer()
host, gateway := realtime.New(snapshots, store, store, music, preparer, realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
host, gateway := realtime.New(snapshots, store, store, store, music, preparer, realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(context.Background())
wait, err := realtime.Start(ctx, host, gateway)
if err != nil {
+139
View File
@@ -0,0 +1,139 @@
package github
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/dihemoe/alisten/internal/app/port"
)
var _ port.GitHubOAuth = (*OAuth)(nil)
const (
timeout = 10 * time.Second
// GitHub 的这两个响应都只有几百字节。
maxResponseBytes = 64 << 10
)
type Endpoint struct {
AuthorizeURL string
TokenURL string
UserURL string
}
var GitHubCom = Endpoint{
AuthorizeURL: "https://github.com/login/oauth/authorize",
TokenURL: "https://github.com/login/oauth/access_token",
UserURL: "https://api.github.com/user",
}
type Config struct {
RedirectURL string
Endpoint Endpoint
}
type OAuth struct {
client *http.Client
cfg Config
}
func New(client *http.Client, cfg Config) *OAuth {
return &OAuth{client: client, cfg: cfg}
}
func (o *OAuth) AuthorizeURL(app port.GitHubOAuthApp, state, verifier string) string {
challenge := sha256.Sum256([]byte(verifier))
q := url.Values{
"client_id": {app.ClientID},
"redirect_uri": {o.cfg.RedirectURL},
"state": {state},
"code_challenge": {base64.RawURLEncoding.EncodeToString(challenge[:])},
"code_challenge_method": {"S256"},
}
return o.cfg.Endpoint.AuthorizeURL + "?" + q.Encode()
}
func (o *OAuth) UserID(ctx context.Context, app port.GitHubOAuthApp, code, verifier string) (int64, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
token, err := o.token(ctx, app, code, verifier)
if err != nil {
return 0, fmt.Errorf("向 GitHub 换取令牌:%w", err)
}
id, err := o.user(ctx, token)
if err != nil {
return 0, fmt.Errorf("读取 GitHub 用户:%w", err)
}
return id, nil
}
func (o *OAuth) token(ctx context.Context, app port.GitHubOAuthApp, code, verifier string) (string, error) {
form := url.Values{"client_id": {app.ClientID}, "client_secret": {app.ClientSecret}, "code": {code}, "code_verifier": {verifier}, "redirect_uri": {o.cfg.RedirectURL}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.cfg.Endpoint.TokenURL, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
// 授权码错误或过期时,GitHub 也以 200 返回,错误放在响应体的 error 中。
var body struct {
AccessToken string `json:"access_token"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
if err := o.do(req, &body); err != nil {
return "", err
}
if body.Error != "" {
return "", fmt.Errorf("GitHub 拒绝了授权码:%s(%s)", body.Error, body.ErrorDescription)
}
if body.AccessToken == "" {
return "", fmt.Errorf("GitHub 没有给出令牌")
}
return body.AccessToken, nil
}
func (o *OAuth) user(ctx context.Context, token string) (int64, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, o.cfg.Endpoint.UserURL, nil)
if err != nil {
return 0, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
// GitHub 的 REST API 拒绝不带 User-Agent 的请求。
req.Header.Set("User-Agent", "alisten")
var body struct {
ID int64 `json:"id"`
}
if err := o.do(req, &body); err != nil {
return 0, err
}
if body.ID <= 0 {
return 0, fmt.Errorf("GitHub 返回的用户 ID 不是正数:%d", body.ID)
}
return body.ID, nil
}
func (o *OAuth) do(req *http.Request, v any) error {
resp, err := o.client.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GitHub 返回 HTTP %d", resp.StatusCode)
}
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBytes)).Decode(v); err != nil {
return fmt.Errorf("解码 GitHub 的响应:%w", err)
}
return nil
}
+170
View File
@@ -0,0 +1,170 @@
package github
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"testing/synctest"
"time"
"github.com/dihemoe/alisten/internal/app/port"
)
const (
verifier = "dBjftJeZ4CVP-mJ92K1D5gV9dxWXJyqi5aZoxwYkrUM"
challenge = "kXQYIXxOaWujAPbjqX_yBdiwMfNi4RzDNMDwDNpE7QQ"
)
var app = port.GitHubOAuthApp{ClientID: "client-1", ClientSecret: "secret-1"}
// 假的 GitHub:授权码 good 签发时绑定了 challenge,带上 verifier 才能换到令牌 token-1,令牌 token-1 对应用户 42。
type fakeGitHub struct {
token func(w http.ResponseWriter, r *http.Request)
user func(w http.ResponseWriter, r *http.Request)
}
func newFake(t *testing.T) (*fakeGitHub, Config) {
t.Helper()
fake := &fakeGitHub{}
fake.token = func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
t.Error(err)
}
if r.Method != http.MethodPost || r.Header.Get("Accept") != "application/json" {
t.Errorf("换取令牌的请求 = %s,Accept: %q", r.Method, r.Header.Get("Accept"))
}
want := url.Values{"client_id": {"client-1"}, "client_secret": {"secret-1"}, "code": {r.PostForm.Get("code")}, "code_verifier": {verifier}, "redirect_uri": {"https://alisten.example/api/auth/github/callback"}}
if r.PostForm.Encode() != want.Encode() {
t.Errorf("换取令牌的参数 = %s,期望 %s", r.PostForm.Encode(), want.Encode())
}
w.Header().Set("Content-Type", "application/json")
if r.PostForm.Get("code") == "good" {
_, _ = w.Write([]byte(`{"access_token":"token-1","token_type":"bearer","scope":""}`))
return
}
_, _ = w.Write([]byte(`{"error":"bad_verification_code","error_description":"The code passed is incorrect or expired.","error_uri":"/apps/managing-oauth-apps/troubleshooting-oauth-app-access-token-request-errors/#bad-verification-code"}`))
}
fake.user = func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer token-1" {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"message":"Bad credentials"}`))
return
}
if r.Header.Get("User-Agent") == "" || r.Header.Get("Accept") != "application/vnd.github+json" {
t.Errorf("用户接口的请求头 User-Agent: %q,Accept: %q", r.Header.Get("User-Agent"), r.Header.Get("Accept"))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"login":"octocat","id":42,"name":"The Octocat"}`))
}
mux := http.NewServeMux()
mux.HandleFunc("/login/oauth/access_token", func(w http.ResponseWriter, r *http.Request) { fake.token(w, r) })
mux.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) { fake.user(w, r) })
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
return fake, Config{
RedirectURL: "https://alisten.example/api/auth/github/callback",
Endpoint: Endpoint{
AuthorizeURL: server.URL + "/login/oauth/authorize",
TokenURL: server.URL + "/login/oauth/access_token",
UserURL: server.URL + "/user",
},
}
}
func TestAuthorizeURL(t *testing.T) {
o := New(http.DefaultClient, Config{RedirectURL: "https://alisten.example/api/auth/github/callback", Endpoint: GitHubCom})
u, err := url.Parse(o.AuthorizeURL(app, "state 1&2", verifier))
if err != nil {
t.Fatal(err)
}
if got := u.Scheme + "://" + u.Host + u.Path; got != "https://github.com/login/oauth/authorize" {
t.Errorf("授权地址 = %s", got)
}
want := url.Values{
"client_id": {"client-1"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
"redirect_uri": {"https://alisten.example/api/auth/github/callback"},
"state": {"state 1&2"},
}
if got := u.Query(); got.Encode() != want.Encode() {
t.Errorf("授权地址的参数 = %s,期望 %s:不申请任何 scope,只读公开资料", got.Encode(), want.Encode())
}
}
func TestUserID(t *testing.T) {
_, cfg := newFake(t)
o := New(http.DefaultClient, cfg)
if id, err := o.UserID(t.Context(), app, "good", verifier); err != nil || id != 42 {
t.Errorf("UserID = %d, %v,期望 42", id, err)
}
}
func TestUserIDFails(t *testing.T) {
for _, tc := range []struct {
name string
code string
token func(w http.ResponseWriter, r *http.Request)
user func(w http.ResponseWriter, r *http.Request)
want string
}{
{name: "授权码错误或过期", code: "expired", want: "bad_verification_code"},
{name: "令牌接口返回非 200", code: "good", token: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}, want: "503"},
{name: "令牌接口返回的不是 JSON", code: "good", token: func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`access_token=token-1&token_type=bearer`))
}, want: "解码"},
{name: "令牌为空", code: "good", token: func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"access_token":""}`))
}, want: "没有给出令牌"},
{name: "用户接口拒绝令牌", code: "good", user: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}, want: "401"},
{name: "用户 ID 不是正数", code: "good", user: func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"login":"ghost"}`))
}, want: "用户 ID"},
} {
t.Run(tc.name, func(t *testing.T) {
fake, cfg := newFake(t)
if tc.token != nil {
fake.token = tc.token
}
if tc.user != nil {
fake.user = tc.user
}
_, err := New(http.DefaultClient, cfg).UserID(t.Context(), app, tc.code, verifier)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("UserID 的错误 = %v,期望包含 %q", err, tc.want)
}
if err != nil && strings.Contains(err.Error(), "secret-1") {
t.Errorf("错误信息中不能有 client secret:%v", err)
}
})
}
}
// 不响应的 GitHub:请求一直挂着,直到请求的 ctx 结束。
type hangingTransport struct{}
func (hangingTransport) RoundTrip(r *http.Request) (*http.Response, error) {
<-r.Context().Done()
return nil, r.Context().Err()
}
func TestUserIDTimesOut(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
o := New(&http.Client{Transport: hangingTransport{}}, Config{Endpoint: GitHubCom})
start := time.Now()
if _, err := o.UserID(t.Context(), app, "good", verifier); !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("GitHub 不响应时的错误 = %v,期望超过期限", err)
}
if elapsed := time.Since(start); elapsed != 10*time.Second {
t.Errorf("等了 %v 才中止,期望 10 秒", elapsed)
}
})
}
@@ -0,0 +1,57 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
"github.com/dihemoe/alisten/internal/app/port"
)
var _ port.GitHubOAuthApps = (*Store)(nil)
const githubCredential = "github"
// Client ID 和 Client Secret 存在凭据表的同一行里,一起写入、一起删除。
type githubOAuthApp struct {
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
}
func (s *Store) GitHubOAuthApp(ctx context.Context) (port.GitHubOAuthApp, error) {
value, err := s.q.Credential(ctx, githubCredential)
if errors.Is(err, pgx.ErrNoRows) {
return port.GitHubOAuthApp{}, nil
}
if err != nil {
return port.GitHubOAuthApp{}, fmt.Errorf("读取 GitHub OAuth App:%w", err)
}
var app githubOAuthApp
// 不包装解码的错误:其中可能带有原值的片段,也就可能带有 Client Secret。
if err := json.Unmarshal([]byte(value), &app); err != nil || app.ClientID == "" || app.ClientSecret == "" {
return port.GitHubOAuthApp{}, errors.New("凭据表中 GitHub OAuth App 的格式不正确")
}
return port.GitHubOAuthApp(app), nil
}
func (s *Store) SetGitHubOAuthApp(ctx context.Context, app port.GitHubOAuthApp) error {
value, err := json.Marshal(githubOAuthApp(app))
if err != nil {
return fmt.Errorf("编码 GitHub OAuth App:%w", err)
}
if err := s.q.SetCredential(ctx, query.SetCredentialParams{Name: githubCredential, Value: string(value)}); err != nil {
return fmt.Errorf("保存 GitHub OAuth App:%w", err)
}
return nil
}
func (s *Store) DeleteGitHubOAuthApp(ctx context.Context) error {
if err := s.q.DeleteCredential(ctx, githubCredential); err != nil {
return fmt.Errorf("清除 GitHub OAuth App:%w", err)
}
return nil
}
@@ -0,0 +1,91 @@
package postgres
import (
"fmt"
"strings"
"sync"
"testing"
"github.com/dihemoe/alisten/internal/app/port"
)
func TestGitHubOAuthApp(t *testing.T) {
store, pool := newStore(t)
ctx := t.Context()
if app, err := store.GitHubOAuthApp(ctx); err != nil || app != (port.GitHubOAuthApp{}) {
t.Fatalf("没有配置时 = %#v, %v,期望零值", app, err)
}
if err := store.SetNeteaseCookie(ctx, "MUSIC_U=1"); err != nil {
t.Fatal(err)
}
for _, want := range []port.GitHubOAuthApp{{ClientID: "client-1", ClientSecret: "secret-1"}, {ClientID: "client-2", ClientSecret: "secret-2"}} {
if err := store.SetGitHubOAuthApp(ctx, want); err != nil {
t.Fatal(err)
}
if got, err := store.GitHubOAuthApp(ctx); err != nil || got != want {
t.Errorf("GitHubOAuthApp = %#v, %v,期望 %#v", got, err, want)
}
}
// 并发地保存和读取:读到的 Client ID 和 Secret 总是同一次保存的。
var wg sync.WaitGroup
for i := range 4 {
wg.Go(func() {
for j := range 20 {
app := port.GitHubOAuthApp{ClientID: fmt.Sprintf("client-%d-%d", i, j), ClientSecret: fmt.Sprintf("secret-%d-%d", i, j)}
if err := store.SetGitHubOAuthApp(ctx, app); err != nil {
t.Error(err)
return
}
}
})
wg.Go(func() {
for range 20 {
got, err := store.GitHubOAuthApp(ctx)
if err != nil {
t.Error(err)
return
}
if strings.TrimPrefix(got.ClientID, "client-") != strings.TrimPrefix(got.ClientSecret, "secret-") {
t.Errorf("读到的 Client ID 和 Secret 不是同一次保存的:%#v", got)
}
}
})
}
wg.Wait()
if err := store.DeleteGitHubOAuthApp(ctx); err != nil {
t.Fatal(err)
}
if got, err := store.GitHubOAuthApp(ctx); err != nil || got != (port.GitHubOAuthApp{}) {
t.Errorf("清除之后 = %#v, %v,期望零值", got, err)
}
if err := store.DeleteGitHubOAuthApp(ctx); err != nil {
t.Errorf("没有配置时清除 = %v,期望什么也不做", err)
}
var rows int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM credentials").Scan(&rows); err != nil {
t.Fatal(err)
}
if cookie, err := store.NeteaseCookie(ctx); err != nil || cookie != "MUSIC_U=1" || rows != 1 {
t.Errorf("清除之后网易云 Cookie = %q, %v,凭据表有 %d 行,期望只删除 GitHub 的一行", cookie, err, rows)
}
}
// 存储中的值不是保存时的格式时报错,错误信息中不带原值:其中可能有 Client Secret。
func TestGitHubOAuthAppRejectsCorruptValue(t *testing.T) {
store, pool := newStore(t)
for _, value := range []string{`not json secret-1`, `{"clientId":"client-1"}`, `{"clientSecret":"secret-1"}`} {
if _, err := pool.Exec(t.Context(), "INSERT INTO credentials (name, value) VALUES ('github', $1) ON CONFLICT (name) DO UPDATE SET value = excluded.value", value); err != nil {
t.Fatal(err)
}
_, err := store.GitHubOAuthApp(t.Context())
if err == nil {
t.Errorf("存储的值为 %q 时应报错", value)
continue
}
if strings.Contains(err.Error(), "secret-1") {
t.Errorf("错误信息中有原值:%v", err)
}
}
}
@@ -9,6 +9,48 @@ import (
"context"
)
// iteratorForInsertPlaylistItems implements pgx.CopyFromSource.
type iteratorForInsertPlaylistItems struct {
rows []InsertPlaylistItemsParams
skippedFirstNextCall bool
}
func (r *iteratorForInsertPlaylistItems) Next() bool {
if len(r.rows) == 0 {
return false
}
if !r.skippedFirstNextCall {
r.skippedFirstNextCall = true
return true
}
r.rows = r.rows[1:]
return len(r.rows) > 0
}
func (r iteratorForInsertPlaylistItems) Values() ([]interface{}, error) {
return []interface{}{
r.rows[0].PlaylistID,
r.rows[0].Source,
r.rows[0].TrackID,
r.rows[0].Title,
r.rows[0].Artist,
r.rows[0].Album,
r.rows[0].CoverURL,
r.rows[0].CoverKey,
r.rows[0].DurationMs,
r.rows[0].PageURL,
r.rows[0].Position,
}, nil
}
func (r iteratorForInsertPlaylistItems) Err() error {
return nil
}
func (q *Queries) InsertPlaylistItems(ctx context.Context, arg []InsertPlaylistItemsParams) (int64, error) {
return q.db.CopyFrom(ctx, []string{"playlist_items"}, []string{"playlist_id", "source", "track_id", "title", "artist", "album", "cover_url", "cover_key", "duration_ms", "page_url", "position"}, &iteratorForInsertPlaylistItems{rows: arg})
}
// iteratorForInsertPlays implements pgx.CopyFromSource.
type iteratorForInsertPlays struct {
rows []InsertPlaysParams
@@ -0,0 +1,234 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: listens.sql
package query
import (
"context"
"time"
)
const clearListens = `-- name: ClearListens :exec
DELETE FROM listens WHERE member_id = $1
`
func (q *Queries) ClearListens(ctx context.Context, memberID string) error {
_, err := q.db.Exec(ctx, clearListens, memberID)
return err
}
const deleteListen = `-- name: DeleteListen :exec
DELETE FROM listens WHERE member_id = $1 AND source = $2 AND track_id = $3
`
type DeleteListenParams struct {
MemberID string
Source string
TrackID string
}
func (q *Queries) DeleteListen(ctx context.Context, arg DeleteListenParams) error {
_, err := q.db.Exec(ctx, deleteListen, arg.MemberID, arg.Source, arg.TrackID)
return err
}
const listenRecording = `-- name: ListenRecording :one
SELECT record_listens FROM members WHERE id = $1
`
func (q *Queries) ListenRecording(ctx context.Context, id string) (bool, error) {
row := q.db.QueryRow(ctx, listenRecording, id)
var record_listens bool
err := row.Scan(&record_listens)
return record_listens, err
}
const lockListeners = `-- name: LockListeners :many
SELECT id FROM members WHERE id = ANY($1::uuid[]) AND record_listens ORDER BY id FOR NO KEY UPDATE
`
// 按 ID 的顺序锁住开启了记录的成员:同一个成员的历史依次写入,删掉超出上限的部分时不会多删或少删;
// 加锁的顺序固定,同时写几个成员的事务之间不会死锁。
func (q *Queries) LockListeners(ctx context.Context, ids []string) ([]string, error) {
rows, err := q.db.Query(ctx, lockListeners, ids)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const memberListens = `-- name: MemberListens :many
SELECT source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
FROM listens WHERE member_id = $1
ORDER BY heard_at DESC, source, track_id
LIMIT $2
`
type MemberListensParams struct {
MemberID string
Max int32
}
type MemberListensRow struct {
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
DurationMs int64
PageURL string
HeardAt time.Time
}
func (q *Queries) MemberListens(ctx context.Context, arg MemberListensParams) ([]MemberListensRow, error) {
rows, err := q.db.Query(ctx, memberListens, arg.MemberID, arg.Max)
if err != nil {
return nil, err
}
defer rows.Close()
var items []MemberListensRow
for rows.Next() {
var i MemberListensRow
if err := rows.Scan(
&i.Source,
&i.TrackID,
&i.Title,
&i.Artist,
&i.Album,
&i.CoverURL,
&i.CoverKey,
&i.DurationMs,
&i.PageURL,
&i.HeardAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const moveListens = `-- name: MoveListens :exec
WITH moved AS (
DELETE FROM listens WHERE member_id = $2::uuid
RETURNING source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
)
INSERT INTO listens (member_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at)
SELECT $1::uuid, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
FROM moved
WHERE EXISTS (SELECT FROM members WHERE id = $1::uuid AND record_listens)
ON CONFLICT (member_id, source, track_id) DO UPDATE
SET title = excluded.title, artist = excluded.artist, album = excluded.album, cover_url = excluded.cover_url,
cover_key = excluded.cover_key, duration_ms = excluded.duration_ms, page_url = excluded.page_url,
heard_at = greatest(listens.heard_at, excluded.heard_at)
`
type MoveListensParams struct {
ToID string
FromID string
}
// 并入的规则与 UpsertListens 相同,并入的那份算新写入的;to_id 关闭了记录时只删不并。
func (q *Queries) MoveListens(ctx context.Context, arg MoveListensParams) error {
_, err := q.db.Exec(ctx, moveListens, arg.ToID, arg.FromID)
return err
}
const setListenRecording = `-- name: SetListenRecording :execrows
UPDATE members SET record_listens = $1 WHERE id = $2
`
type SetListenRecordingParams struct {
RecordListens bool
ID string
}
func (q *Queries) SetListenRecording(ctx context.Context, arg SetListenRecordingParams) (int64, error) {
result, err := q.db.Exec(ctx, setListenRecording, arg.RecordListens, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const trimListens = `-- name: TrimListens :exec
DELETE FROM listens l
USING (
SELECT member_id, source, track_id,
row_number() OVER (PARTITION BY member_id ORDER BY heard_at DESC, source, track_id) AS n
FROM listens WHERE member_id = ANY($1::uuid[])
) ranked
WHERE l.member_id = ranked.member_id AND l.source = ranked.source AND l.track_id = ranked.track_id AND ranked.n > $2::bigint
`
type TrimListensParams struct {
MemberIds []string
Keep int64
}
// 排序与 MemberListens 相同:留下的正是读取时返回的那些。
func (q *Queries) TrimListens(ctx context.Context, arg TrimListensParams) error {
_, err := q.db.Exec(ctx, trimListens, arg.MemberIds, arg.Keep)
return err
}
const upsertListens = `-- name: UpsertListens :exec
INSERT INTO listens (member_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at)
SELECT m, $1::text, $2::text, $3::text, $4::text, $5::text, $6::text,
$7::text, $8::bigint, $9::text, $10::timestamptz
FROM unnest($11::uuid[]) AS m
ON CONFLICT (member_id, source, track_id) DO UPDATE
SET title = excluded.title, artist = excluded.artist, album = excluded.album, cover_url = excluded.cover_url,
cover_key = excluded.cover_key, duration_ms = excluded.duration_ms, page_url = excluded.page_url,
heard_at = greatest(listens.heard_at, excluded.heard_at)
`
type UpsertListensParams struct {
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
DurationMs int64
PageURL string
HeardAt time.Time
MemberIds []string
}
func (q *Queries) UpsertListens(ctx context.Context, arg UpsertListensParams) error {
_, err := q.db.Exec(ctx, upsertListens,
arg.Source,
arg.TrackID,
arg.Title,
arg.Artist,
arg.Album,
arg.CoverURL,
arg.CoverKey,
arg.DurationMs,
arg.PageURL,
arg.HeardAt,
arg.MemberIds,
)
return err
}
@@ -9,8 +9,50 @@ import (
"context"
)
const accountByGitHub = `-- name: AccountByGitHub :one
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id FROM members WHERE github_id = $1
`
func (q *Queries) AccountByGitHub(ctx context.Context, githubID *int64) (Member, error) {
row := q.db.QueryRow(ctx, accountByGitHub, githubID)
var i Member
err := row.Scan(
&i.ID,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
const accountByID = `-- name: AccountByID :one
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id FROM members WHERE id = $1 AND (username IS NOT NULL OR github_id IS NOT NULL)
`
func (q *Queries) AccountByID(ctx context.Context, id string) (Member, error) {
row := q.db.QueryRow(ctx, accountByID, id)
var i Member
err := row.Scan(
&i.ID,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
const accountByUsername = `-- name: AccountByUsername :one
SELECT id, name, avatar_hash, created_at, username, password_hash, email FROM members WHERE username = $1
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id FROM members WHERE username = $1
`
func (q *Queries) AccountByUsername(ctx context.Context, username *string) (Member, error) {
@@ -24,13 +66,54 @@ func (q *Queries) AccountByUsername(ctx context.Context, username *string) (Memb
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
const addMemberCredentials = `-- name: AddMemberCredentials :execrows
UPDATE members SET username = $1, password_hash = $2
WHERE id = $3 AND username IS NULL AND github_id IS NOT NULL
`
type AddMemberCredentialsParams struct {
Username *string
PasswordHash *string
ID string
}
// 只给只绑定了 GitHub 的账号设置,它的“我喜欢”已经在绑定时创建了。
func (q *Queries) AddMemberCredentials(ctx context.Context, arg AddMemberCredentialsParams) (int64, error) {
result, err := q.db.Exec(ctx, addMemberCredentials, arg.Username, arg.PasswordHash, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const bindGitHub = `-- name: BindGitHub :one
UPDATE members SET github_id = $1
WHERE id = $2 AND github_id IS NULL
RETURNING (username IS NULL)::boolean AS was_guest
`
type BindGitHubParams struct {
GithubID *int64
ID string
}
// 只给还没有绑定 GitHub 的成员绑定,已经绑定过的成员不受影响,没有返回的行。was_guest 表示成员原来是游客:这次绑定就是注册。
func (q *Queries) BindGitHub(ctx context.Context, arg BindGitHubParams) (bool, error) {
row := q.db.QueryRow(ctx, bindGitHub, arg.GithubID, arg.ID)
var was_guest bool
err := row.Scan(&was_guest)
return was_guest, err
}
const createMember = `-- name: CreateMember :one
INSERT INTO members (name) VALUES ($1)
RETURNING id, name, avatar_hash, created_at, username, password_hash, email
RETURNING id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id
`
func (q *Queries) CreateMember(ctx context.Context, name string) (Member, error) {
@@ -44,6 +127,8 @@ func (q *Queries) CreateMember(ctx context.Context, name string) (Member, error)
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
@@ -122,6 +207,7 @@ SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id
`
// 让修改密码等到这次登录提交之后,它随后删除会话时会删掉这次登录签发的会话。
// 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE:同一个账号的几次登录依次并入游客的历史,否则两边会按相反的顺序抢同一批历史行而死锁。
func (q *Queries) LockMemberPassword(ctx context.Context, id string) (string, error) {
row := q.db.QueryRow(ctx, lockMemberPassword, id)
var password_hash string
@@ -130,23 +216,24 @@ func (q *Queries) LockMemberPassword(ctx context.Context, id string) (string, er
}
const memberAccountProfile = `-- name: MemberAccountProfile :one
SELECT username, email FROM members WHERE id = $1
SELECT username, email, github_id FROM members WHERE id = $1
`
type MemberAccountProfileRow struct {
Username *string
Email *string
GithubID *int64
}
func (q *Queries) MemberAccountProfile(ctx context.Context, id string) (MemberAccountProfileRow, error) {
row := q.db.QueryRow(ctx, memberAccountProfile, id)
var i MemberAccountProfileRow
err := row.Scan(&i.Username, &i.Email)
err := row.Scan(&i.Username, &i.Email, &i.GithubID)
return i, err
}
const memberByToken = `-- name: MemberByToken :one
SELECT id, name, avatar_hash, created_at, username, password_hash, email FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE token_hash = $1)
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE token_hash = $1)
`
func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member, error) {
@@ -160,6 +247,8 @@ func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member,
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
@@ -182,7 +271,7 @@ func (q *Queries) MemberHasSession(ctx context.Context, arg MemberHasSessionPara
const registerMember = `-- name: RegisterMember :execrows
UPDATE members SET username = $1, password_hash = $2
WHERE id = $3 AND username IS NULL
WHERE id = $3 AND username IS NULL AND github_id IS NULL
`
type RegisterMemberParams struct {
@@ -191,7 +280,7 @@ type RegisterMemberParams struct {
ID string
}
// 只给游客设置:已经注册过的成员不受影响,影响的行数为 0。
// 只给游客设置。只绑定了 GitHub 的账号补设用户名和密码要删除其他会话,用 AddMemberCredentials。
func (q *Queries) RegisterMember(ctx context.Context, arg RegisterMemberParams) (int64, error) {
result, err := q.db.Exec(ctx, registerMember, arg.Username, arg.PasswordHash, arg.ID)
if err != nil {
@@ -217,13 +306,37 @@ func (q *Queries) SetMemberPassword(ctx context.Context, arg SetMemberPasswordPa
return result.RowsAffected(), nil
}
const takeSession = `-- name: TakeSession :one
DELETE FROM member_sessions WHERE token_hash = $1 RETURNING member_id
`
func (q *Queries) TakeSession(ctx context.Context, tokenHash []byte) (string, error) {
row := q.db.QueryRow(ctx, takeSession, tokenHash)
var member_id string
err := row.Scan(&member_id)
return member_id, err
}
const unbindGitHub = `-- name: UnbindGitHub :execrows
UPDATE members SET github_id = NULL WHERE id = $1 AND password_hash IS NOT NULL
`
// 没有密码的账号解绑之后就再也无法登录,所以只给设置了密码的账号解绑。
func (q *Queries) UnbindGitHub(ctx context.Context, id string) (int64, error) {
result, err := q.db.Exec(ctx, unbindGitHub, id)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const updateMember = `-- name: UpdateMember :one
UPDATE members
SET name = coalesce($1, name),
avatar_hash = CASE WHEN $2::text IS NULL THEN avatar_hash ELSE NULLIF($2::text, '') END,
email = CASE WHEN username IS NULL THEN NULL WHEN $3::text IS NULL THEN email ELSE NULLIF($3::text, '') END
email = CASE WHEN username IS NULL AND github_id IS NULL THEN NULL WHEN $3::text IS NULL THEN email ELSE NULLIF($3::text, '') END
WHERE id = $4
RETURNING id, name, avatar_hash, created_at, username, password_hash, email
RETURNING id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id
`
type UpdateMemberParams struct {
@@ -250,6 +363,8 @@ func (q *Queries) UpdateMember(ctx context.Context, arg UpdateMemberParams) (Mem
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
@@ -9,13 +9,15 @@ import (
)
type Member struct {
ID string
Name string
AvatarHash *string
CreatedAt time.Time
Username *string
PasswordHash *string
Email *string
ID string
Name string
AvatarHash *string
CreatedAt time.Time
Username *string
PasswordHash *string
Email *string
RecordListens bool
GithubID *int64
}
type PlaylistItem struct {
@@ -53,6 +53,39 @@ func (q *Queries) CopyPlaylistItems(ctx context.Context, arg CopyPlaylistItemsPa
return result.RowsAffected(), nil
}
const copySharedPlaylistItems = `-- name: CopySharedPlaylistItems :one
WITH shared AS (
SELECT id FROM playlists WHERE share_id = $1::text
), copied AS (
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
SELECT $2, i.source, i.track_id, i.title, i.artist, i.album, i.cover_url, i.cover_key, i.duration_ms, i.page_url,
row_number() OVER (ORDER BY i.position)
FROM playlist_items i
JOIN shared s ON s.id = i.playlist_id
RETURNING 1
)
SELECT EXISTS (SELECT FROM shared) AS found, (SELECT count(*) FROM copied)::bigint AS copied
`
type CopySharedPlaylistItemsParams struct {
ShareID string
ToID string
}
type CopySharedPlaylistItemsRow struct {
Found bool
Copied int64
}
// 确认分享还在和复制曲目在同一条语句里,看到的是同一个快照:要么完整复制,要么读不到分享。不锁来源歌单:两个人同时复制
// 对方分享的“我喜欢”时,各自已经锁住了自己的“我喜欢”,再去锁对方的就会互相等待。
func (q *Queries) CopySharedPlaylistItems(ctx context.Context, arg CopySharedPlaylistItemsParams) (CopySharedPlaylistItemsRow, error) {
row := q.db.QueryRow(ctx, copySharedPlaylistItems, arg.ShareID, arg.ToID)
var i CopySharedPlaylistItemsRow
err := row.Scan(&i.Found, &i.Copied)
return i, err
}
const countPlaylistItems = `-- name: CountPlaylistItems :one
SELECT count(*) FROM playlist_items WHERE playlist_id = $1
`
@@ -182,6 +215,20 @@ func (q *Queries) InsertPlaylistItem(ctx context.Context, arg InsertPlaylistItem
return err
}
type InsertPlaylistItemsParams struct {
PlaylistID string
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
DurationMs int64
PageURL string
Position int64
}
const lockFavorites = `-- name: LockFavorites :exec
SELECT id FROM playlists WHERE owner_id = $1 AND kind = 'favorites' FOR UPDATE
`
@@ -244,7 +291,7 @@ func (q *Queries) MemberPlaylistCovers(ctx context.Context, ownerID string) ([]M
}
const memberPlaylists = `-- name: MemberPlaylists :many
SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, count(i.track_id)::bigint AS track_count
SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, p.share_id, count(i.track_id)::bigint AS track_count
FROM playlists p
LEFT JOIN playlist_items i ON i.playlist_id = p.id
WHERE p.owner_id = $1
@@ -258,6 +305,7 @@ type MemberPlaylistsRow struct {
Name string
CreatedAt time.Time
UpdatedAt time.Time
ShareID *string
TrackCount int64
}
@@ -276,6 +324,7 @@ func (q *Queries) MemberPlaylists(ctx context.Context, ownerID string) ([]Member
&i.Name,
&i.CreatedAt,
&i.UpdatedAt,
&i.ShareID,
&i.TrackCount,
); err != nil {
return nil, err
@@ -288,6 +337,25 @@ func (q *Queries) MemberPlaylists(ctx context.Context, ownerID string) ([]Member
return items, nil
}
const playlistCover = `-- name: PlaylistCover :one
SELECT cover_url, cover_key FROM playlist_items
WHERE playlist_id = $1 AND (cover_url IS NOT NULL OR cover_key IS NOT NULL)
ORDER BY position DESC
LIMIT 1
`
type PlaylistCoverRow struct {
CoverURL *string
CoverKey *string
}
func (q *Queries) PlaylistCover(ctx context.Context, playlistID string) (PlaylistCoverRow, error) {
row := q.db.QueryRow(ctx, playlistCover, playlistID)
var i PlaylistCoverRow
err := row.Scan(&i.CoverURL, &i.CoverKey)
return i, err
}
const playlistHasItem = `-- name: PlaylistHasItem :one
SELECT EXISTS (SELECT FROM playlist_items WHERE playlist_id = $1 AND source = $2 AND track_id = $3)
`
@@ -444,6 +512,52 @@ func (q *Queries) SetPlaylistSeq(ctx context.Context, arg SetPlaylistSeqParams)
return err
}
const setPlaylistShare = `-- name: SetPlaylistShare :execrows
UPDATE playlists SET share_id = $1 WHERE id = $2 AND owner_id = $3
`
type SetPlaylistShareParams struct {
ShareID *string
ID string
OwnerID string
}
func (q *Queries) SetPlaylistShare(ctx context.Context, arg SetPlaylistShareParams) (int64, error) {
result, err := q.db.Exec(ctx, setPlaylistShare, arg.ShareID, arg.ID, arg.OwnerID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const sharedPlaylist = `-- name: SharedPlaylist :one
SELECT p.id, p.kind, p.name, m.name AS owner_name, m.avatar_hash AS owner_avatar_hash
FROM playlists p
JOIN members m ON m.id = p.owner_id
WHERE p.share_id = $1::text
`
type SharedPlaylistRow struct {
ID string
Kind string
Name string
OwnerName string
OwnerAvatarHash *string
}
func (q *Queries) SharedPlaylist(ctx context.Context, shareID string) (SharedPlaylistRow, error) {
row := q.db.QueryRow(ctx, sharedPlaylist, shareID)
var i SharedPlaylistRow
err := row.Scan(
&i.ID,
&i.Kind,
&i.Name,
&i.OwnerName,
&i.OwnerAvatarHash,
)
return i, err
}
const touchPlaylist = `-- name: TouchPlaylist :exec
UPDATE playlists SET updated_at = now() WHERE id = $1
`
@@ -106,3 +106,57 @@ func (q *Queries) SavePreparedTrack(ctx context.Context, arg SavePreparedTrackPa
)
return err
}
const trackMetas = `-- name: TrackMetas :many
SELECT t.source, t.track_id, t.title, t.artist, t.album, t.cover_url, t.cover_key, t.page_url, t.duration_ms
FROM unnest($1::text[]) WITH ORDINALITY AS s(source, ord)
JOIN unnest($2::text[]) WITH ORDINALITY AS k(track_id, ord) USING (ord)
JOIN tracks t ON t.source = s.source AND t.track_id = k.track_id
`
type TrackMetasParams struct {
Sources []string
TrackIds []string
}
type TrackMetasRow struct {
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
PageURL string
DurationMs int64
}
func (q *Queries) TrackMetas(ctx context.Context, arg TrackMetasParams) ([]TrackMetasRow, error) {
rows, err := q.db.Query(ctx, trackMetas, arg.Sources, arg.TrackIds)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TrackMetasRow
for rows.Next() {
var i TrackMetasRow
if err := rows.Scan(
&i.Source,
&i.TrackID,
&i.Title,
&i.Artist,
&i.Album,
&i.CoverURL,
&i.CoverKey,
&i.PageURL,
&i.DurationMs,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+119
View File
@@ -0,0 +1,119 @@
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
)
var (
_ port.ListenRecorder = (*Store)(nil)
_ port.Listens = (*Store)(nil)
)
func (s *Store) RecordListen(ctx context.Context, memberIDs []string, l port.Listen) error {
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
ids, err := q.LockListeners(ctx, memberIDs)
if err != nil || len(ids) == 0 {
return err
}
m := l.Meta
err = q.UpsertListens(ctx, query.UpsertListensParams{
MemberIds: ids,
Source: string(l.Track.Source),
TrackID: l.Track.ID,
Title: m.Title,
Artist: nullable(m.Artist),
Album: nullable(m.Album),
CoverURL: nullable(m.CoverURL),
CoverKey: nullable(string(m.CoverKey)),
DurationMs: m.Duration.Milliseconds(),
PageURL: m.PageURL,
HeardAt: l.HeardAt,
})
if err != nil {
return err
}
return q.TrimListens(ctx, query.TrimListensParams{MemberIds: ids, Keep: port.ListenLimit})
})
if err != nil {
return fmt.Errorf("给 %d 个成员记录听到的 %s:%s:%w", len(memberIDs), l.Track.Source, l.Track.ID, err)
}
return nil
}
// 在替换会话的事务中调用:账号行已经被互斥地锁住,与同时进行的记录、同时登录这个账号的其他游客依次写入账号的历史。
func moveListens(ctx context.Context, q *query.Queries, fromID, toID string) error {
if err := q.MoveListens(ctx, query.MoveListensParams{FromID: fromID, ToID: toID}); err != nil {
return err
}
return q.TrimListens(ctx, query.TrimListensParams{MemberIds: []string{toID}, Keep: port.ListenLimit})
}
func (s *Store) MemberListens(ctx context.Context, memberID string) ([]port.Listen, error) {
rows, err := s.q.MemberListens(ctx, query.MemberListensParams{MemberID: memberID, Max: port.ListenLimit})
if err != nil {
return nil, fmt.Errorf("读取成员 %s 的播放历史:%w", memberID, err)
}
listens := make([]port.Listen, len(rows))
for i, r := range rows {
listens[i] = port.Listen{
Track: track.Track{Source: track.Source(r.Source), ID: r.TrackID},
Meta: track.Meta{
Title: r.Title,
Artist: orEmpty(r.Artist),
Album: orEmpty(r.Album),
CoverURL: orEmpty(r.CoverURL),
CoverKey: track.ObjectKey(orEmpty(r.CoverKey)),
PageURL: r.PageURL,
Duration: time.Duration(r.DurationMs) * time.Millisecond,
},
HeardAt: r.HeardAt,
}
}
return listens, nil
}
func (s *Store) DeleteListen(ctx context.Context, memberID string, t track.Track) error {
if err := s.q.DeleteListen(ctx, query.DeleteListenParams{MemberID: memberID, Source: string(t.Source), TrackID: t.ID}); err != nil {
return fmt.Errorf("删除成员 %s 听过的 %s:%s:%w", memberID, t.Source, t.ID, err)
}
return nil
}
func (s *Store) ClearListens(ctx context.Context, memberID string) error {
if err := s.q.ClearListens(ctx, memberID); err != nil {
return fmt.Errorf("清空成员 %s 的播放历史:%w", memberID, err)
}
return nil
}
func (s *Store) ListenRecording(ctx context.Context, memberID string) (bool, error) {
on, err := s.q.ListenRecording(ctx, memberID)
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
if err != nil {
return false, fmt.Errorf("读取成员 %s 是否记录播放历史:%w", memberID, err)
}
return on, nil
}
func (s *Store) SetListenRecording(ctx context.Context, memberID string, on bool) error {
n, err := s.q.SetListenRecording(ctx, query.SetListenRecordingParams{ID: memberID, RecordListens: on})
if err == nil && n == 0 {
err = port.ErrMemberNotFound
}
if err != nil {
return fmt.Errorf("设置成员 %s 是否记录播放历史:%w", memberID, err)
}
return nil
}
@@ -0,0 +1,346 @@
package postgres
import (
"errors"
"fmt"
"slices"
"sync"
"testing"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
)
var heard = time.Date(2026, 11, 10, 20, 0, 0, 0, time.UTC)
func sunnyAt(at time.Time) port.Listen {
return port.Listen{
Track: track.Track{Source: track.NetEase, ID: "186016"},
Meta: track.Meta{Title: "晴天", Artist: "周杰伦", Album: "叶惠美", CoverURL: "https://p1.music.126.net/a.jpg", PageURL: "https://music.163.com/#/song?id=186016", Duration: 269500 * time.Millisecond},
HeardAt: at,
}
}
func clipAt(at time.Time) port.Listen {
return port.Listen{
Track: track.Track{Source: track.Link, ID: "https://example.com/a.mp3"},
Meta: track.Meta{Title: "a.mp3", CoverKey: "covers/abc", PageURL: "https://example.com/a.mp3", Duration: time.Minute},
HeardAt: at,
}
}
func songAt(id string, at time.Time) port.Listen {
return port.Listen{Track: track.Track{Source: track.NetEase, ID: id}, Meta: track.Meta{Title: id, PageURL: track.NetEasePageURL(id)}, HeardAt: at}
}
func record(t *testing.T, store *Store, members []string, l port.Listen) {
t.Helper()
if err := store.RecordListen(t.Context(), members, l); err != nil {
t.Fatal(err)
}
}
// 时间统一换成 UTC,便于直接比较。
func listensOf(t *testing.T, store *Store, memberID string) []port.Listen {
t.Helper()
listens, err := store.MemberListens(t.Context(), memberID)
if err != nil {
t.Fatal(err)
}
for i := range listens {
listens[i].HeardAt = listens[i].HeardAt.UTC()
}
return listens
}
func heardIDs(listens []port.Listen) []string {
ids := make([]string, len(listens))
for i, l := range listens {
ids[i] = l.Track.ID
}
return ids
}
func TestRecordListenKeepsLatestHearingOfEachTrack(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
record(t, store, []string{alice.ID, bob.ID}, sunnyAt(heard))
record(t, store, []string{alice.ID}, clipAt(heard.Add(time.Minute)))
if got, want := listensOf(t, store, alice.ID), []port.Listen{clipAt(heard.Add(time.Minute)), sunnyAt(heard)}; !slices.Equal(got, want) {
t.Errorf("alice 的历史 = %#v,期望最近听到的在前 %#v", got, want)
}
if got, want := listensOf(t, store, bob.ID), []port.Listen{sunnyAt(heard)}; !slices.Equal(got, want) {
t.Errorf("bob 的历史 = %#v,期望 %#v", got, want)
}
// 同一首歌再次记录:heard_at 取较晚的,元数据用新的。
renamed := sunnyAt(heard.Add(-time.Hour))
renamed.Meta.Title = "晴天(重制)"
record(t, store, []string{alice.ID}, renamed)
want := sunnyAt(heard)
want.Meta.Title = "晴天(重制)"
if got := listensOf(t, store, alice.ID); !slices.Equal(got, []port.Listen{clipAt(heard.Add(time.Minute)), want}) {
t.Errorf("较早的一次记录之后 = %#v,期望保留较晚的时间、换成新的元数据", got)
}
record(t, store, []string{alice.ID}, sunnyAt(heard.Add(time.Hour)))
if got := heardIDs(listensOf(t, store, alice.ID)); !slices.Equal(got, []string{"186016", "https://example.com/a.mp3"}) {
t.Errorf("再次听到《晴天》之后的顺序 = %v,期望它排到最前", got)
}
}
func TestRecordListenTruncatesToStoredPrecision(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
l := sunnyAt(heard.Add(1234567 * time.Nanosecond))
l.Meta.Duration = 3*time.Minute + 1234*time.Microsecond
record(t, store, []string{alice.ID}, l)
got := listensOf(t, store, alice.ID)
if len(got) != 1 || got[0].Meta.Duration != 3*time.Minute+time.Millisecond || !got[0].HeardAt.Equal(heard.Add(1234*time.Microsecond)) {
t.Errorf("历史 = %#v,期望时长按毫秒、时间按微秒截断", got)
}
}
func TestRecordListenSkipsMembersWhoTurnedRecordingOff(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
if on, err := store.ListenRecording(t.Context(), bob.ID); err != nil || !on {
t.Fatalf("新成员的 ListenRecording = %v, %v,期望默认开启", on, err)
}
record(t, store, []string{bob.ID}, sunnyAt(heard))
if err := store.SetListenRecording(t.Context(), bob.ID, false); err != nil {
t.Fatal(err)
}
if on, err := store.ListenRecording(t.Context(), bob.ID); err != nil || on {
t.Errorf("关闭之后 ListenRecording = %v, %v", on, err)
}
record(t, store, []string{alice.ID, bob.ID, "01a15778-1911-7a28-8b2e-e08c2a25f388"}, clipAt(heard.Add(time.Minute)))
if got := heardIDs(listensOf(t, store, alice.ID)); !slices.Equal(got, []string{"https://example.com/a.mp3"}) {
t.Errorf("alice 的历史 = %v", got)
}
if got := heardIDs(listensOf(t, store, bob.ID)); !slices.Equal(got, []string{"186016"}) {
t.Errorf("bob 关闭记录之后的历史 = %v,期望只有关闭之前的", got)
}
if err := store.SetListenRecording(t.Context(), bob.ID, true); err != nil {
t.Fatal(err)
}
record(t, store, []string{bob.ID}, clipAt(heard.Add(2*time.Minute)))
if got := heardIDs(listensOf(t, store, bob.ID)); len(got) != 2 {
t.Errorf("重新开启之后的历史 = %v,期望继续记录", got)
}
missing := "01a15778-1911-7a28-8b2e-e08c2a25f388"
if _, err := store.ListenRecording(t.Context(), missing); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("不存在的成员的 ListenRecording 错误 = %v,期望 ErrMemberNotFound", err)
}
if err := store.SetListenRecording(t.Context(), missing, false); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("不存在的成员的 SetListenRecording 错误 = %v,期望 ErrMemberNotFound", err)
}
}
func TestRecordListenKeepsLatest500(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
// 第 i 首在 heard 之后 i 分钟听到,s0 最早。
for _, m := range []string{alice.ID, bob.ID} {
if _, err := pool.Exec(t.Context(), `
INSERT INTO listens (member_id, source, track_id, title, duration_ms, page_url, heard_at)
SELECT $1, 'netease', 's' || i, 's' || i, 0, 'https://music.163.com', $2::timestamptz + i * interval '1 minute'
FROM generate_series(0, 499) AS i`, m, heard); err != nil {
t.Fatal(err)
}
}
record(t, store, []string{alice.ID, bob.ID}, songAt("new", heard.Add(time.Hour*24)))
for _, m := range []string{alice.ID, bob.ID} {
got := heardIDs(listensOf(t, store, m))
if len(got) != port.ListenLimit || got[0] != "new" || got[len(got)-1] != "s1" {
t.Errorf("第 501 首之后有 %d 首,最新 %q、最早 %q,期望 500 首,s0 被删掉", len(got), got[0], got[len(got)-1])
}
}
// 比现有的都早的一首,记下之后它自己就是超出的部分。
record(t, store, []string{alice.ID}, songAt("old", heard.Add(-time.Hour)))
if got := heardIDs(listensOf(t, store, alice.ID)); slices.Contains(got, "old") || len(got) != port.ListenLimit {
t.Errorf("更早的一首记下之后有 %d 首,含它 = %v,期望不留", len(got), slices.Contains(got, "old"))
}
}
// 同时开播的几个房间里有同样的两个成员,各自按不同的顺序给出成员,并发写入都成功,每首歌只留一条。
func TestConcurrentRecordsOfSameMembers(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
var wg sync.WaitGroup
errs := make(chan error, 40)
for i := range 40 {
members := []string{alice.ID, bob.ID}
if i%2 == 1 {
members = []string{bob.ID, alice.ID}
}
wg.Go(func() {
errs <- store.RecordListen(t.Context(), members, songAt(fmt.Sprint(i/2%4), heard.Add(time.Duration(i)*time.Second)))
})
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
for _, m := range []string{alice.ID, bob.ID} {
if got := heardIDs(listensOf(t, store, m)); !slices.Equal(got, []string{"3", "2", "1", "0"}) {
t.Errorf("并发记录之后的历史 = %v,期望每首一条、按最后一次听到的时间排列", got)
}
}
}
func TestDeleteAndClearListens(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
record(t, store, []string{alice.ID, bob.ID}, sunnyAt(heard))
record(t, store, []string{alice.ID, bob.ID}, clipAt(heard.Add(time.Minute)))
if err := store.DeleteListen(t.Context(), alice.ID, sunnyAt(heard).Track); err != nil {
t.Fatal(err)
}
if err := store.DeleteListen(t.Context(), alice.ID, track.Track{Source: track.QQ, ID: "missing"}); err != nil {
t.Errorf("删除不在历史里的歌 = %v,期望忽略", err)
}
if got := heardIDs(listensOf(t, store, alice.ID)); !slices.Equal(got, []string{"https://example.com/a.mp3"}) {
t.Errorf("删除《晴天》之后 alice 的历史 = %v", got)
}
if err := store.ClearListens(t.Context(), alice.ID); err != nil {
t.Fatal(err)
}
if got := listensOf(t, store, alice.ID); len(got) != 0 {
t.Errorf("清空之后 alice 的历史 = %v", got)
}
if got := heardIDs(listensOf(t, store, bob.ID)); len(got) != 2 {
t.Errorf("alice 删除和清空之后 bob 的历史 = %v,期望不受影响", got)
}
if _, err := pool.Exec(t.Context(), "DELETE FROM members WHERE id = $1", bob.ID); err != nil {
t.Fatal(err)
}
var left int
if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM listens WHERE member_id = $1", bob.ID).Scan(&left); err != nil || left != 0 {
t.Errorf("删除成员之后还剩 %d 条历史(%v),期望随成员删除", left, err)
}
}
func TestReplaceSessionMovesGuestListensIntoAccount(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
guest := createMember(t, store, "guest", "游客-3c4d")
sunny := sunnyAt(heard.Add(time.Hour))
sunny.Meta.Title = "晴天(游客听到的)"
record(t, store, []string{alice.ID}, sunnyAt(heard))
record(t, store, []string{alice.ID}, songAt("1", heard.Add(time.Minute)))
record(t, store, []string{guest.ID}, sunny)
record(t, store, []string{guest.ID}, clipAt(heard.Add(-time.Hour)))
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "hash"); err != nil {
t.Fatal(err)
}
if got, want := listensOf(t, store, alice.ID), []port.Listen{sunny, songAt("1", heard.Add(time.Minute)), clipAt(heard.Add(-time.Hour))}; !slices.Equal(got, want) {
t.Errorf("登录之后账号的历史 = %#v,期望并入游客的历史 %#v", got, want)
}
if got := listensOf(t, store, guest.ID); len(got) != 0 {
t.Errorf("登录之后游客的历史 = %v,期望移走", got)
}
}
func TestReplaceSessionMergeFollowsRecordingRules(t *testing.T) {
store, pool := newStore(t)
alice := registered(t, store, "alice")
guest := createMember(t, store, "guest", "游客-3c4d")
other := createMember(t, store, "other", "游客-5e6f")
if _, err := pool.Exec(t.Context(), `
INSERT INTO listens (member_id, source, track_id, title, duration_ms, page_url, heard_at)
SELECT $1, 'netease', 's' || i, 's' || i, 0, 'https://music.163.com', $2::timestamptz + i * interval '1 minute'
FROM generate_series(0, 499) AS i`, alice.ID, heard); err != nil {
t.Fatal(err)
}
record(t, store, []string{guest.ID}, songAt("new", heard.Add(24*time.Hour)))
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "hash"); err != nil {
t.Fatal(err)
}
if got := heardIDs(listensOf(t, store, alice.ID)); len(got) != port.ListenLimit || got[0] != "new" || slices.Contains(got, "s0") {
t.Errorf("并入之后账号有 %d 首,期望 500 首、最早的 s0 被删掉", len(got))
}
if err := store.SetListenRecording(t.Context(), alice.ID, false); err != nil {
t.Fatal(err)
}
record(t, store, []string{other.ID}, songAt("other", heard.Add(48*time.Hour)))
if err := store.ReplaceSession(t.Context(), tokenHash("other"), alice.ID, tokenHash("alice-3"), "hash"); err != nil {
t.Fatal(err)
}
if got := heardIDs(listensOf(t, store, alice.ID)); slices.Contains(got, "other") {
t.Error("账号关闭记录时不应并入游客的历史")
}
if got := listensOf(t, store, other.ID); len(got) != 0 {
t.Errorf("游客的历史 = %v,账号不并入时也随会话一起删掉", got)
}
}
func TestReplaceSessionKeepsListensWhenRejected(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
guest := createMember(t, store, "guest", "游客-3c4d")
record(t, store, []string{guest.ID}, sunnyAt(heard))
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "changed"); !errors.Is(err, port.ErrAccountPasswordChanged) {
t.Fatalf("密码哈希不符时 = %v", err)
}
if err := store.ReplaceSession(t.Context(), tokenHash("nobody"), alice.ID, tokenHash("alice-2"), "hash"); !errors.Is(err, port.ErrSessionNotFound) {
t.Fatalf("旧会话不存在时 = %v", err)
}
if got := listensOf(t, store, guest.ID); len(got) != 1 {
t.Errorf("登录被拒绝之后游客的历史 = %v,期望不变", got)
}
if got := listensOf(t, store, alice.ID); len(got) != 0 {
t.Errorf("登录被拒绝之后账号的历史 = %v,期望不变", got)
}
}
// 两台设备上的游客同时登录同一个账号,两边听过同一批歌、写入的先后相反:两次合并依次进行,都成功,账号里每首歌只留一条。
func TestConcurrentReplaceSessionsMergeIntoSameAccount(t *testing.T) {
store, pool := newStore(t)
const songs = 200
for round := range 5 {
alice := registered(t, store, fmt.Sprintf("alice-%d", round))
guests := []string{fmt.Sprintf("guest-%d-a", round), fmt.Sprintf("guest-%d-b", round)}
for i, token := range guests {
guest := createMember(t, store, token, "游客-"+token)
if _, err := pool.Exec(t.Context(), `
INSERT INTO listens (member_id, source, track_id, title, duration_ms, page_url, heard_at)
SELECT $1, 'netease', 's' || i, 's' || i, 0, 'https://music.163.com', $2::timestamptz + i * interval '1 minute'
FROM generate_series(0, $3 - 1) AS i ORDER BY i * $4`, guest.ID, heard, songs, 1-2*i); err != nil {
t.Fatal(err)
}
}
start := make(chan struct{})
errs := make([]error, len(guests))
var wg sync.WaitGroup
for i, token := range guests {
wg.Go(func() {
<-start
errs[i] = store.ReplaceSession(t.Context(), tokenHash(token), alice.ID, tokenHash(token+"-alice"), "hash")
})
}
close(start)
wg.Wait()
if err := errors.Join(errs...); err != nil {
t.Fatalf("第 %d 轮同时登录:%v", round, err)
}
got := heardIDs(listensOf(t, store, alice.ID))
if len(got) != songs || got[0] != fmt.Sprintf("s%d", songs-1) || got[songs-1] != "s0" {
t.Errorf("第 %d 轮并入之后账号有 %d 首,期望 %d 首、每首一条、按听到的时间排列", round, len(got), songs)
}
}
}
+97 -6
View File
@@ -66,7 +66,7 @@ func (s *Store) AccountProfile(ctx context.Context, memberID string) (port.Accou
if err != nil {
return port.AccountProfile{}, fmt.Errorf("读取成员 %s 的账号资料:%w", memberID, err)
}
return port.AccountProfile{Username: orEmpty(row.Username), Email: orEmpty(row.Email)}, nil
return port.AccountProfile{Username: orEmpty(row.Username), Email: orEmpty(row.Email), GitHub: row.GithubID != nil}, nil
}
func (s *Store) Register(ctx context.Context, memberID, username, passwordHash string) error {
@@ -87,6 +87,27 @@ func (s *Store) Register(ctx context.Context, memberID, username, passwordHash s
return nil
}
func (s *Store) AddCredentials(ctx context.Context, memberID, username, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
var deleted [][]byte
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
// UPDATE 锁住成员行,之后用新的语句删除会话:同时在用 GitHub 登录的设备提交之后才开始删,它签发的会话也会被删掉。
n, err := q.AddMemberCredentials(ctx, query.AddMemberCredentialsParams{ID: memberID, Username: &username, PasswordHash: &passwordHash})
if err != nil {
return usernameError(err)
}
if n == 0 {
return port.ErrAlreadyRegistered
}
deleted, err = q.DeleteMemberSessions(ctx, query.DeleteMemberSessionsParams{MemberID: memberID, Keep: keepTokenHash})
return err
})
if err != nil {
return nil, fmt.Errorf("给成员 %s 补设用户名和密码:%w", memberID, err)
}
return deleted, nil
}
func usernameError(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == uniqueViolation && pgErr.ConstraintName == "members_username_key" {
@@ -103,7 +124,71 @@ func (s *Store) AccountByUsername(ctx context.Context, username string) (port.Ac
if err != nil {
return port.Account{}, fmt.Errorf("按用户名查找成员:%w", err)
}
return port.Account{Member: memberOf(row), Username: orEmpty(row.Username), Email: orEmpty(row.Email), PasswordHash: orEmpty(row.PasswordHash)}, nil
return accountOf(row), nil
}
func (s *Store) AccountByID(ctx context.Context, memberID string) (port.Account, error) {
row, err := s.q.AccountByID(ctx, memberID)
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
if err != nil {
return port.Account{}, fmt.Errorf("读取成员 %s 的账号:%w", memberID, err)
}
return accountOf(row), nil
}
func (s *Store) AccountByGitHub(ctx context.Context, githubID int64) (port.Account, error) {
row, err := s.q.AccountByGitHub(ctx, &githubID)
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
if err != nil {
return port.Account{}, fmt.Errorf("按 GitHub 账号查找成员:%w", err)
}
return accountOf(row), nil
}
func accountOf(row query.Member) port.Account {
account := port.Account{Member: memberOf(row), Username: orEmpty(row.Username), Email: orEmpty(row.Email), PasswordHash: orEmpty(row.PasswordHash)}
if row.GithubID != nil {
account.GitHubID = *row.GithubID
}
return account
}
func (s *Store) BindGitHub(ctx context.Context, memberID string, githubID int64) error {
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
wasGuest, err := q.BindGitHub(ctx, query.BindGitHubParams{ID: memberID, GithubID: &githubID})
var pgErr *pgconn.PgError
switch {
case errors.Is(err, pgx.ErrNoRows):
return port.ErrGitHubAlreadyBound
case errors.As(err, &pgErr) && pgErr.Code == uniqueViolation && pgErr.ConstraintName == "members_github_id_key":
return port.ErrGitHubTaken
case err != nil:
return err
case !wasGuest:
return nil
}
return q.CreateFavorites(ctx, memberID)
})
if err != nil {
return fmt.Errorf("给成员 %s 绑定 GitHub:%w", memberID, err)
}
return nil
}
func (s *Store) UnbindGitHub(ctx context.Context, memberID string) error {
n, err := s.q.UnbindGitHub(ctx, memberID)
if err == nil && n == 0 {
err = port.ErrNoPassword
}
if err != nil {
return fmt.Errorf("给成员 %s 解绑 GitHub:%w", memberID, err)
}
return nil
}
func (s *Store) ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error {
@@ -116,14 +201,20 @@ func (s *Store) ReplaceSession(ctx context.Context, oldTokenHash []byte, memberI
if current != passwordHash {
return port.ErrAccountPasswordChanged
}
n, err := q.DeleteSession(ctx, oldTokenHash)
previous, err := q.TakeSession(ctx, oldTokenHash)
if errors.Is(err, pgx.ErrNoRows) {
return port.ErrSessionNotFound
}
if err != nil {
return err
}
if n == 0 {
return port.ErrSessionNotFound
if err := q.CreateSession(ctx, query.CreateSessionParams{TokenHash: newTokenHash, MemberID: memberID}); err != nil {
return err
}
return q.CreateSession(ctx, query.CreateSessionParams{TokenHash: newTokenHash, MemberID: memberID})
if previous == memberID {
return nil
}
return moveListens(ctx, q, previous, memberID)
})
if err != nil {
return fmt.Errorf("给成员 %s 更换会话:%w", memberID, err)
@@ -428,6 +428,135 @@ func TestReplaceSessionWaitsForPasswordChange(t *testing.T) {
}
}
func favoritesCount(t *testing.T, store *Store, memberID string) int {
t.Helper()
lists, err := store.MemberPlaylists(t.Context(), memberID)
if err != nil {
t.Fatal(err)
}
n := 0
for _, l := range lists {
if l.Favorites {
n++
}
}
return n
}
func TestBindGitHub(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
if err := store.BindGitHub(t.Context(), alice.ID, 42); err != nil {
t.Fatal(err)
}
if p, err := store.AccountProfile(t.Context(), alice.ID); err != nil || p != (port.AccountProfile{GitHub: true}) || !p.Registered() {
t.Errorf("绑定之后的 AccountProfile = %#v, %v,期望只绑定了 GitHub 的已注册账号", p, err)
}
want := port.Account{Member: alice, GitHubID: 42}
if got, err := store.AccountByGitHub(t.Context(), 42); err != nil || got != want {
t.Errorf("AccountByGitHub = %#v, %v,期望 %#v", got, err, want)
}
if got, err := store.AccountByID(t.Context(), alice.ID); err != nil || got != want {
t.Errorf("AccountByID = %#v, %v,期望 %#v", got, err, want)
}
if n := favoritesCount(t, store, alice.ID); n != 1 {
t.Errorf("游客绑定 GitHub 就是注册,应创建一个“我喜欢”,实际 %d 个", n)
}
if err := store.BindGitHub(t.Context(), bob.ID, 42); !errors.Is(err, port.ErrGitHubTaken) {
t.Errorf("GitHub 账号已经绑定了别人 = %v,期望 ErrGitHubTaken", err)
}
if err := store.BindGitHub(t.Context(), alice.ID, 43); !errors.Is(err, port.ErrGitHubAlreadyBound) {
t.Errorf("已经绑定过的成员再绑定 = %v,期望 ErrGitHubAlreadyBound", err)
}
if p, err := store.AccountProfile(t.Context(), bob.ID); err != nil || p.Registered() || favoritesCount(t, store, bob.ID) != 0 {
t.Errorf("绑定失败之后 bob 的 AccountProfile = %#v, %v,应仍是游客", p, err)
}
if _, err := store.AccountByGitHub(t.Context(), 99); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("没有绑定的 GitHub 账号 = %v,期望 ErrMemberNotFound", err)
}
if _, err := store.AccountByID(t.Context(), bob.ID); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("游客的 AccountByID = %v,期望 ErrMemberNotFound", err)
}
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, nil, ptr("alice@example.com")); err != nil {
t.Fatal(err)
}
var email string
if err := pool.QueryRow(t.Context(), "SELECT coalesce(email, 'NULL') FROM members WHERE id = $1", alice.ID).Scan(&email); err != nil || email != "alice@example.com" {
t.Errorf("只绑定了 GitHub 的账号保存邮箱之后 email 列 = %q, %v", email, err)
}
}
func TestAddCredentialsAfterGitHub(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
if err := store.BindGitHub(t.Context(), alice.ID, 42); err != nil {
t.Fatal(err)
}
if err := store.UnbindGitHub(t.Context(), alice.ID); !errors.Is(err, port.ErrNoPassword) {
t.Errorf("没有密码的账号解绑 = %v,期望 ErrNoPassword", err)
}
if err := store.Register(t.Context(), alice.ID, "alice", "hash-a"); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("只绑定了 GitHub 的账号用 Register 设置用户名 = %v,期望 ErrAlreadyRegistered", err)
}
guest := createMember(t, store, "guest", "游客-5e6f")
if _, err := store.AddCredentials(t.Context(), guest.ID, "guest", "hash-g", tokenHash("guest")); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("游客用 AddCredentials 设置用户名 = %v,期望 ErrAlreadyRegistered", err)
}
if err := store.Register(t.Context(), guest.ID, "taken", "hash-g"); err != nil {
t.Fatal(err)
}
if _, err := store.AddCredentials(t.Context(), alice.ID, "taken", "hash-a", tokenHash("alice")); !errors.Is(err, port.ErrUsernameTaken) {
t.Errorf("用户名已被占用时 = %v,期望 ErrUsernameTaken", err)
}
createMember(t, store, "other", "游客-7a8b")
if err := store.ReplaceSession(t.Context(), tokenHash("other"), alice.ID, tokenHash("alice-2"), ""); err != nil {
t.Fatal(err)
}
deleted, err := store.AddCredentials(t.Context(), alice.ID, "alice", "hash-a", tokenHash("alice"))
if err != nil || !sameHashes(deleted, [][]byte{tokenHash("alice-2")}) {
t.Fatalf("AddCredentials 返回 %x, %v,期望被删除的 alice-2", deleted, err)
}
if ownerOf(t, store, "alice") != alice.ID || ownerOf(t, store, "alice-2") != "" {
t.Error("AddCredentials 应只留下发出请求的会话")
}
if _, err := store.AddCredentials(t.Context(), alice.ID, "alice2", "hash-c", tokenHash("alice")); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("再补设一次 = %v,期望 ErrAlreadyRegistered", err)
}
if n := favoritesCount(t, store, alice.ID); n != 1 {
t.Errorf("只绑定了 GitHub 的账号补设用户名之后有 %d 个“我喜欢”,期望仍是 1 个", n)
}
want := port.Account{Member: alice, Username: "alice", PasswordHash: "hash-a", GitHubID: 42}
if got, err := store.AccountByUsername(t.Context(), "alice"); err != nil || got != want {
t.Errorf("AccountByUsername = %#v, %v,期望 %#v", got, err, want)
}
if err := store.UnbindGitHub(t.Context(), alice.ID); err != nil {
t.Fatal(err)
}
if p, err := store.AccountProfile(t.Context(), alice.ID); err != nil || p != (port.AccountProfile{Username: "alice"}) {
t.Errorf("解绑之后的 AccountProfile = %#v, %v", p, err)
}
if _, err := store.AccountByGitHub(t.Context(), 42); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("解绑之后按 GitHub 查找 = %v,期望 ErrMemberNotFound", err)
}
if err := store.UnbindGitHub(t.Context(), alice.ID); err != nil {
t.Errorf("没有绑定时解绑 = %v,期望什么也不做", err)
}
bob := createMember(t, store, "bob", "游客-3c4d")
if err := store.Register(t.Context(), bob.ID, "bob", "hash-b"); err != nil {
t.Fatal(err)
}
if err := store.BindGitHub(t.Context(), bob.ID, 42); err != nil {
t.Fatal(err)
}
if n := favoritesCount(t, store, bob.ID); n != 1 {
t.Errorf("已注册的账号绑定 GitHub 之后有 %d 个“我喜欢”,期望仍是 1 个", n)
}
}
func TestOnlyAccountsHavePasswordOrEmail(t *testing.T) {
store, pool := newStore(t)
guest := createMember(t, store, "guest", "游客-1a2b")
@@ -442,3 +571,49 @@ func TestOnlyAccountsHavePasswordOrEmail(t *testing.T) {
}
}
}
// 补设用户名和密码时,用 GitHub 登录的另一台设备恰好在签发会话:补设要等它提交,再把它签发的会话一并删除。
func TestAddCredentialsWaitsForGitHubLogin(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
if err := store.BindGitHub(t.Context(), alice.ID, 42); err != nil {
t.Fatal(err)
}
createMember(t, store, "guest", "游客-3c4d")
// 模拟 ReplaceSession 的事务,先不提交。
tx, err := pool.Begin(t.Context())
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback(t.Context()) }()
if _, err := tx.Exec(t.Context(), "SELECT 1 FROM members WHERE id = $1 FOR NO KEY UPDATE", alice.ID); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(t.Context(), "DELETE FROM member_sessions WHERE token_hash = $1", tokenHash("guest")); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(t.Context(), "INSERT INTO member_sessions (token_hash, member_id) VALUES ($1, $2)", tokenHash("alice-2"), alice.ID); err != nil {
t.Fatal(err)
}
type result struct {
deleted [][]byte
err error
}
done := make(chan result, 1)
go func() {
deleted, err := store.AddCredentials(t.Context(), alice.ID, "alice", "hash-a", tokenHash("alice"))
done <- result{deleted, err}
}()
waitForLockWait(t, pool, 1)
if err := tx.Commit(t.Context()); err != nil {
t.Fatal(err)
}
if r := <-done; r.err != nil || !sameHashes(r.deleted, [][]byte{tokenHash("alice-2")}) {
t.Errorf("登录提交之后,AddCredentials 返回 %x, %v,期望删除登录签发的 alice-2", r.deleted, r.err)
}
if ownerOf(t, store, "alice-2") != "" || ownerOf(t, store, "alice") != alice.ID {
t.Error("补设之后应只留下发出请求的会话")
}
}
+148 -14
View File
@@ -42,6 +42,7 @@ func (s *Store) MemberPlaylists(ctx context.Context, ownerID string) ([]port.Pla
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
Cover: covers[r.ID],
ShareID: orEmpty(r.ShareID),
}
}
return lists, nil
@@ -49,19 +50,8 @@ func (s *Store) MemberPlaylists(ctx context.Context, ownerID string) ([]port.Pla
func (s *Store) CreatePlaylist(ctx context.Context, ownerID, name string, limit int) (port.PlaylistRecord, error) {
var created query.CreatePlaylistRow
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
if err := q.LockFavorites(ctx, ownerID); err != nil {
return err
}
n, err := q.CountPlaylists(ctx, ownerID)
if err != nil {
return err
}
if n >= int64(limit) {
return port.ErrPlaylistLimit
}
created, err = q.CreatePlaylist(ctx, query.CreatePlaylistParams{OwnerID: ownerID, Name: name})
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) (err error) {
created, err = createPlaylist(ctx, s.q.WithTx(tx), ownerID, name, limit)
return err
})
if err != nil {
@@ -70,6 +60,66 @@ func (s *Store) CreatePlaylist(ctx context.Context, ownerID, name string, limit
return port.PlaylistRecord{ID: created.ID, Name: name, CreatedAt: created.CreatedAt, UpdatedAt: created.UpdatedAt}, nil
}
func (s *Store) ImportPlaylist(ctx context.Context, ownerID, name string, items []port.PlaylistItem, limit int) (port.PlaylistRecord, error) {
var created query.CreatePlaylistRow
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) (err error) {
q := s.q.WithTx(tx)
if created, err = createPlaylist(ctx, q, ownerID, name, limit); err != nil {
return err
}
seq := int64(len(items))
rows := make([]query.InsertPlaylistItemsParams, len(items))
for i, item := range items {
t, m := item.Track, item.Meta
rows[i] = query.InsertPlaylistItemsParams{
PlaylistID: created.ID,
Source: string(t.Source),
TrackID: t.ID,
Title: m.Title,
Artist: nullable(m.Artist),
Album: nullable(m.Album),
CoverURL: nullable(m.CoverURL),
CoverKey: nullable(string(m.CoverKey)),
DurationMs: m.Duration.Milliseconds(),
PageURL: m.PageURL,
Position: seq - int64(i),
}
}
if _, err = q.InsertPlaylistItems(ctx, rows); err != nil {
return err
}
return q.SetPlaylistSeq(ctx, query.SetPlaylistSeqParams{ID: created.ID, ItemSeq: seq})
})
if err != nil {
return port.PlaylistRecord{}, fmt.Errorf("成员 %s 导入 %d 首歌到新歌单:%w", ownerID, len(items), err)
}
return port.PlaylistRecord{ID: created.ID, Name: name, TrackCount: len(items), CreatedAt: created.CreatedAt, UpdatedAt: created.UpdatedAt, Cover: firstCover(items)}, nil
}
// 要在事务中调用:“我喜欢”的行锁持续到新建之后,同一个账号并发新建时才会依次计数。
func createPlaylist(ctx context.Context, q *query.Queries, ownerID, name string, limit int) (query.CreatePlaylistRow, error) {
if err := q.LockFavorites(ctx, ownerID); err != nil {
return query.CreatePlaylistRow{}, err
}
n, err := q.CountPlaylists(ctx, ownerID)
if err != nil {
return query.CreatePlaylistRow{}, err
}
if n >= int64(limit) {
return query.CreatePlaylistRow{}, port.ErrPlaylistLimit
}
return q.CreatePlaylist(ctx, query.CreatePlaylistParams{OwnerID: ownerID, Name: name})
}
func firstCover(items []port.PlaylistItem) *port.Cover {
for _, item := range items {
if m := item.Meta; m.CoverURL != "" || m.CoverKey != "" {
return &port.Cover{URL: m.CoverURL, Key: m.CoverKey}
}
}
return nil
}
func (s *Store) RenamePlaylist(ctx context.Context, ownerID, id, name string) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
@@ -131,6 +181,10 @@ func (s *Store) PlaylistItems(ctx context.Context, ownerID, id string) ([]port.P
if err != nil {
return nil, fmt.Errorf("读取歌单 %s 的曲目:%w", id, err)
}
return playlistItems(rows), nil
}
func playlistItems(rows []query.PlaylistItem) []port.PlaylistItem {
items := make([]port.PlaylistItem, len(rows))
for i, r := range rows {
items[i] = port.PlaylistItem{
@@ -146,7 +200,7 @@ func (s *Store) PlaylistItems(ctx context.Context, ownerID, id string) ([]port.P
},
}
}
return items, nil
return items
}
func (s *Store) AddPlaylistItem(ctx context.Context, ownerID, id string, item port.PlaylistItem, limit int) error {
@@ -306,6 +360,86 @@ func (s *Store) MovePlaylistItems(ctx context.Context, ownerID, id string, track
return nil
}
func (s *Store) SetPlaylistShare(ctx context.Context, ownerID, id, shareID string) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
}
n, err := s.q.SetPlaylistShare(ctx, query.SetPlaylistShareParams{ID: id, OwnerID: ownerID, ShareID: nullable(shareID)})
if err == nil && n == 0 {
err = port.ErrPlaylistNotFound
}
if err != nil {
return fmt.Errorf("设置歌单 %s 的分享:%w", id, err)
}
return nil
}
func (s *Store) SharedPlaylist(ctx context.Context, shareID string) (port.SharedPlaylist, error) {
var shared port.SharedPlaylist
// 歌单和曲目在同一个快照里读:分开读时,删除歌单可能恰好发生在两次读取之间,读到歌单还在、曲目却已经没了。
err := pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
p, err := q.SharedPlaylist(ctx, shareID)
if errors.Is(err, pgx.ErrNoRows) {
return port.ErrShareNotFound
}
if err != nil {
return err
}
rows, err := q.PlaylistItems(ctx, p.ID)
if err != nil {
return err
}
shared = port.SharedPlaylist{
Name: p.Name,
Favorites: p.Kind == favoritesKind,
OwnerName: p.OwnerName,
OwnerAvatarHash: orEmpty(p.OwnerAvatarHash),
Items: playlistItems(rows),
}
return nil
})
if err != nil {
return port.SharedPlaylist{}, fmt.Errorf("读取分享的歌单:%w", err)
}
return shared, nil
}
func (s *Store) CopySharedPlaylist(ctx context.Context, ownerID, shareID, name string, limit int) (port.PlaylistRecord, error) {
var record port.PlaylistRecord
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
created, err := createPlaylist(ctx, q, ownerID, name, limit)
if err != nil {
return err
}
copied, err := q.CopySharedPlaylistItems(ctx, query.CopySharedPlaylistItemsParams{ShareID: shareID, ToID: created.ID})
if err != nil {
return err
}
if !copied.Found {
return port.ErrShareNotFound
}
if err := q.SetPlaylistSeq(ctx, query.SetPlaylistSeqParams{ID: created.ID, ItemSeq: copied.Copied}); err != nil {
return err
}
record = port.PlaylistRecord{ID: created.ID, Name: name, TrackCount: int(copied.Copied), CreatedAt: created.CreatedAt, UpdatedAt: created.UpdatedAt}
cover, err := q.PlaylistCover(ctx, created.ID)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
if err != nil {
return err
}
record.Cover = &port.Cover{URL: orEmpty(cover.CoverURL), Key: track.ObjectKey(orEmpty(cover.CoverKey))}
return nil
})
if err != nil {
return port.PlaylistRecord{}, fmt.Errorf("成员 %s 复制分享的歌单:%w", ownerID, err)
}
return record, nil
}
func splitTracks(tracks []track.Track) (sources, ids []string) {
sources, ids = make([]string, len(tracks)), make([]string, len(tracks))
for i, t := range tracks {
@@ -610,3 +610,238 @@ func TestMovePlaylistItemsAdvancesUpdatedAt(t *testing.T) {
t.Error("移动之后更新时间没有推进")
}
}
func TestImportPlaylist(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
items := []port.PlaylistItem{songItem("3"), clipItem, songItem("1")}
imported, err := store.ImportPlaylist(t.Context(), alice.ID, "导入的歌单", items, 50)
if err != nil {
t.Fatal(err)
}
if got, err := store.PlaylistItems(t.Context(), alice.ID, imported.ID); err != nil || !slices.Equal(got, items) {
t.Errorf("导入之后的曲目 = %#v, %v,期望与来源同序 %#v", got, err, items)
}
lists, err := store.MemberPlaylists(t.Context(), alice.ID)
if err != nil || len(lists) != 2 {
t.Fatalf("歌单 = %#v, %v,期望“我喜欢”和导入的歌单", lists, err)
}
read := lists[1]
if imported.ID != read.ID || imported.Name != "导入的歌单" || imported.Favorites || imported.TrackCount != 3 ||
!imported.CreatedAt.Equal(read.CreatedAt) || !imported.UpdatedAt.Equal(read.UpdatedAt) {
t.Errorf("导入返回 %#v,期望与读出的 %#v 一致、共 3 首", imported, read)
}
if want := (port.Cover{URL: songItem("3").Meta.CoverURL}); imported.Cover == nil || *imported.Cover != want || read.Cover == nil || *read.Cover != want {
t.Errorf("封面 = %+v / %+v,期望排在最前的 %+v", imported.Cover, read.Cover, want)
}
if err := store.AddPlaylistItem(t.Context(), alice.ID, imported.ID, songItem("9"), 1000); err != nil {
t.Fatal(err)
}
if got := displayIDs(t, store, alice.ID, imported.ID); !slices.Equal(got, []string{"9", "3", clipItem.Track.ID, "1"}) {
t.Errorf("导入之后再加入 = %v,期望新加入的排在最前", got)
}
}
func TestImportEmptyPlaylist(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
imported, err := store.ImportPlaylist(t.Context(), alice.ID, "空的", nil, 50)
if err != nil || imported.Name != "空的" || imported.TrackCount != 0 || imported.Cover != nil {
t.Fatalf("导入空列表 = %#v, %v,期望建出一个没有曲目的歌单", imported, err)
}
if got, err := store.PlaylistItems(t.Context(), alice.ID, imported.ID); err != nil || len(got) != 0 {
t.Errorf("曲目 = %#v, %v,期望为空", got, err)
}
}
func TestImportPlaylistRollsBackAsAWhole(t *testing.T) {
store, pool := newStore(t)
alice := registered(t, store, "alice")
items := []port.PlaylistItem{songItem("1"), songItem("2")}
if _, err := store.ImportPlaylist(t.Context(), alice.ID, "超限", items, 1); !errors.Is(err, port.ErrPlaylistLimit) {
t.Errorf("歌单数到了上限 = %v,期望 ErrPlaylistLimit", err)
}
broken := songItem("3")
broken.Meta.Title = "坏\x00标题"
if _, err := store.ImportPlaylist(t.Context(), alice.ID, "写入失败", append(items, broken), 50); err == nil {
t.Error("有一首写不进去时应报错")
}
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || len(lists) != 1 {
t.Errorf("失败之后的歌单 = %#v, %v,期望只剩“我喜欢”", lists, err)
}
var count int
if err := pool.QueryRow(t.Context(), `SELECT count(*) FROM playlist_items`).Scan(&count); err != nil || count != 0 {
t.Errorf("失败之后的曲目数 = %d, %v,期望 0", count, err)
}
}
func TestConcurrentImportsRespectLimit(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
var wg sync.WaitGroup
for i := range 10 {
wg.Go(func() {
_, _ = store.ImportPlaylist(t.Context(), alice.ID, "导入"+strconv.Itoa(i), []port.PlaylistItem{songItem(strconv.Itoa(i))}, 5)
})
}
wg.Wait()
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || len(lists) != 5 {
t.Errorf("并发导入之后有 %d 个歌单, %v,期望正好 5 个(含“我喜欢”)", len(lists), err)
}
}
func TestSharePlaylist(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
hash := "0bc83cb571cd1c50ba6f3e8a78ef1346"
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, &hash, nil); err != nil {
t.Fatal(err)
}
favorites := favoritesOf(t, store, alice.ID)
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, sunnyItem, 1000); err != nil {
t.Fatal(err)
}
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, clipItem, 1000); err != nil {
t.Fatal(err)
}
before := updatedAt(t, store, alice.ID, favorites)
if err := store.SetPlaylistShare(t.Context(), alice.ID, favorites, "share-1"); err != nil {
t.Fatal(err)
}
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || lists[0].ShareID != "share-1" {
t.Errorf("歌单 = %#v, %v,期望带上分享 ID", lists, err)
}
if !updatedAt(t, store, alice.ID, favorites).Equal(before) {
t.Error("分享改变了歌单的更新时间")
}
shared, err := store.SharedPlaylist(t.Context(), "share-1")
want := port.SharedPlaylist{Name: "我喜欢", Favorites: true, OwnerName: alice.Name, OwnerAvatarHash: hash, Items: []port.PlaylistItem{clipItem, sunnyItem}}
if err != nil || shared.Name != want.Name || shared.Favorites != want.Favorites || shared.OwnerName != want.OwnerName ||
shared.OwnerAvatarHash != want.OwnerAvatarHash || !slices.Equal(shared.Items, want.Items) {
t.Errorf("分享的歌单 = %#v, %v,期望 %#v", shared, err, want)
}
if err := store.SetPlaylistShare(t.Context(), alice.ID, favorites, "share-2"); err != nil {
t.Fatal(err)
}
if _, err := store.SharedPlaylist(t.Context(), "share-1"); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("重新生成之后的旧链接 = %v,期望 ErrShareNotFound", err)
}
if shared, err := store.SharedPlaylist(t.Context(), "share-2"); err != nil || len(shared.Items) != 2 {
t.Errorf("新链接 = %#v, %v", shared, err)
}
for _, id := range []string{favorites, "not-a-uuid", "00000000-0000-7000-8000-000000000000"} {
if err := store.SetPlaylistShare(t.Context(), bob.ID, id, "share-3"); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("分享别人或不存在的歌单 %s = %v,期望 ErrPlaylistNotFound", id, err)
}
if err := store.SetPlaylistShare(t.Context(), bob.ID, id, ""); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("停止分享别人或不存在的歌单 %s = %v,期望 ErrPlaylistNotFound", id, err)
}
}
for range 2 {
if err := store.SetPlaylistShare(t.Context(), alice.ID, favorites, ""); err != nil {
t.Fatalf("停止分享 = %v,期望重复停止也成功", err)
}
}
if _, err := store.SharedPlaylist(t.Context(), "share-2"); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("停止分享之后 = %v,期望 ErrShareNotFound", err)
}
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || lists[0].ShareID != "" {
t.Errorf("停止分享之后的歌单 = %#v, %v,期望没有分享 ID", lists, err)
}
}
func TestShareIDIsUnique(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
if err := store.SetPlaylistShare(t.Context(), alice.ID, favoritesOf(t, store, alice.ID), "share-1"); err != nil {
t.Fatal(err)
}
if err := store.SetPlaylistShare(t.Context(), bob.ID, favoritesOf(t, store, bob.ID), "share-1"); err == nil {
t.Error("同一个分享 ID 设给第二个歌单时应报错")
}
if shared, err := store.SharedPlaylist(t.Context(), "share-1"); err != nil || shared.OwnerName != alice.Name {
t.Errorf("分享 = %#v, %v,期望仍是第一个歌单", shared, err)
}
}
func TestDeletedPlaylistIsNoLongerShared(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
list := filled(t, store, alice.ID, "通勤", "1")
if err := store.SetPlaylistShare(t.Context(), alice.ID, list, "share-1"); err != nil {
t.Fatal(err)
}
if err := store.DeletePlaylist(t.Context(), alice.ID, list); err != nil {
t.Fatal(err)
}
if _, err := store.SharedPlaylist(t.Context(), "share-1"); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("删除歌单之后 = %v,期望 ErrShareNotFound", err)
}
}
func TestCopySharedPlaylist(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
source := filled(t, store, alice.ID, "通勤", "1", "2", "3")
if err := store.MovePlaylistItems(t.Context(), alice.ID, source, trackList("1"), &trackList("3")[0]); err != nil {
t.Fatal(err)
}
if err := store.SetPlaylistShare(t.Context(), alice.ID, source, "share-1"); err != nil {
t.Fatal(err)
}
original := displayIDs(t, store, alice.ID, source)
copied, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-1", "抄来的", 50)
if err != nil {
t.Fatal(err)
}
if want := (port.Cover{URL: songItem("1").Meta.CoverURL}); copied.Name != "抄来的" || copied.Favorites || copied.TrackCount != 3 ||
copied.ShareID != "" || copied.Cover == nil || *copied.Cover != want {
t.Errorf("复制返回 %#v,期望 3 首、封面是排在最前的 %+v、没有分享", copied, want)
}
lists, err := store.MemberPlaylists(t.Context(), bob.ID)
if err != nil || len(lists) != 2 {
t.Fatalf("歌单 = %#v, %v,期望“我喜欢”和复制的歌单", lists, err)
}
if read := lists[1]; read.ID != copied.ID || read.Name != copied.Name || read.TrackCount != 3 || read.ShareID != "" ||
!read.CreatedAt.Equal(copied.CreatedAt) || !read.UpdatedAt.Equal(copied.UpdatedAt) {
t.Errorf("读出的 %#v,期望与复制返回的 %#v 一致", read, copied)
}
items, err := store.PlaylistItems(t.Context(), bob.ID, copied.ID)
sourceItems, _ := store.PlaylistItems(t.Context(), alice.ID, source)
if err != nil || !slices.Equal(items, sourceItems) {
t.Errorf("复制的曲目 = %#v, %v,期望与原歌单相同 %#v", items, err, sourceItems)
}
if err := store.AddPlaylistItem(t.Context(), bob.ID, copied.ID, songItem("9"), 1000); err != nil {
t.Fatal(err)
}
if got := displayIDs(t, store, bob.ID, copied.ID); !slices.Equal(got, append([]string{"9"}, original...)) {
t.Errorf("复制之后再加入 = %v,期望新加入的排在最前、其余保持 %v", got, original)
}
if got := displayIDs(t, store, alice.ID, source); !slices.Equal(got, original) {
t.Errorf("原歌单 = %v,期望不变 %v", got, original)
}
empty := filled(t, store, alice.ID, "空的")
if err := store.SetPlaylistShare(t.Context(), alice.ID, empty, "share-2"); err != nil {
t.Fatal(err)
}
if copied, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-2", "空的", 50); err != nil || copied.TrackCount != 0 || copied.Cover != nil {
t.Errorf("复制空歌单 = %#v, %v,期望建出一个没有曲目的歌单", copied, err)
}
if _, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-1", "超限", 3); !errors.Is(err, port.ErrPlaylistLimit) {
t.Errorf("歌单数到了上限 = %v,期望 ErrPlaylistLimit", err)
}
if _, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-x", "失效", 50); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("读不到的分享 = %v,期望 ErrShareNotFound", err)
}
if lists, err := store.MemberPlaylists(t.Context(), bob.ID); err != nil || len(lists) != 3 {
t.Errorf("失败之后有 %d 个歌单, %v,期望没有多建", len(lists), err)
}
}
+23 -4
View File
@@ -13,7 +13,10 @@ import (
"github.com/dihemoe/alisten/internal/domain/track"
)
var _ port.Tracks = (*Store)(nil)
var (
_ port.Tracks = (*Store)(nil)
_ port.TrackLookup = (*Store)(nil)
)
func (s *Store) PreparedTrack(ctx context.Context, t track.Track) (port.PreparedTrack, bool, error) {
row, err := s.q.PreparedTrack(ctx, query.PreparedTrackParams{Source: string(t.Source), TrackID: t.ID})
@@ -27,6 +30,19 @@ func (s *Store) PreparedTrack(ctx context.Context, t track.Track) (port.Prepared
return port.PreparedTrack{Meta: meta, Result: result, MediaKey: orEmpty(row.MediaKey), MediaSize: orZero(row.MediaSize)}, true, nil
}
func (s *Store) TrackMetas(ctx context.Context, ts []track.Track) (map[track.Track]track.Meta, error) {
sources, ids := splitTracks(ts)
rows, err := s.q.TrackMetas(ctx, query.TrackMetasParams{Sources: sources, TrackIds: ids})
if err != nil {
return nil, fmt.Errorf("读取 %d 首歌的元数据:%w", len(ts), err)
}
found := make(map[track.Track]track.Meta, len(rows))
for _, r := range rows {
found[track.Track{Source: track.Source(r.Source), ID: r.TrackID}] = storedMeta(r.Title, orEmpty(r.Artist), orEmpty(r.Album), orEmpty(r.CoverURL), orEmpty(r.CoverKey), r.PageURL, r.DurationMs)
}
return found, nil
}
func (s *Store) SavePreparedTrack(ctx context.Context, t track.Track, p port.PreparedTrack) error {
params := query.SavePreparedTrackParams{
Source: string(t.Source),
@@ -53,9 +69,12 @@ func (s *Store) SavePreparedTrack(ctx context.Context, t track.Track, p port.Pre
}
func prepared(title, artist, album, coverURL, coverKey, pageURL string, durationMs int64, gain float64, lyric, lyricWords, lyricTrans string) (track.Meta, track.Prepared) {
d := time.Duration(durationMs) * time.Millisecond
return track.Meta{Title: title, Artist: artist, Album: album, CoverURL: coverURL, CoverKey: track.ObjectKey(coverKey), PageURL: pageURL, Duration: d},
track.Prepared{Duration: d, Gain: gain, Lyric: lyric, LyricWords: lyricWords, LyricTrans: lyricTrans}
meta := storedMeta(title, artist, album, coverURL, coverKey, pageURL, durationMs)
return meta, track.Prepared{Duration: meta.Duration, Gain: gain, Lyric: lyric, LyricWords: lyricWords, LyricTrans: lyricTrans}
}
func storedMeta(title, artist, album, coverURL, coverKey, pageURL string, durationMs int64) track.Meta {
return track.Meta{Title: title, Artist: artist, Album: album, CoverURL: coverURL, CoverKey: track.ObjectKey(coverKey), PageURL: pageURL, Duration: time.Duration(durationMs) * time.Millisecond}
}
func orZero(p *int64) int64 {
@@ -1,6 +1,7 @@
package postgres
import (
"maps"
"testing"
"time"
@@ -124,3 +125,42 @@ func TestLinkTrackRoundTrip(t *testing.T) {
t.Errorf("恢复出的条目 = %+v,期望已就绪,元数据取自曲目表", snap.Waiting[0])
}
}
func TestTrackMetas(t *testing.T) {
store, _ := newStore(t)
link := track.Track{Source: track.Link, ID: "https://example.com/a.mp3"}
saved := map[track.Track]port.PreparedTrack{
netease("1"): {
Meta: track.Meta{Title: "晴天", Artist: "周杰伦", Album: "叶惠美", CoverURL: "https://p1.music.126.net/a.jpg", PageURL: "https://music.163.com/#/song?id=1", Duration: time.Minute},
Result: track.Prepared{Duration: 61500 * time.Millisecond, Gain: 0.5, Lyric: "词", LyricWords: "逐字", LyricTrans: "翻译"},
},
link: {
Meta: track.Meta{Title: "a.mp3", CoverKey: "link/abc/cover.webp", PageURL: link.ID, Duration: 3 * time.Second},
Result: track.Prepared{Duration: 3 * time.Second, Gain: 1},
MediaKey: "abc",
MediaSize: 12290,
},
}
want := map[track.Track]track.Meta{}
for tr, p := range saved {
if err := store.SavePreparedTrack(t.Context(), tr, p); err != nil {
t.Fatal(err)
}
read, _, err := store.PreparedTrack(t.Context(), tr)
if err != nil {
t.Fatal(err)
}
want[tr] = read.Meta
}
if want[netease("1")].Duration != 61500*time.Millisecond {
t.Fatalf("PreparedTrack 读出的时长 = %v,期望准备得到的时长", want[netease("1")].Duration)
}
wanted := []track.Track{netease("2"), netease("1"), {Source: track.QQ, ID: "1"}, link, netease("1")}
got, err := store.TrackMetas(t.Context(), wanted)
if err != nil || !maps.Equal(got, want) {
t.Errorf("批量读取 = %#v, %v\n期望只有命中的、与 PreparedTrack 读出的元数据一致 %#v:音源和 ID 都要对上,重复的只算一次", got, err, want)
}
if got, err := store.TrackMetas(t.Context(), nil); err != nil || len(got) != 0 {
t.Errorf("什么也不查 = %#v, %v,期望空的结果", got, err)
}
}
+193
View File
@@ -0,0 +1,193 @@
package redis
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"
goredis "github.com/redis/go-redis/v9"
"github.com/dihemoe/alisten/internal/app/port"
)
var (
_ port.PairingCodes = (*Codes)(nil)
_ port.GitHubLogins = (*Codes)(nil)
_ port.GitHubVerifications = (*Codes)(nil)
)
const (
pairingPrefix = "pairing:"
githubRequestPrefix = "github:request:"
githubGrantPrefix = "github:grant:"
githubVerifiedPrefix = "github:verified:"
)
// 配对码、GitHub 登录的 state 和兑换码、用 GitHub 确认身份的记录,键中只有它们的哈希。丢失时用户重新生成、重新登录或重新确认即可。
type Codes struct {
client *goredis.Client
}
func NewCodes(client *goredis.Client) *Codes {
return &Codes{client: client}
}
func codeKey(prefix string, hash []byte) string {
return prefix + hex.EncodeToString(hash)
}
type pairingRecord struct {
MemberID string `json:"memberId"`
Session []byte `json:"session"`
}
type githubRequestRecord struct {
Intent string `json:"intent"`
Client string `json:"client"`
MemberID string `json:"memberId"`
Nonce []byte `json:"nonce"`
Verifier string `json:"verifier"`
}
type githubGrantRecord struct {
githubRequestRecord
GitHubID int64 `json:"githubId"`
}
var (
intentNames = map[port.GitHubIntent]string{port.GitHubLogin: "login", port.GitHubBind: "bind", port.GitHubVerify: "verify"}
clientNames = map[port.GitHubClient]string{port.GitHubWeb: "web", port.GitHubApp: "app"}
)
func requestRecord(r port.GitHubRequest) githubRequestRecord {
return githubRequestRecord{Intent: intentNames[r.Intent], Client: clientNames[r.Client], MemberID: r.MemberID, Nonce: r.NonceHash, Verifier: r.CodeVerifier}
}
func (r githubRequestRecord) request() (port.GitHubRequest, error) {
intent, ok := keyOf(intentNames, r.Intent)
if !ok {
return port.GitHubRequest{}, fmt.Errorf("不认识的意图 %q", r.Intent)
}
client, ok := keyOf(clientNames, r.Client)
if !ok {
return port.GitHubRequest{}, fmt.Errorf("不认识的客户端 %q", r.Client)
}
return port.GitHubRequest{Intent: intent, Client: client, MemberID: r.MemberID, NonceHash: r.Nonce, CodeVerifier: r.Verifier}, nil
}
func keyOf[K comparable](names map[K]string, name string) (K, bool) {
for k, n := range names {
if n == name {
return k, true
}
}
var zero K
return zero, false
}
func (c *Codes) CreatePairing(ctx context.Context, codeHash []byte, p port.Pairing, ttl time.Duration) (bool, error) {
value, err := json.Marshal(pairingRecord{MemberID: p.MemberID, Session: p.SessionTokenHash})
if err != nil {
return false, err
}
created, err := c.client.SetNX(ctx, codeKey(pairingPrefix, codeHash), value, ttl).Result()
if err != nil {
return false, fmt.Errorf("保存配对码:%w", err)
}
return created, nil
}
func (c *Codes) TakePairing(ctx context.Context, codeHash []byte) (port.Pairing, error) {
var record pairingRecord
if err := c.take(ctx, codeKey(pairingPrefix, codeHash), &record); err != nil {
return port.Pairing{}, fmt.Errorf("取出配对码:%w", err)
}
return port.Pairing{MemberID: record.MemberID, SessionTokenHash: record.Session}, nil
}
func (c *Codes) SaveRequest(ctx context.Context, stateHash []byte, r port.GitHubRequest, ttl time.Duration) error {
if err := c.save(ctx, codeKey(githubRequestPrefix, stateHash), requestRecord(r), ttl); err != nil {
return fmt.Errorf("保存 GitHub 授权请求:%w", err)
}
return nil
}
func (c *Codes) TakeRequest(ctx context.Context, stateHash []byte) (port.GitHubRequest, error) {
var record githubRequestRecord
if err := c.take(ctx, codeKey(githubRequestPrefix, stateHash), &record); err != nil {
return port.GitHubRequest{}, fmt.Errorf("取出 GitHub 授权请求:%w", err)
}
r, err := record.request()
if err != nil {
return port.GitHubRequest{}, fmt.Errorf("取出 GitHub 授权请求:%w", err)
}
return r, nil
}
func (c *Codes) SaveGrant(ctx context.Context, codeHash []byte, g port.GitHubGrant, ttl time.Duration) error {
record := githubGrantRecord{githubRequestRecord: requestRecord(g.Request), GitHubID: g.GitHubID}
if err := c.save(ctx, codeKey(githubGrantPrefix, codeHash), record, ttl); err != nil {
return fmt.Errorf("保存 GitHub 登录的兑换码:%w", err)
}
return nil
}
func (c *Codes) TakeGrant(ctx context.Context, codeHash []byte) (port.GitHubGrant, error) {
var record githubGrantRecord
if err := c.take(ctx, codeKey(githubGrantPrefix, codeHash), &record); err != nil {
return port.GitHubGrant{}, fmt.Errorf("取出 GitHub 登录的兑换码:%w", err)
}
r, err := record.request()
if err != nil {
return port.GitHubGrant{}, fmt.Errorf("取出 GitHub 登录的兑换码:%w", err)
}
return port.GitHubGrant{Request: r, GitHubID: record.GitHubID}, nil
}
func (c *Codes) SaveVerification(ctx context.Context, tokenHash []byte, ttl time.Duration) error {
if err := c.client.Set(ctx, codeKey(githubVerifiedPrefix, tokenHash), "", ttl).Err(); err != nil {
return fmt.Errorf("保存用 GitHub 确认身份的记录:%w", err)
}
return nil
}
func (c *Codes) Verified(ctx context.Context, tokenHash []byte) (bool, error) {
n, err := c.client.Exists(ctx, codeKey(githubVerifiedPrefix, tokenHash)).Result()
if err != nil {
return false, fmt.Errorf("读取用 GitHub 确认身份的记录:%w", err)
}
return n == 1, nil
}
func (c *Codes) DeleteVerification(ctx context.Context, tokenHash []byte) error {
if err := c.client.Del(ctx, codeKey(githubVerifiedPrefix, tokenHash)).Err(); err != nil {
return fmt.Errorf("删除用 GitHub 确认身份的记录:%w", err)
}
return nil
}
func (c *Codes) save(ctx context.Context, key string, record any, ttl time.Duration) error {
value, err := json.Marshal(record)
if err != nil {
return err
}
return c.client.Set(ctx, key, value, ttl).Err()
}
// GETDEL 让取出和删除是一个原子操作:并发的两次兑换只有一次能拿到。
func (c *Codes) take(ctx context.Context, key string, record any) error {
value, err := c.client.GetDel(ctx, key).Bytes()
if errors.Is(err, goredis.Nil) {
return port.ErrCodeNotFound
}
if err != nil {
return err
}
if err := json.Unmarshal(value, record); err != nil {
return fmt.Errorf("解码存储的值:%w", err)
}
return nil
}
+137
View File
@@ -0,0 +1,137 @@
package redis
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"testing"
"time"
"github.com/dihemoe/alisten/internal/app/port"
)
func hashOf(s string) []byte {
sum := sha256.Sum256([]byte(s))
return sum[:]
}
func TestPairingCodes(t *testing.T) {
client := newClient(t)
codes := NewCodes(client)
p := port.Pairing{MemberID: "00000000-0000-7000-8000-000000000001", SessionTokenHash: hashOf("session")}
created, err := codes.CreatePairing(t.Context(), hashOf("ABCD2345"), p, 5*time.Minute)
if err != nil || !created {
t.Fatalf("CreatePairing = %v, %v", created, err)
}
key := "pairing:" + hex.EncodeToString(hashOf("ABCD2345"))
if ttl := client.PTTL(t.Context(), key).Val(); ttl < 4*time.Minute || ttl > 5*time.Minute {
t.Errorf("配对码的有效期 = %v,期望 5 分钟", ttl)
}
other := port.Pairing{MemberID: "00000000-0000-7000-8000-000000000002", SessionTokenHash: hashOf("other")}
if created, err := codes.CreatePairing(t.Context(), hashOf("ABCD2345"), other, time.Minute); err != nil || created {
t.Errorf("哈希已经有配对码时 CreatePairing = %v, %v,期望不覆盖", created, err)
}
got, err := codes.TakePairing(t.Context(), hashOf("ABCD2345"))
if err != nil || got.MemberID != p.MemberID || !bytes.Equal(got.SessionTokenHash, p.SessionTokenHash) {
t.Errorf("TakePairing = %#v, %v,期望 %#v", got, err, p)
}
if _, err := codes.TakePairing(t.Context(), hashOf("ABCD2345")); !errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("第二次取 = %v,期望 ErrCodeNotFound", err)
}
if _, err := codes.TakePairing(t.Context(), hashOf("ZZZZ2345")); !errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("不存在的配对码 = %v,期望 ErrCodeNotFound", err)
}
if err := client.Set(t.Context(), "pairing:"+hex.EncodeToString(hashOf("BROKEN22")), "not json", time.Minute).Err(); err != nil {
t.Fatal(err)
}
if _, err := codes.TakePairing(t.Context(), hashOf("BROKEN22")); err == nil || errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("存储中的值无法解码时 = %v,期望报错", err)
}
}
func TestGitHubLogins(t *testing.T) {
client := newClient(t)
codes := NewCodes(client)
r := port.GitHubRequest{Intent: port.GitHubBind, Client: port.GitHubApp, MemberID: "00000000-0000-7000-8000-000000000001", NonceHash: hashOf("nonce"), CodeVerifier: "verifier-1"}
if err := codes.SaveRequest(t.Context(), hashOf("state"), r, 10*time.Minute); err != nil {
t.Fatal(err)
}
if ttl := client.PTTL(t.Context(), "github:request:"+hex.EncodeToString(hashOf("state"))).Val(); ttl < 9*time.Minute || ttl > 10*time.Minute {
t.Errorf("state 的有效期 = %v,期望 10 分钟", ttl)
}
if _, err := codes.TakeGrant(t.Context(), hashOf("state")); !errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("用 state 取兑换码 = %v,期望 ErrCodeNotFound:两种键互不相通", err)
}
got, err := codes.TakeRequest(t.Context(), hashOf("state"))
if err != nil || got.Intent != r.Intent || got.Client != r.Client || got.MemberID != r.MemberID || !bytes.Equal(got.NonceHash, r.NonceHash) || got.CodeVerifier != r.CodeVerifier {
t.Errorf("TakeRequest = %#v, %v,期望 %#v", got, err, r)
}
if _, err := codes.TakeRequest(t.Context(), hashOf("state")); !errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("第二次取 state = %v,期望 ErrCodeNotFound", err)
}
g := port.GitHubGrant{Request: port.GitHubRequest{Intent: port.GitHubLogin, Client: port.GitHubWeb, MemberID: r.MemberID, NonceHash: r.NonceHash}, GitHubID: 9007199254740993}
if err := codes.SaveGrant(t.Context(), hashOf("code"), g, time.Minute); err != nil {
t.Fatal(err)
}
if ttl := client.PTTL(t.Context(), "github:grant:"+hex.EncodeToString(hashOf("code"))).Val(); ttl < 50*time.Second || ttl > time.Minute {
t.Errorf("兑换码的有效期 = %v,期望 1 分钟", ttl)
}
gotGrant, err := codes.TakeGrant(t.Context(), hashOf("code"))
if err != nil || gotGrant.GitHubID != g.GitHubID || gotGrant.Request.Intent != port.GitHubLogin || gotGrant.Request.Client != port.GitHubWeb || gotGrant.Request.MemberID != r.MemberID || !bytes.Equal(gotGrant.Request.NonceHash, r.NonceHash) {
t.Errorf("TakeGrant = %#v, %v,期望 %#v", gotGrant, err, g)
}
if _, err := codes.TakeGrant(t.Context(), hashOf("code")); !errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("第二次取兑换码 = %v,期望 ErrCodeNotFound", err)
}
for name, record := range map[string]string{
"意图": `{"intent":"steal","client":"web","memberId":"x","nonce":""}`,
"客户端": `{"intent":"login","client":"evil","memberId":"x","nonce":""}`,
} {
if err := client.Set(t.Context(), "github:request:"+hex.EncodeToString(hashOf("odd")), record, time.Minute).Err(); err != nil {
t.Fatal(err)
}
if _, err := codes.TakeRequest(t.Context(), hashOf("odd")); err == nil || errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("存储中的%s不认识时 = %v,期望报错", name, err)
}
}
}
func TestGitHubVerifications(t *testing.T) {
client := newClient(t)
codes := NewCodes(client)
if err := codes.SaveVerification(t.Context(), hashOf("session"), 10*time.Minute); err != nil {
t.Fatal(err)
}
if ttl := client.PTTL(t.Context(), "github:verified:"+hex.EncodeToString(hashOf("session"))).Val(); ttl < 9*time.Minute || ttl > 10*time.Minute {
t.Errorf("确认的有效期 = %v,期望 10 分钟", ttl)
}
for range 2 {
if ok, err := codes.Verified(t.Context(), hashOf("session")); err != nil || !ok {
t.Errorf("Verified = %v, %v,期望读取不删除", ok, err)
}
}
if err := codes.DeleteVerification(t.Context(), hashOf("session")); err != nil {
t.Fatal(err)
}
if ok, err := codes.Verified(t.Context(), hashOf("session")); err != nil || ok {
t.Errorf("删除之后 Verified = %v, %v,期望 false", ok, err)
}
if err := codes.DeleteVerification(t.Context(), hashOf("session")); err != nil {
t.Errorf("删除不存在的确认 = %v,期望什么也不做", err)
}
g := port.GitHubGrant{Request: port.GitHubRequest{Intent: port.GitHubVerify, Client: port.GitHubWeb, MemberID: "00000000-0000-7000-8000-000000000001", NonceHash: hashOf("nonce")}, GitHubID: 42}
if err := codes.SaveGrant(t.Context(), hashOf("other"), g, time.Minute); err != nil {
t.Fatal(err)
}
if ok, err := codes.Verified(t.Context(), hashOf("other")); err != nil || ok {
t.Errorf("同一个哈希的兑换码当作确认 = %v, %v,期望 false:两种键互不相通", ok, err)
}
if got, err := codes.TakeGrant(t.Context(), hashOf("other")); err != nil || got.Request.Intent != port.GitHubVerify {
t.Errorf("TakeGrant = %#v, %v,期望意图 verify 原样取回", got, err)
}
}
+2 -3
View File
@@ -2,7 +2,6 @@ package redis
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -36,14 +35,14 @@ func (d *Devices) SaveDevice(ctx context.Context, deviceHash []byte, memberID st
if err != nil {
return err
}
if err := d.client.Set(ctx, devicePrefix+hex.EncodeToString(deviceHash), value, ttl).Err(); err != nil {
if err := d.client.Set(ctx, codeKey(devicePrefix, deviceHash), value, ttl).Err(); err != nil {
return fmt.Errorf("保存登录设备凭证:%w", err)
}
return nil
}
func (d *Devices) DeviceMember(ctx context.Context, deviceHash []byte) (string, []byte, error) {
value, err := d.client.Get(ctx, devicePrefix+hex.EncodeToString(deviceHash)).Bytes()
value, err := d.client.Get(ctx, codeKey(devicePrefix, deviceHash)).Bytes()
if errors.Is(err, goredis.Nil) {
return "", nil, port.ErrDeviceNotFound
}
@@ -2,7 +2,6 @@ package redis
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"testing"
@@ -11,11 +10,6 @@ import (
"github.com/dihemoe/alisten/internal/app/port"
)
func hashOf(s string) []byte {
sum := sha256.Sum256([]byte(s))
return sum[:]
}
func TestLoginDevices(t *testing.T) {
client := newClient(t)
devices := NewDevices(client)
@@ -0,0 +1,85 @@
package shortlink
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"time"
"github.com/dihemoe/alisten/internal/app/port"
)
const (
maxRedirects = 5
timeout = 5 * time.Second
)
type Resolver struct {
allowed func(host string) bool
done func(link string) bool
client *http.Client
}
var _ port.ShortLinks = (*Resolver)(nil)
// allowed 检查每一跳(包括起点)的主机。跳转到 done 认得的地址时就停下,不再请求它:
// 网易云的短链接最后一跳是很慢的歌单页面,逐跳请求到底会超过总超时。
func New(allowed func(host string) bool, done func(link string) bool) *Resolver {
return &Resolver{
allowed: allowed,
done: done,
// 跳转由 Resolve 逐跳处理,这样每一跳的主机都能先检查再发请求。
client: &http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
},
}
}
func (r *Resolver) Resolve(ctx context.Context, link string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
current := link
for hop := 0; ; hop++ {
u, err := url.Parse(current)
if err != nil {
return "", fmt.Errorf("解析链接: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", fmt.Errorf("不支持的协议 %q", u.Scheme)
}
if !r.allowed(u.Hostname()) {
return "", fmt.Errorf("跳转到不受信任的主机 %q", u.Hostname())
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, current, nil)
if err != nil {
return "", err
}
resp, err := r.client.Do(req)
if err != nil {
return "", fmt.Errorf("请求短链接: %w", err)
}
_ = resp.Body.Close()
switch resp.StatusCode {
case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther,
http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
default:
return current, nil
}
if hop == maxRedirects {
return "", errors.New("跳转次数过多")
}
next, err := resp.Location()
if err != nil {
return "", fmt.Errorf("读取跳转地址: %w", err)
}
current = next.String()
if r.done(current) {
return current, nil
}
}
}
@@ -0,0 +1,131 @@
package shortlink
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/synctest"
"time"
)
func allowLoopback(host string) bool { return host == "127.0.0.1" }
func never(string) bool { return false }
func TestResolve(t *testing.T) {
var methods []string
mux := http.NewServeMux()
mux.HandleFunc("/short", func(w http.ResponseWriter, r *http.Request) {
methods = append(methods, r.Method)
http.Redirect(w, r, "/mid", http.StatusFound)
})
mux.HandleFunc("/mid", func(w http.ResponseWriter, r *http.Request) {
methods = append(methods, r.Method)
http.Redirect(w, r, "/playlist?id=1", http.StatusMovedPermanently)
})
mux.HandleFunc("/playlist", func(w http.ResponseWriter, r *http.Request) {
methods = append(methods, r.Method)
_, _ = w.Write([]byte("body"))
})
mux.HandleFunc("/l", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, r.URL.Path+"x", http.StatusFound)
})
mux.HandleFunc("/lx", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/l", http.StatusFound)
})
mux.HandleFunc("/five", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/f2", http.StatusFound) })
mux.HandleFunc("/f2", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/f3", http.StatusFound) })
mux.HandleFunc("/f3", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/f4", http.StatusFound) })
mux.HandleFunc("/f4", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/f5", http.StatusFound) })
mux.HandleFunc("/f5", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/playlist?id=2", http.StatusFound) })
mux.HandleFunc("/evil", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "http://localhost:1/x", http.StatusFound)
})
srv := httptest.NewServer(mux)
defer srv.Close()
r := New(allowLoopback, never)
ctx := context.Background()
t.Run("正常跳转且只发 GET", func(t *testing.T) {
methods = nil
got, err := r.Resolve(ctx, srv.URL+"/short")
if err != nil {
t.Fatal(err)
}
if want := srv.URL + "/playlist?id=1"; got != want {
t.Fatalf("got %q; want %q", got, want)
}
if strings.Join(methods, ",") != "GET,GET,GET" {
t.Fatalf("methods = %v", methods)
}
})
t.Run("跳到认得的地址时不再请求它", func(t *testing.T) {
methods = nil
recognized := New(allowLoopback, func(link string) bool { return strings.Contains(link, "/playlist?") })
got, err := recognized.Resolve(ctx, srv.URL+"/short")
if err != nil {
t.Fatal(err)
}
if want := srv.URL + "/playlist?id=1"; got != want {
t.Fatalf("got %q; want %q", got, want)
}
if strings.Join(methods, ",") != "GET,GET" {
t.Fatalf("methods = %v,期望不请求 /playlist", methods)
}
})
t.Run("五次跳转可以", func(t *testing.T) {
got, err := r.Resolve(ctx, srv.URL+"/five")
if err != nil {
t.Fatal(err)
}
if want := srv.URL + "/playlist?id=2"; got != want {
t.Fatalf("got %q; want %q", got, want)
}
})
t.Run("超过五次", func(t *testing.T) {
if _, err := r.Resolve(ctx, srv.URL+"/l"); err == nil {
t.Fatal("want error")
}
})
t.Run("跳到不允许的主机", func(t *testing.T) {
if _, err := r.Resolve(ctx, srv.URL+"/evil"); err == nil {
t.Fatal("want error")
}
})
t.Run("起点不允许", func(t *testing.T) {
if _, err := New(func(string) bool { return false }, never).Resolve(ctx, srv.URL+"/playlist"); err == nil {
t.Fatal("want error")
}
})
t.Run("非 http 协议", func(t *testing.T) {
if _, err := r.Resolve(ctx, "file:///etc/passwd"); err == nil {
t.Fatal("want error")
}
})
}
// 不响应的上游:请求一直挂着,直到请求的 ctx 结束。
type hangingTransport struct{}
func (hangingTransport) RoundTrip(r *http.Request) (*http.Response, error) {
<-r.Context().Done()
return nil, r.Context().Err()
}
func TestResolveTimesOut(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
r := New(allowLoopback, never)
r.client.Transport = hangingTransport{}
start := time.Now()
if _, err := r.Resolve(t.Context(), "http://127.0.0.1/slow"); !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("上游不响应时的错误 = %v,期望超过期限", err)
}
if elapsed := time.Since(start); elapsed != 5*time.Second {
t.Errorf("等了 %v 才中止,期望 5 秒", elapsed)
}
})
}
+1 -1
View File
@@ -34,7 +34,7 @@ func newService(t *testing.T, token string) fixture {
t.Helper()
store := porttest.New()
node := porttest.NewRedis().Node("a")
host, gateway := realtime.New(store, store, store, nil, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
host, gateway := realtime.New(store, store, store, store, nil, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(context.Background())
wait, err := realtime.Start(ctx, host, gateway)
if err != nil {
+8
View File
@@ -84,6 +84,14 @@ func (s *Service) PlaylistTracks(ctx context.Context, roomID string, source trac
if err := s.take(ctx, roomID); err != nil {
return nil, err
}
return s.PlaylistSongs(ctx, source, playlistID)
}
// 不取房间的搜索额度:导入歌单按成员另外限流。
func (s *Service) PlaylistSongs(ctx context.Context, source track.Source, playlistID string) ([]port.Song, error) {
if source != track.NetEase {
return nil, unsupported(source, "歌单内的歌曲")
}
songs, err := s.netease.PlaylistTracks(ctx, playlistID)
if err != nil {
return nil, upstream(source, "获取歌单内的歌曲", err)
+20
View File
@@ -244,6 +244,26 @@ func TestFirstMatch(t *testing.T) {
}
}
func TestPlaylistSongsSkipsRoomQuota(t *testing.T) {
f := newFixture(t, history{})
songs := []port.Song{song(track.NetEase, "1", "晴天"), song(track.NetEase, "2", "七里香")}
f.add(f.netease, songs...)
f.netease.AddPlaylist(port.Playlist{ID: "p1", Name: "周杰伦"}, "2", "1")
if got, err := f.svc.PlaylistSongs(t.Context(), track.NetEase, "p1"); err != nil || !reflect.DeepEqual(got, []port.Song{songs[1], songs[0]}) {
t.Errorf("歌单内的歌曲 = %v, %v,期望按歌单的顺序", got, err)
}
if f.quota.calls != 0 {
t.Errorf("取了 %d 次房间的搜索额度,导入不走房间的额度", f.quota.calls)
}
if _, err := f.svc.PlaylistSongs(t.Context(), track.QQ, "p1"); !errors.Is(err, port.ErrProviderFailed) {
t.Errorf("QQ 音乐的歌单 = %v,期望 ErrProviderFailed", err)
}
f.netease.Fail(errors.New("网易云不可用"))
if _, err := f.svc.PlaylistSongs(t.Context(), track.NetEase, "p1"); !errors.Is(err, port.ErrProviderFailed) {
t.Errorf("上游出错时的错误 = %v,期望 ErrProviderFailed", err)
}
}
func TestTrackMetaOfBilibili(t *testing.T) {
f := newFixture(t, history{})
video := track.Track{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}
+114 -34
View File
@@ -38,22 +38,30 @@ const (
const loginDeviceTTL = 180 * 24 * time.Hour
// 只绑定了 GitHub 的账号没有用户名,也没有密码。
type Account struct {
Username string
Email string
GitHub bool
}
func accountOf(a port.Account) Account {
return Account{Username: a.Username, Email: a.Email, GitHub: a.GitHubID != 0}
}
// 删除会话之后,用这些会话的票据建立的房间连接随之断开:网关立即通知各实例,通知丢失时最迟在网关的一个核对周期之后断开。
type Accounts struct {
members port.Members
site port.Site
quota port.AccountQuota
devices port.LoginDevices
gateway *realtime.Gateway
members port.Members
site port.Site
quota port.AccountQuota
pairings port.PairingCodes
verifications port.GitHubVerifications
devices port.LoginDevices
gateway *realtime.Gateway
}
func NewAccounts(members port.Members, site port.Site, quota port.AccountQuota, devices port.LoginDevices, gateway *realtime.Gateway) *Accounts {
return &Accounts{members: members, site: site, quota: quota, devices: devices, gateway: gateway}
func NewAccounts(members port.Members, site port.Site, quota port.AccountQuota, pairings port.PairingCodes, verifications port.GitHubVerifications, devices port.LoginDevices, gateway *realtime.Gateway) *Accounts {
return &Accounts{members: members, site: site, quota: quota, pairings: pairings, verifications: verifications, devices: devices, gateway: gateway}
}
// 游客没有账号,ok 为 false。
@@ -62,11 +70,22 @@ func (a *Accounts) Account(ctx context.Context, memberID string) (account Accoun
if err != nil {
return Account{}, false, err
}
return Account{Username: profile.Username, Email: profile.Email}, profile.Username != "", nil
return Account{Username: profile.Username, Email: profile.Email, GitHub: profile.GitHub}, profile.Registered(), nil
}
// 给游客设置用户名和密码,成员和它的会话都不变。username 和 password 已经按协议校验过格式。
func (a *Accounts) Register(ctx context.Context, memberID, clientIP, username, password string) (Account, error) {
// 给游客设置用户名和密码,成员和它的会话都不变;只绑定了 GitHub 的账号也用它补设用户名和密码,见 addCredentials。
// token 是发出请求的成员 token,username 和 password 已经按协议校验过格式。
func (a *Accounts) Register(ctx context.Context, memberID, token, clientIP, username, password string) (Account, error) {
profile, err := a.members.AccountProfile(ctx, memberID)
if err != nil {
return Account{}, err
}
switch {
case profile.Username != "":
return Account{}, port.ErrAlreadyRegistered
case profile.GitHub:
return a.addCredentials(ctx, memberID, token, clientIP, username, password, profile.Email)
}
settings, err := a.site.Settings(ctx)
if err != nil {
return Account{}, err
@@ -84,17 +103,45 @@ func (a *Accounts) Register(ctx context.Context, memberID, clientIP, username, p
if err := a.members.Register(ctx, memberID, username, hash); err != nil {
return Account{}, err
}
return Account{Username: username}, nil
return Account{Username: username, Email: profile.Email, GitHub: profile.GitHub}, nil
}
// 拿到会话的人不能抢先给只绑定了 GitHub 的账号设成自己的密码:发出请求的会话要在 10 分钟内用账号绑定的 GitHub 确认过身份
// (GitHub.Exchange 的 verify)。补设之后,账号除了这个会话,其他会话都失效,与修改密码相同:对方随之下线。
// 补设不是注册新账号,注册关闭时照样可以。
func (a *Accounts) addCredentials(ctx context.Context, memberID, token, clientIP, username, password, email string) (Account, error) {
tokenHash := hashSecret(token)
verified, err := a.verifications.Verified(ctx, tokenHash)
if err != nil {
return Account{}, err
}
if !verified {
return Account{}, ErrGitHubUnverified
}
if err := a.take(ctx, "register:ip:"+quotaAddr(clientIP), registerBurst, registerRefill); err != nil {
return Account{}, err
}
hash, err := HashPassword(ctx, password)
if err != nil {
return Account{}, err
}
// 失败时(比如用户名已被占用)确认保留,换个用户名可以重试;同一个账号只能补设一次,由存储保证。
deleted, err := a.members.AddCredentials(ctx, memberID, username, hash, tokenHash)
if err != nil {
return Account{}, err
}
a.gateway.RevokeSessions(ctx, deleted)
// 账号已经有了用户名,这次确认再也用不上:删不掉也只是等它过期,不影响补设的结果。
_ = a.verifications.DeleteVerification(ctx, tokenHash)
return Account{Username: username, Email: email, GitHub: true}, nil
}
// current 是发出请求的成员,currentToken 是它的成员 token。只有游客能登录:成功后签发账号的新会话,游客的这个会话随即失效。
// device 是这台设备此前登录时拿到的登录设备凭证,没有时为空字符串,已经按协议校验过格式。成功时返回这台设备此后要带上的凭证:
// 带来的凭证属于这个账号、签发之后密码也没变过时续期并原样返回,否则签发新的。
func (a *Accounts) Login(ctx context.Context, current member.Member, currentToken, clientIP, username, password, device string) (Session, Account, string, error) {
if _, registered, err := a.Account(ctx, current.ID); err != nil {
if err := a.requireGuest(ctx, current.ID); err != nil {
return Session{}, Account{}, "", err
} else if registered {
return Session{}, Account{}, "", ErrAlreadyLoggedIn
}
// 按 IP 限制一个来源的尝试次数,按用户名限制一个账号被尝试的次数:换着 IP 猜同一个账号也会受限。
// 带着这个账号的凭证时改按凭证计数:别人反复猜密码耗尽了用户名额度,账号主人的设备仍然能登录。
@@ -123,22 +170,17 @@ func (a *Accounts) Login(ctx context.Context, current member.Member, currentToke
if err := a.devices.SaveDevice(ctx, hashSecret(device), account.Member.ID, passwordStamp(account.PasswordHash), loginDeviceTTL); err != nil {
return Session{}, Account{}, "", err
}
token, hash := newSecret()
guest := hashSecret(currentToken)
err = a.members.ReplaceSession(ctx, guest, account.Member.ID, hash, account.PasswordHash)
switch {
case errors.Is(err, port.ErrSessionNotFound):
return Session{}, Account{}, "", ErrInvalidMemberToken
case errors.Is(err, port.ErrAccountPasswordChanged):
session, err := a.signIn(ctx, currentToken, account)
if errors.Is(err, port.ErrAccountPasswordChanged) {
return Session{}, Account{}, "", ErrInvalidCredentials
case err != nil:
}
if err != nil {
return Session{}, Account{}, "", err
}
a.gateway.RevokeSessions(ctx, [][]byte{guest})
return Session{MemberToken: token, Member: account.Member}, Account{Username: account.Username, Email: account.Email}, device, nil
return session, accountOf(account), device, nil
}
// 未知、过期、属于别的账号,或者签发之后账号的密码变过(修改、重置)的凭证,都当作没带。
// 未知、过期、属于别的账号,或者签发之后账号的密码变过(修改、重置、补设)的凭证,都当作没带。
func (a *Accounts) knownDevice(ctx context.Context, device, username string) (bool, error) {
if device == "" {
return false, nil
@@ -167,9 +209,36 @@ func passwordStamp(passwordHash string) []byte {
return sum[:16]
}
// 只有游客能登录:已经登录了账号的设备要先退出。
func (a *Accounts) requireGuest(ctx context.Context, memberID string) error {
if _, registered, err := a.Account(ctx, memberID); err != nil {
return err
} else if registered {
return ErrAlreadyLoggedIn
}
return nil
}
// 给发出请求的游客签发 account 的新会话,游客的这个会话随即失效。account.PasswordHash 是校验时读到的密码哈希,
// 此后密码被修改或重置过时返回 port.ErrAccountPasswordChanged,由调用方按各自的登录方式告诉用户。
func (a *Accounts) signIn(ctx context.Context, currentToken string, account port.Account) (Session, error) {
token, hash := newSecret()
guest := hashSecret(currentToken)
err := a.members.ReplaceSession(ctx, guest, account.Member.ID, hash, account.PasswordHash)
if errors.Is(err, port.ErrSessionNotFound) {
return Session{}, ErrInvalidMemberToken
}
if err != nil {
return Session{}, err
}
a.gateway.RevokeSessions(ctx, [][]byte{guest})
return Session{MemberToken: token, Member: account.Member}, nil
}
func (a *Accounts) verify(ctx context.Context, username, password string) (port.Account, error) {
account, err := a.members.AccountByUsername(ctx, username)
if errors.Is(err, port.ErrMemberNotFound) {
// 空的密码哈希表示没有设置过密码,任何密码都不对;和用户名不存在一样做一次同样的计算。
if errors.Is(err, port.ErrMemberNotFound) || (err == nil && account.PasswordHash == "") {
dummy, err := dummyPasswordHash()
if err != nil {
return port.Account{}, err
@@ -208,16 +277,14 @@ func (a *Accounts) ChangePassword(ctx context.Context, memberID, token, currentP
return err
}
username := profile.Username
if username == "" {
switch {
case profile.GitHub && username == "":
return port.ErrNoPassword
case username == "":
return ErrNotRegistered
}
if err := a.take(ctx, "password:member:"+memberID, passwordBurst, passwordRefill); err != nil {
return err
}
account, err := a.verify(ctx, username, currentPassword)
if errors.Is(err, ErrInvalidCredentials) {
return ErrWrongCurrentPassword
} else if err != nil {
account, err := a.checkCurrentPassword(ctx, memberID, username, currentPassword)
if err != nil {
return err
}
hash, err := HashPassword(ctx, newPassword)
@@ -238,6 +305,19 @@ func (a *Accounts) ChangePassword(ctx context.Context, memberID, token, currentP
return nil
}
// 修改密码、绑定和解绑 GitHub 都要输入当前密码:只凭一个会话不能更换登录凭证,否则拿到会话的人换上自己的凭证之后,
// 本人修改密码、后台重置密码都赶不走他。额度按成员计数,这几种操作共用。
func (a *Accounts) checkCurrentPassword(ctx context.Context, memberID, username, password string) (port.Account, error) {
if err := a.take(ctx, "password:member:"+memberID, passwordBurst, passwordRefill); err != nil {
return port.Account{}, err
}
account, err := a.verify(ctx, username, password)
if errors.Is(err, ErrInvalidCredentials) {
return port.Account{}, ErrWrongCurrentPassword
}
return account, err
}
// Find 和 ResetPassword 只供后台使用,调用方要先校验管理 token。
func (a *Accounts) Find(ctx context.Context, username string) (port.Account, error) {
account, err := a.members.AccountByUsername(ctx, username)
+169 -12
View File
@@ -15,6 +15,7 @@ import (
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/port/porttest"
"github.com/dihemoe/alisten/internal/domain/member"
"github.com/dihemoe/alisten/internal/domain/track"
)
const clientIP = "203.0.113.7"
@@ -63,7 +64,8 @@ func newAccounts(t *testing.T) (a *Accounts, s *Service, store *porttest.Store,
}
}
}
return NewAccounts(store, store, q, porttest.NewDevices(), gateway), New(store, store, store, gateway), store, q, revoked
codes := porttest.NewCodes()
return NewAccounts(store, store, q, codes, codes, porttest.NewDevices(), gateway), New(store, store, store, gateway), store, q, revoked
}
// 不计顺序。
@@ -115,7 +117,7 @@ func tokenOwner(t *testing.T, store *porttest.Store, token string) (m member.Mem
func registerAccount(t *testing.T, a *Accounts, s *Service, username, password string) Session {
t.Helper()
session := createMember(t, s)
if _, err := a.Register(t.Context(), session.Member.ID, clientIP, username, password); err != nil {
if _, err := a.Register(t.Context(), session.Member.ID, session.MemberToken, clientIP, username, password); err != nil {
t.Fatal(err)
}
return session
@@ -124,7 +126,7 @@ func registerAccount(t *testing.T, a *Accounts, s *Service, username, password s
func TestRegister(t *testing.T) {
a, s, store, q, _ := newAccounts(t)
guest := createMember(t, s)
account, err := a.Register(t.Context(), guest.Member.ID, clientIP, "alice", "correct horse")
account, err := a.Register(t.Context(), guest.Member.ID, guest.MemberToken, clientIP, "alice", "correct horse")
if err != nil || account != (Account{Username: "alice"}) {
t.Fatalf("Register = %#v, %v", account, err)
}
@@ -187,16 +189,17 @@ func TestGuestHasNoAccount(t *testing.T) {
func TestRegisterRejected(t *testing.T) {
a, s, store, q, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
if _, err := a.Register(t.Context(), createMember(t, s).Member.ID, clientIP, "alice", "another horse"); !errors.Is(err, port.ErrUsernameTaken) {
other := createMember(t, s)
if _, err := a.Register(t.Context(), other.Member.ID, other.MemberToken, clientIP, "alice", "another horse"); !errors.Is(err, port.ErrUsernameTaken) {
t.Errorf("用户名已被占用时 = %v,期望 ErrUsernameTaken", err)
}
if _, err := a.Register(t.Context(), alice.Member.ID, clientIP, "alice2", "another horse"); !errors.Is(err, port.ErrAlreadyRegistered) {
if _, err := a.Register(t.Context(), alice.Member.ID, alice.MemberToken, clientIP, "alice2", "another horse"); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("已经注册过时 = %v,期望 ErrAlreadyRegistered", err)
}
guest := createMember(t, s)
q.exhausted["register:ip:"+clientIP] = true
if _, err := a.Register(t.Context(), guest.Member.ID, clientIP, "bob", "correct horse"); !errors.Is(err, ErrTooManyAttempts) {
if _, err := a.Register(t.Context(), guest.Member.ID, guest.MemberToken, clientIP, "bob", "correct horse"); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("额度用完时 = %v,期望 ErrTooManyAttempts", err)
}
if _, ok, _ := a.Account(t.Context(), guest.Member.ID); ok {
@@ -207,7 +210,7 @@ func TestRegisterRejected(t *testing.T) {
t.Fatal(err)
}
q.taken = nil
if _, err := a.Register(t.Context(), guest.Member.ID, "198.51.100.1", "bob", "correct horse"); !errors.Is(err, ErrRegistrationClosed) {
if _, err := a.Register(t.Context(), guest.Member.ID, guest.MemberToken, "198.51.100.1", "bob", "correct horse"); !errors.Is(err, ErrRegistrationClosed) {
t.Errorf("关闭注册时 = %v,期望 ErrRegistrationClosed", err)
}
if len(q.taken) != 0 {
@@ -252,6 +255,30 @@ func TestLogin(t *testing.T) {
}
}
func TestLoginMovesGuestListensIntoAccount(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
heard := time.Date(2026, 11, 10, 20, 0, 0, 0, time.UTC)
sunny := port.Listen{Track: track.Track{Source: track.NetEase, ID: "186016"}, Meta: track.Meta{Title: "晴天"}, HeardAt: heard}
rain := port.Listen{Track: track.Track{Source: track.NetEase, ID: "185811"}, Meta: track.Meta{Title: "七里香"}, HeardAt: heard.Add(time.Minute)}
if err := store.RecordListen(t.Context(), []string{alice.Member.ID}, sunny); err != nil {
t.Fatal(err)
}
if err := store.RecordListen(t.Context(), []string{guest.Member.ID}, rain); err != nil {
t.Fatal(err)
}
if _, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", ""); err != nil {
t.Fatal(err)
}
if listens, err := store.MemberListens(t.Context(), alice.Member.ID); err != nil || !slices.Equal(listens, []port.Listen{rain, sunny}) {
t.Errorf("登录之后账号的历史 = %#v, %v,期望并入游客听过的《七里香》", listens, err)
}
if listens, err := store.MemberListens(t.Context(), guest.Member.ID); err != nil || len(listens) != 0 {
t.Errorf("登录之后游客的历史 = %#v, %v,期望移走", listens, err)
}
}
func TestLoginRejected(t *testing.T) {
a, s, store, q, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
@@ -309,7 +336,7 @@ func newDeviceAccounts(t *testing.T) (*Accounts, *Service, *porttest.Store, *fak
gateway := newGateway(store, node, node)
q := &fakeQuota{exhausted: map[string]bool{}}
devices := &recordingDevices{Devices: porttest.NewDevices()}
return NewAccounts(store, store, q, devices, gateway), New(store, store, store, gateway), store, q, devices
return NewAccounts(store, store, q, porttest.NewCodes(), porttest.NewCodes(), devices, gateway), New(store, store, store, gateway), store, q, devices
}
func deviceQuota(device string) string {
@@ -587,7 +614,7 @@ func TestAccountQuotaCountsIPv6ByNetwork(t *testing.T) {
}
q.taken = nil
if _, err := a.Register(t.Context(), guest.Member.ID, "2001:db8:1:2:aaaa:bbbb:cccc:dddd", "alice", "correct horse"); err != nil {
if _, err := a.Register(t.Context(), guest.Member.ID, guest.MemberToken, "2001:db8:1:2:aaaa:bbbb:cccc:dddd", "alice", "correct horse"); err != nil {
t.Fatal(err)
}
if want := []string{"register:ip:2001:db8:1:2::/64"}; !slices.Equal(q.taken, want) {
@@ -710,7 +737,7 @@ func TestAccountOperationsSucceedWhenRevocationFails(t *testing.T) {
node := porttest.NewRedis().Node("a")
gateway := newGateway(store, node, noRevocation{node})
s := New(store, store, store, gateway)
a := NewAccounts(store, store, &fakeQuota{exhausted: map[string]bool{}}, porttest.NewDevices(), gateway)
a := NewAccounts(store, store, &fakeQuota{exhausted: map[string]bool{}}, porttest.NewCodes(), porttest.NewCodes(), porttest.NewDevices(), gateway)
alice := registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
@@ -777,7 +804,7 @@ func TestLoginRejectedWhenGuestSessionOrPasswordChangesMeanwhile(t *testing.T) {
}
node := porttest.NewRedis().Node("a")
changing := NewAccounts(passwordChangedMembers{store}, store, &fakeQuota{exhausted: map[string]bool{}}, porttest.NewDevices(), newGateway(store, node, node))
changing := NewAccounts(passwordChangedMembers{store}, store, &fakeQuota{exhausted: map[string]bool{}}, porttest.NewCodes(), porttest.NewCodes(), porttest.NewDevices(), newGateway(store, node, node))
guest = createMember(t, s)
if _, _, _, err := changing.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", ""); !errors.Is(err, ErrInvalidCredentials) {
t.Errorf("密码在校验之后被修改,Login = %v,期望 ErrInvalidCredentials", err)
@@ -804,7 +831,7 @@ func TestChangePasswordRejectedWhenPasswordChangesMeanwhile(t *testing.T) {
alice := registerAccount(t, a, s, "alice", "correct horse")
node := porttest.NewRedis().Node("a")
racing := func(meanwhile func()) *Accounts {
return NewAccounts(passwordReadMembers{store, meanwhile}, store, &fakeQuota{exhausted: map[string]bool{}}, porttest.NewDevices(), newGateway(store, node, node))
return NewAccounts(passwordReadMembers{store, meanwhile}, store, &fakeQuota{exhausted: map[string]bool{}}, porttest.NewCodes(), porttest.NewCodes(), porttest.NewDevices(), newGateway(store, node, node))
}
var reset string
@@ -854,3 +881,133 @@ func TestLoginDoesPasswordWorkForUnknownUsername(t *testing.T) {
}
}
}
// 绑定 GitHub 的游客成为没有用户名和密码的账号。
func githubAccount(t *testing.T, s *Service, store *porttest.Store, githubID int64) Session {
t.Helper()
session := createMember(t, s)
if err := store.BindGitHub(t.Context(), session.Member.ID, githubID); err != nil {
t.Fatal(err)
}
return session
}
func TestGitHubOnlyAccount(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
gh := githubAccount(t, s, store, 42)
if got, ok, err := a.Account(t.Context(), gh.Member.ID); err != nil || !ok || got != (Account{GitHub: true}) {
t.Errorf("只绑定了 GitHub 的账号 Account = %#v, %v, %v", got, ok, err)
}
if err := a.ChangePassword(t.Context(), gh.Member.ID, gh.MemberToken, "correct horse", "battery staple"); !errors.Is(err, port.ErrNoPassword) {
t.Errorf("没有密码的账号修改密码 = %v,期望 ErrNoPassword", err)
}
}
// 跳过 GitHub 授权,直接给会话记下用 GitHub 确认过身份。完整的确认流程见 TestGitHubVerify。
func verifiedByGitHub(t *testing.T, a *Accounts, s Session) {
t.Helper()
if err := a.verifications.SaveVerification(t.Context(), hashSecret(s.MemberToken), githubVerificationTTL); err != nil {
t.Fatal(err)
}
}
// 只绑定了 GitHub 的账号补设用户名和密码,不是注册新账号:注册关闭时也可以。
func TestGitHubAccountSetsPasswordWhileRegistrationClosed(t *testing.T) {
a, s, store, q, _ := newAccounts(t)
gh := githubAccount(t, s, store, 42)
if _, err := s.UpdateProfile(t.Context(), gh.Member.ID, Profile{Email: ptr("gh@example.com")}); err != nil {
t.Fatal(err)
}
if err := store.SetRegistrationOpen(t.Context(), false); err != nil {
t.Fatal(err)
}
guest := createMember(t, s)
if _, err := a.Register(t.Context(), guest.Member.ID, guest.MemberToken, clientIP, "bob", "correct horse"); !errors.Is(err, ErrRegistrationClosed) {
t.Fatalf("关闭注册时游客注册 = %v,期望 ErrRegistrationClosed", err)
}
verifiedByGitHub(t, a, gh)
q.taken = nil
account, err := a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "alice", "correct horse")
if want := (Account{Username: "alice", Email: "gh@example.com", GitHub: true}); err != nil || account != want {
t.Fatalf("Register = %#v, %v,期望 %#v", account, err, want)
}
if want := []string{"register:ip:" + clientIP}; !slices.Equal(q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", q.taken, want)
}
if _, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", ""); err != nil {
t.Errorf("设置密码之后用密码登录 = %v", err)
}
if _, err := a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "alice2", "correct horse"); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("再设置一次用户名 = %v,期望 ErrAlreadyRegistered", err)
}
}
// 拿到只绑定了 GitHub 的账号的一个会话(这里是兑换了配对码),不能抢先设成自己的用户名和密码:要先用账号绑定的 GitHub 确认,
// 确认只对确认时的那个会话有效。本人确认之后设置,其他设备全部退出,对方随之下线。
func TestGitHubAccountCredentialsNeedGitHubVerification(t *testing.T) {
a, s, store, _, revoked := newAccounts(t)
gh := githubAccount(t, s, store, 42)
mallory := createMember(t, s)
stolen, _, err := a.RedeemPairingCode(t.Context(), mallory.Member, mallory.MemberToken, clientIP, createPairingCode(t, a, gh))
if err != nil {
t.Fatal(err)
}
stolenTicket := issueTicket(t, s, stolen, createRoom(t, store, ""))
revoked()
if _, err := a.Register(t.Context(), gh.Member.ID, stolen.MemberToken, clientIP, "mallory", "mallory horse"); !errors.Is(err, ErrGitHubUnverified) {
t.Errorf("没有用 GitHub 确认就补设 = %v,期望 ErrGitHubUnverified", err)
}
verifiedByGitHub(t, a, gh)
if _, err := a.Register(t.Context(), gh.Member.ID, stolen.MemberToken, clientIP, "mallory", "mallory horse"); !errors.Is(err, ErrGitHubUnverified) {
t.Errorf("用别的会话的确认补设 = %v,期望 ErrGitHubUnverified", err)
}
if got, _, _ := a.Account(t.Context(), gh.Member.ID); got != (Account{GitHub: true}) {
t.Fatalf("没有确认时账号 = %#v,期望仍然没有用户名和密码", got)
}
registerAccount(t, a, s, "taken", "correct horse")
if _, err := a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "taken", "correct horse"); !errors.Is(err, port.ErrUsernameTaken) {
t.Errorf("用户名已被占用时 = %v,期望 ErrUsernameTaken", err)
}
account, err := a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "octocat", "correct horse")
if err != nil || account != (Account{Username: "octocat", GitHub: true}) {
t.Fatalf("换个用户名再补设 = %#v, %v,期望用户名被占用不消耗确认", account, err)
}
if ok, err := a.verifications.Verified(t.Context(), hashSecret(gh.MemberToken)); err != nil || ok {
t.Errorf("补设之后的确认 = %v, %v,期望已经用掉", ok, err)
}
if _, ok := tokenOwner(t, store, gh.MemberToken); !ok {
t.Error("补设不影响发出请求的会话")
}
if _, ok := tokenOwner(t, store, stolen.MemberToken); ok {
t.Error("补设之后其他会话应失效")
}
if got := revoked(); !sameHashes(got, stolen.MemberToken) {
t.Errorf("补设之后广播的会话 = %x,期望其他设备的会话", got)
}
if ticketValid(t, s, stolenTicket) {
t.Error("补设之后其他设备的票据应失效")
}
if err := a.ChangePassword(t.Context(), gh.Member.ID, gh.MemberToken, "correct horse", "battery staple"); err != nil {
t.Errorf("本人随后修改密码 = %v", err)
}
}
// 存储中的空密码哈希表示没有设置过密码,不是格式错误的哈希:按密码错误处理,不报内部错误。
func TestEmptyPasswordHashIsWrongPassword(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
alice := createMember(t, s)
if err := store.Register(t.Context(), alice.Member.ID, "alice", ""); err != nil {
t.Fatal(err)
}
guest := createMember(t, s)
for _, password := range []string{"", "correct horse"} {
if _, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", password, ""); !errors.Is(err, ErrInvalidCredentials) {
t.Errorf("密码哈希为空时用 %q 登录 = %v,期望 ErrInvalidCredentials", password, err)
}
}
if err := a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "battery staple"); !errors.Is(err, ErrWrongCurrentPassword) {
t.Errorf("密码哈希为空时修改密码 = %v,期望 ErrWrongCurrentPassword", err)
}
}
+272
View File
@@ -0,0 +1,272 @@
package identity
import (
"context"
"crypto/subtle"
"errors"
"fmt"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/member"
)
var (
ErrGitHubRequestExpired = errors.New("GitHub 授权已经过期,请重新发起")
ErrGitHubFailed = errors.New("没有从 GitHub 确认你的身份,请稍后再试")
ErrGitHubLoginExpired = errors.New("GitHub 登录已经失效,请重新发起")
ErrGitHubMismatch = errors.New("需要用这个账号绑定的 GitHub 确认,请在 GitHub 上换成绑定的账号再试")
ErrGitHubUnverified = errors.New("先用这个账号绑定的 GitHub 确认身份,再设置用户名和密码")
ErrGitHubDisabled = errors.New("站点没有开启 GitHub 登录")
)
const (
githubRequestTTL = 10 * time.Minute
githubGrantTTL = time.Minute
// 用 GitHub 确认身份之后,要在这段时间内补设用户名和密码。
githubVerificationTTL = 10 * time.Minute
// 每次发起授权都在 Redis 中保存一个 10 分钟的 state,额度与登录相同。
githubAuthorizeBurst = 10
githubAuthorizeRefill = time.Minute
)
// 后台配置了 GitHub OAuth App 才能使用,没有配置时 Authorize、Callback、Exchange 和 Unbind 都返回 ErrGitHubDisabled。它们每次都重新读取
// 配置,不缓存:后台的修改在所有实例上立即生效,实例之间不必同步。流程:Authorize 记下意图、客户端、nonce 和 PKCE 的 verifier,返回
// GitHub 的授权地址;GitHub 回调时 Callback 换取用户 ID,生成一次性的兑换码,GitHub 没有授权时 Cancel 作废 state,两者都返回记下的
// 客户端,回调跳回它;发起授权的客户端用兑换码和它保存的 nonce 调用 Exchange,按意图登录、注册、绑定或确认身份。
type GitHub struct {
accounts *Accounts
apps port.GitHubOAuthApps
oauth port.GitHubOAuth
logins port.GitHubLogins
}
func NewGitHub(accounts *Accounts, apps port.GitHubOAuthApps, oauth port.GitHubOAuth, logins port.GitHubLogins) *GitHub {
return &GitHub{accounts: accounts, apps: apps, oauth: oauth, logins: logins}
}
// 没有配置时返回空字符串。不提供读取 Client Secret 的方法:它只用来向 GitHub 换取令牌。
func (g *GitHub) ClientID(ctx context.Context) (string, error) {
app, err := g.apps.GitHubOAuthApp(ctx)
if err != nil {
return "", err
}
return app.ClientID, nil
}
// Client ID 和 Client Secret 必须已经校验过,都不为空。
func (g *GitHub) SetOAuthApp(ctx context.Context, app port.GitHubOAuthApp) error {
return g.apps.SetGitHubOAuthApp(ctx, app)
}
// 关闭 GitHub 登录。已经绑定的账号保持绑定,重新配置之后照常登录。
func (g *GitHub) DeleteOAuthApp(ctx context.Context) error {
return g.apps.DeleteGitHubOAuthApp(ctx)
}
func (g *GitHub) app(ctx context.Context) (port.GitHubOAuthApp, error) {
app, err := g.apps.GitHubOAuthApp(ctx)
if err != nil {
return port.GitHubOAuthApp{}, err
}
if app.ClientID == "" {
return port.GitHubOAuthApp{}, ErrGitHubDisabled
}
return app, nil
}
// nonce 由发起授权的客户端生成并保存,兑换时要原样带上。
func (g *GitHub) Authorize(ctx context.Context, memberID, clientIP string, intent port.GitHubIntent, client port.GitHubClient, nonce, currentPassword string) (string, error) {
app, err := g.app(ctx)
if err != nil {
return "", err
}
profile, err := g.accounts.members.AccountProfile(ctx, memberID)
if err != nil {
return "", err
}
switch {
case intent == port.GitHubLogin && profile.Registered():
return "", ErrAlreadyLoggedIn
case intent == port.GitHubBind && !profile.Registered():
return "", ErrNotRegistered
case intent == port.GitHubBind && profile.GitHub:
return "", port.ErrGitHubAlreadyBound
case intent == port.GitHubVerify && !profile.Registered():
return "", ErrNotRegistered
case intent == port.GitHubVerify && profile.Username != "":
return "", port.ErrAlreadyRegistered
}
// 能发起绑定的账号没有绑定 GitHub,一定设置过密码。
if intent == port.GitHubBind {
if _, err := g.accounts.checkCurrentPassword(ctx, memberID, profile.Username, currentPassword); err != nil {
return "", err
}
}
if err := g.accounts.take(ctx, "github:ip:"+quotaAddr(clientIP), githubAuthorizeBurst, githubAuthorizeRefill); err != nil {
return "", err
}
state, stateHash := newSecret()
verifier, _ := newSecret()
request := port.GitHubRequest{Intent: intent, Client: client, MemberID: memberID, NonceHash: hashSecret(nonce), CodeVerifier: verifier}
if err := g.logins.SaveRequest(ctx, stateHash, request, githubRequestTTL); err != nil {
return "", err
}
return g.oauth.AuthorizeURL(app, state, verifier), nil
}
// 只换取 GitHub 用户 ID,不修改账号,返回发起授权的客户端和一次性的兑换码。登录、注册和绑定都留给 Exchange:回调请求不带成员 token
// 和 nonce,谁都可以把自己发起的授权地址发给别人,在回调时绑定,别人一授权,他的 GitHub 就绑到了发起者的账号上。
// 取出 state 之后出错时也返回发起授权的客户端,失败的结果同样要跳回它。授权进行中后台换了 OAuth App 时,GitHub 不认原来签发的
// 授权码,返回 ErrGitHubFailed。
func (g *GitHub) Callback(ctx context.Context, state, code string) (port.GitHubClient, string, error) {
request, err := g.logins.TakeRequest(ctx, hashSecret(state))
expired := errors.Is(err, port.ErrCodeNotFound)
if err != nil && !expired {
return 0, "", err
}
// 关闭了 GitHub 登录时重新发起也没有用,所以 state 无效时也不提示过期。
app, err := g.app(ctx)
if err != nil {
return request.Client, "", err
}
if expired {
return 0, "", ErrGitHubRequestExpired
}
githubID, err := g.oauth.UserID(ctx, app, code, request.CodeVerifier)
if err != nil {
return request.Client, "", fmt.Errorf("%w:%w", ErrGitHubFailed, err)
}
exchange, hash := newSecret()
if err := g.logins.SaveGrant(ctx, hash, port.GitHubGrant{Request: request, GitHubID: githubID}, githubGrantTTL); err != nil {
return request.Client, "", err
}
return request.Client, exchange, nil
}
// GitHub 没有授权(用户拒绝或者 GitHub 报错)时作废 state,返回发起授权的客户端。
func (g *GitHub) Cancel(ctx context.Context, state string) (port.GitHubClient, error) {
request, err := g.logins.TakeRequest(ctx, hashSecret(state))
if errors.Is(err, port.ErrCodeNotFound) {
return 0, ErrGitHubRequestExpired
}
if err != nil {
return 0, err
}
return request.Client, nil
}
type GitHubResult struct {
// 登录了已经绑定这个 GitHub 账号的账号时才有:发出请求的游客会话已经换成这个账号的新会话。
Session *Session
Member member.Member
Account Account
}
// current 和 currentToken 是发出请求的成员和它的成员 token,必须是发起授权的那个成员。
func (g *GitHub) Exchange(ctx context.Context, current member.Member, currentToken, code, nonce string) (GitHubResult, error) {
if _, err := g.app(ctx); err != nil {
return GitHubResult{}, err
}
grant, err := g.logins.TakeGrant(ctx, hashSecret(code))
if errors.Is(err, port.ErrCodeNotFound) {
return GitHubResult{}, ErrGitHubLoginExpired
}
if err != nil {
return GitHubResult{}, err
}
if grant.Request.MemberID != current.ID || subtle.ConstantTimeCompare(grant.Request.NonceHash, hashSecret(nonce)) != 1 {
return GitHubResult{}, ErrGitHubLoginExpired
}
switch grant.Request.Intent {
case port.GitHubBind:
return g.bind(ctx, current, grant.GitHubID)
case port.GitHubVerify:
return g.verify(ctx, current, currentToken, grant.GitHubID)
}
return g.login(ctx, current, currentToken, grant.GitHubID)
}
func (g *GitHub) login(ctx context.Context, current member.Member, currentToken string, githubID int64) (GitHubResult, error) {
a := g.accounts
if err := a.requireGuest(ctx, current.ID); err != nil {
return GitHubResult{}, err
}
account, err := a.members.AccountByGitHub(ctx, githubID)
if err == nil {
session, err := a.signIn(ctx, currentToken, account)
// 账号的密码恰好在这时被修改或重置:修改删除了其他会话,这次登录也作废,重新登录即可。
if errors.Is(err, port.ErrAccountPasswordChanged) {
return GitHubResult{}, ErrGitHubLoginExpired
}
if err != nil {
return GitHubResult{}, err
}
return GitHubResult{Session: &session, Member: session.Member, Account: accountOf(account)}, nil
}
if !errors.Is(err, port.ErrMemberNotFound) {
return GitHubResult{}, err
}
// 没有绑定过的 GitHub 账号登录就是注册:绑到发出请求的游客上,成员和它的会话都不变。
settings, err := a.site.Settings(ctx)
if err != nil {
return GitHubResult{}, err
}
if !settings.RegistrationOpen {
return GitHubResult{}, ErrRegistrationClosed
}
if err := a.members.BindGitHub(ctx, current.ID, githubID); err != nil {
return GitHubResult{}, err
}
return GitHubResult{Member: current, Account: Account{GitHub: true}}, nil
}
func (g *GitHub) bind(ctx context.Context, current member.Member, githubID int64) (GitHubResult, error) {
profile, err := g.accounts.members.AccountProfile(ctx, current.ID)
if err != nil {
return GitHubResult{}, err
}
if !profile.Registered() {
return GitHubResult{}, ErrNotRegistered
}
if err := g.accounts.members.BindGitHub(ctx, current.ID, githubID); err != nil {
return GitHubResult{}, err
}
return GitHubResult{Member: current, Account: Account{Username: profile.Username, Email: profile.Email, GitHub: true}}, nil
}
// 只确认身份,不修改账号:GitHub 账号要是这个账号绑定的。确认记在发出请求的会话上,Accounts.Register 补设用户名和密码时要用。
func (g *GitHub) verify(ctx context.Context, current member.Member, currentToken string, githubID int64) (GitHubResult, error) {
account, err := g.accounts.members.AccountByID(ctx, current.ID)
if err != nil {
return GitHubResult{}, err
}
if account.GitHubID != githubID {
return GitHubResult{}, ErrGitHubMismatch
}
if err := g.accounts.verifications.SaveVerification(ctx, hashSecret(currentToken), githubVerificationTTL); err != nil {
return GitHubResult{}, err
}
return GitHubResult{Member: current, Account: accountOf(account)}, nil
}
// 解绑之后要能用密码登录,所以账号必须设置过密码。
func (g *GitHub) Unbind(ctx context.Context, memberID, currentPassword string) error {
if _, err := g.app(ctx); err != nil {
return err
}
profile, err := g.accounts.members.AccountProfile(ctx, memberID)
if err != nil {
return err
}
switch {
case !profile.Registered():
return ErrNotRegistered
case profile.Username == "":
return port.ErrNoPassword
}
if _, err := g.accounts.checkCurrentPassword(ctx, memberID, profile.Username, currentPassword); err != nil {
return err
}
return g.accounts.members.UnbindGitHub(ctx, memberID)
}
+625
View File
@@ -0,0 +1,625 @@
package identity
import (
"context"
"errors"
"net/url"
"slices"
"sync/atomic"
"testing"
"testing/synctest"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/port/porttest"
"github.com/dihemoe/alisten/internal/domain/track"
)
// 授权码就是 GitHub 用户 ID 的名字:code-42 换到 42,不认识的授权码换取失败。GitHub 上有 client-1 和 client-2 两个 OAuth App,
// Client Secret 对不上时换取失败。
type fakeOAuth struct {
users map[string]int64
secrets map[string]string
calls atomic.Int32
}
func (f *fakeOAuth) AuthorizeURL(app port.GitHubOAuthApp, state, _ string) string {
return "https://github.test/login/oauth/authorize?" + url.Values{"client_id": {app.ClientID}, "state": {state}}.Encode()
}
func (f *fakeOAuth) UserID(_ context.Context, app port.GitHubOAuthApp, code, _ string) (int64, error) {
f.calls.Add(1)
if secret, ok := f.secrets[app.ClientID]; !ok || secret != app.ClientSecret {
return 0, errors.New("incorrect_client_credentials")
}
id, ok := f.users[code]
if !ok {
return 0, errors.New("bad_verification_code")
}
return id, nil
}
type githubFixture struct {
g *GitHub
a *Accounts
s *Service
store *porttest.Store
q *fakeQuota
oauth *fakeOAuth
revoked func() [][]byte
}
func newGitHub(t *testing.T) githubFixture {
t.Helper()
a, s, store, q, revoked := newAccounts(t)
oauth := &fakeOAuth{users: map[string]int64{"code-42": 42, "code-7": 7}, secrets: map[string]string{"client-1": "secret-1", "client-2": "secret-2"}}
g := NewGitHub(a, store, oauth, porttest.NewCodes())
if err := g.SetOAuthApp(t.Context(), port.GitHubOAuthApp{ClientID: "client-1", ClientSecret: "secret-1"}); err != nil {
t.Fatal(err)
}
return githubFixture{g: g, a: a, s: s, store: store, q: q, oauth: oauth, revoked: revoked}
}
func clientIDOf(t *testing.T, authorizeURL string) string {
t.Helper()
u, err := url.Parse(authorizeURL)
if err != nil {
t.Fatal(err)
}
return u.Query().Get("client_id")
}
func stateOf(t *testing.T, authorizeURL string) string {
t.Helper()
u, err := url.Parse(authorizeURL)
if err != nil {
t.Fatal(err)
}
return u.Query().Get("state")
}
// 走完授权和回调,返回兑换码。
func (f githubFixture) authorize(t *testing.T, s Session, intent port.GitHubIntent, nonce, code string) string {
t.Helper()
// 绑定要输入当前密码,测试中的账号都用这个密码注册。
authorizeURL, err := f.g.Authorize(t.Context(), s.Member.ID, clientIP, intent, port.GitHubWeb, nonce, "correct horse")
if err != nil {
t.Fatal(err)
}
_, exchange, err := f.g.Callback(t.Context(), stateOf(t, authorizeURL), code)
if err != nil {
t.Fatal(err)
}
return exchange
}
func (f githubFixture) flow(t *testing.T, s Session, intent port.GitHubIntent, code string) (GitHubResult, error) {
t.Helper()
exchange := f.authorize(t, s, intent, "nonce-"+s.MemberToken, code)
return f.g.Exchange(t.Context(), s.Member, s.MemberToken, exchange, "nonce-"+s.MemberToken)
}
func TestGitHubRegistersThenLogsIn(t *testing.T) {
f := newGitHub(t)
first := createMember(t, f.s)
result, err := f.flow(t, first, port.GitHubLogin, "code-42")
if err != nil {
t.Fatal(err)
}
if result.Session != nil || result.Member != first.Member || result.Account != (Account{GitHub: true}) {
t.Errorf("首次用 GitHub 登录 = %#v,期望注册:成员和会话都不变,账号只绑定了 GitHub", result)
}
if _, ok := tokenOwner(t, f.store, first.MemberToken); !ok {
t.Error("注册不更换会话")
}
if lists, err := f.store.MemberPlaylists(t.Context(), first.Member.ID); err != nil || len(lists) != 1 || !lists[0].Favorites {
t.Errorf("注册之后的歌单 = %#v, %v,期望一个“我喜欢”", lists, err)
}
guest := createMember(t, f.s)
f.revoked()
result, err = f.flow(t, guest, port.GitHubLogin, "code-42")
if err != nil {
t.Fatal(err)
}
if result.Session == nil || result.Session.Member != first.Member || result.Member != first.Member || result.Account != (Account{GitHub: true}) {
t.Fatalf("用已绑定的 GitHub 登录 = %#v,期望登录第一次注册的账号", result)
}
if m, ok := tokenOwner(t, f.store, result.Session.MemberToken); !ok || m != first.Member {
t.Error("登录应签发账号的新会话")
}
if _, ok := tokenOwner(t, f.store, guest.MemberToken); ok {
t.Error("登录之后游客原来的 token 应失效")
}
if got := f.revoked(); !sameHashes(got, guest.MemberToken) {
t.Errorf("广播的被删除的会话 = %x,期望游客原来的会话", got)
}
}
func TestGitHubLoginMovesGuestListensIntoAccount(t *testing.T) {
f := newGitHub(t)
owner := createMember(t, f.s)
if _, err := f.flow(t, owner, port.GitHubLogin, "code-42"); err != nil {
t.Fatal(err)
}
guest := createMember(t, f.s)
heard := time.Date(2026, 11, 10, 20, 0, 0, 0, time.UTC)
sunny := port.Listen{Track: track.Track{Source: track.NetEase, ID: "186016"}, Meta: track.Meta{Title: "晴天"}, HeardAt: heard}
rain := port.Listen{Track: track.Track{Source: track.NetEase, ID: "185811"}, Meta: track.Meta{Title: "七里香"}, HeardAt: heard.Add(time.Minute)}
if err := f.store.RecordListen(t.Context(), []string{owner.Member.ID}, sunny); err != nil {
t.Fatal(err)
}
if err := f.store.RecordListen(t.Context(), []string{guest.Member.ID}, rain); err != nil {
t.Fatal(err)
}
if _, err := f.flow(t, guest, port.GitHubLogin, "code-42"); err != nil {
t.Fatal(err)
}
if listens, err := f.store.MemberListens(t.Context(), owner.Member.ID); err != nil || !slices.Equal(listens, []port.Listen{rain, sunny}) {
t.Errorf("用 GitHub 登录之后账号的历史 = %#v, %v,期望并入游客听过的《七里香》", listens, err)
}
if listens, err := f.store.MemberListens(t.Context(), guest.Member.ID); err != nil || len(listens) != 0 {
t.Errorf("用 GitHub 登录之后游客的历史 = %#v, %v,期望移走", listens, err)
}
}
func TestGitHubRegistrationClosed(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
if err := f.store.SetRegistrationOpen(t.Context(), false); err != nil {
t.Fatal(err)
}
if _, err := f.flow(t, alice, port.GitHubBind, "code-7"); err != nil {
t.Errorf("关闭注册时已有账号绑定 GitHub = %v,期望照常", err)
}
guest := createMember(t, f.s)
if _, err := f.flow(t, guest, port.GitHubLogin, "code-42"); !errors.Is(err, ErrRegistrationClosed) {
t.Errorf("关闭注册时首次用 GitHub 登录 = %v,期望 ErrRegistrationClosed", err)
}
if _, registered, _ := f.a.Account(t.Context(), guest.Member.ID); registered {
t.Error("关闭注册时游客不应变成账号")
}
result, err := f.flow(t, guest, port.GitHubLogin, "code-7")
if err != nil || result.Session == nil || result.Session.Member != alice.Member {
t.Errorf("关闭注册时用已绑定的 GitHub 登录 = %#v, %v,期望登录 alice", result, err)
}
}
func TestGitHubBind(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
bob := registerAccount(t, f.a, f.s, "bob", "correct horse")
guest := createMember(t, f.s)
result, err := f.flow(t, alice, port.GitHubBind, "code-42")
if err != nil {
t.Fatal(err)
}
if result.Session != nil || result.Member != alice.Member || result.Account != (Account{Username: "alice", GitHub: true}) {
t.Errorf("绑定 = %#v,期望 alice 绑定了 GitHub,会话不变", result)
}
if _, err := f.flow(t, bob, port.GitHubBind, "code-42"); !errors.Is(err, port.ErrGitHubTaken) {
t.Errorf("绑定已经属于别人的 GitHub = %v,期望 ErrGitHubTaken", err)
}
for _, tc := range []struct {
name string
session Session
intent port.GitHubIntent
want error
}{
{"已经绑定了 GitHub 的账号再绑定", alice, port.GitHubBind, port.ErrGitHubAlreadyBound},
{"游客绑定", guest, port.GitHubBind, ErrNotRegistered},
{"已经登录了账号的设备用 GitHub 登录", bob, port.GitHubLogin, ErrAlreadyLoggedIn},
} {
if _, err := f.g.Authorize(t.Context(), tc.session.Member.ID, clientIP, tc.intent, port.GitHubWeb, "nonce", "correct horse"); !errors.Is(err, tc.want) {
t.Errorf("%s = %v,期望 %v", tc.name, err, tc.want)
}
}
result, err = f.flow(t, guest, port.GitHubLogin, "code-42")
if err != nil || result.Session == nil || result.Session.Member != alice.Member || result.Account != (Account{Username: "alice", GitHub: true}) {
t.Errorf("绑定之后用 GitHub 登录 = %#v, %v,期望登录 alice", result, err)
}
}
// 兑换码只能由发起授权的那台设备、带着它保存的 nonce 兑换:别人把自己发起的授权发给你,你授权之后得到的兑换码对他也没用。
func TestGitHubExchangeChecksNonceAndMember(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
mallory := registerAccount(t, f.a, f.s, "mallory", "correct horse")
exchange := f.authorize(t, mallory, port.GitHubBind, "mallory-nonce", "code-42")
if _, err := f.g.Exchange(t.Context(), alice.Member, alice.MemberToken, exchange, "alice-nonce"); !errors.Is(err, ErrGitHubLoginExpired) {
t.Errorf("别的设备兑换 = %v,期望 ErrGitHubLoginExpired", err)
}
if _, err := f.g.Exchange(t.Context(), mallory.Member, mallory.MemberToken, exchange, "mallory-nonce"); !errors.Is(err, ErrGitHubLoginExpired) {
t.Errorf("兑换失败之后再兑换 = %v,期望兑换码已经作废", err)
}
exchange = f.authorize(t, mallory, port.GitHubBind, "mallory-nonce", "code-42")
if _, err := f.g.Exchange(t.Context(), mallory.Member, mallory.MemberToken, exchange, "guessed-nonce"); !errors.Is(err, ErrGitHubLoginExpired) {
t.Errorf("nonce 不对 = %v,期望 ErrGitHubLoginExpired", err)
}
if _, err := f.g.Exchange(t.Context(), mallory.Member, mallory.MemberToken, "unknown", "mallory-nonce"); !errors.Is(err, ErrGitHubLoginExpired) {
t.Errorf("不存在的兑换码 = %v,期望 ErrGitHubLoginExpired", err)
}
if p, _ := f.store.AccountProfile(t.Context(), mallory.Member.ID); p.GitHub {
t.Error("兑换失败时不应绑定")
}
guest := createMember(t, f.s)
exchange = f.authorize(t, guest, port.GitHubLogin, "nonce", "code-42")
if _, err := f.a.Register(t.Context(), guest.Member.ID, guest.MemberToken, clientIP, "guest", "correct horse"); err != nil {
t.Fatal(err)
}
if _, err := f.g.Exchange(t.Context(), guest.Member, guest.MemberToken, exchange, "nonce"); !errors.Is(err, ErrAlreadyLoggedIn) {
t.Errorf("发起登录之后注册了账号再兑换 = %v,期望 ErrAlreadyLoggedIn", err)
}
}
func TestGitHubCallback(t *testing.T) {
f := newGitHub(t)
guest := createMember(t, f.s)
f.q.taken = nil
authorizeURL, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, port.GitHubWeb, "nonce", "")
if err != nil {
t.Fatal(err)
}
if want := []string{"github:ip:" + clientIP}; !slices.Equal(f.q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", f.q.taken, want)
}
state := stateOf(t, authorizeURL)
if len(state) != 43 {
t.Errorf("state = %q,期望 256 位随机值的 base64url", state)
}
if _, _, err := f.g.Callback(t.Context(), state, "unknown-code"); !errors.Is(err, ErrGitHubFailed) {
t.Errorf("GitHub 换取失败 = %v,期望 ErrGitHubFailed", err)
}
calls := f.oauth.calls.Load()
if _, _, err := f.g.Callback(t.Context(), state, "code-42"); !errors.Is(err, ErrGitHubRequestExpired) {
t.Errorf("同一个 state 再回调一次 = %v,期望 ErrGitHubRequestExpired", err)
}
if _, _, err := f.g.Callback(t.Context(), "forged", "code-42"); !errors.Is(err, ErrGitHubRequestExpired) {
t.Errorf("不存在的 state = %v,期望 ErrGitHubRequestExpired", err)
}
if got := f.oauth.calls.Load(); got != calls {
t.Error("state 无效时不应请求 GitHub")
}
f.q.exhausted["github:ip:"+clientIP] = true
if _, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, port.GitHubWeb, "nonce", ""); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("额度用完时发起授权 = %v,期望 ErrTooManyAttempts", err)
}
}
// 发起授权的客户端记在 state 对应的记录里:回调成功、换取失败和 GitHub 没有授权时,都按记录跳回发起授权的客户端。
func TestGitHubCallbackReturnsClient(t *testing.T) {
f := newGitHub(t)
guest := createMember(t, f.s)
start := func(client port.GitHubClient) string {
t.Helper()
authorizeURL, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, client, "nonce", "")
if err != nil {
t.Fatal(err)
}
return stateOf(t, authorizeURL)
}
for _, client := range []port.GitHubClient{port.GitHubWeb, port.GitHubApp} {
if got, exchange, err := f.g.Callback(t.Context(), start(client), "code-42"); err != nil || got != client || exchange == "" {
t.Errorf("客户端 %v 发起的授权回调 = %v, %q, %v,期望跳回发起授权的客户端", client, got, exchange, err)
}
if got, _, err := f.g.Callback(t.Context(), start(client), "unknown-code"); !errors.Is(err, ErrGitHubFailed) || got != client {
t.Errorf("客户端 %v 发起的授权换取失败 = %v, %v,期望 ErrGitHubFailed 并跳回发起授权的客户端", client, got, err)
}
state := start(client)
if got, err := f.g.Cancel(t.Context(), state); err != nil || got != client {
t.Errorf("客户端 %v 发起的授权没有得到授权 = %v, %v,期望跳回发起授权的客户端", client, got, err)
}
calls := f.oauth.calls.Load()
if _, _, err := f.g.Callback(t.Context(), state, "code-42"); !errors.Is(err, ErrGitHubRequestExpired) {
t.Errorf("没有得到授权之后用同一个 state 回调 = %v,期望 state 已经作废", err)
}
if _, err := f.g.Cancel(t.Context(), state); !errors.Is(err, ErrGitHubRequestExpired) {
t.Errorf("没有得到授权之后再取消同一个 state = %v,期望 ErrGitHubRequestExpired", err)
}
if got := f.oauth.calls.Load(); got != calls {
t.Error("state 已经作废时不应请求 GitHub")
}
}
if _, err := f.g.Cancel(t.Context(), "forged"); !errors.Is(err, ErrGitHubRequestExpired) {
t.Errorf("不存在的 state 没有得到授权 = %v,期望 ErrGitHubRequestExpired", err)
}
}
// state 10 分钟内有效,兑换码 1 分钟内有效。
func TestGitHubCodesExpire(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
f := newGitHub(t)
guest := createMember(t, f.s)
authorizeURL, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, port.GitHubWeb, "nonce", "")
if err != nil {
t.Fatal(err)
}
time.Sleep(10*time.Minute + time.Second)
if _, _, err := f.g.Callback(t.Context(), stateOf(t, authorizeURL), "code-42"); !errors.Is(err, ErrGitHubRequestExpired) {
t.Errorf("10 分钟之后回调 = %v,期望 ErrGitHubRequestExpired", err)
}
authorizeURL, err = f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, port.GitHubWeb, "nonce", "")
if err != nil {
t.Fatal(err)
}
time.Sleep(9 * time.Minute)
_, exchange, err := f.g.Callback(t.Context(), stateOf(t, authorizeURL), "code-42")
if err != nil {
t.Fatalf("9 分钟之后回调 = %v,期望 state 仍然有效", err)
}
time.Sleep(time.Minute + time.Second)
if _, err := f.g.Exchange(t.Context(), guest.Member, guest.MemberToken, exchange, "nonce"); !errors.Is(err, ErrGitHubLoginExpired) {
t.Errorf("1 分钟之后兑换 = %v,期望 ErrGitHubLoginExpired", err)
}
})
}
// 只凭会话不能更换登录凭证:绑定 GitHub 要输入当前密码,额度与修改密码共用。
func TestGitHubBindRequiresCurrentPassword(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
f.q.taken = nil
for _, password := range []string{"", "wrong horse"} {
if _, err := f.g.Authorize(t.Context(), alice.Member.ID, clientIP, port.GitHubBind, port.GitHubWeb, "nonce", password); !errors.Is(err, ErrWrongCurrentPassword) {
t.Errorf("当前密码为 %q 时发起绑定 = %v,期望 ErrWrongCurrentPassword", password, err)
}
}
if want := []string{"password:member:" + alice.Member.ID, "password:member:" + alice.Member.ID}; !slices.Equal(f.q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", f.q.taken, want)
}
if result, err := f.flow(t, alice, port.GitHubBind, "code-42"); err != nil || result.Account != (Account{Username: "alice", GitHub: true}) {
t.Errorf("输入正确的当前密码之后绑定 = %#v, %v", result, err)
}
bob := registerAccount(t, f.a, f.s, "bob", "correct horse")
f.q.exhausted["password:member:"+bob.Member.ID] = true
if _, err := f.g.Authorize(t.Context(), bob.Member.ID, clientIP, port.GitHubBind, port.GitHubWeb, "nonce", "correct horse"); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("额度用完时发起绑定 = %v,期望 ErrTooManyAttempts", err)
}
}
func TestGitHubUnbind(t *testing.T) {
f := newGitHub(t)
gh := createMember(t, f.s)
if _, err := f.flow(t, gh, port.GitHubLogin, "code-42"); err != nil {
t.Fatal(err)
}
if err := f.g.Unbind(t.Context(), gh.Member.ID, "correct horse"); !errors.Is(err, port.ErrNoPassword) {
t.Errorf("没有密码的账号解绑 = %v,期望 ErrNoPassword", err)
}
if _, err := f.flow(t, gh, port.GitHubVerify, "code-42"); err != nil {
t.Fatal(err)
}
if _, err := f.a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "octocat", "correct horse"); err != nil {
t.Fatal(err)
}
f.q.taken = nil
for _, password := range []string{"", "wrong horse"} {
if err := f.g.Unbind(t.Context(), gh.Member.ID, password); !errors.Is(err, ErrWrongCurrentPassword) {
t.Errorf("当前密码为 %q 时解绑 = %v,期望 ErrWrongCurrentPassword", password, err)
}
}
if want := []string{"password:member:" + gh.Member.ID, "password:member:" + gh.Member.ID}; !slices.Equal(f.q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", f.q.taken, want)
}
if account, _, _ := f.a.Account(t.Context(), gh.Member.ID); !account.GitHub {
t.Error("当前密码不对时不应解绑")
}
if err := f.g.Unbind(t.Context(), gh.Member.ID, "correct horse"); err != nil {
t.Fatal(err)
}
if account, ok, err := f.a.Account(t.Context(), gh.Member.ID); err != nil || !ok || account != (Account{Username: "octocat"}) {
t.Errorf("解绑之后 Account = %#v, %v, %v", account, ok, err)
}
if err := f.g.Unbind(t.Context(), createMember(t, f.s).Member.ID, "correct horse"); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客解绑 = %v,期望 ErrNotRegistered", err)
}
}
// 拿到账号一个会话的人(比如借用过的设备)不知道密码,就不能把账号的 GitHub 换成自己的:本人修改密码或后台重置之后,他无法再用 GitHub 登录回来。
func TestGitHubCannotBeSwappedWithSessionAlone(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
bob := registerAccount(t, f.a, f.s, "bob", "correct horse")
if _, err := f.flow(t, alice, port.GitHubBind, "code-42"); err != nil {
t.Fatal(err)
}
borrowed := createMember(t, f.s)
stolen, _, _, err := f.a.Login(t.Context(), borrowed.Member, borrowed.MemberToken, clientIP, "alice", "correct horse", "")
if err != nil {
t.Fatal(err)
}
if err := f.g.Unbind(t.Context(), stolen.Member.ID, "guessed horse"); !errors.Is(err, ErrWrongCurrentPassword) {
t.Errorf("不知道密码时解绑 = %v,期望 ErrWrongCurrentPassword", err)
}
if _, err := f.g.Authorize(t.Context(), bob.Member.ID, clientIP, port.GitHubBind, port.GitHubWeb, "nonce", "guessed horse"); !errors.Is(err, ErrWrongCurrentPassword) {
t.Errorf("不知道密码时给没有绑定 GitHub 的账号发起绑定 = %v,期望 ErrWrongCurrentPassword", err)
}
if err := f.a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "battery staple"); err != nil {
t.Fatal(err)
}
if _, err := f.a.ResetPassword(t.Context(), "alice"); err != nil {
t.Fatal(err)
}
attacker := createMember(t, f.s)
result, err := f.flow(t, attacker, port.GitHubLogin, "code-7")
if err != nil || result.Session != nil || result.Member != attacker.Member {
t.Errorf("用自己的 GitHub 登录 = %#v, %v,期望只注册了新账号,进不了 alice", result, err)
}
if account, err := f.store.AccountByGitHub(t.Context(), 42); err != nil || account.Member != alice.Member {
t.Errorf("GitHub 42 属于 %#v, %v,期望仍是 alice", account.Member, err)
}
}
// 只绑定了 GitHub 的账号补设用户名和密码之前,要用账号绑定的 GitHub 确认身份。
func TestGitHubVerify(t *testing.T) {
f := newGitHub(t)
gh := createMember(t, f.s)
if _, err := f.flow(t, gh, port.GitHubLogin, "code-42"); err != nil {
t.Fatal(err)
}
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
for _, tc := range []struct {
name string
session Session
want error
}{
{"设置了密码的账号", alice, port.ErrAlreadyRegistered},
{"游客", createMember(t, f.s), ErrNotRegistered},
} {
if _, err := f.g.Authorize(t.Context(), tc.session.Member.ID, clientIP, port.GitHubVerify, port.GitHubWeb, "nonce", ""); !errors.Is(err, tc.want) {
t.Errorf("%s发起确认 = %v,期望 %v", tc.name, err, tc.want)
}
}
if _, err := f.flow(t, gh, port.GitHubVerify, "code-7"); !errors.Is(err, ErrGitHubMismatch) {
t.Errorf("用别的 GitHub 确认 = %v,期望 ErrGitHubMismatch", err)
}
if _, err := f.a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "octocat", "correct horse"); !errors.Is(err, ErrGitHubUnverified) {
t.Errorf("用别的 GitHub 确认之后补设 = %v,期望 ErrGitHubUnverified", err)
}
result, err := f.flow(t, gh, port.GitHubVerify, "code-42")
if err != nil || result.Session != nil || result.Member != gh.Member || result.Account != (Account{GitHub: true}) {
t.Fatalf("用绑定的 GitHub 确认 = %#v, %v,期望成员和会话都不变", result, err)
}
if _, ok := tokenOwner(t, f.store, gh.MemberToken); !ok {
t.Error("确认不更换会话")
}
if account, err := f.a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "octocat", "correct horse"); err != nil || account != (Account{Username: "octocat", GitHub: true}) {
t.Errorf("确认之后补设 = %#v, %v", account, err)
}
}
// 用 GitHub 确认身份之后 10 分钟内要补设用户名和密码。
func TestGitHubVerificationExpires(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
f := newGitHub(t)
gh := createMember(t, f.s)
if _, err := f.flow(t, gh, port.GitHubLogin, "code-42"); err != nil {
t.Fatal(err)
}
if _, err := f.flow(t, gh, port.GitHubVerify, "code-42"); err != nil {
t.Fatal(err)
}
time.Sleep(10*time.Minute + time.Second)
if _, err := f.a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "octocat", "correct horse"); !errors.Is(err, ErrGitHubUnverified) {
t.Errorf("确认 10 分钟之后补设 = %v,期望 ErrGitHubUnverified", err)
}
})
}
// 读到账号之后、签发会话之前,密码被修改或重置:修改时删除了其他会话,这次登录不能留下新会话。
func TestGitHubLoginRejectedWhenPasswordChangesMeanwhile(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
if _, err := f.flow(t, alice, port.GitHubBind, "code-42"); err != nil {
t.Fatal(err)
}
node := porttest.NewRedis().Node("a")
changing := NewAccounts(passwordChangedMembers{f.store}, f.store, &fakeQuota{exhausted: map[string]bool{}}, porttest.NewCodes(), porttest.NewCodes(), porttest.NewDevices(), newGateway(f.store, node, node))
f.g = NewGitHub(changing, f.store, f.oauth, porttest.NewCodes())
guest := createMember(t, f.s)
if _, err := f.flow(t, guest, port.GitHubLogin, "code-42"); !errors.Is(err, ErrGitHubLoginExpired) {
t.Errorf("密码在登录期间被修改 = %v,期望 ErrGitHubLoginExpired", err)
}
if _, ok := tokenOwner(t, f.store, guest.MemberToken); !ok {
t.Error("登录失败时游客的 token 应仍然有效")
}
}
// 后台保存的 OAuth App:读取只给出 Client ID,授权和换取令牌都用最近一次保存的,清除之后就关闭了 GitHub 登录。
func TestGitHubOAuthApp(t *testing.T) {
f := newGitHub(t)
guest := createMember(t, f.s)
if id, err := f.g.ClientID(t.Context()); err != nil || id != "client-1" {
t.Errorf("ClientID = %q, %v,期望 client-1", id, err)
}
if err := f.g.SetOAuthApp(t.Context(), port.GitHubOAuthApp{ClientID: "client-2", ClientSecret: "secret-2"}); err != nil {
t.Fatal(err)
}
if id, err := f.g.ClientID(t.Context()); err != nil || id != "client-2" {
t.Errorf("换成 client-2 之后 ClientID = %q, %v", id, err)
}
authorizeURL, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, port.GitHubWeb, "nonce", "")
if err != nil {
t.Fatal(err)
}
if got := clientIDOf(t, authorizeURL); got != "client-2" {
t.Errorf("授权地址的 client_id = %q,期望新保存的 client-2", got)
}
if _, exchange, err := f.g.Callback(t.Context(), stateOf(t, authorizeURL), "code-42"); err != nil || exchange == "" {
t.Errorf("用新的 Client Secret 换取 = %q, %v", exchange, err)
}
if err := f.g.DeleteOAuthApp(t.Context()); err != nil {
t.Fatal(err)
}
if id, err := f.g.ClientID(t.Context()); err != nil || id != "" {
t.Errorf("清除之后 ClientID = %q, %v,期望空字符串", id, err)
}
if err := f.g.DeleteOAuthApp(t.Context()); err != nil {
t.Errorf("没有配置时再清除 = %v,期望什么也不做", err)
}
}
// 没有配置 OAuth App 时,发起授权、兑换和解绑都返回 ErrGitHubDisabled。授权进行中关闭了 GitHub 登录,回调也失败,
// 照样跳回发起授权的客户端;重新开启之后恢复。
func TestGitHubDisabled(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
if _, err := f.flow(t, alice, port.GitHubBind, "code-42"); err != nil {
t.Fatal(err)
}
guest := createMember(t, f.s)
pending, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, port.GitHubApp, "nonce", "")
if err != nil {
t.Fatal(err)
}
exchange := f.authorize(t, guest, port.GitHubLogin, "nonce", "code-7")
if err := f.g.DeleteOAuthApp(t.Context()); err != nil {
t.Fatal(err)
}
f.q.taken = nil
for _, intent := range []port.GitHubIntent{port.GitHubLogin, port.GitHubBind, port.GitHubVerify} {
if _, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, intent, port.GitHubWeb, "nonce", "correct horse"); !errors.Is(err, ErrGitHubDisabled) {
t.Errorf("关闭之后发起 %v = %v,期望 ErrGitHubDisabled", intent, err)
}
}
if len(f.q.taken) != 0 {
t.Errorf("关闭之后发起授权取了额度 %v", f.q.taken)
}
calls := f.oauth.calls.Load()
if client, _, err := f.g.Callback(t.Context(), stateOf(t, pending), "code-42"); !errors.Is(err, ErrGitHubDisabled) || client != port.GitHubApp {
t.Errorf("授权进行中关闭,回调 = %v, %v,期望 ErrGitHubDisabled 并跳回 App", client, err)
}
if client, _, err := f.g.Callback(t.Context(), "forged", "code-42"); !errors.Is(err, ErrGitHubDisabled) || client != 0 {
t.Errorf("关闭之后不存在的 state 回调 = %v, %v,期望 ErrGitHubDisabled:重新发起也没有用,不提示过期", client, err)
}
if got := f.oauth.calls.Load(); got != calls {
t.Error("关闭之后不应请求 GitHub")
}
if _, err := f.g.Exchange(t.Context(), guest.Member, guest.MemberToken, exchange, "nonce"); !errors.Is(err, ErrGitHubDisabled) {
t.Errorf("关闭之后兑换 = %v,期望 ErrGitHubDisabled", err)
}
if err := f.g.Unbind(t.Context(), alice.Member.ID, "correct horse"); !errors.Is(err, ErrGitHubDisabled) {
t.Errorf("关闭之后解绑 = %v,期望 ErrGitHubDisabled", err)
}
if p, err := f.store.AccountProfile(t.Context(), alice.Member.ID); err != nil || !p.GitHub {
t.Errorf("关闭之后账号的绑定 = %#v, %v,期望不变", p, err)
}
if p, err := f.store.AccountProfile(t.Context(), guest.Member.ID); err != nil || p.Registered() {
t.Errorf("关闭之后游客 = %#v, %v,期望兑换没有注册账号", p, err)
}
if err := f.g.SetOAuthApp(t.Context(), port.GitHubOAuthApp{ClientID: "client-1", ClientSecret: "secret-1"}); err != nil {
t.Fatal(err)
}
if result, err := f.flow(t, guest, port.GitHubLogin, "code-7"); err != nil || result.Account != (Account{GitHub: true}) {
t.Errorf("重新开启之后用 GitHub 注册 = %#v, %v", result, err)
}
}
+2 -2
View File
@@ -28,7 +28,7 @@ func newService(t *testing.T) (*Service, *porttest.Store) {
// 没有调用 realtime.Start:这些测试不运行房间。
func newGateway(store *porttest.Store, node *porttest.Node, bus port.Bus) *realtime.Gateway {
_, gateway := realtime.New(store, store, store, nil, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: bus}, slog.New(slog.DiscardHandler))
_, gateway := realtime.New(store, store, store, store, nil, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: bus}, slog.New(slog.DiscardHandler))
return gateway
}
@@ -206,7 +206,7 @@ func TestEnterRoomLeavesWhenTicketIsRevokedWhileJoining(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
store := porttest.New()
node := porttest.NewRedis().Node("a")
host, gateway := realtime.New(store, store, store, nil, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
host, gateway := realtime.New(store, store, store, store, nil, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(t.Context())
wait, err := realtime.Start(ctx, host, gateway)
if err != nil {
+90
View File
@@ -0,0 +1,90 @@
package identity
import (
"context"
"errors"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/member"
)
var ErrInvalidPairingCode = errors.New("配对码不对或已经过期")
const (
pairingTTL = 5 * time.Minute
// 生成配对码只占 Redis 的空间:额度防止一个账号在短时间内灌入大量配对码。
pairingCreateBurst = 5
pairingCreateRefill = time.Minute
// 配对码有 32^8 ≈ 1.1e12 种,5 分钟就过期:按 IP 的额度与登录相同,足以让猜中的可能性忽略不计。
pairingRedeemBurst = 10
pairingRedeemRefill = time.Minute
// 新配对码与有效的配对码撞上的可能性约为万亿分之一,重试几次足够。
pairingAttempts = 3
)
type PairingCode struct {
Code string
ExpiresAt time.Time
}
// token 是发出请求的会话:配对码属于它,它被删除(退出登录、修改或重置密码)时配对码随之作废。
func (a *Accounts) CreatePairingCode(ctx context.Context, memberID, token string) (PairingCode, error) {
if _, registered, err := a.Account(ctx, memberID); err != nil {
return PairingCode{}, err
} else if !registered {
return PairingCode{}, ErrNotRegistered
}
if err := a.take(ctx, "pairing:member:"+memberID, pairingCreateBurst, pairingCreateRefill); err != nil {
return PairingCode{}, err
}
expires := time.Now().Add(pairingTTL)
p := port.Pairing{MemberID: memberID, SessionTokenHash: hashSecret(token)}
for range pairingAttempts {
code := newPairingCode()
created, err := a.pairings.CreatePairing(ctx, hashSecret(code), p, pairingTTL)
if err != nil {
return PairingCode{}, err
}
if created {
return PairingCode{Code: code, ExpiresAt: expires}, nil
}
}
return PairingCode{}, errors.New("生成的配对码都与有效的配对码重复")
}
// 兑换配对码就是登录:与 Login 相同,只有游客能兑换,成功后游客的这个会话随即失效。code 已经按协议校验过格式。
func (a *Accounts) RedeemPairingCode(ctx context.Context, current member.Member, currentToken, clientIP, code string) (Session, Account, error) {
if err := a.requireGuest(ctx, current.ID); err != nil {
return Session{}, Account{}, err
}
if err := a.take(ctx, "pairing:ip:"+quotaAddr(clientIP), pairingRedeemBurst, pairingRedeemRefill); err != nil {
return Session{}, Account{}, err
}
pairing, err := a.pairings.TakePairing(ctx, hashSecret(code))
if errors.Is(err, port.ErrCodeNotFound) {
return Session{}, Account{}, ErrInvalidPairingCode
}
if err != nil {
return Session{}, Account{}, err
}
// 先读密码哈希,再确认生成配对码的会话还在:修改或重置密码在此之前提交时,那个会话已经被删除;在此之后提交时,
// 签发会话会因为密码哈希变了而失败。
account, err := a.members.AccountByID(ctx, pairing.MemberID)
if err != nil {
return Session{}, Account{}, err
}
if _, err := a.members.MemberByToken(ctx, pairing.SessionTokenHash); errors.Is(err, port.ErrMemberNotFound) {
return Session{}, Account{}, ErrInvalidPairingCode
} else if err != nil {
return Session{}, Account{}, err
}
session, err := a.signIn(ctx, currentToken, account)
if errors.Is(err, port.ErrAccountPasswordChanged) {
return Session{}, Account{}, ErrInvalidPairingCode
}
if err != nil {
return Session{}, Account{}, err
}
return session, accountOf(account), nil
}
+237
View File
@@ -0,0 +1,237 @@
package identity
import (
"errors"
"regexp"
"slices"
"testing"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/port/porttest"
"github.com/dihemoe/alisten/internal/domain/track"
)
func TestPairingCodeAlphabet(t *testing.T) {
format := regexp.MustCompile(`^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{8}$`)
seen := map[rune]bool{}
codes := map[string]bool{}
for range 1000 {
code := newPairingCode()
if !format.MatchString(code) {
t.Fatalf("配对码 %q 应是 8 位,不含 0、O、1、I", code)
}
codes[code] = true
for _, r := range code {
seen[r] = true
}
}
if len(seen) != 32 {
t.Errorf("1000 个配对码只用到 %d 个字符,期望字母表中的 32 个都会出现", len(seen))
}
if len(codes) < 999 {
t.Errorf("1000 个配对码中只有 %d 个不同", len(codes))
}
}
func createPairingCode(t *testing.T, a *Accounts, s Session) string {
t.Helper()
code, err := a.CreatePairingCode(t.Context(), s.Member.ID, s.MemberToken)
if err != nil {
t.Fatal(err)
}
return code.Code
}
func TestPairDevice(t *testing.T) {
a, s, store, q, revoked := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
guestTicket := issueTicket(t, s, guest, createRoom(t, store, ""))
q.taken = nil
before := time.Now()
code, err := a.CreatePairingCode(t.Context(), alice.Member.ID, alice.MemberToken)
if err != nil {
t.Fatal(err)
}
if code.ExpiresAt.Before(before.Add(5*time.Minute-time.Second)) || code.ExpiresAt.After(time.Now().Add(5*time.Minute)) {
t.Errorf("配对码在 %v 过期,期望 5 分钟之后", code.ExpiresAt.Sub(before))
}
revoked()
session, account, err := a.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, clientIP, code.Code)
if err != nil {
t.Fatal(err)
}
if session.Member != alice.Member || account != (Account{Username: "alice"}) {
t.Errorf("兑换配对码 = %#v, %#v,期望登录 alice", session.Member, account)
}
if m, ok := tokenOwner(t, store, session.MemberToken); !ok || m != alice.Member {
t.Error("兑换配对码应签发账号的新会话")
}
if _, ok := tokenOwner(t, store, guest.MemberToken); ok {
t.Error("兑换之后游客原来的 token 应失效")
}
if got := revoked(); !sameHashes(got, guest.MemberToken) || ticketValid(t, s, guestTicket) {
t.Errorf("广播的被删除的会话 = %x,期望游客原来的会话", got)
}
if _, ok := tokenOwner(t, store, alice.MemberToken); !ok {
t.Error("兑换不影响生成配对码的会话")
}
if want := []string{"pairing:member:" + alice.Member.ID, "pairing:ip:" + clientIP}; !slices.Equal(q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", q.taken, want)
}
other := createMember(t, s)
if _, _, err := a.RedeemPairingCode(t.Context(), other.Member, other.MemberToken, clientIP, code.Code); !errors.Is(err, ErrInvalidPairingCode) {
t.Errorf("同一个配对码再兑换一次 = %v,期望 ErrInvalidPairingCode", err)
}
if _, _, err := a.RedeemPairingCode(t.Context(), other.Member, other.MemberToken, clientIP, "ABCD2345"); !errors.Is(err, ErrInvalidPairingCode) {
t.Errorf("不存在的配对码 = %v,期望 ErrInvalidPairingCode", err)
}
if _, ok := tokenOwner(t, store, other.MemberToken); !ok {
t.Error("兑换失败时游客的 token 应仍然有效")
}
}
func TestPairingMovesGuestListensIntoAccount(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
heard := time.Date(2026, 11, 10, 20, 0, 0, 0, time.UTC)
sunny := port.Listen{Track: track.Track{Source: track.NetEase, ID: "186016"}, Meta: track.Meta{Title: "晴天"}, HeardAt: heard}
rain := port.Listen{Track: track.Track{Source: track.NetEase, ID: "185811"}, Meta: track.Meta{Title: "七里香"}, HeardAt: heard.Add(time.Minute)}
if err := store.RecordListen(t.Context(), []string{alice.Member.ID}, sunny); err != nil {
t.Fatal(err)
}
if err := store.RecordListen(t.Context(), []string{guest.Member.ID}, rain); err != nil {
t.Fatal(err)
}
code := createPairingCode(t, a, alice)
if _, _, err := a.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, clientIP, code); err != nil {
t.Fatal(err)
}
if listens, err := store.MemberListens(t.Context(), alice.Member.ID); err != nil || !slices.Equal(listens, []port.Listen{rain, sunny}) {
t.Errorf("兑换配对码之后账号的历史 = %#v, %v,期望并入游客听过的《七里香》", listens, err)
}
if listens, err := store.MemberListens(t.Context(), guest.Member.ID); err != nil || len(listens) != 0 {
t.Errorf("兑换配对码之后游客的历史 = %#v, %v,期望移走", listens, err)
}
}
func TestPairingRejected(t *testing.T) {
a, s, _, q, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
bob := registerAccount(t, a, s, "bob", "correct horse")
guest := createMember(t, s)
q.taken = nil
if _, err := a.CreatePairingCode(t.Context(), guest.Member.ID, guest.MemberToken); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客生成配对码 = %v,期望 ErrNotRegistered", err)
}
if len(q.taken) != 0 {
t.Errorf("游客不应消耗额度:%v", q.taken)
}
code := createPairingCode(t, a, alice)
if _, _, err := a.RedeemPairingCode(t.Context(), bob.Member, bob.MemberToken, clientIP, code); !errors.Is(err, ErrAlreadyLoggedIn) {
t.Errorf("已经登录了账号的设备兑换 = %v,期望 ErrAlreadyLoggedIn", err)
}
q.exhausted["pairing:ip:"+clientIP] = true
if _, _, err := a.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, clientIP, code); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("额度用完时兑换 = %v,期望 ErrTooManyAttempts", err)
}
if _, _, err := a.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, "198.51.100.1", code); err != nil {
t.Errorf("被拒绝的兑换不应用掉配对码:%v", err)
}
q.exhausted["pairing:member:"+bob.Member.ID] = true
if _, err := a.CreatePairingCode(t.Context(), bob.Member.ID, bob.MemberToken); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("额度用完时生成配对码 = %v,期望 ErrTooManyAttempts", err)
}
}
// 让其他设备退出的办法是修改密码或者重置密码:此前生成的配对码要随生成它的会话一起作废,否则拿到配对码的人还能登录进来。
func TestPairingCodeDiesWithItsSession(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
login := func() Session {
t.Helper()
guest := createMember(t, s)
session, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", "")
if err != nil {
t.Fatal(err)
}
return session
}
redeem := func(code string) error {
t.Helper()
guest := createMember(t, s)
_, _, err := a.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, clientIP, code)
return err
}
other := login()
code := createPairingCode(t, a, other)
if err := a.Logout(t.Context(), other.MemberToken); err != nil {
t.Fatal(err)
}
if err := redeem(code); !errors.Is(err, ErrInvalidPairingCode) {
t.Errorf("生成配对码的会话退出之后兑换 = %v,期望 ErrInvalidPairingCode", err)
}
other = login()
code = createPairingCode(t, a, other)
if err := a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "correct horse"); err != nil {
t.Fatal(err)
}
if err := redeem(code); !errors.Is(err, ErrInvalidPairingCode) {
t.Errorf("另一台设备修改密码之后兑换 = %v,期望 ErrInvalidPairingCode", err)
}
code = createPairingCode(t, a, alice)
if err := a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "correct horse"); err != nil {
t.Fatal(err)
}
if err := redeem(code); err != nil {
t.Errorf("生成配对码的会话自己修改了密码,它仍然有效,兑换 = %v", err)
}
code = createPairingCode(t, a, alice)
if _, err := a.ResetPassword(t.Context(), "alice"); err != nil {
t.Fatal(err)
}
if err := redeem(code); !errors.Is(err, ErrInvalidPairingCode) {
t.Errorf("重置密码之后兑换 = %v,期望 ErrInvalidPairingCode", err)
}
if _, ok := tokenOwner(t, store, alice.MemberToken); ok {
t.Fatal("重置密码应删除账号的全部会话")
}
}
func TestPairGitHubOnlyAccount(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
gh := githubAccount(t, s, store, 42)
code := createPairingCode(t, a, gh)
guest := createMember(t, s)
session, account, err := a.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, clientIP, code)
if err != nil || session.Member != gh.Member || account != (Account{GitHub: true}) {
t.Errorf("兑换只绑定了 GitHub 的账号的配对码 = %#v, %#v, %v", session.Member, account, err)
}
}
// 读到账号的密码哈希之后、签发会话之前,密码被修改或重置:修改时删除了其他会话,这次兑换不能留下新会话。
func TestRedeemRejectedWhenPasswordChangesMeanwhile(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
codes := porttest.NewCodes()
node := porttest.NewRedis().Node("a")
changing := NewAccounts(passwordChangedMembers{store}, store, &fakeQuota{exhausted: map[string]bool{}}, codes, codes, porttest.NewDevices(), newGateway(store, node, node))
code, err := changing.CreatePairingCode(t.Context(), alice.Member.ID, alice.MemberToken)
if err != nil {
t.Fatal(err)
}
guest := createMember(t, s)
if _, _, err := changing.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, clientIP, code.Code); !errors.Is(err, ErrInvalidPairingCode) {
t.Errorf("密码在兑换期间被修改 = %v,期望 ErrInvalidPairingCode", err)
}
if _, ok := tokenOwner(t, store, guest.MemberToken); !ok {
t.Error("兑换失败时游客的 token 应仍然有效")
}
}
+11
View File
@@ -4,6 +4,7 @@ import (
"crypto/md5"
"crypto/rand"
"crypto/sha256"
"encoding/base32"
"encoding/base64"
"encoding/hex"
"strings"
@@ -39,6 +40,16 @@ func avatarHash(email string) string {
return hex.EncodeToString(sum[:])
}
// 配对码要能手动输入:去掉了容易认错的 0、O、1、I,剩下 32 个字符,每个字符正好对应 5 位随机值。
var pairingEncoding = base32.NewEncoding("23456789ABCDEFGHJKLMNPQRSTUVWXYZ").WithPadding(base32.NoPadding)
// 40 位随机值,8 个字符。
func newPairingCode() string {
b := make([]byte, 5)
rand.Read(b)
return pairingEncoding.EncodeToString(b)
}
// 后台重置密码时生成:96 位随机值的 base64url 编码,共 16 个字符,方便转告用户。
func newPassword() string {
b := make([]byte, 12)
+38
View File
@@ -0,0 +1,38 @@
package listen
import (
"context"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
)
// 游客和已注册成员都有播放历史,不像歌单要求注册。
type Service struct {
listens port.Listens
}
func New(listens port.Listens) *Service {
return &Service{listens: listens}
}
func (s *Service) List(ctx context.Context, memberID string) ([]port.Listen, error) {
return s.listens.MemberListens(ctx, memberID)
}
// 链接曲目的 t.ID 必须已经按 track.LinkID 规范化。
func (s *Service) Remove(ctx context.Context, memberID string, t track.Track) error {
return s.listens.DeleteListen(ctx, memberID, t)
}
func (s *Service) Clear(ctx context.Context, memberID string) error {
return s.listens.ClearListens(ctx, memberID)
}
func (s *Service) Recording(ctx context.Context, memberID string) (bool, error) {
return s.listens.ListenRecording(ctx, memberID)
}
func (s *Service) SetRecording(ctx context.Context, memberID string, on bool) error {
return s.listens.SetListenRecording(ctx, memberID, on)
}
+1 -1
View File
@@ -14,7 +14,7 @@ import (
func newService(t *testing.T, store *porttest.Store) (*Service, *realtime.Gateway) {
t.Helper()
node := porttest.NewRedis().Node("a")
host, gateway := realtime.New(store, store, store, nil, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
host, gateway := realtime.New(store, store, store, store, nil, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(context.Background())
wait, err := realtime.Start(ctx, host, gateway)
if err != nil {
+186
View File
@@ -0,0 +1,186 @@
package playlist
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/dihemoe/alisten/internal/app/port"
playlistlink "github.com/dihemoe/alisten/internal/domain/playlist"
"github.com/dihemoe/alisten/internal/domain/track"
)
const (
defaultImportName = "导入的歌单"
// 解析短链接和导入平台歌单都要访问外部,各自按成员限流,额度相同。
outboundBurst = 10
outboundRefill = 6 * time.Minute
)
var (
ErrInvalidPlaylistLink = errors.New("不是网易云或 QQ 音乐的歌单链接")
ErrUnsupportedPlaylist = errors.New("暂时只能导入网易云的歌单")
ErrShortLinkFailed = errors.New("没能打开这个短链接")
)
type ImportedTrack struct {
Track track.Track
Title, Artist, Album, CoverURL, PageURL string
Duration time.Duration
}
// 链接曲目的 Track.ID 必须已经按 track.LinkID 规范化。
func (s *Service) ImportFile(ctx context.Context, memberID, name string, tracks []ImportedTrack) (port.PlaylistRecord, int, error) {
if err := s.registered(ctx, memberID); err != nil {
return port.PlaylistRecord{}, 0, err
}
name, err := validName(name)
if err != nil {
name = defaultImportName
}
tracks = firstOccurrences(tracks, func(t ImportedTrack) track.Track { return t.Track })
keys := make([]track.Track, len(tracks))
for i, t := range tracks {
keys[i] = t.Track
}
known, err := s.tracks.TrackMetas(ctx, keys)
if err != nil {
return port.PlaylistRecord{}, 0, err
}
items := make([]port.PlaylistItem, 0, len(tracks))
for _, t := range tracks {
if m, ok := known[t.Track]; ok {
items = append(items, port.PlaylistItem{Track: t.Track, Meta: m})
} else if strings.TrimSpace(t.Title) != "" {
items = append(items, port.PlaylistItem{Track: t.Track, Meta: fileMeta(t)})
}
}
return s.importItems(ctx, memberID, name, items)
}
func (s *Service) ResolveLink(ctx context.Context, memberID, raw string) (track.Source, string, error) {
if err := s.registered(ctx, memberID); err != nil {
return "", "", err
}
link, err := importable(playlistlink.ParseLink(raw))
if err != nil {
return "", "", err
}
if !link.Short {
return link.Source, link.ID, nil
}
if err := s.take(ctx, "playlist-link:member:"+memberID, outboundBurst, outboundRefill); err != nil {
return "", "", err
}
final, err := s.shortLinks.Resolve(ctx, link.URL)
if err != nil {
return "", "", fmt.Errorf("解析短链接 %s:%w:%w", link.URL, err, ErrShortLinkFailed)
}
resolved, err := importable(playlistlink.ParseLink(final))
if err != nil {
return "", "", err
}
if resolved.Short {
return "", "", fmt.Errorf("短链接 %s 停在了 %s:%w", link.URL, final, ErrShortLinkFailed)
}
return resolved.Source, resolved.ID, nil
}
func (s *Service) ImportPlatform(ctx context.Context, memberID, name string, source track.Source, playlistID string) (port.PlaylistRecord, int, error) {
if err := s.registered(ctx, memberID); err != nil {
return port.PlaylistRecord{}, 0, err
}
name, err := validName(name)
if err != nil {
return port.PlaylistRecord{}, 0, err
}
if source != track.NetEase {
return port.PlaylistRecord{}, 0, ErrUnsupportedPlaylist
}
if err := s.take(ctx, "playlist-import:member:"+memberID, outboundBurst, outboundRefill); err != nil {
return port.PlaylistRecord{}, 0, err
}
songs, err := s.catalog.PlaylistSongs(ctx, source, playlistID)
if err != nil {
return port.PlaylistRecord{}, 0, err
}
songs = firstOccurrences(songs, func(song port.Song) track.Track { return song.Track })
items := make([]port.PlaylistItem, len(songs))
for i, song := range songs {
items[i] = port.PlaylistItem(song)
}
return s.importItems(ctx, memberID, name, items)
}
func (s *Service) importItems(ctx context.Context, memberID, name string, items []port.PlaylistItem) (port.PlaylistRecord, int, error) {
p, err := s.playlists.ImportPlaylist(ctx, memberID, name, items, maxPlaylists)
if errors.Is(err, port.ErrPlaylistLimit) {
return port.PlaylistRecord{}, 0, ErrTooManyPlaylists
}
if err != nil {
return port.PlaylistRecord{}, 0, err
}
return p, len(items), nil
}
// 只看前 maxItems 首,其中重复的歌只留第一次出现的位置。
func firstOccurrences[T any](items []T, key func(T) track.Track) []T {
items = items[:min(len(items), maxItems)]
seen := make(map[track.Track]bool, len(items))
kept := make([]T, 0, len(items))
for _, item := range items {
if k := key(item); !seen[k] {
seen[k] = true
kept = append(kept, item)
}
}
return kept
}
func importable(link playlistlink.Link, ok bool) (playlistlink.Link, error) {
switch {
case !ok:
return playlistlink.Link{}, ErrInvalidPlaylistLink
case link.Source != track.NetEase:
return playlistlink.Link{}, ErrUnsupportedPlaylist
}
return link, nil
}
// 曲目表里没有的歌只能用文件里的元数据。文件可以随意改写,所以地址只留平台自己的 https 地址。
func fileMeta(t ImportedTrack) track.Meta {
m := track.Meta{Title: t.Title, Artist: t.Artist, Album: t.Album, Duration: t.Duration}
switch id := t.Track.ID; t.Track.Source {
case track.NetEase:
m.CoverURL = platformURL(t.CoverURL, func(host string) bool { return strings.HasSuffix(host, ".music.126.net") })
if m.PageURL = platformURL(t.PageURL, isHost("music.163.com")); m.PageURL == "" {
m.PageURL = track.NetEasePageURL(id)
}
case track.QQ:
m.CoverURL = platformURL(t.CoverURL, isHost("y.gtimg.cn"))
if m.PageURL = platformURL(t.PageURL, isHost("y.qq.com")); m.PageURL == "" {
m.PageURL = track.QQPageURL(id)
}
// B 站和链接曲目的封面存在导出站点的对象存储里,换一个站点就失效。
case track.Bilibili:
m.PageURL = track.BilibiliPageURL(id)
case track.Link:
m.PageURL = id
}
return m
}
func platformURL(raw string, allowed func(host string) bool) string {
u, err := url.Parse(raw)
if err != nil || u.Scheme != "https" || u.User != nil || u.Port() != "" || !allowed(strings.ToLower(u.Hostname())) {
return ""
}
return raw
}
func isHost(want string) func(string) bool {
return func(host string) bool { return host == want }
}
+300
View File
@@ -0,0 +1,300 @@
package playlist
import (
"errors"
"slices"
"strconv"
"strings"
"testing"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
)
func netease(id string) track.Track { return track.Track{Source: track.NetEase, ID: id} }
func qq(id string) track.Track { return track.Track{Source: track.QQ, ID: id} }
func itemTracks(items []port.PlaylistItem) []track.Track {
tracks := make([]track.Track, len(items))
for i, item := range items {
tracks[i] = item.Track
}
return tracks
}
func TestImportFileChoosesMeta(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
clipMeta := track.Meta{Title: "a.mp3", CoverKey: "link/abc/cover.webp", PageURL: clip.ID, Duration: time.Minute}
knownMeta := track.Meta{Title: "已知", PageURL: "https://y.qq.com/n/ryqq/songDetail/known"}
f.tracks[sunny] = port.PreparedTrack{Meta: sunnyMeta}
f.tracks[clip] = port.PreparedTrack{Meta: clipMeta}
f.tracks[qq("known")] = port.PreparedTrack{Meta: knownMeta}
video := track.Track{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}
other := track.Track{Source: track.Link, ID: "https://example.com/b.mp3"}
cases := []struct {
name string
in ImportedTrack
want *track.Meta // nil 表示跳过
}{
{"曲目表里有:完全用服务端的元数据",
ImportedTrack{Track: sunny, Title: "文件里的标题", Artist: "别人", CoverURL: "https://p1.music.126.net/other.jpg", Duration: time.Second},
&sunnyMeta},
{"链接曲目在曲目表里:用服务端的封面",
ImportedTrack{Track: clip, Title: "文件里的标题", CoverURL: "https://evil.example/a.jpg"},
&clipMeta},
{"曲目表里有:文件里的标题为空也不跳过",
ImportedTrack{Track: qq("known")},
&knownMeta},
{"网易云:平台的 https 地址保留",
ImportedTrack{Track: netease("185811"), Title: "七里香", Artist: "周杰伦", Album: "七里香", CoverURL: "https://p2.music.126.net/b.jpg", PageURL: "https://music.163.com/song?id=185811", Duration: 299 * time.Second},
&track.Meta{Title: "七里香", Artist: "周杰伦", Album: "七里香", CoverURL: "https://p2.music.126.net/b.jpg", PageURL: "https://music.163.com/song?id=185811", Duration: 299 * time.Second}},
{"网易云:http 和伪装的域名丢弃,页面链接由 ID 推出",
ImportedTrack{Track: netease("1"), Title: "一", CoverURL: "http://p1.music.126.net/a.jpg", PageURL: "https://music.163.com.evil.example/song?id=1"},
&track.Meta{Title: "一", PageURL: "https://music.163.com/#/song?id=1"}},
{"网易云:封面只认 music.126.net 的子域名,带 userinfo 的地址丢弃",
ImportedTrack{Track: netease("2"), Title: "二", CoverURL: "https://music.126.net.evil.example/a.jpg", PageURL: "https://user@music.163.com/song?id=2"},
&track.Meta{Title: "二", PageURL: "https://music.163.com/#/song?id=2"}},
{"网易云:封面主机只是字面上以 music.126.net 结尾的丢弃",
ImportedTrack{Track: netease("6"), Title: "六", CoverURL: "https://evilmusic.126.net/a.jpg", PageURL: "https://music.163.com/song?id=6"},
&track.Meta{Title: "六", PageURL: "https://music.163.com/song?id=6"}},
{"网易云:带端口的地址丢弃",
ImportedTrack{Track: netease("3"), Title: "三", CoverURL: "https://p1.music.126.net:8443/a.jpg", PageURL: "https://music.163.com:8443/song?id=3"},
&track.Meta{Title: "三", PageURL: "https://music.163.com/#/song?id=3"}},
{"QQ 音乐:平台的 https 地址保留,域名不区分大小写",
ImportedTrack{Track: qq("0039MnYb0qxYhV"), Title: "晴天", CoverURL: "https://Y.GTIMG.CN/music/a.jpg", PageURL: "https://y.qq.com/n/ryqq/songDetail/0039MnYb0qxYhV"},
&track.Meta{Title: "晴天", CoverURL: "https://Y.GTIMG.CN/music/a.jpg", PageURL: "https://y.qq.com/n/ryqq/songDetail/0039MnYb0qxYhV"}},
{"QQ 音乐:别家平台的地址也丢弃",
ImportedTrack{Track: qq("002"), Title: "二", CoverURL: "https://p1.music.126.net/a.jpg", PageURL: "https://music.163.com/#/song?id=2"},
&track.Meta{Title: "二", PageURL: "https://y.qq.com/n/ryqq/songDetail/002"}},
{"B 站:页面链接由 ID 推出,封面丢弃",
ImportedTrack{Track: video, Title: "晴天(钢琴版)", Artist: "某 UP 主", CoverURL: "https://i0.hdslb.com/a.jpg", PageURL: "https://www.bilibili.com/video/BV1xx411c7mD", Duration: 2 * time.Minute},
&track.Meta{Title: "晴天(钢琴版)", Artist: "某 UP 主", PageURL: "https://www.bilibili.com/video/BV1xx411c7mD?p=2", Duration: 2 * time.Minute}},
{"链接曲目:页面链接就是 ID,封面丢弃",
ImportedTrack{Track: other, Title: "b.mp3", CoverURL: "https://example.com/b.jpg", PageURL: "https://evil.example/"},
&track.Meta{Title: "b.mp3", PageURL: other.ID}},
{"曲目表里没有、标题为空:跳过",
ImportedTrack{Track: netease("4"), Artist: "无名"},
nil},
{"曲目表里没有、标题只有空白:跳过",
ImportedTrack{Track: netease("5"), Title: " \t"},
nil},
}
in := make([]ImportedTrack, len(cases))
var want []track.Track
for i, c := range cases {
in[i] = c.in
if c.want != nil {
want = append(want, c.in.Track)
}
}
p, n, err := f.svc.ImportFile(t.Context(), alice.ID, "导入", in)
if err != nil || n != len(want) || p.TrackCount != len(want) {
t.Fatalf("ImportFile = %#v, %d, %v,期望导入 %d 首", p, n, err, len(want))
}
items, err := f.svc.Items(t.Context(), alice.ID, p.ID)
if err != nil {
t.Fatal(err)
}
if got := itemTracks(items); !slices.Equal(got, want) {
t.Errorf("导入的曲目 = %v,期望与文件同序 %v", got, want)
}
for _, c := range cases {
i := slices.IndexFunc(items, func(item port.PlaylistItem) bool { return item.Track == c.in.Track })
switch {
case c.want == nil && i >= 0:
t.Errorf("%s:导入了 %#v", c.name, items[i].Meta)
case c.want != nil && i < 0:
t.Errorf("%s:没有导入", c.name)
case c.want != nil && items[i].Meta != *c.want:
t.Errorf("%s:元数据 = %#v\n期望 %#v", c.name, items[i].Meta, *c.want)
}
}
}
func TestImportFileTruncatesThenKeepsFirstOccurrences(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
in := []ImportedTrack{{Track: netease("b"), Title: "B"}, {Track: netease("a"), Title: "A"}, {Track: netease("b"), Title: "又一个 B"}}
for i := range 999 {
in = append(in, ImportedTrack{Track: netease(strconv.Itoa(i)), Title: "歌"})
}
p, n, err := f.svc.ImportFile(t.Context(), alice.ID, "导入", in)
if err != nil || n != 999 {
t.Fatalf("ImportFile = %#v, %d, %v,期望前 1000 首去掉 1 首重复的,导入 999 首", p, n, err)
}
items, err := f.svc.Items(t.Context(), alice.ID, p.ID)
if err != nil {
t.Fatal(err)
}
if items[0].Meta.Title != "B" || items[1].Track != netease("a") || items[2].Track != netease("0") || items[len(items)-1].Track != netease("996") {
t.Errorf("导入的曲目以 %v 开头、%v 结尾,期望重复的歌留在第一次出现的位置、第 1000 首之后的不导入", itemTracks(items[:3]), items[len(items)-1].Track)
}
}
func TestImportFileFallsBackToDefaultName(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
song := []ImportedTrack{{Track: sunny, Title: "晴天"}}
for _, name := range []string{"", " ", strings.Repeat("歌", 51), "通\n勤", "\u200b"} {
if p, _, err := f.svc.ImportFile(t.Context(), alice.ID, name, song); err != nil || p.Name != "导入的歌单" {
t.Errorf("文件里的名字 %q:%#v, %v,期望改用“导入的歌单”", name, p, err)
}
}
if p, _, err := f.svc.ImportFile(t.Context(), alice.ID, " 通勤 ", song); err != nil || p.Name != "通勤" {
t.Errorf("合法的名字 = %#v, %v,期望去掉首尾空白后照用", p, err)
}
}
func TestImportFileIsNotRateLimitedButRespectsPlaylistLimit(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
f.quota.exhausted = true
for i := range 49 {
if _, _, err := f.svc.ImportFile(t.Context(), alice.ID, "导入"+strconv.Itoa(i), nil); err != nil {
t.Fatalf("第 %d 次导入 = %v", i+1, err)
}
}
if len(f.quota.keys) != 0 {
t.Errorf("导入文件不访问外部,不应取额度:%v", f.quota.keys)
}
if _, _, err := f.svc.ImportFile(t.Context(), alice.ID, "第 51 个", nil); !errors.Is(err, ErrTooManyPlaylists) {
t.Errorf("第 51 个歌单 = %v,期望 ErrTooManyPlaylists", err)
}
}
func TestResolveLink(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
f.links["https://163cn.tv/abc"] = "https://music.163.com/playlist?id=456&userid=1"
f.links["https://163cn.tv/song"] = "https://music.163.com/song?id=1"
f.links["https://163cn.tv/stuck"] = "https://163cn.tv/stuck"
f.links["https://163cn.tv/qq"] = "https://y.qq.com/n/ryqq/playlist/789"
linkQuota := "playlist-link:member:" + alice.ID
cases := []struct {
raw string
id string
err error
quota []string
}{
{raw: "https://music.163.com/#/playlist?id=123", id: "123"},
{raw: "分享歌单:通勤 https://163cn.tv/abc (来自网易云音乐)", id: "456", quota: []string{linkQuota}},
{raw: "https://163cn.tv/broken", err: ErrShortLinkFailed, quota: []string{linkQuota}},
{raw: "https://163cn.tv/stuck", err: ErrShortLinkFailed, quota: []string{linkQuota}},
{raw: "https://163cn.tv/song", err: ErrInvalidPlaylistLink, quota: []string{linkQuota}},
{raw: "https://163cn.tv/qq", err: ErrUnsupportedPlaylist, quota: []string{linkQuota}},
{raw: "https://example.com/playlist?id=1", err: ErrInvalidPlaylistLink},
{raw: "随便一段文字", err: ErrInvalidPlaylistLink},
{raw: "https://y.qq.com/n/ryqq/playlist/789", err: ErrUnsupportedPlaylist},
{raw: "https://c6.y.qq.com/base/fcgi-bin/u?__=abc", err: ErrUnsupportedPlaylist},
}
for _, c := range cases {
before := len(f.quota.keys)
source, id, err := f.svc.ResolveLink(t.Context(), alice.ID, c.raw)
if c.err != nil && !errors.Is(err, c.err) {
t.Errorf("ResolveLink(%q) = %v,期望 %v", c.raw, err, c.err)
}
if c.err == nil && (err != nil || source != track.NetEase || id != c.id) {
t.Errorf("ResolveLink(%q) = %q, %q, %v,期望网易云的歌单 %s", c.raw, source, id, err, c.id)
}
if taken := f.quota.keys[before:]; !slices.Equal(taken, c.quota) {
t.Errorf("ResolveLink(%q) 取的额度 = %v,期望 %v:长链接在本地识别,只有解析短链接才取链接额度", c.raw, taken, c.quota)
}
}
if f.quota.burst != 10 || f.quota.refill != 6*time.Minute {
t.Errorf("链接额度是突发 %d 次、每 %v 恢复 1 次,期望突发 10 次、每 6 分钟恢复 1 次", f.quota.burst, f.quota.refill)
}
}
func TestImportPlatform(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
sevenMeta := track.Meta{Title: "七里香", Artist: "周杰伦", PageURL: "https://music.163.com/#/song?id=185811", Duration: 299 * time.Second}
f.netease.Add("185811", sevenMeta, "", nil)
f.netease.AddPlaylist(port.Playlist{ID: "p1", Name: "周杰伦"}, "185811", sunny.ID, "185811")
p, n, err := f.svc.ImportPlatform(t.Context(), alice.ID, " 周杰伦 ", track.NetEase, "p1")
if err != nil || n != 2 || p.Name != "周杰伦" || p.TrackCount != 2 {
t.Fatalf("ImportPlatform = %#v, %d, %v,期望去掉重复的、导入 2 首", p, n, err)
}
items, err := f.svc.Items(t.Context(), alice.ID, p.ID)
want := []port.PlaylistItem{{Track: netease("185811"), Meta: sevenMeta}, {Track: sunny, Meta: sunnyMeta}}
if err != nil || !slices.Equal(items, want) {
t.Errorf("导入的曲目 = %#v, %v\n期望与平台同序、用音源的元数据 %#v", items, err, want)
}
if want := []string{"playlist-import:member:" + alice.ID}; !slices.Equal(f.quota.keys, want) || f.quota.burst != 10 || f.quota.refill != 6*time.Minute {
t.Errorf("额度 = %v(突发 %d,每 %v 恢复 1 次),期望 %v(突发 10,每 6 分钟恢复 1 次)", f.quota.keys, f.quota.burst, f.quota.refill, want)
}
}
func TestImportPlatformTruncatesThenDedupes(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
ids := []string{"0"}
for i := range 1001 {
id := strconv.Itoa(i)
f.netease.Add(id, track.Meta{Title: "歌" + id}, "", nil)
ids = append(ids, id)
}
f.netease.AddPlaylist(port.Playlist{ID: "big"}, ids...)
p, n, err := f.svc.ImportPlatform(t.Context(), alice.ID, "大歌单", track.NetEase, "big")
if err != nil || n != 999 {
t.Fatalf("ImportPlatform = %#v, %d, %v,期望前 1000 首去掉 1 首重复的,导入 999 首", p, n, err)
}
items, err := f.svc.Items(t.Context(), alice.ID, p.ID)
if err != nil || items[0].Track != netease("0") || items[len(items)-1].Track != netease("998") {
t.Errorf("导入的曲目 = %d 首, %v,期望从 0 到 998", len(items), err)
}
}
func TestImportPlatformRejects(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
f.netease.AddPlaylist(port.Playlist{ID: "p1"}, sunny.ID)
if _, _, err := f.svc.ImportPlatform(t.Context(), alice.ID, "QQ", track.QQ, "p1"); !errors.Is(err, ErrUnsupportedPlaylist) {
t.Errorf("QQ 音乐的歌单 = %v,期望 ErrUnsupportedPlaylist", err)
}
if _, _, err := f.svc.ImportPlatform(t.Context(), alice.ID, " ", track.NetEase, "p1"); !errors.Is(err, ErrInvalidName) {
t.Errorf("空的歌单名 = %v,期望 ErrInvalidName", err)
}
if len(f.quota.keys) != 0 {
t.Errorf("被拒绝的请求不应取额度:%v", f.quota.keys)
}
if _, _, err := f.svc.ImportPlatform(t.Context(), alice.ID, "不存在", track.NetEase, "missing"); !errors.Is(err, port.ErrProviderFailed) {
t.Errorf("音源里没有的歌单 = %v,期望 ErrProviderFailed", err)
}
for i := range 49 {
if _, err := f.svc.Create(t.Context(), alice.ID, "歌单"+strconv.Itoa(i)); err != nil {
t.Fatal(err)
}
}
if _, _, err := f.svc.ImportPlatform(t.Context(), alice.ID, "第 51 个", track.NetEase, "p1"); !errors.Is(err, ErrTooManyPlaylists) {
t.Errorf("第 51 个歌单 = %v,期望 ErrTooManyPlaylists", err)
}
}
func TestPlatformImportsAreRateLimited(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
f.netease.AddPlaylist(port.Playlist{ID: "p1"}, sunny.ID)
f.links["https://163cn.tv/abc"] = "https://music.163.com/playlist?id=1"
f.quota.exhausted = true
if source, id, err := f.svc.ResolveLink(t.Context(), alice.ID, "https://music.163.com/playlist?id=1"); err != nil || source != track.NetEase || id != "1" {
t.Errorf("额度用完时解析长链接 = %q, %q, %v,期望照常识别", source, id, err)
}
if _, _, err := f.svc.ResolveLink(t.Context(), alice.ID, "https://163cn.tv/abc"); !errors.Is(err, ErrTooFast) {
t.Errorf("额度用完时解析短链接 = %v,期望 ErrTooFast", err)
}
if _, _, err := f.svc.ImportPlatform(t.Context(), alice.ID, "周杰伦", track.NetEase, "p1"); !errors.Is(err, ErrTooFast) {
t.Errorf("额度用完时导入 = %v,期望 ErrTooFast", err)
}
if want := []string{"playlist-link:member:" + alice.ID, "playlist-import:member:" + alice.ID}; !slices.Equal(f.quota.keys, want) {
t.Errorf("取的额度 = %v,期望短链接取链接额度、导入取导入额度 %v", f.quota.keys, want)
}
if lists, _ := f.svc.List(t.Context(), alice.ID); len(lists) != 1 {
t.Errorf("额度用完时不应建歌单:%#v", lists)
}
}
+21 -13
View File
@@ -32,15 +32,16 @@ var (
)
type Service struct {
playlists port.Playlists
members port.Members
tracks port.Tracks
catalog *catalog.Service
quota port.AccountQuota
playlists port.Playlists
members port.Members
tracks port.TrackLookup
catalog *catalog.Service
quota port.AccountQuota
shortLinks port.ShortLinks
}
func New(playlists port.Playlists, members port.Members, tracks port.Tracks, catalog *catalog.Service, quota port.AccountQuota) *Service {
return &Service{playlists: playlists, members: members, tracks: tracks, catalog: catalog, quota: quota}
func New(playlists port.Playlists, members port.Members, tracks port.TrackLookup, catalog *catalog.Service, quota port.AccountQuota, shortLinks port.ShortLinks) *Service {
return &Service{playlists: playlists, members: members, tracks: tracks, catalog: catalog, quota: quota, shortLinks: shortLinks}
}
func (s *Service) List(ctx context.Context, memberID string) ([]port.PlaylistRecord, error) {
@@ -95,13 +96,9 @@ func (s *Service) Add(ctx context.Context, memberID, id string, t track.Track) (
if err := s.registered(ctx, memberID); err != nil {
return port.PlaylistItem{}, err
}
wait, err := s.quota.Take(ctx, "playlist:member:"+memberID, addBurst, addRefill)
if err != nil {
if err := s.take(ctx, "playlist:member:"+memberID, addBurst, addRefill); err != nil {
return port.PlaylistItem{}, err
}
if wait > 0 {
return port.PlaylistItem{}, ErrTooFast
}
meta, err := s.meta(ctx, t)
if err != nil {
return port.PlaylistItem{}, err
@@ -154,12 +151,23 @@ func (s *Service) registered(ctx context.Context, memberID string) error {
if err != nil {
return err
}
if profile.Username == "" {
if !profile.Registered() {
return ErrNotRegistered
}
return nil
}
func (s *Service) take(ctx context.Context, key string, burst int, refill time.Duration) error {
wait, err := s.quota.Take(ctx, key, burst, refill)
if err != nil {
return err
}
if wait > 0 {
return ErrTooFast
}
return nil
}
// 链接曲目准备好之前没有元数据,音源也查不到。
func (s *Service) meta(ctx context.Context, t track.Track) (track.Meta, error) {
if t.Source != track.Link {
+54 -6
View File
@@ -31,6 +31,16 @@ func (t tracksTable) PreparedTrack(_ context.Context, k track.Track) (port.Prepa
return p, ok, nil
}
func (t tracksTable) TrackMetas(_ context.Context, ts []track.Track) (map[track.Track]track.Meta, error) {
found := map[track.Track]track.Meta{}
for _, k := range ts {
if p, ok := t[k]; ok {
found[k] = p.Meta
}
}
return found, nil
}
func (tracksTable) SavePreparedTrack(context.Context, track.Track, port.PreparedTrack) error {
return nil
}
@@ -54,21 +64,37 @@ func (roomQuota) TakePick(context.Context, string, int, time.Duration) (time.Dur
type accountQuota struct {
exhausted bool
keys []string
// 最近一次取额度时给的额度。
burst int
refill time.Duration
}
func (q *accountQuota) Take(_ context.Context, key string, _ int, _ time.Duration) (time.Duration, error) {
func (q *accountQuota) Take(_ context.Context, key string, burst int, refill time.Duration) (time.Duration, error) {
q.keys = append(q.keys, key)
q.burst, q.refill = burst, refill
if q.exhausted {
return time.Second, nil
}
return 0, nil
}
// 短链接到解析结果;不在表里的短链接解析失败。
type shortLinks map[string]string
func (l shortLinks) Resolve(_ context.Context, link string) (string, error) {
if final, ok := l[link]; ok {
return final, nil
}
return "", errors.New("跳转到了不受信任的主机")
}
type fixture struct {
svc *Service
store *porttest.Store
tracks tracksTable
quota *accountQuota
svc *Service
store *porttest.Store
tracks tracksTable
quota *accountQuota
netease *porttest.Source
links shortLinks
}
func newFixture(t *testing.T) fixture {
@@ -80,7 +106,8 @@ func newFixture(t *testing.T) fixture {
tracks := tracksTable{}
music := catalog.New(netease, porttest.NewSource(track.QQ), bilibili, bilibili, tracks, noHistory{}, store, roomQuota{})
q := &accountQuota{}
return fixture{svc: New(store, store, tracks, music, q), store: store, tracks: tracks, quota: q}
links := shortLinks{}
return fixture{svc: New(store, store, tracks, music, q, links), store: store, tracks: tracks, quota: q, netease: netease, links: links}
}
func (f fixture) member(t *testing.T, username string) member.Member {
@@ -131,11 +158,32 @@ func TestGuestCannotUsePlaylists(t *testing.T) {
if err := f.svc.Move(t.Context(), guest.ID, "any", []track.Track{sunny}, nil); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客调整顺序 = %v,期望 ErrNotRegistered", err)
}
if _, _, err := f.svc.ImportFile(t.Context(), guest.ID, "导入", []ImportedTrack{{Track: sunny, Title: "晴天"}}); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客导入文件 = %v,期望 ErrNotRegistered", err)
}
if _, _, err := f.svc.ResolveLink(t.Context(), guest.ID, "https://music.163.com/playlist?id=1"); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客解析歌单链接 = %v,期望 ErrNotRegistered", err)
}
if _, _, err := f.svc.ImportPlatform(t.Context(), guest.ID, "导入", track.NetEase, "1"); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客导入平台歌单 = %v,期望 ErrNotRegistered", err)
}
if len(f.quota.keys) != 0 {
t.Errorf("游客不应消耗额度:%v", f.quota.keys)
}
}
func TestGitHubAccountUsesPlaylists(t *testing.T) {
f := newFixture(t)
gh := f.member(t, "")
if err := f.store.BindGitHub(t.Context(), gh.ID, 42); err != nil {
t.Fatal(err)
}
lists, err := f.svc.List(t.Context(), gh.ID)
if err != nil || len(lists) != 1 || !lists[0].Favorites {
t.Errorf("只绑定了 GitHub 的账号的歌单 = %#v, %v,期望注册时建好的“我喜欢”", lists, err)
}
}
func TestCreateValidatesName(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
+73
View File
@@ -0,0 +1,73 @@
package playlist
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"github.com/dihemoe/alisten/internal/app/port"
)
// 分享 ID 是 128 位随机值,拿到链接的人才能打开,无法猜出别人的分享。
const shareIDBytes = 16
var shareIDLength = base64.RawURLEncoding.EncodedLen(shareIDBytes)
// 开启分享和重新生成是同一个操作:每次都换一个新的分享 ID,旧链接随即失效。
func (s *Service) Share(ctx context.Context, memberID, id string) (string, error) {
if err := s.registered(ctx, memberID); err != nil {
return "", err
}
b := make([]byte, shareIDBytes)
// crypto/rand.Read 不会返回错误。
rand.Read(b)
shareID := base64.RawURLEncoding.EncodeToString(b)
if err := s.playlists.SetPlaylistShare(ctx, memberID, id, shareID); err != nil {
return "", err
}
return shareID, nil
}
func (s *Service) Unshare(ctx context.Context, memberID, id string) error {
if err := s.registered(ctx, memberID); err != nil {
return err
}
return s.playlists.SetPlaylistShare(ctx, memberID, id, "")
}
// 不需要身份。
func (s *Service) Shared(ctx context.Context, shareID string) (port.SharedPlaylist, error) {
if !validShareID(shareID) {
return port.SharedPlaylist{}, port.ErrShareNotFound
}
return s.playlists.SharedPlaylist(ctx, shareID)
}
func (s *Service) CopyShared(ctx context.Context, memberID, shareID, name string) (port.PlaylistRecord, error) {
if err := s.registered(ctx, memberID); err != nil {
return port.PlaylistRecord{}, err
}
name, err := validName(name)
if err != nil {
return port.PlaylistRecord{}, err
}
if !validShareID(shareID) {
return port.PlaylistRecord{}, port.ErrShareNotFound
}
// 来源歌单本身不超过曲目上限,复制出的也不会超过。
p, err := s.playlists.CopySharedPlaylist(ctx, memberID, shareID, name, maxPlaylists)
if errors.Is(err, port.ErrPlaylistLimit) {
return port.PlaylistRecord{}, ErrTooManyPlaylists
}
return p, err
}
// 分享 ID 取自地址,可能是任意文本:不像生成出来的就不必查存储,NUL 之类的字符也就不会到达数据库。
func validShareID(id string) bool {
if len(id) != shareIDLength {
return false
}
_, err := base64.RawURLEncoding.DecodeString(id)
return err == nil
}
+186
View File
@@ -0,0 +1,186 @@
package playlist
import (
"context"
"errors"
"reflect"
"regexp"
"slices"
"strconv"
"strings"
"testing"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/port/porttest"
"github.com/dihemoe/alisten/internal/domain/track"
)
var shareIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{22}$`)
func TestGuestCannotShareOrCopy(t *testing.T) {
f := newFixture(t)
guest := f.member(t, "")
alice := f.member(t, "alice")
favorites := f.favorites(t, alice.ID)
if _, err := f.svc.Share(t.Context(), guest.ID, favorites); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客开启分享 = %v,期望 ErrNotRegistered", err)
}
if err := f.svc.Unshare(t.Context(), guest.ID, favorites); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客停止分享 = %v,期望 ErrNotRegistered", err)
}
shareID, err := f.svc.Share(t.Context(), alice.ID, favorites)
if err != nil {
t.Fatal(err)
}
if _, err := f.svc.CopyShared(t.Context(), guest.ID, shareID, "抄来的"); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客复制 = %v,期望 ErrNotRegistered", err)
}
if _, err := f.svc.Shared(t.Context(), shareID); err != nil {
t.Errorf("不需要身份就能读取:%v", err)
}
if len(f.quota.keys) != 0 {
t.Errorf("分享和复制不应消耗额度:%v", f.quota.keys)
}
}
func TestShareGeneratesANewLinkEachTime(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
bob := f.member(t, "bob")
favorites := f.favorites(t, alice.ID)
item, err := f.svc.Add(t.Context(), alice.ID, favorites, sunny)
if err != nil {
t.Fatal(err)
}
first, err := f.svc.Share(t.Context(), alice.ID, favorites)
if err != nil || !shareIDPattern.MatchString(first) {
t.Fatalf("分享 ID = %q, %v,期望 22 个 base64url 字符", first, err)
}
shared, err := f.svc.Shared(t.Context(), first)
want := port.SharedPlaylist{Name: "我喜欢", Favorites: true, OwnerName: alice.Name, Items: []port.PlaylistItem{item}}
if err != nil || !reflect.DeepEqual(shared, want) {
t.Errorf("分享的歌单 = %#v, %v,期望 %#v", shared, err, want)
}
if lists, err := f.svc.List(t.Context(), alice.ID); err != nil || lists[0].ShareID != first {
t.Errorf("歌单 = %#v, %v,期望带上分享 ID", lists, err)
}
second, err := f.svc.Share(t.Context(), alice.ID, favorites)
if err != nil || !shareIDPattern.MatchString(second) || second == first {
t.Fatalf("重新生成 = %q, %v,期望换一个新的分享 ID", second, err)
}
if _, err := f.svc.Shared(t.Context(), first); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("旧链接 = %v,期望 ErrShareNotFound", err)
}
if _, err := f.svc.Shared(t.Context(), second); err != nil {
t.Errorf("新链接 = %v", err)
}
if _, err := f.svc.Share(t.Context(), bob.ID, favorites); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("分享别人的歌单 = %v,期望 ErrPlaylistNotFound", err)
}
if err := f.svc.Unshare(t.Context(), bob.ID, favorites); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("停止别人的分享 = %v,期望 ErrPlaylistNotFound", err)
}
if err := f.svc.Unshare(t.Context(), alice.ID, favorites); err != nil {
t.Fatal(err)
}
if _, err := f.svc.Shared(t.Context(), second); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("停止分享之后 = %v,期望 ErrShareNotFound", err)
}
}
type shareLookups struct {
*porttest.Store
lookups int
}
func (s *shareLookups) SharedPlaylist(ctx context.Context, shareID string) (port.SharedPlaylist, error) {
s.lookups++
return s.Store.SharedPlaylist(ctx, shareID)
}
func (s *shareLookups) CopySharedPlaylist(ctx context.Context, ownerID, shareID, name string, limit int) (port.PlaylistRecord, error) {
s.lookups++
return s.Store.CopySharedPlaylist(ctx, ownerID, shareID, name, limit)
}
func TestMalformedShareIDsAreNotLookedUp(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
lookups := &shareLookups{Store: f.store}
f.svc.playlists = lookups
valid := strings.Repeat("a", 21)
for _, id := range []string{"", "abc", valid, valid + "aa", valid + "\x00", valid + "=", valid + "+", valid + "/", "\xff" + valid} {
if _, err := f.svc.Shared(t.Context(), id); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("读取 %q = %v,期望 ErrShareNotFound", id, err)
}
if _, err := f.svc.CopyShared(t.Context(), alice.ID, id, "抄来的"); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("复制 %q = %v,期望 ErrShareNotFound", id, err)
}
}
if lookups.lookups != 0 {
t.Errorf("格式不对的分享 ID 查了 %d 次存储,期望不查", lookups.lookups)
}
if _, err := f.svc.Shared(t.Context(), valid+"a"); !errors.Is(err, port.ErrShareNotFound) || lookups.lookups != 1 {
t.Errorf("格式正确却不存在的分享 = %v,查了 %d 次,期望查一次后 ErrShareNotFound", err, lookups.lookups)
}
}
func TestCopySharedPlaylist(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
bob := f.member(t, "bob")
rain := netease("185811")
f.netease.Add(rain.ID, track.Meta{Title: "七里香", Artist: "周杰伦", PageURL: "https://music.163.com/#/song?id=185811"}, "", nil)
commute, err := f.svc.Create(t.Context(), alice.ID, "通勤")
if err != nil {
t.Fatal(err)
}
for _, song := range []track.Track{sunny, rain} {
if _, err := f.svc.Add(t.Context(), alice.ID, commute.ID, song); err != nil {
t.Fatal(err)
}
}
shareID, err := f.svc.Share(t.Context(), alice.ID, commute.ID)
if err != nil {
t.Fatal(err)
}
keys := len(f.quota.keys)
copied, err := f.svc.CopyShared(t.Context(), bob.ID, shareID, " 抄来的 ")
if err != nil || copied.Name != "抄来的" || copied.TrackCount != 2 || copied.ShareID != "" {
t.Fatalf("复制 = %#v, %v,期望去掉名称首尾的空白、带 2 首、没有分享", copied, err)
}
got, err := f.svc.Items(t.Context(), bob.ID, copied.ID)
original, _ := f.svc.Items(t.Context(), alice.ID, commute.ID)
if err != nil || !slices.Equal(got, original) {
t.Errorf("复制的曲目 = %#v, %v,期望与原歌单相同 %#v", got, err, original)
}
if len(f.quota.keys) != keys {
t.Errorf("复制不应消耗额度:%v", f.quota.keys[keys:])
}
if _, err := f.svc.CopyShared(t.Context(), bob.ID, shareID, " "); !errors.Is(err, ErrInvalidName) {
t.Errorf("名称为空白 = %v,期望 ErrInvalidName", err)
}
if err := f.svc.Unshare(t.Context(), alice.ID, commute.ID); err != nil {
t.Fatal(err)
}
if _, err := f.svc.CopyShared(t.Context(), bob.ID, shareID, "抄来的"); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("停止分享之后复制 = %v,期望 ErrShareNotFound", err)
}
shareID, err = f.svc.Share(t.Context(), alice.ID, commute.ID)
if err != nil {
t.Fatal(err)
}
for i := range 48 {
if _, err := f.svc.Create(t.Context(), bob.ID, "歌单"+strconv.Itoa(i)); err != nil {
t.Fatal(err)
}
}
if _, err := f.svc.CopyShared(t.Context(), bob.ID, shareID, "第 51 个"); !errors.Is(err, ErrTooManyPlaylists) {
t.Errorf("第 51 个歌单 = %v,期望 ErrTooManyPlaylists", err)
}
}
+87
View File
@@ -0,0 +1,87 @@
package port
import (
"context"
"time"
)
type GitHubIntent int
const (
// 游客用 GitHub 登录:GitHub 账号没有绑定过时,就是注册。
GitHubLogin GitHubIntent = iota + 1
// 已注册的成员绑定 GitHub。
GitHubBind
// 只绑定了 GitHub 的账号补设用户名和密码之前,用账号绑定的 GitHub 确认身份。
GitHubVerify
)
// 发起授权的客户端,GitHub 授权结束之后跳回它。
type GitHubClient int
const (
GitHubWeb GitHubClient = iota + 1
// App 用系统浏览器授权,授权结束之后要跳回 App。
GitHubApp
)
// 发起 GitHub 授权时记下的请求,以 state 为键。
type GitHubRequest struct {
Intent GitHubIntent
Client GitHubClient
// 发起授权的成员。
MemberID string
// 发起授权的客户端保存的 nonce 的 SHA-256。
NonceHash []byte
// PKCE 的 code verifier:GitHub 签发的授权码只能配合它换取令牌,别人的授权码放进这次授权的回调里换不到令牌。
CodeVerifier string
}
// GitHub 确认了用户的身份,等待发起授权的客户端兑换,以兑换码为键。
type GitHubGrant struct {
Request GitHubRequest
GitHubID int64
}
// 后台填写的 GitHub OAuth App。
type GitHubOAuthApp struct {
ClientID string
ClientSecret string
}
// Client ID 和 Client Secret 一起保存、一起清除,读到的两者总是同一次保存的。
type GitHubOAuthApps interface {
// 没有配置时返回零值。
GitHubOAuthApp(ctx context.Context) (GitHubOAuthApp, error)
SetGitHubOAuthApp(ctx context.Context, app GitHubOAuthApp) error
// 没有配置时什么也不做。
DeleteGitHubOAuthApp(ctx context.Context) error
}
// GitHub 的 OAuth 授权,使用调用方传入的 OAuth App。
type GitHubOAuth interface {
// 请用户授权的地址:用户同意之后,GitHub 带着 state 和授权码回调,授权码与 verifier 绑定。
AuthorizeURL(app GitHubOAuthApp, state, verifier string) string
// 用回调带回的授权码和发起那次授权时的 verifier 换取 GitHub 用户的 ID。
UserID(ctx context.Context, app GitHubOAuthApp, code, verifier string) (int64, error)
}
// state 和兑换码只以 SHA-256 哈希的形式出现在端口中,所有实例共用。
type GitHubLogins interface {
SaveRequest(ctx context.Context, stateHash []byte, r GitHubRequest, ttl time.Duration) error
// 取出并删除,同一个 state 只能取出一次。不存在或已过期时返回 ErrCodeNotFound。
TakeRequest(ctx context.Context, stateHash []byte) (GitHubRequest, error)
SaveGrant(ctx context.Context, codeHash []byte, g GitHubGrant, ttl time.Duration) error
// 与 TakeRequest 相同。
TakeGrant(ctx context.Context, codeHash []byte) (GitHubGrant, error)
}
// 只绑定了 GitHub 的账号补设用户名和密码之前,要用账号绑定的 GitHub 确认身份。确认记在发出请求的会话上,以会话的成员 token
// 的 SHA-256 哈希为键,所有实例共用。丢失时用户重新确认即可。
type GitHubVerifications interface {
SaveVerification(ctx context.Context, tokenHash []byte, ttl time.Duration) error
// 不存在或已过期时返回 false。
Verified(ctx context.Context, tokenHash []byte) (bool, error)
// 不存在时什么也不做。
DeleteVerification(ctx context.Context, tokenHash []byte) error
}
+36
View File
@@ -0,0 +1,36 @@
package port
import (
"context"
"time"
"github.com/dihemoe/alisten/internal/domain/track"
)
// 每个成员的播放历史最多保留的歌数,超出的部分从最早听到的删起。
const ListenLimit = 500
// 成员在房间里听到过的一首歌。元数据是听到时的快照。
type Listen struct {
Track track.Track
Meta track.Meta
HeardAt time.Time
}
type ListenRecorder interface {
// 只记给开启了记录的成员。同一首歌已有记录时 HeardAt 取较晚的,元数据用新的;
// 每个成员超过 ListenLimit 首的部分随即删掉。
RecordListen(ctx context.Context, memberIDs []string, l Listen) error
}
type Listens interface {
// 最近听到的在前,最多 ListenLimit 首。
MemberListens(ctx context.Context, memberID string) ([]Listen, error)
// 不在历史里的歌忽略。
DeleteListen(ctx context.Context, memberID string, t track.Track) error
ClearListens(ctx context.Context, memberID string) error
// 成员不存在时返回 ErrMemberNotFound。
ListenRecording(ctx context.Context, memberID string) (bool, error)
// 关闭之后已有的历史保留。成员不存在时返回 ErrMemberNotFound。
SetListenRecording(ctx context.Context, memberID string, on bool) error
}
+28 -3
View File
@@ -13,20 +13,30 @@ var (
ErrAlreadyRegistered = errors.New("已经注册过了")
ErrSessionNotFound = errors.New("会话不存在")
ErrAccountPasswordChanged = errors.New("账号密码已修改")
ErrGitHubTaken = errors.New("这个 GitHub 账号已经绑定了其他账号")
ErrGitHubAlreadyBound = errors.New("已经绑定了 GitHub 账号,先解绑再绑定其他的")
ErrNoPassword = errors.New("账号还没有设置密码,先设置用户名和密码")
)
// 设置了用户名的成员。
// 设置了用户名或绑定了 GitHub 的成员。用户名和密码总是一起设置,只绑定了 GitHub 的账号两者都为空。
type Account struct {
Member member.Member
Username string
Email string
PasswordHash string
// 没有绑定 GitHub 时为 0。
GitHubID int64
}
// 游客的两个字段都为空。
// 游客的各个字段都为空。
type AccountProfile struct {
Username string
Email string
GitHub bool
}
func (p AccountProfile) Registered() bool {
return p.Username != "" || p.GitHub
}
// 成员 token 只以 SHA-256 哈希的形式出现在端口中。一个成员 token 是成员的一个会话,同一个成员可以有多个会话。
@@ -38,12 +48,27 @@ type Members interface {
// name、avatarHash 或 email 为 nil 时保持原值,email 为空字符串表示清除。游客的 email 不保存。
UpdateMember(ctx context.Context, id string, name, avatarHash, email *string) (member.Member, error)
AccountProfile(ctx context.Context, memberID string) (AccountProfile, error)
// 用户名已属于其他成员时返回 ErrUsernameTaken,成员已经注册过时返回 ErrAlreadyRegistered。同时为这个账号创建“我喜欢”。
// 给游客设置用户名和密码,同时为这个账号创建“我喜欢”。用户名已属于其他成员时返回 ErrUsernameTaken,成员已经设置了用户名或绑定了
// GitHub 时返回 ErrAlreadyRegistered。
Register(ctx context.Context, memberID, username, passwordHash string) error
// 给只绑定了 GitHub 的账号补设用户名和密码,并在同一个事务中删除这个成员除 keepTokenHash 以外的全部会话,返回被删除的会话的
// token 哈希。用户名已属于其他成员时返回 ErrUsernameTaken,成员不是只绑定了 GitHub 的账号时返回 ErrAlreadyRegistered,两种情况
// 都不做修改。
AddCredentials(ctx context.Context, memberID, username, passwordHash string, keepTokenHash []byte) ([][]byte, error)
AccountByUsername(ctx context.Context, username string) (Account, error)
// 成员是游客时返回 ErrMemberNotFound。
AccountByID(ctx context.Context, memberID string) (Account, error)
AccountByGitHub(ctx context.Context, githubID int64) (Account, error)
// 这个 GitHub 账号已经绑定了其他成员时返回 ErrGitHubTaken,成员已经绑定了 GitHub 时返回 ErrGitHubAlreadyBound,两种情况都不做修改。
// 成员原来是游客时,绑定就是注册:同时为这个账号创建“我喜欢”。
BindGitHub(ctx context.Context, memberID string, githubID int64) error
// 账号没有设置密码时返回 ErrNoPassword:解绑之后它就再也无法登录。没有绑定 GitHub 时什么也不做。
UnbindGitHub(ctx context.Context, memberID string) error
// 在同一个事务中删除 oldTokenHash 的会话,给 memberID 创建 newTokenHash 的会话。oldTokenHash 的会话已经不存在时返回
// ErrSessionNotFound;memberID 的密码哈希已经不是 passwordHash 时返回 ErrAccountPasswordChanged:密码在校验之后被修改过,
// 修改时对会话的删除已经执行,此时创建的会话不会再被删除。两种情况都不做任何修改。
// 同一个事务中把 oldTokenHash 所属成员的播放历史移到 memberID,并入的规则与 ListenRecorder.RecordListen 相同,
// memberID 关闭了记录时不并入;两者是同一个成员时不动。
ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error
DeleteSession(ctx context.Context, tokenHash []byte) error
// 在同一个事务中修改已注册成员的密码哈希,并删除该成员的全部会话。返回被删除的会话的 token 哈希。成员是游客时返回 ErrMemberNotFound。
+25
View File
@@ -0,0 +1,25 @@
package port
import (
"context"
"errors"
"time"
)
// 配对码、GitHub 登录的 state 和兑换码都只能用一次:取出即删除,到期自动失效。
var ErrCodeNotFound = errors.New("不存在或已经过期")
// 已注册成员生成的配对码:新设备兑换之后登录这个账号。
type Pairing struct {
MemberID string
// 生成配对码的会话:它被删除之后(退出登录、修改或重置密码),配对码随之作废。
SessionTokenHash []byte
}
// 配对码只以 SHA-256 哈希的形式出现在端口中,所有实例共用。
type PairingCodes interface {
// 这个哈希已经有配对码时返回 false,不覆盖它。
CreatePairing(ctx context.Context, codeHash []byte, p Pairing, ttl time.Duration) (bool, error)
// 取出并删除,同一个配对码只能取出一次。不存在或已过期时返回 ErrCodeNotFound。
TakePairing(ctx context.Context, codeHash []byte) (Pairing, error)
}
+23
View File
@@ -12,6 +12,7 @@ var (
ErrPlaylistNotFound = errors.New("歌单不存在")
ErrFavoritesFixed = errors.New("“我喜欢”不能重命名或删除")
ErrPlaylistLimit = errors.New("歌单或歌单里的歌已达上限")
ErrShareNotFound = errors.New("分享已失效")
)
type PlaylistRecord struct {
@@ -22,6 +23,8 @@ type PlaylistRecord struct {
CreatedAt time.Time
UpdatedAt time.Time
Cover *Cover
// 没有分享时为空。
ShareID string
}
// 链接曲目的封面在对象存储中,只有对象键。
@@ -35,12 +38,25 @@ type PlaylistItem struct {
Meta track.Meta
}
// 分享页上显示的内容:不带所有者的成员 ID 和用户名。
type SharedPlaylist struct {
Name string
Favorites bool
OwnerName string
OwnerAvatarHash string
// 新加入的在前。
Items []PlaylistItem
}
// 所有方法都按 ownerID 过滤:别人的歌单和不存在的歌单一样,返回 ErrPlaylistNotFound。“我喜欢”由 Members.Register 创建。
type Playlists interface {
// “我喜欢”在最前,其余按创建的先后。Cover 是歌单里排在最前、有封面的那首歌的封面,没有时为 nil。
MemberPlaylists(ctx context.Context, ownerID string) ([]PlaylistRecord, error)
// 已有 limit 个歌单(含“我喜欢”)时返回 ErrPlaylistLimit。
CreatePlaylist(ctx context.Context, ownerID, name string, limit int) (PlaylistRecord, error)
// 新建歌单并写入 items,items[0] 排在最前;返回的记录带曲目数和封面。已有 limit 个歌单(含“我喜欢”)时返回 ErrPlaylistLimit。
// 调用方保证 items 中没有重复的歌、不超过歌单的曲目上限。
ImportPlaylist(ctx context.Context, ownerID, name string, items []PlaylistItem, limit int) (PlaylistRecord, error)
// “我喜欢”返回 ErrFavoritesFixed。
RenamePlaylist(ctx context.Context, ownerID, id, name string) error
// “我喜欢”返回 ErrFavoritesFixed。
@@ -59,4 +75,11 @@ type Playlists interface {
CopyPlaylistItems(ctx context.Context, ownerID, fromID, toID string, tracks []track.Track, limit int) (int, error)
// 按 playlist.Move 的规则调整顺序,错误原样返回。
MovePlaylistItems(ctx context.Context, ownerID, id string, tracks []track.Track, before *track.Track) error
// shareID 为空字符串时停止分享。shareID 已被其他歌单使用时返回错误。
SetPlaylistShare(ctx context.Context, ownerID, id, shareID string) error
// 没有歌单使用这个 shareID 时返回 ErrShareNotFound。
SharedPlaylist(ctx context.Context, shareID string) (SharedPlaylist, error)
// 新建 ownerID 的歌单,复制 shareID 对应歌单的全部曲目,顺序不变;返回的记录带曲目数和封面。已有 limit 个歌单(含“我喜欢”)时
// 返回 ErrPlaylistLimit,没有歌单使用这个 shareID 时返回 ErrShareNotFound,两种情况都不新建歌单。
CopySharedPlaylist(ctx context.Context, ownerID, shareID, name string, limit int) (PlaylistRecord, error)
}
+97
View File
@@ -0,0 +1,97 @@
package porttest
import (
"context"
"sync"
"time"
"github.com/dihemoe/alisten/internal/app/port"
)
var (
_ port.PairingCodes = (*Codes)(nil)
_ port.GitHubLogins = (*Codes)(nil)
_ port.GitHubVerifications = (*Codes)(nil)
)
// 时间取自 time.Now,可以在 synctest 中使用。
type Codes struct {
mu sync.Mutex
entries map[string]codeEntry
}
type codeEntry struct {
value any
expires time.Time
}
func NewCodes() *Codes {
return &Codes{entries: map[string]codeEntry{}}
}
func (c *Codes) put(key string, value any, ttl time.Duration, overwrite bool) bool {
c.mu.Lock()
defer c.mu.Unlock()
if e, ok := c.entries[key]; ok && !overwrite && time.Now().Before(e.expires) {
return false
}
c.entries[key] = codeEntry{value: value, expires: time.Now().Add(ttl)}
return true
}
func take[T any](c *Codes, key string) (T, error) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries[key]
delete(c.entries, key)
if !ok || !time.Now().Before(e.expires) {
var zero T
return zero, port.ErrCodeNotFound
}
return e.value.(T), nil
}
func (c *Codes) CreatePairing(_ context.Context, codeHash []byte, p port.Pairing, ttl time.Duration) (bool, error) {
return c.put("pairing:"+string(codeHash), p, ttl, false), nil
}
func (c *Codes) TakePairing(_ context.Context, codeHash []byte) (port.Pairing, error) {
return take[port.Pairing](c, "pairing:"+string(codeHash))
}
func (c *Codes) SaveRequest(_ context.Context, stateHash []byte, r port.GitHubRequest, ttl time.Duration) error {
c.put("github:request:"+string(stateHash), r, ttl, true)
return nil
}
func (c *Codes) TakeRequest(_ context.Context, stateHash []byte) (port.GitHubRequest, error) {
return take[port.GitHubRequest](c, "github:request:"+string(stateHash))
}
func (c *Codes) SaveGrant(_ context.Context, codeHash []byte, g port.GitHubGrant, ttl time.Duration) error {
c.put("github:grant:"+string(codeHash), g, ttl, true)
return nil
}
func (c *Codes) TakeGrant(_ context.Context, codeHash []byte) (port.GitHubGrant, error) {
return take[port.GitHubGrant](c, "github:grant:"+string(codeHash))
}
func (c *Codes) SaveVerification(_ context.Context, tokenHash []byte, ttl time.Duration) error {
c.put("github:verified:"+string(tokenHash), struct{}{}, ttl, true)
return nil
}
func (c *Codes) Verified(_ context.Context, tokenHash []byte) (bool, error) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries["github:verified:"+string(tokenHash)]
return ok && time.Now().Before(e.expires), nil
}
func (c *Codes) DeleteVerification(_ context.Context, tokenHash []byte) error {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.entries, "github:verified:"+string(tokenHash))
return nil
}
+95
View File
@@ -0,0 +1,95 @@
package porttest
import (
"errors"
"testing"
"testing/synctest"
"time"
"github.com/dihemoe/alisten/internal/app/port"
)
// 与 Redis 适配器的 TestPairingCodes、TestGitHubLogins 相同的语义。
func TestCodes(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
codes := NewCodes()
p := port.Pairing{MemberID: "m1", SessionTokenHash: []byte("session")}
if created, err := codes.CreatePairing(t.Context(), []byte("code"), p, time.Minute); err != nil || !created {
t.Fatalf("CreatePairing = %v, %v", created, err)
}
if created, _ := codes.CreatePairing(t.Context(), []byte("code"), port.Pairing{MemberID: "m2"}, time.Minute); created {
t.Error("哈希已经有配对码时不应覆盖")
}
if got, err := codes.TakePairing(t.Context(), []byte("code")); err != nil || got.MemberID != "m1" {
t.Errorf("TakePairing = %#v, %v", got, err)
}
if _, err := codes.TakePairing(t.Context(), []byte("code")); !errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("第二次取 = %v,期望 ErrCodeNotFound", err)
}
if _, err := codes.CreatePairing(t.Context(), []byte("late"), p, time.Minute); err != nil {
t.Fatal(err)
}
r := port.GitHubRequest{Intent: port.GitHubLogin, MemberID: "m1", NonceHash: []byte("nonce")}
if err := codes.SaveRequest(t.Context(), []byte("late"), r, 2*time.Minute); err != nil {
t.Fatal(err)
}
if err := codes.SaveGrant(t.Context(), []byte("late"), port.GitHubGrant{Request: r, GitHubID: 42}, 3*time.Minute); err != nil {
t.Fatal(err)
}
time.Sleep(time.Minute)
if _, err := codes.TakePairing(t.Context(), []byte("late")); !errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("过期之后取配对码 = %v,期望 ErrCodeNotFound", err)
}
if created, _ := codes.CreatePairing(t.Context(), []byte("late"), p, time.Minute); !created {
t.Error("过期的配对码不应挡住新的")
}
if got, err := codes.TakeRequest(t.Context(), []byte("late")); err != nil || got.Intent != port.GitHubLogin {
t.Errorf("同一个哈希的 state = %#v, %v,三种键互不相通", got, err)
}
time.Sleep(2 * time.Minute)
if _, err := codes.TakeGrant(t.Context(), []byte("late")); !errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("过期之后取兑换码 = %v,期望 ErrCodeNotFound", err)
}
})
}
// 与 Redis 适配器的 TestGitHubVerifications 相同的语义。
func TestGitHubVerifications(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
codes := NewCodes()
if err := codes.SaveVerification(t.Context(), []byte("session"), 10*time.Minute); err != nil {
t.Fatal(err)
}
for range 2 {
if ok, err := codes.Verified(t.Context(), []byte("session")); err != nil || !ok {
t.Errorf("Verified = %v, %v,期望读取不删除", ok, err)
}
}
if err := codes.DeleteVerification(t.Context(), []byte("session")); err != nil {
t.Fatal(err)
}
if ok, err := codes.Verified(t.Context(), []byte("session")); err != nil || ok {
t.Errorf("删除之后 Verified = %v, %v,期望 false", ok, err)
}
if err := codes.DeleteVerification(t.Context(), []byte("session")); err != nil {
t.Errorf("删除不存在的确认 = %v,期望什么也不做", err)
}
r := port.GitHubRequest{Intent: port.GitHubVerify, MemberID: "m1", NonceHash: []byte("nonce")}
if err := codes.SaveGrant(t.Context(), []byte("other"), port.GitHubGrant{Request: r, GitHubID: 42}, time.Minute); err != nil {
t.Fatal(err)
}
if ok, err := codes.Verified(t.Context(), []byte("other")); err != nil || ok {
t.Errorf("同一个哈希的兑换码当作确认 = %v, %v,期望 false:两种键互不相通", ok, err)
}
if err := codes.SaveVerification(t.Context(), []byte("late"), 10*time.Minute); err != nil {
t.Fatal(err)
}
time.Sleep(10 * time.Minute)
if ok, err := codes.Verified(t.Context(), []byte("late")); err != nil || ok {
t.Errorf("过期之后 Verified = %v, %v,期望 false", ok, err)
}
})
}
+91
View File
@@ -0,0 +1,91 @@
package porttest
import (
"cmp"
"context"
"slices"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
)
var (
_ port.ListenRecorder = (*Store)(nil)
_ port.Listens = (*Store)(nil)
)
func (s *Store) RecordListen(_ context.Context, memberIDs []string, l port.Listen) error {
m := l.Meta
if err := checkText(string(l.Track.Source), l.Track.ID, m.Title, m.Artist, m.Album, m.CoverURL, string(m.CoverKey), m.PageURL); err != nil {
return err
}
// 与 PostgreSQL 存下的精度一致。
l.Meta.Duration = m.Duration.Truncate(time.Millisecond)
l.HeardAt = l.HeardAt.Truncate(time.Microsecond)
s.mu.Lock()
defer s.mu.Unlock()
for _, id := range memberIDs {
if stored := s.member(id); stored != nil && !stored.listensOff {
s.addListen(id, l)
}
}
return nil
}
// 调用方持有 s.mu。
func (s *Store) addListen(memberID string, l port.Listen) {
list := s.listens[memberID]
if i := slices.IndexFunc(list, func(old port.Listen) bool { return old.Track == l.Track }); i >= 0 {
if list[i].HeardAt.After(l.HeardAt) {
l.HeardAt = list[i].HeardAt
}
list = slices.Delete(list, i, i+1)
}
list = append(list, l)
slices.SortFunc(list, func(a, b port.Listen) int {
return cmp.Or(b.HeardAt.Compare(a.HeardAt), cmp.Compare(a.Track.Source, b.Track.Source), cmp.Compare(a.Track.ID, b.Track.ID))
})
s.listens[memberID] = list[:min(len(list), port.ListenLimit)]
}
func (s *Store) MemberListens(_ context.Context, memberID string) ([]port.Listen, error) {
s.mu.Lock()
defer s.mu.Unlock()
return slices.Clone(s.listens[memberID]), nil
}
func (s *Store) DeleteListen(_ context.Context, memberID string, t track.Track) error {
s.mu.Lock()
defer s.mu.Unlock()
s.listens[memberID] = slices.DeleteFunc(s.listens[memberID], func(l port.Listen) bool { return l.Track == t })
return nil
}
func (s *Store) ClearListens(_ context.Context, memberID string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.listens, memberID)
return nil
}
func (s *Store) ListenRecording(_ context.Context, memberID string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil {
return false, port.ErrMemberNotFound
}
return !m.listensOff, nil
}
func (s *Store) SetListenRecording(_ context.Context, memberID string, on bool) error {
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil {
return port.ErrMemberNotFound
}
m.listensOff = !on
return nil
}
+234 -10
View File
@@ -33,9 +33,12 @@ type Store struct {
// 房间 ID 到 bot 成员 ID。
bots map[string]string
cookies map[string]string
githubApp port.GitHubOAuthApp
brandName string
registrationClosed bool
playlists []*storedPlaylist
// 成员 ID 到播放历史,按 MemberListens 的顺序排列。
listens map[string][]port.Listen
}
type storedMember struct {
@@ -43,6 +46,23 @@ type storedMember struct {
username string
email string
passwordHash string
githubID int64
// 零值表示记录播放历史,与 members.record_listens 的默认值一致。
listensOff bool
}
func (m *storedMember) registered() bool {
return m.username != "" || m.githubID != 0
}
func (m *storedMember) account() port.Account {
return port.Account{Member: m.member, Username: m.username, Email: m.email, PasswordHash: m.passwordHash, GitHubID: m.githubID}
}
// 调用方持有 s.mu。
func (s *Store) createFavorites(memberID string) {
now := time.Now()
s.playlists = append(s.playlists, &storedPlaylist{id: s.newID(), owner: memberID, name: "我喜欢", favorites: true, created: now, updated: now})
}
type storedTicket struct {
@@ -58,6 +78,7 @@ var (
_ port.ExistingTickets = (*Store)(nil)
_ port.RoomDirectory = (*Store)(nil)
_ port.Credentials = (*Store)(nil)
_ port.GitHubOAuthApps = (*Store)(nil)
_ port.APIKeys = (*Store)(nil)
_ port.Site = (*Store)(nil)
_ port.Playlists = (*Store)(nil)
@@ -70,6 +91,7 @@ func New() *Store {
tickets: map[string]storedTicket{},
bots: map[string]string{},
cookies: map[string]string{},
listens: map[string][]port.Listen{},
}
}
@@ -147,7 +169,7 @@ func (s *Store) UpdateMember(_ context.Context, id string, name, avatarHash, ema
if avatarHash != nil {
m.AvatarHash = *avatarHash
}
if email != nil && stored.username != "" {
if email != nil && stored.registered() {
stored.email = *email
}
return *m, nil
@@ -162,7 +184,7 @@ func (s *Store) AccountProfile(_ context.Context, memberID string) (port.Account
if m == nil {
return port.AccountProfile{}, port.ErrMemberNotFound
}
return port.AccountProfile{Username: m.username, Email: m.email}, nil
return port.AccountProfile{Username: m.username, Email: m.email, GitHub: m.githubID != 0}, nil
}
func (s *Store) Register(_ context.Context, memberID, username, passwordHash string) error {
@@ -172,29 +194,95 @@ func (s *Store) Register(_ context.Context, memberID, username, passwordHash str
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil || m.username != "" {
if m == nil || m.registered() {
return port.ErrAlreadyRegistered
}
if slices.ContainsFunc(s.members, func(other storedMember) bool { return other.username == username }) {
return port.ErrUsernameTaken
}
s.createFavorites(memberID)
m.username, m.passwordHash = username, passwordHash
now := time.Now()
s.playlists = append(s.playlists, &storedPlaylist{id: s.newID(), owner: memberID, name: "我喜欢", favorites: true, created: now, updated: now})
return nil
}
func (s *Store) AddCredentials(_ context.Context, memberID, username, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
if err := checkText(username, passwordHash); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil || m.username != "" || m.githubID == 0 {
return nil, port.ErrAlreadyRegistered
}
if slices.ContainsFunc(s.members, func(other storedMember) bool { return other.username == username }) {
return nil, port.ErrUsernameTaken
}
m.username, m.passwordHash = username, passwordHash
return s.deleteSessions(memberID, keepTokenHash), nil
}
func (s *Store) AccountByUsername(_ context.Context, username string) (port.Account, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, m := range s.members {
if username != "" && m.username == username {
return port.Account{Member: m.member, Username: m.username, Email: m.email, PasswordHash: m.passwordHash}, nil
return m.account(), nil
}
}
return port.Account{}, port.ErrMemberNotFound
}
func (s *Store) AccountByID(_ context.Context, memberID string) (port.Account, error) {
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil || !m.registered() {
return port.Account{}, port.ErrMemberNotFound
}
return m.account(), nil
}
func (s *Store) AccountByGitHub(_ context.Context, githubID int64) (port.Account, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, m := range s.members {
if m.githubID == githubID {
return m.account(), nil
}
}
return port.Account{}, port.ErrMemberNotFound
}
func (s *Store) BindGitHub(_ context.Context, memberID string, githubID int64) error {
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil || m.githubID != 0 {
return port.ErrGitHubAlreadyBound
}
if slices.ContainsFunc(s.members, func(other storedMember) bool { return other.githubID == githubID }) {
return port.ErrGitHubTaken
}
if !m.registered() {
s.createFavorites(memberID)
}
m.githubID = githubID
return nil
}
func (s *Store) UnbindGitHub(_ context.Context, memberID string) error {
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
// 密码总是和用户名一起设置:没有用户名,就是 PostgreSQL 中 password_hash 为 NULL。
if m == nil || m.username == "" {
return port.ErrNoPassword
}
m.githubID = 0
return nil
}
func (s *Store) ReplaceSession(_ context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -205,7 +293,8 @@ func (s *Store) ReplaceSession(_ context.Context, oldTokenHash []byte, memberID
if m.passwordHash != passwordHash {
return port.ErrAccountPasswordChanged
}
if _, ok := s.sessions[string(oldTokenHash)]; !ok {
previous, ok := s.sessions[string(oldTokenHash)]
if !ok {
return port.ErrSessionNotFound
}
if _, ok := s.sessions[string(newTokenHash)]; ok {
@@ -213,6 +302,15 @@ func (s *Store) ReplaceSession(_ context.Context, oldTokenHash []byte, memberID
}
s.deleteSession(string(oldTokenHash))
s.sessions[string(newTokenHash)] = memberID
if previous != memberID {
moved := s.listens[previous]
delete(s.listens, previous)
if !m.listensOff {
for _, l := range moved {
s.addListen(memberID, l)
}
}
}
return nil
}
@@ -492,6 +590,26 @@ func (s *Store) SetBilibiliCredential(_ context.Context, credential string) erro
return nil
}
func (s *Store) GitHubOAuthApp(context.Context) (port.GitHubOAuthApp, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.githubApp, nil
}
func (s *Store) SetGitHubOAuthApp(_ context.Context, app port.GitHubOAuthApp) error {
s.mu.Lock()
defer s.mu.Unlock()
s.githubApp = app
return nil
}
func (s *Store) DeleteGitHubOAuthApp(context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
s.githubApp = port.GitHubOAuthApp{}
return nil
}
func (s *Store) Settings(context.Context) (port.SiteSettings, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -538,6 +656,7 @@ type storedPlaylist struct {
favorites bool
items []port.PlaylistItem
created, updated time.Time
shareID string
}
func (p *storedPlaylist) cover() *port.Cover {
@@ -575,6 +694,7 @@ func (s *Store) MemberPlaylists(_ context.Context, ownerID string) ([]port.Playl
CreatedAt: p.created,
UpdatedAt: p.updated,
Cover: p.cover(),
ShareID: p.shareID,
}
if p.favorites {
lists = append([]port.PlaylistRecord{r}, lists...)
@@ -591,6 +711,44 @@ func (s *Store) CreatePlaylist(_ context.Context, ownerID, name string, limit in
}
s.mu.Lock()
defer s.mu.Unlock()
p, err := s.newPlaylist(ownerID, name, limit)
if err != nil {
return port.PlaylistRecord{}, err
}
return port.PlaylistRecord{ID: p.id, Name: name, CreatedAt: p.created, UpdatedAt: p.updated}, nil
}
func (s *Store) ImportPlaylist(_ context.Context, ownerID, name string, items []port.PlaylistItem, limit int) (port.PlaylistRecord, error) {
if err := checkText(name); err != nil {
return port.PlaylistRecord{}, err
}
seen := make(map[track.Track]bool, len(items))
for _, item := range items {
m := item.Meta
if err := checkText(item.Track.ID, m.Title, m.Artist, m.Album, m.CoverURL, string(m.CoverKey), m.PageURL); err != nil {
return port.PlaylistRecord{}, err
}
if seen[item.Track] {
return port.PlaylistRecord{}, fmt.Errorf("歌单里已经有 %s/%s:违反主键约束", item.Track.Source, item.Track.ID)
}
seen[item.Track] = true
}
s.mu.Lock()
defer s.mu.Unlock()
p, err := s.newPlaylist(ownerID, name, limit)
if err != nil {
return port.PlaylistRecord{}, err
}
for i := len(items) - 1; i >= 0; i-- {
item := items[i]
item.Meta.Duration = item.Meta.Duration.Truncate(time.Millisecond)
p.items = append(p.items, item)
}
return port.PlaylistRecord{ID: p.id, Name: name, TrackCount: len(p.items), CreatedAt: p.created, UpdatedAt: p.updated, Cover: p.cover()}, nil
}
// 调用方持有 s.mu。
func (s *Store) newPlaylist(ownerID, name string, limit int) (*storedPlaylist, error) {
n := 0
for _, p := range s.playlists {
if p.owner == ownerID {
@@ -598,12 +756,12 @@ func (s *Store) CreatePlaylist(_ context.Context, ownerID, name string, limit in
}
}
if n >= limit {
return port.PlaylistRecord{}, port.ErrPlaylistLimit
return nil, port.ErrPlaylistLimit
}
now := time.Now()
p := &storedPlaylist{id: s.newID(), owner: ownerID, name: name, created: now, updated: now}
s.playlists = append(s.playlists, p)
return port.PlaylistRecord{ID: p.id, Name: name, CreatedAt: now, UpdatedAt: now}, nil
return p, nil
}
func (s *Store) RenamePlaylist(_ context.Context, ownerID, id, name string) error {
@@ -645,11 +803,16 @@ func (s *Store) PlaylistItems(_ context.Context, ownerID, id string) ([]port.Pla
if p == nil {
return nil, port.ErrPlaylistNotFound
}
return p.display(), nil
}
// 新加入的在前。
func (p *storedPlaylist) display() []port.PlaylistItem {
items := make([]port.PlaylistItem, 0, len(p.items))
for i := len(p.items) - 1; i >= 0; i-- {
items = append(items, p.items[i])
}
return items, nil
return items
}
func (s *Store) AddPlaylistItem(_ context.Context, ownerID, id string, item port.PlaylistItem, limit int) error {
@@ -755,3 +918,64 @@ func (s *Store) MovePlaylistItems(_ context.Context, ownerID, id string, tracks
p.updated = time.Now()
return nil
}
func (s *Store) SetPlaylistShare(_ context.Context, ownerID, id, shareID string) error {
if err := checkText(shareID); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
p := s.playlist(ownerID, id)
if p == nil {
return port.ErrPlaylistNotFound
}
if other := s.shared(shareID); other != nil && other != p {
return fmt.Errorf("分享 ID %s 已被其他歌单使用:违反唯一约束", shareID)
}
p.shareID = shareID
return nil
}
// 调用方持有 s.mu。
func (s *Store) shared(shareID string) *storedPlaylist {
for _, p := range s.playlists {
if shareID != "" && p.shareID == shareID {
return p
}
}
return nil
}
func (s *Store) SharedPlaylist(_ context.Context, shareID string) (port.SharedPlaylist, error) {
if err := checkText(shareID); err != nil {
return port.SharedPlaylist{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
p := s.shared(shareID)
if p == nil {
return port.SharedPlaylist{}, port.ErrShareNotFound
}
owner := s.member(p.owner).member
return port.SharedPlaylist{Name: p.name, Favorites: p.favorites, OwnerName: owner.Name, OwnerAvatarHash: owner.AvatarHash, Items: p.display()}, nil
}
func (s *Store) CopySharedPlaylist(_ context.Context, ownerID, shareID, name string, limit int) (port.PlaylistRecord, error) {
if err := checkText(shareID, name); err != nil {
return port.PlaylistRecord{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
p, err := s.newPlaylist(ownerID, name, limit)
if err != nil {
return port.PlaylistRecord{}, err
}
from := s.shared(shareID)
if from == nil {
// 与 PostgreSQL 的事务一样先新建再复制,读不到分享时撤销新建的歌单。
s.playlists = slices.DeleteFunc(s.playlists, func(other *storedPlaylist) bool { return other == p })
return port.PlaylistRecord{}, port.ErrShareNotFound
}
p.items = slices.Clone(from.items)
return port.PlaylistRecord{ID: p.id, Name: name, TrackCount: len(p.items), CreatedAt: p.created, UpdatedAt: p.updated, Cover: p.cover()}, nil
}
+174
View File
@@ -1,6 +1,7 @@
package porttest
import (
"errors"
"testing"
"time"
@@ -20,6 +21,112 @@ func TestUpdateMemberRejectsTextPostgreSQLCannotStore(t *testing.T) {
}
}
func favoritesCount(t *testing.T, store *Store, memberID string) int {
t.Helper()
lists, err := store.MemberPlaylists(t.Context(), memberID)
if err != nil {
t.Fatal(err)
}
n := 0
for _, l := range lists {
if l.Favorites {
n++
}
}
return n
}
// 与 postgres 适配器的 TestBindGitHub、TestAddCredentialsAfterGitHub 相同的场景。
func TestBindGitHub(t *testing.T) {
store := New()
alice, err := store.CreateMember(t.Context(), []byte("alice"), "甲")
if err != nil {
t.Fatal(err)
}
bob, err := store.CreateMember(t.Context(), []byte("bob"), "乙")
if err != nil {
t.Fatal(err)
}
if err := store.UnbindGitHub(t.Context(), alice.ID); !errors.Is(err, port.ErrNoPassword) {
t.Errorf("游客解绑 = %v,期望 ErrNoPassword", err)
}
if err := store.BindGitHub(t.Context(), alice.ID, 42); err != nil {
t.Fatal(err)
}
if p, err := store.AccountProfile(t.Context(), alice.ID); err != nil || p != (port.AccountProfile{GitHub: true}) {
t.Errorf("绑定之后的 AccountProfile = %#v, %v", p, err)
}
want := port.Account{Member: alice, GitHubID: 42}
if got, err := store.AccountByGitHub(t.Context(), 42); err != nil || got != want {
t.Errorf("AccountByGitHub = %#v, %v,期望 %#v", got, err, want)
}
if got, err := store.AccountByID(t.Context(), alice.ID); err != nil || got != want {
t.Errorf("AccountByID = %#v, %v,期望 %#v", got, err, want)
}
if _, err := store.AccountByID(t.Context(), bob.ID); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("游客的 AccountByID = %v,期望 ErrMemberNotFound", err)
}
if err := store.BindGitHub(t.Context(), bob.ID, 42); !errors.Is(err, port.ErrGitHubTaken) {
t.Errorf("GitHub 账号已经绑定了别人 = %v,期望 ErrGitHubTaken", err)
}
if err := store.BindGitHub(t.Context(), alice.ID, 43); !errors.Is(err, port.ErrGitHubAlreadyBound) {
t.Errorf("已经绑定过的成员再绑定 = %v,期望 ErrGitHubAlreadyBound", err)
}
email := "alice@example.com"
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, nil, &email); err != nil {
t.Fatal(err)
}
if p, _ := store.AccountProfile(t.Context(), alice.ID); p.Email != email {
t.Errorf("只绑定了 GitHub 的账号保存邮箱之后 = %q", p.Email)
}
if err := store.UnbindGitHub(t.Context(), alice.ID); !errors.Is(err, port.ErrNoPassword) {
t.Errorf("没有密码的账号解绑 = %v,期望 ErrNoPassword", err)
}
if err := store.Register(t.Context(), alice.ID, "alice", "hash-a"); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("只绑定了 GitHub 的账号用 Register 设置用户名 = %v,期望 ErrAlreadyRegistered", err)
}
if _, err := store.AddCredentials(t.Context(), bob.ID, "bob", "hash-b", []byte("bob")); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("游客用 AddCredentials 设置用户名 = %v,期望 ErrAlreadyRegistered", err)
}
if _, err := store.CreateMember(t.Context(), []byte("guest"), "丙"); err != nil {
t.Fatal(err)
}
if err := store.ReplaceSession(t.Context(), []byte("guest"), alice.ID, []byte("alice-2"), ""); err != nil {
t.Fatal(err)
}
deleted, err := store.AddCredentials(t.Context(), alice.ID, "alice", "hash-a", []byte("alice"))
if err != nil || len(deleted) != 1 || string(deleted[0]) != "alice-2" {
t.Fatalf("AddCredentials = %q, %v,期望删除 alice-2", deleted, err)
}
if m, err := store.MemberByToken(t.Context(), []byte("alice")); err != nil || m.ID != alice.ID {
t.Errorf("发出请求的会话 = %#v, %v,期望保留", m, err)
}
if _, err := store.MemberByToken(t.Context(), []byte("alice-2")); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("其他会话 = %v,期望已删除", err)
}
if n := favoritesCount(t, store, alice.ID); n != 1 {
t.Errorf("绑定 GitHub 再补设用户名之后有 %d 个“我喜欢”,期望 1 个", n)
}
if _, err := store.AddCredentials(t.Context(), alice.ID, "alice2", "hash-c", []byte("alice")); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("再补设一次 = %v,期望 ErrAlreadyRegistered", err)
}
if err := store.UnbindGitHub(t.Context(), alice.ID); err != nil {
t.Fatal(err)
}
if p, _ := store.AccountProfile(t.Context(), alice.ID); p != (port.AccountProfile{Username: "alice", Email: email}) {
t.Errorf("解绑之后的 AccountProfile = %#v", p)
}
if err := store.Register(t.Context(), bob.ID, "bob", "hash-b"); err != nil {
t.Fatal(err)
}
if err := store.BindGitHub(t.Context(), bob.ID, 42); err != nil {
t.Fatal(err)
}
if n := favoritesCount(t, store, bob.ID); n != 1 {
t.Errorf("已注册的账号绑定 GitHub 之后有 %d 个“我喜欢”,期望 1 个", n)
}
}
func TestAddPlaylistItemKeepsMilliseconds(t *testing.T) {
store := New()
m, err := store.CreateMember(t.Context(), []byte("token"), "甲")
@@ -45,3 +152,70 @@ func TestAddPlaylistItemKeepsMilliseconds(t *testing.T) {
t.Errorf("Duration = %v,期望 %v", items[0].Meta.Duration, want)
}
}
func TestRecordListenKeepsWhatPostgreSQLKeeps(t *testing.T) {
store := New()
m, err := store.CreateMember(t.Context(), []byte("token"), "甲")
if err != nil {
t.Fatal(err)
}
heard := time.Date(2026, 11, 10, 20, 0, 0, 1234567, time.UTC)
l := port.Listen{Track: track.Track{Source: "netease", ID: "1"}, Meta: track.Meta{Title: "歌", Duration: 3*time.Minute + 1234*time.Microsecond}, HeardAt: heard}
if err := store.RecordListen(t.Context(), []string{m.ID}, l); err != nil {
t.Fatal(err)
}
listens, err := store.MemberListens(t.Context(), m.ID)
if err != nil {
t.Fatal(err)
}
if len(listens) != 1 || listens[0].Meta.Duration != 3*time.Minute+time.Millisecond || !listens[0].HeardAt.Equal(heard.Truncate(time.Microsecond)) {
t.Errorf("历史 = %#v,期望时长按毫秒、时间按微秒截断", listens)
}
l.Meta.Title = "坏\x00标题"
if err := store.RecordListen(t.Context(), []string{m.ID}, l); err == nil {
t.Error("含 NUL 的标题应报错")
}
}
func TestImportPlaylistRejectsWhatPostgreSQLRejects(t *testing.T) {
store := New()
m, err := store.CreateMember(t.Context(), []byte("token"), "甲")
if err != nil {
t.Fatal(err)
}
song := port.PlaylistItem{Track: track.Track{Source: "netease", ID: "1"}, Meta: track.Meta{Title: "歌"}}
broken := port.PlaylistItem{Track: track.Track{Source: "netease", ID: "2"}, Meta: track.Meta{Title: "坏\x00标题"}}
for name, items := range map[string][]port.PlaylistItem{"重复的歌": {song, song}, "含 NUL 的标题": {song, broken}} {
if _, err := store.ImportPlaylist(t.Context(), m.ID, "导入", items, 10); err == nil {
t.Errorf("%s应报错", name)
}
}
if lists, _ := store.MemberPlaylists(t.Context(), m.ID); len(lists) != 0 {
t.Errorf("失败之后的歌单 = %#v,期望一个也没有建", lists)
}
}
func TestSetPlaylistShareRejectsDuplicates(t *testing.T) {
store := New()
m, err := store.CreateMember(t.Context(), []byte("token"), "甲")
if err != nil {
t.Fatal(err)
}
first, err := store.CreatePlaylist(t.Context(), m.ID, "一", 10)
if err != nil {
t.Fatal(err)
}
second, err := store.CreatePlaylist(t.Context(), m.ID, "二", 10)
if err != nil {
t.Fatal(err)
}
if err := store.SetPlaylistShare(t.Context(), m.ID, first.ID, "share"); err != nil {
t.Fatal(err)
}
if err := store.SetPlaylistShare(t.Context(), m.ID, second.ID, "share"); err == nil {
t.Error("同一个分享 ID 设给第二个歌单时应报错")
}
if err := store.SetPlaylistShare(t.Context(), m.ID, first.ID, "share"); err != nil {
t.Errorf("重新设成自己已有的分享 ID = %v,期望成功", err)
}
}
+8
View File
@@ -0,0 +1,8 @@
package port
import "context"
type ShortLinks interface {
// 返回跳转之后的最终地址;跳转到不受信任的主机、跳转过多或超时都返回错误。
Resolve(ctx context.Context, link string) (string, error)
}
+6
View File
@@ -20,3 +20,9 @@ type Tracks interface {
PreparedTrack(ctx context.Context, t track.Track) (p PreparedTrack, ok bool, err error)
SavePreparedTrack(ctx context.Context, t track.Track, p PreparedTrack) error
}
type TrackLookup interface {
PreparedTrack(ctx context.Context, t track.Track) (p PreparedTrack, ok bool, err error)
// 与 PreparedTrack 读出的 Meta 相同,时长是准备得到的时长。
TrackMetas(ctx context.Context, ts []track.Track) (map[track.Track]track.Meta, error)
}
+4 -4
View File
@@ -169,7 +169,7 @@ func newFlakyBusInstance(t *testing.T, store *porttest.Store, logger *slog.Logge
songs := newSongs()
node := porttest.NewRedis().Node("a")
bus := &flakyBus{Bus: node}
h, g := New(store, store, unrevoked{}, songs.music, songs.preparer, Cluster{Instance: "a", Leases: node, Liveness: node, Bus: bus}, logger)
h, g := New(store, store, store, unrevoked{}, songs.music, songs.preparer, Cluster{Instance: "a", Leases: node, Liveness: node, Bus: bus}, logger)
return h, g, node, bus
}
@@ -381,7 +381,7 @@ func TestGatewayRejoinsAfterItsAliveKeyExpired(t *testing.T) {
a := startInstance(t, redis, "a", store, store, songs)
node := redis.Node("b")
liveness := &flakyLiveness{Liveness: node}
h, g := New(store, store, unrevoked{}, songs.music, songs.preparer, Cluster{Instance: "b", Leases: node, Liveness: liveness, Bus: node}, slog.New(slog.DiscardHandler))
h, g := New(store, store, store, unrevoked{}, songs.music, songs.preparer, Cluster{Instance: "b", Leases: node, Liveness: liveness, Bus: node}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(context.Background())
wait, err := Start(ctx, h, g)
if err != nil {
@@ -457,7 +457,7 @@ func startFlakyInstance(t *testing.T, redis *porttest.Redis, id string, snapshot
t.Helper()
node := redis.Node(id)
leases, bus := &flakyOwners{Leases: node}, &flakyForwards{Bus: node}
h, g := New(snapshots, store, unrevoked{}, songs.music, songs.preparer, Cluster{Instance: id, Leases: leases, Liveness: node, Bus: bus}, slog.New(slog.DiscardHandler))
h, g := New(snapshots, store, store, unrevoked{}, songs.music, songs.preparer, Cluster{Instance: id, Leases: leases, Liveness: node, Bus: bus}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(context.Background())
wait, err := Start(ctx, h, g)
if err != nil {
@@ -722,7 +722,7 @@ func TestCommandFollowsOwnerAfterLosingTakeover(t *testing.T) {
node := f.redis.Node("c")
leases := &staleLeases{Leases: node}
songs := newSongs()
h, g := New(store, store, unrevoked{}, songs.music, songs.preparer, Cluster{Instance: "c", Leases: leases, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
h, g := New(store, store, store, unrevoked{}, songs.music, songs.preparer, Cluster{Instance: "c", Leases: leases, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(context.Background())
wait, err := Start(ctx, h, g)
if err != nil {
+1 -1
View File
@@ -76,7 +76,7 @@ func TestPreparationKeepsRequestValues(t *testing.T) {
node := redis.Node("a")
preparer := contextPreparer{started: make(chan context.Context, 1)}
f := startHost(t, &hostFixture{store: store, redis: redis, node: node, spy: spy{redis.Node("spy")}},
newHost(store, store, preparer, newSongs().music, cluster(node, "a"), slog.New(slog.DiscardHandler)))
newHost(store, store, store, preparer, newSongs().music, cluster(node, "a"), slog.New(slog.DiscardHandler)))
id := f.createRoom(t, room.Persistent)
ctx, cancel := context.WithCancel(context.WithValue(context.Background(), requestValue{}, "点歌"))
+2 -2
View File
@@ -87,8 +87,8 @@ type roomLink struct {
confirmed uint64
}
func New(snapshots port.RoomSnapshots, directory port.RoomDirectory, tickets port.ExistingTickets, music *catalog.Service, preparer port.Preparer, cluster Cluster, logger *slog.Logger) (*Host, *Gateway) {
h := newHost(snapshots, directory, preparer, music, cluster, logger)
func New(snapshots port.RoomSnapshots, directory port.RoomDirectory, listens port.ListenRecorder, tickets port.ExistingTickets, music *catalog.Service, preparer port.Preparer, cluster Cluster, logger *slog.Logger) (*Host, *Gateway) {
h := newHost(snapshots, directory, listens, preparer, music, cluster, logger)
g := &Gateway{
self: cluster.Instance,
host: h,
+2 -2
View File
@@ -43,7 +43,7 @@ func startInstance(t *testing.T, redis *porttest.Redis, id string, snapshots por
func startLoggingInstance(t *testing.T, redis *porttest.Redis, id string, snapshots port.RoomSnapshots, store *porttest.Store, tickets port.ExistingTickets, songs *songs, logger *slog.Logger) *instance {
t.Helper()
node := redis.Node(id)
h, g := New(snapshots, store, tickets, songs.music, songs.preparer, cluster(node, id), logger)
h, g := New(snapshots, store, store, tickets, songs.music, songs.preparer, cluster(node, id), logger)
ctx, stop := context.WithCancel(context.Background())
wait, err := Start(ctx, h, g)
if err != nil {
@@ -603,7 +603,7 @@ func startCountingGateway(t *testing.T, store *porttest.Store) (*Gateway, *portt
songs := newSongs()
c := cluster(node, "a")
c.Bus = bus
h, g := New(store, store, unrevoked{}, songs.music, songs.preparer, c, slog.New(slog.DiscardHandler))
h, g := New(store, store, store, unrevoked{}, songs.music, songs.preparer, c, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(context.Background())
wait, err := Start(ctx, h, g)
if err != nil {
+15 -1
View File
@@ -49,6 +49,7 @@ type Host struct {
self string
snapshots port.RoomSnapshots
directory port.RoomDirectory
listens port.ListenRecorder
preparer port.Preparer
catalog *catalog.Service
leases port.Leases
@@ -120,11 +121,12 @@ type hostedRoom struct {
timers map[room.Token]*time.Timer
}
func newHost(snapshots port.RoomSnapshots, directory port.RoomDirectory, preparer port.Preparer, music *catalog.Service, cluster Cluster, logger *slog.Logger) *Host {
func newHost(snapshots port.RoomSnapshots, directory port.RoomDirectory, listens port.ListenRecorder, preparer port.Preparer, music *catalog.Service, cluster Cluster, logger *slog.Logger) *Host {
return &Host{
self: cluster.Instance,
snapshots: snapshots,
directory: directory,
listens: listens,
preparer: preparer,
catalog: music,
leases: cluster.Leases,
@@ -592,6 +594,8 @@ func (h *Host) execute(ctx context.Context, r *hostedRoom, effects []room.Effect
r.info = e.Snapshot.Info
case room.RecordPlay:
// 已经和 Persist 在同一个事务中写入。
case room.RecordListen:
h.recordListen(ctx, e)
case room.Emit:
if m, ok := e.Event.(room.MembersChanged); ok && !h.setOnline(ctx, r, len(m.Sessions)) {
return false
@@ -679,6 +683,16 @@ func (h *Host) persist(ctx context.Context, r *hostedRoom, s room.Snapshot, play
return h.snapshots.SaveRoom(ctx, r.term, s, plays)
}
// 播放历史不是房间的状态:写入失败不放弃房间。与落盘一样不随 ctx 取消。
func (h *Host) recordListen(ctx context.Context, l room.RecordListen) {
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), storeTimeout)
defer cancel()
listen := port.Listen{Track: l.Entry.Track, Meta: l.Entry.Meta, HeardAt: l.StartedAt}
if err := h.listens.RecordListen(writeCtx, l.Members, listen); err != nil {
h.logger.WarnContext(ctx, "写入播放历史失败", "error", err)
}
}
func (h *Host) remove(ctx context.Context, r *hostedRoom) error {
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), storeTimeout)
defer cancel()
+129 -7
View File
@@ -161,7 +161,7 @@ func newHostFixture(t *testing.T, snapshots port.RoomSnapshots, store *porttest.
redis := porttest.NewRedis()
node := redis.Node("a")
return startHost(t, &hostFixture{store: store, redis: redis, node: node, spy: spy{redis.Node("spy")}},
newHost(snapshots, store, songs.preparer, songs.music, cluster(node, "a"), slog.New(slog.DiscardHandler)))
newHost(snapshots, store, store, songs.preparer, songs.music, cluster(node, "a"), slog.New(slog.DiscardHandler)))
}
func startHost(t *testing.T, f *hostFixture, h *Host) *hostFixture {
@@ -289,6 +289,128 @@ func TestPersistFailureAbandonsRoom(t *testing.T) {
})
}
// 写入时把此前发布的房间消息和这次写入按发生的先后记进同一个日志。
type orderedListens struct {
*porttest.Store
spy spy
fail bool
mu sync.Mutex
log []string
}
func (l *orderedListens) RecordListen(ctx context.Context, memberIDs []string, listen port.Listen) error {
l.mu.Lock()
defer l.mu.Unlock()
for _, m := range l.spy.take() {
l.log = append(l.log, describe(m))
}
l.log = append(l.log, fmt.Sprintf("listen %d %s", len(memberIDs), listen.Track.ID))
if l.fail {
return errors.New("连接中断")
}
return l.Store.RecordListen(ctx, memberIDs, listen)
}
func (l *orderedListens) messages() []string {
l.mu.Lock()
defer l.mu.Unlock()
for _, m := range l.spy.take() {
l.log = append(l.log, describe(m))
}
log := l.log
l.log = nil
return log
}
func newListenFixture(t *testing.T, fail bool, logger *slog.Logger) (*hostFixture, *orderedListens) {
t.Helper()
store := porttest.New()
songs := newSongs()
songs.add(sunny, sunnyM, sunnyP)
redis := porttest.NewRedis()
node := redis.Node("a")
listens := &orderedListens{Store: store, spy: spy{redis.Node("spy")}, fail: fail}
f := startHost(t, &hostFixture{store: store, redis: redis, node: node, spy: listens.spy},
newHost(store, store, listens, songs.preparer, songs.music, cluster(node, "a"), logger))
return f, listens
}
func createMembers(t *testing.T, store *porttest.Store, names ...string) []member.Member {
t.Helper()
members := make([]member.Member, len(names))
for i, name := range names {
m, err := store.CreateMember(t.Context(), []byte(name), name)
if err != nil {
t.Fatal(err)
}
members[i] = m
}
return members
}
func checkLog(t *testing.T, got []string, want ...string) {
t.Helper()
if !slices.Equal(got, want) {
t.Errorf("房间消息和写入的先后:\n%s\n期望:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
}
}
func TestListensAreWrittenBeforeTheBroadcast(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
f, listens := newListenFixture(t, false, slog.New(slog.DiscardHandler))
members := createMembers(t, f.store, "alice", "bob")
a, b := members[0], members[1]
id := f.createRoom(t, room.Persistent)
start := time.Now()
f.send(t, id, room.Join{Session: "s1", Member: a})
f.send(t, id, command("r1", a, room.Enqueue{Track: sunny, Meta: sunnyM}))
checkLog(t, listens.messages(),
"started 1",
"1/1 room.MembersChanged",
"1/2 room.Activity",
"1/3 room.QueueChanged",
"reply r1 room.EnqueueResult",
"listen 1 186016",
"1/4 room.QueueChanged",
"1/5 room.PlaybackChanged",
)
f.send(t, id, room.Join{Session: "s2", Member: b})
checkLog(t, listens.messages(), "listen 1 186016", "1/6 room.MembersChanged")
startedAt := start.Add(200 * time.Millisecond)
for _, m := range members {
got, err := f.store.MemberListens(t.Context(), m.ID)
if err != nil || len(got) != 1 || got[0].Track != sunny || got[0].Meta.Title != sunnyM.Title || !got[0].HeardAt.Equal(startedAt) {
t.Errorf("%s 的历史 = %+v, %v,期望《晴天》,时间是开播的 %v", m.Name, got, err, startedAt)
}
}
})
}
func TestListenFailureKeepsRoomRunning(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
var logs logBuffer
f, listens := newListenFixture(t, true, slog.New(slog.NewJSONHandler(&logs, nil)))
a := createMembers(t, f.store, "alice")[0]
id := f.createRoom(t, room.Persistent)
f.send(t, id, room.Join{Session: "s1", Member: a})
f.send(t, id, command("r1", a, room.Enqueue{Track: sunny, Meta: sunnyM}))
if got := listens.messages(); !slices.Contains(got, "1/5 room.PlaybackChanged") {
t.Errorf("写入失败之后的房间消息 = %v,期望照常广播开播", got)
}
if owner := f.owner(t, id); owner != "a" {
t.Errorf("写入失败之后房间的 owner = %q,期望不放弃房间", owner)
}
entries := logs.entries(t, "写入播放历史失败")
if len(entries) != 1 || entries[0]["level"] != "WARN" {
t.Errorf("写入失败的日志 = %v,期望一条 WARN", entries)
}
})
}
// 每次 Restore 的 token 都从 1 开始:放弃房间时如果不停下计时器,它会提前关闭重新接管后的房间。
func TestTimersOfAbandonedRoomAreDiscarded(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
@@ -341,7 +463,7 @@ func TestPrepareOfAbandonedRunIsDiscarded(t *testing.T) {
redis := porttest.NewRedis()
node := redis.Node("a")
f := startHost(t, &hostFixture{store: store.Store, redis: redis, node: node, spy: spy{redis.Node("spy")}},
newHost(store, store.Store, preparer, newSongs().music, cluster(node, "a"), slog.New(slog.DiscardHandler)))
newHost(store, store.Store, store.Store, preparer, newSongs().music, cluster(node, "a"), slog.New(slog.DiscardHandler)))
id := f.createRoom(t, room.Persistent)
f.send(t, id, command("r1", alice, room.Enqueue{Track: sunny, Meta: sunnyM}))
f.spy.take()
@@ -461,7 +583,7 @@ func TestHostAcceptsRoomsOnlyBetweenStartAndShutdown(t *testing.T) {
songs := newSongs()
redis := porttest.NewRedis()
watcher := spy{redis.Node("spy")}
h := newHost(store, store, songs.preparer, songs.music, cluster(redis.Node("a"), "a"), slog.New(slog.DiscardHandler))
h := newHost(store, store, store, songs.preparer, songs.music, cluster(redis.Node("a"), "a"), slog.New(slog.DiscardHandler))
id, err := store.CreateRoom(t.Context(), port.NewRoom{Kind: room.Persistent, Name: "房间"})
if err != nil {
t.Fatal(err)
@@ -673,7 +795,7 @@ func newFlakyFixture(t *testing.T) (*hostFixture, *flakyLeases) {
node := redis.Node("a")
leases := &flakyLeases{Leases: node, resume: make(chan struct{})}
f := startHost(t, &hostFixture{store: store, redis: redis, node: node, spy: spy{redis.Node("spy")}},
newHost(store, store, songs.preparer, songs.music, Cluster{Instance: "a", Leases: leases, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler)))
newHost(store, store, store, songs.preparer, songs.music, Cluster{Instance: "a", Leases: leases, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler)))
return f, leases
}
@@ -876,7 +998,7 @@ func TestFailedReleaseNoticeIsLogged(t *testing.T) {
node := redis.Node("a")
var logs logBuffer
f := startHost(t, &hostFixture{store: store, redis: redis, node: node, spy: spy{redis.Node("spy")}},
newHost(store, store, songs.preparer, songs.music, Cluster{Instance: "a", Leases: node, Liveness: node, Bus: noReleaseNotice{node}}, slog.New(logctx.NewHandler(slog.NewJSONHandler(&logs, nil)))))
newHost(store, store, store, songs.preparer, songs.music, Cluster{Instance: "a", Leases: node, Liveness: node, Bus: noReleaseNotice{node}}, slog.New(logctx.NewHandler(slog.NewJSONHandler(&logs, nil)))))
id := f.createRoom(t, room.Persistent)
f.send(t, id, room.Join{Session: "s1", Member: alice})
if err := f.host.Shutdown(t.Context()); err != nil {
@@ -1000,7 +1122,7 @@ func TestTakeoverFailuresAreLoggedWhenTheyStart(t *testing.T) {
node := redis.Node("a")
var logs logBuffer
f := startHost(t, &hostFixture{store: store.Store, redis: redis, node: node, spy: spy{redis.Node("spy")}},
newHost(store, store, songs.preparer, songs.music, cluster(node, "a"), slog.New(slog.NewJSONHandler(&logs, nil))))
newHost(store, store, store, songs.preparer, songs.music, cluster(node, "a"), slog.New(slog.NewJSONHandler(&logs, nil))))
for range 3 {
f.createRoom(t, room.Persistent)
}
@@ -1087,7 +1209,7 @@ func TestRecoveredGatewayKeepsItsSessions(t *testing.T) {
gateway, unreachable := redis.Node("b"), redis.Node("c")
liveness := &recoveringLiveness{Liveness: node, gateway: gateway, unreachable: "c"}
f := startHost(t, &hostFixture{store: store, redis: redis, node: node, spy: spy{redis.Node("spy")}},
newHost(store, store, songs.preparer, songs.music, Cluster{Instance: "a", Leases: node, Liveness: liveness, Bus: node}, slog.New(slog.DiscardHandler)))
newHost(store, store, store, songs.preparer, songs.music, Cluster{Instance: "a", Leases: node, Liveness: liveness, Bus: node}, slog.New(slog.DiscardHandler)))
id := f.createRoom(t, room.Persistent)
for _, g := range []*porttest.Node{gateway, unreachable} {
if _, err := g.Heartbeat(t.Context(), aliveTTL); err != nil {
+2 -2
View File
@@ -343,7 +343,7 @@ func TestRevocationNoticesAreBestEffort(t *testing.T) {
}
store := porttest.New()
var logs logBuffer
_, g := New(store, store, unrevoked{}, nil, porttest.NewPreparer(), Cluster{Instance: "a", Leases: node, Liveness: node, Bus: contextBus{node}}, slog.New(slog.NewJSONHandler(&logs, nil)))
_, g := New(store, store, store, unrevoked{}, nil, porttest.NewPreparer(), Cluster{Instance: "a", Leases: node, Liveness: node, Bus: contextBus{node}}, slog.New(slog.NewJSONHandler(&logs, nil)))
revokeAll := func(ctx context.Context) {
g.RevokeSessions(ctx, [][]byte{[]byte("s")})
g.Revoke(ctx, "r")
@@ -430,7 +430,7 @@ func TestObserverSkipsSnapshotOfEarlierRun(t *testing.T) {
redis := porttest.NewRedis()
node := redis.Node("a")
bus := &heldEmits{Bus: node, release: make(chan struct{})}
h, g := New(store, store, unrevoked{}, songs.music, songs.preparer, Cluster{Instance: "a", Leases: node, Liveness: node, Bus: bus}, slog.New(slog.DiscardHandler))
h, g := New(store, store, store, unrevoked{}, songs.music, songs.preparer, Cluster{Instance: "a", Leases: node, Liveness: node, Bus: bus}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(context.Background())
wait, err := Start(ctx, h, g)
if err != nil {
+5
View File
@@ -17,6 +17,8 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
goredis "github.com/redis/go-redis/v9"
"github.com/dihemoe/alisten/internal/adapter/in/api"
"github.com/dihemoe/alisten/internal/adapter/out/github"
"github.com/dihemoe/alisten/internal/adapter/out/storage"
)
@@ -32,6 +34,7 @@ type serveConfig struct {
// 浏览器访问对象存储时使用的地址前缀,结尾没有 /。
storageURL string
tracing bool
github github.Config
}
func loadServeConfig() (serveConfig, error) {
@@ -92,6 +95,8 @@ func loadServeConfig() (serveConfig, error) {
storage: objects,
storageURL: storageURL,
tracing: tracing,
// 与后台显示给管理员、要他填进 OAuth App 的回调地址相同。
github: github.Config{RedirectURL: origin + api.GitHubCallbackPath, Endpoint: github.GitHubCom},
}, nil
}
+5 -1
View File
@@ -211,7 +211,8 @@ func (b *logBuffer) String() string {
// 各场景共用一个 Valkey,而 Pub/Sub 不按逻辑库隔离:实例频道靠随机的实例 ID 区分;
// members 和 tracks.prepared 两个频道靠场景依次运行、每个场景结束时停止服务来隔开。
func startService(t *testing.T) service {
// configure 在读取配置之后修改它,用来换上环境变量配置不了的测试替身,比如假的 GitHub 的地址。
func startService(t *testing.T, configure ...func(*serveConfig)) service {
t.Helper()
if err := runMigrate(newLogger(io.Discard, slog.LevelInfo)); err != nil {
t.Fatal(err)
@@ -220,6 +221,9 @@ func startService(t *testing.T) service {
if err != nil {
t.Fatal(err)
}
for _, f := range configure {
f(&cfg)
}
spans := tracetest.NewSpanRecorder()
tracer := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spans))
cfg.tracing = true
+579
View File
@@ -0,0 +1,579 @@
package bootstrap
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"sync"
"testing"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"github.com/dihemoe/alisten/internal/adapter/out/github"
)
// 假的 GitHub:有 client-1 和 client-2 两个 OAuth App,Client Secret 分别是 secret-1 和 secret-2。用户在授权页同意时签发的授权码
// code-<n> 绑定授权地址中的 client_id 和 code_challenge,换取令牌时 client_id 和 code_verifier 必须与它们对上;授权码换到令牌
// token-<n>,令牌 token-<n> 是用户 n。
type fakeGitHub struct {
t *testing.T
endpoint github.Endpoint
mu sync.Mutex
approved map[string]approval
}
type approval struct {
clientID string
challenge string
}
var githubSecrets = map[string]string{"client-1": "secret-1", "client-2": "secret-2"}
func newFakeGitHub(t *testing.T) *fakeGitHub {
t.Helper()
f := &fakeGitHub{t: t, approved: map[string]approval{}}
mux := http.NewServeMux()
mux.HandleFunc("POST /login/oauth/access_token", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
clientID := r.FormValue("client_id")
if r.FormValue("redirect_uri") != "https://alisten.example/api/auth/github/callback" {
t.Errorf("换取令牌的参数 = %v", r.Form)
}
if secret, ok := githubSecrets[clientID]; !ok || r.FormValue("client_secret") != secret {
_, _ = w.Write([]byte(`{"error":"incorrect_client_credentials","error_description":"The client_id and/or client_secret passed are incorrect."}`))
return
}
code := r.FormValue("code")
f.mu.Lock()
a, approved := f.approved[code]
delete(f.approved, code)
f.mu.Unlock()
verifier := sha256.Sum256([]byte(r.FormValue("code_verifier")))
n, ok := strings.CutPrefix(code, "code-")
if !ok || !approved || a.clientID != clientID || base64.RawURLEncoding.EncodeToString(verifier[:]) != a.challenge {
_, _ = w.Write([]byte(`{"error":"bad_verification_code","error_description":"The code passed is incorrect or expired."}`))
return
}
_ = json.NewEncoder(w).Encode(map[string]string{"access_token": "token-" + n, "token_type": "bearer"})
})
mux.HandleFunc("GET /user", func(w http.ResponseWriter, r *http.Request) {
n, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer token-")
if !ok {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"login":"octocat","id":` + n + `}`))
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
f.endpoint = github.Endpoint{AuthorizeURL: server.URL + "/login/oauth/authorize", TokenURL: server.URL + "/login/oauth/access_token", UserURL: server.URL + "/user"}
return f
}
// 模拟用户在授权页同意授权地址的参数为 authorize 的这次授权,GitHub 签发授权码 code。
func (f *fakeGitHub) approve(authorize url.Values, code string) {
f.t.Helper()
if authorize.Get("code_challenge_method") != "S256" || authorize.Get("code_challenge") == "" {
f.t.Errorf("授权地址的参数 = %s,期望带上 S256 的 code_challenge", authorize.Encode())
}
f.mu.Lock()
defer f.mu.Unlock()
f.approved[code] = approval{clientID: authorize.Get("client_id"), challenge: authorize.Get("code_challenge")}
}
type githubClient struct {
t *testing.T
server *httptest.Server
client *http.Client
github *fakeGitHub
}
func (c githubClient) call(method, path, token, body string) (int, map[string]any) {
c.t.Helper()
req, err := http.NewRequestWithContext(c.t.Context(), method, c.server.URL+path, strings.NewReader(body))
if err != nil {
c.t.Fatal(err)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := c.client.Do(req)
if err != nil {
c.t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(resp.Body)
if err != nil {
c.t.Fatal(err)
}
var v map[string]any
if len(raw) > 0 {
if err := json.Unmarshal(raw, &v); err != nil {
c.t.Fatalf("%s %s 的响应体不是 JSON:%s", method, path, raw)
}
}
return resp.StatusCode, v
}
func (c githubClient) guest() string {
c.t.Helper()
status, body := c.call(http.MethodPost, "/api/members", "", "{}")
if status != http.StatusCreated {
c.t.Fatalf("创建游客 = %d %v", status, body)
}
return body["memberToken"].(string)
}
// 发起授权,返回授权地址的参数。currentPassword 为空时请求中不带当前密码。
func (c githubClient) authorize(token, intent, nonce, currentPassword string) url.Values {
c.t.Helper()
return c.start(token, startGitHubBody(intent, nonce, currentPassword))
}
// 用请求体 request 发起授权,返回授权地址的参数。
func (c githubClient) start(token, request string) url.Values {
c.t.Helper()
status, body := c.call(http.MethodPost, "/api/auth/github", token, request)
if status != http.StatusCreated {
c.t.Fatalf("发起授权 = %d %v", status, body)
}
u, err := url.Parse(body["url"].(string))
if err != nil {
c.t.Fatal(err)
}
q := u.Query()
if githubSecrets[q.Get("client_id")] == "" || q.Get("redirect_uri") != "https://alisten.example/api/auth/github/callback" || q.Get("state") == "" {
c.t.Errorf("授权地址 = %s", u)
}
return q
}
// 在后台配置 OAuth App。
func (c githubClient) configure(clientID string) {
c.t.Helper()
body := `{"clientId":"` + clientID + `","clientSecret":"` + githubSecrets[clientID] + `"}`
if status, resp := c.call(http.MethodPut, "/api/admin/credentials/github", "admin-secret", body); status != http.StatusNoContent {
c.t.Fatalf("配置 GitHub OAuth App = %d %v", status, resp)
}
}
// 回调地址的查询串里有授权码和 state,Client Secret 在换取令牌的请求体和后台的请求体里:它们都不能出现在 span 和日志中。
func checkNoGitHubSecrets(t *testing.T, svc service, values ...string) {
t.Helper()
values = append(values, "secret-1", "secret-2")
for _, span := range svc.spans.Ended() {
texts := []string{span.Status().Description}
for _, kv := range span.Attributes() {
texts = append(texts, kv.Value.String())
}
for _, event := range span.Events() {
for _, kv := range event.Attributes {
texts = append(texts, kv.Value.String())
}
}
for _, text := range texts {
for _, secret := range values {
if strings.Contains(text, secret) {
t.Errorf("span %s 中有 %q:%q", span.Name(), secret, text)
}
}
}
}
logs := svc.logs.String()
for _, secret := range values {
if strings.Contains(logs, secret) {
t.Errorf("日志中有 %q:\n%s", secret, logs)
}
}
}
// 模拟浏览器从 GitHub 跳回来,返回跳转到的地址。
func (c githubClient) callback(query string) string {
c.t.Helper()
req, err := http.NewRequestWithContext(c.t.Context(), http.MethodGet, c.server.URL+"/api/auth/github/callback?"+query, nil)
if err != nil {
c.t.Fatal(err)
}
resp, err := c.client.Do(req)
if err != nil {
c.t.Fatal(err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusFound || resp.Header.Get("Cache-Control") != "no-store" || resp.Header.Get("Referrer-Policy") != "no-referrer" {
c.t.Errorf("回调 = %d,Cache-Control %q,Referrer-Policy %q", resp.StatusCode, resp.Header.Get("Cache-Control"), resp.Header.Get("Referrer-Policy"))
}
return resp.Header.Get("Location")
}
func startGitHubBody(intent, nonce, currentPassword string) string {
if currentPassword == "" {
return `{"intent":"` + intent + `","nonce":"` + nonce + `"}`
}
return `{"intent":"` + intent + `","nonce":"` + nonce + `","currentPassword":"` + currentPassword + `"}`
}
// 走完授权和回调,返回兑换码。
func (c githubClient) exchangeCode(token, intent, nonce, currentPassword, code string) string {
c.t.Helper()
authorize := c.authorize(token, intent, nonce, currentPassword)
c.github.approve(authorize, code)
location := c.callback(url.Values{"code": {code}, "state": {authorize.Get("state")}}.Encode())
exchange, ok := strings.CutPrefix(location, "/auth/github#code=")
if !ok || exchange == "" {
c.t.Fatalf("回调跳转到 %q,期望 /auth/github#code=<兑换码>", location)
}
return exchange
}
func (c githubClient) finish(token, exchange, nonce string) (int, map[string]any) {
c.t.Helper()
return c.call(http.MethodPost, "/api/auth/github/exchange", token, `{"code":"`+exchange+`","nonce":"`+nonce+`"}`)
}
func TestGitHubLogin(t *testing.T) {
newDatabase, newRedis := startPostgres(t), startValkey(t)
contractEnv(t, newDatabase(t), newRedis(t))
fake := newFakeGitHub(t)
svc := startService(t, func(cfg *serveConfig) { cfg.github.Endpoint = fake.endpoint })
client := svc.server.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
c := githubClient{t: t, server: svc.server, client: client, github: fake}
c.configure("client-1")
const nonce = "nonce-0123456789"
if status, body := c.call(http.MethodGet, "/api/site", "", ""); status != http.StatusOK || body["githubLogin"] != true {
t.Errorf("配置了 GitHub 时站点 = %d %v,期望 githubLogin 为 true", status, body)
}
// 首次用 GitHub 登录就是注册:成员和成员 token 都不变。
alice := c.guest()
status, body := c.finish(alice, c.exchangeCode(alice, "login", nonce, "", "code-42"), nonce)
if status != http.StatusOK || body["memberToken"] != nil || !equalJSON(body["account"], `{"github":true}`) {
t.Fatalf("首次用 GitHub 登录 = %d %v,期望注册成只绑定了 GitHub 的账号", status, body)
}
aliceID := body["member"].(map[string]any)["id"]
if status, body := c.call(http.MethodGet, "/api/me", alice, ""); status != http.StatusOK || !equalJSON(body["account"], `{"github":true}`) {
t.Errorf("注册之后 /api/me = %d %v", status, body)
}
// 另一台设备用同一个 GitHub 登录:得到账号的新 token,游客的 token 随即失效。
bob := c.guest()
status, body = c.finish(bob, c.exchangeCode(bob, "login", nonce, "", "code-42"), nonce)
if status != http.StatusOK || body["memberToken"] == nil || body["member"].(map[string]any)["id"] != aliceID {
t.Fatalf("用已绑定的 GitHub 登录 = %d %v,期望登录同一个账号", status, body)
}
if status, _ := c.call(http.MethodGet, "/api/me", bob, ""); status != http.StatusUnauthorized {
t.Errorf("登录之后游客原来的 token = %d,期望 401", status)
}
// 兑换码只能由发起授权的设备带着自己的 nonce 兑换。
carol := c.guest()
exchange := c.exchangeCode(carol, "login", nonce, "", "code-7")
if status, body := c.finish(carol, exchange, "another-nonce-0000"); status != http.StatusUnauthorized || body["error"].(map[string]any)["code"] != "INVALID_CREDENTIALS" {
t.Errorf("nonce 不对 = %d %v,期望 INVALID_CREDENTIALS", status, body)
}
// 回调失败时跳转到落地页并带上错误码。
state := c.authorize(carol, "login", nonce, "").Get("state")
if got := c.callback(url.Values{"code": {"wrong"}, "state": {state}}.Encode()); got != "/auth/github#error=failed" {
t.Errorf("GitHub 不认这个授权码 = %q", got)
}
if got := c.callback(url.Values{"code": {"code-7"}, "state": {state}}.Encode()); got != "/auth/github#error=expired" {
t.Errorf("同一个 state 再回调 = %q", got)
}
if got := c.callback(url.Values{"error": {"access_denied"}, "state": {state}}.Encode()); got != "/auth/github#error=denied" {
t.Errorf("state 已经作废时用户在 GitHub 上取消 = %q", got)
}
// 授权码只能配合发起那次授权时的 verifier 换取令牌:把别人的授权码放进自己发起的回调里,换不到别人的身份。
c.github.approve(c.authorize(carol, "login", nonce, ""), "code-7")
mallory := c.guest()
attack := c.authorize(mallory, "login", nonce, "").Get("state")
if got := c.callback(url.Values{"code": {"code-7"}, "state": {attack}}.Encode()); got != "/auth/github#error=failed" {
t.Errorf("用别人的授权码回调 = %q,期望 GitHub 不认", got)
}
if !slices.ContainsFunc(svc.spans.Ended(), func(span sdktrace.ReadOnlySpan) bool { return span.Name() == "GET /api/auth/github/callback" }) {
t.Error("回调请求也应记 span")
}
checkNoGitHubSecrets(t, svc, attack, "code-7")
// 没有密码的账号不能解绑;用 GitHub 确认身份、设置用户名和密码之后可以。
if status, body := c.call(http.MethodPost, "/api/account/github/removals", alice, `{"currentPassword":"correct horse"}`); status != http.StatusConflict {
t.Errorf("没有密码的账号解绑 = %d %v,期望 409", status, body)
}
if status, body := c.finish(alice, c.exchangeCode(alice, "verify", nonce, "", "code-42"), nonce); status != http.StatusOK {
t.Fatalf("用 GitHub 确认身份 = %d %v", status, body)
}
if status, body := c.call(http.MethodPost, "/api/account", alice, `{"username":"alice","password":"correct horse"}`); status != http.StatusCreated || !equalJSON(body["account"], `{"username":"alice","github":true}`) {
t.Errorf("补设用户名和密码 = %d %v", status, body)
}
if status, _ := c.call(http.MethodPost, "/api/account/github/removals", alice, `{"currentPassword":"correct horse"}`); status != http.StatusNoContent {
t.Errorf("设置密码之后解绑 = %d,期望 204", status)
}
// 已注册的账号绑定 GitHub;关闭注册不影响绑定,但没有绑定过的 GitHub 不能再注册。
if status, _ := c.call(http.MethodDelete, "/api/admin/registration", "admin-secret", ""); status != http.StatusNoContent {
t.Fatalf("关闭注册 = %d", status)
}
if status, body := c.finish(alice, c.exchangeCode(alice, "bind", nonce, "correct horse", "code-42"), nonce); status != http.StatusOK || body["memberToken"] != nil || !equalJSON(body["account"], `{"username":"alice","github":true}`) {
t.Errorf("绑定 = %d %v", status, body)
}
if status, body := c.finish(carol, c.exchangeCode(carol, "login", nonce, "", "code-7"), nonce); status != http.StatusForbidden {
t.Errorf("关闭注册时首次用 GitHub 登录 = %d %v,期望 403", status, body)
}
if status, body := c.call(http.MethodPost, "/api/auth/github", alice, startGitHubBody("bind", nonce, "correct horse")); status != http.StatusConflict {
t.Errorf("已经绑定了 GitHub 的账号再发起绑定 = %d %v,期望 409", status, body)
}
}
// App 发起授权时带上 client app,回调跳回 App 的自定义 scheme。跳转目标只看发起授权时记下的 client,回调地址里的参数改变不了它。
func TestGitHubAppCallback(t *testing.T) {
newDatabase, newRedis := startPostgres(t), startValkey(t)
contractEnv(t, newDatabase(t), newRedis(t))
fake := newFakeGitHub(t)
svc := startService(t, func(cfg *serveConfig) { cfg.github.Endpoint = fake.endpoint })
client := svc.server.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
c := githubClient{t: t, server: svc.server, client: client, github: fake}
c.configure("client-1")
const (
nonce = "nonce-0123456789"
app = `{"intent":"login","nonce":"` + nonce + `","client":"app"}`
web = `{"intent":"login","nonce":"` + nonce + `","client":"web"}`
omitted = `{"intent":"login","nonce":"` + nonce + `"}`
landing = "moe.dihe.alisten:/auth/github#"
)
if status, body := c.call(http.MethodPost, "/api/auth/github", c.guest(), `{"intent":"login","nonce":"`+nonce+`","client":"ios"}`); status != http.StatusBadRequest {
t.Errorf("不认识的 client = %d %v,期望 400", status, body)
}
alice := c.guest()
authorize := c.start(alice, app)
c.github.approve(authorize, "code-42")
location := c.callback(url.Values{"code": {"code-42"}, "state": {authorize.Get("state")}}.Encode())
exchange, ok := strings.CutPrefix(location, landing+"code=")
if !ok || exchange == "" {
t.Fatalf("App 发起的授权回调跳转到 %q,期望 %scode=<兑换码>", location, landing)
}
if status, body := c.finish(alice, exchange, nonce); status != http.StatusOK || !equalJSON(body["account"], `{"github":true}`) {
t.Errorf("App 兑换 = %d %v,期望注册成只绑定了 GitHub 的账号", status, body)
}
// 用户在 GitHub 上拒绝时也跳回 App,state 随即作废;作废之后再回调就跳到网页。
bob := c.guest()
state := c.start(bob, app).Get("state")
if got := c.callback(url.Values{"error": {"access_denied"}, "state": {state}}.Encode()); got != landing+"error=denied" {
t.Errorf("用户在 GitHub 上拒绝 App 发起的授权 = %q", got)
}
if got := c.callback(url.Values{"code": {"code-7"}, "state": {state}}.Encode()); got != "/auth/github#error=expired" {
t.Errorf("拒绝之后用同一个 state 回调 = %q,期望 state 已经作废,跳到网页", got)
}
if got := c.callback(url.Values{"error": {"access_denied"}, "state": {state}}.Encode()); got != "/auth/github#error=denied" {
t.Errorf("state 已经作废时拒绝 = %q,期望跳到网页", got)
}
state = c.start(bob, app).Get("state")
if got := c.callback(url.Values{"error": {"redirect_uri_mismatch"}, "state": {state}}.Encode()); got != landing+"error=failed" {
t.Errorf("GitHub 拒绝 App 发起的授权请求 = %q", got)
}
state = c.start(bob, app).Get("state")
if got := c.callback(url.Values{"code": {"wrong"}, "state": {state}}.Encode()); got != landing+"error=failed" {
t.Errorf("GitHub 不认 App 发起的授权的授权码 = %q", got)
}
hijack := func(client string) string {
return url.Values{
"client": {client}, "redirect_uri": {"https://evil.example/"}, "redirect": {"https://evil.example/"},
"redirect_to": {"evil://steal"}, "return_to": {"https://evil.example/"}, "next": {"//evil.example/"},
}.Encode()
}
for _, tc := range []struct{ name, request, client, want string }{
{"省略 client", omitted, "app", "/auth/github#code="},
{"client 为 web", web, "app", "/auth/github#code="},
{"client 为 app", app, "web", landing + "code="},
} {
authorize := c.start(c.guest(), tc.request)
c.github.approve(authorize, "code-7")
got := c.callback(url.Values{"code": {"code-7"}, "state": {authorize.Get("state")}}.Encode() + "&" + hijack(tc.client))
if code, ok := strings.CutPrefix(got, tc.want); !ok || code == "" {
t.Errorf("%s,回调地址中塞进跳转参数 = %q,期望 %s<兑换码>", tc.name, got, tc.want)
}
}
state = c.start(c.guest(), omitted).Get("state")
if got := c.callback(url.Values{"error": {"access_denied"}, "state": {state}}.Encode() + "&" + hijack("app")); got != "/auth/github#error=denied" {
t.Errorf("网页发起的授权被拒绝,回调地址中塞进跳转参数 = %q,期望跳到网页", got)
}
}
// 只凭会话不能更换登录凭证:绑定和解绑 GitHub 要输入当前密码;只绑定了 GitHub 的账号补设用户名和密码之前,要用账号绑定的
// GitHub 确认身份,补设之后其他设备全部退出。
func TestGitHubReauthentication(t *testing.T) {
newDatabase, newRedis := startPostgres(t), startValkey(t)
contractEnv(t, newDatabase(t), newRedis(t))
fake := newFakeGitHub(t)
svc := startService(t, func(cfg *serveConfig) { cfg.github.Endpoint = fake.endpoint })
client := svc.server.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
c := githubClient{t: t, server: svc.server, client: client, github: fake}
c.configure("client-1")
const nonce = "nonce-0123456789"
errorCode := func(body map[string]any) any {
e, _ := body["error"].(map[string]any)
return e["code"]
}
alice := c.guest()
if status, body := c.call(http.MethodPost, "/api/account", alice, `{"username":"alice","password":"correct horse"}`); status != http.StatusCreated {
t.Fatalf("注册 = %d %v", status, body)
}
for _, tc := range []struct{ name, password string }{{"不带当前密码", ""}, {"当前密码错误", "wrong horse"}} {
if status, body := c.call(http.MethodPost, "/api/auth/github", alice, startGitHubBody("bind", nonce, tc.password)); status != http.StatusUnauthorized || errorCode(body) != "INVALID_CREDENTIALS" {
t.Errorf("%s时发起绑定 = %d %v,期望 INVALID_CREDENTIALS", tc.name, status, body)
}
}
if status, body := c.finish(alice, c.exchangeCode(alice, "bind", nonce, "correct horse", "code-42"), nonce); status != http.StatusOK || !equalJSON(body["account"], `{"username":"alice","github":true}`) {
t.Fatalf("输入正确的当前密码之后绑定 = %d %v", status, body)
}
if status, body := c.call(http.MethodPost, "/api/account/github/removals", alice, `{}`); status != http.StatusBadRequest || errorCode(body) != "INVALID_ARGUMENT" {
t.Errorf("不带当前密码解绑 = %d %v,期望 INVALID_ARGUMENT", status, body)
}
if status, body := c.call(http.MethodPost, "/api/account/github/removals", alice, `{"currentPassword":"wrong horse"}`); status != http.StatusUnauthorized || errorCode(body) != "INVALID_CREDENTIALS" {
t.Errorf("当前密码错误时解绑 = %d %v,期望 INVALID_CREDENTIALS", status, body)
}
if status, body := c.call(http.MethodGet, "/api/me", alice, ""); status != http.StatusOK || !equalJSON(body["account"], `{"username":"alice","github":true}`) {
t.Errorf("解绑失败之后 /api/me = %d %v,期望仍然绑定", status, body)
}
if status, body := c.call(http.MethodPost, "/api/account/github/removals", alice, `{"currentPassword":"correct horse"}`); status != http.StatusNoContent {
t.Errorf("输入正确的当前密码之后解绑 = %d %v,期望 204", status, body)
}
if status, body := c.call(http.MethodPost, "/api/auth/github", alice, startGitHubBody("verify", nonce, "")); status != http.StatusConflict {
t.Errorf("设置了密码的账号发起确认 = %d %v,期望 409", status, body)
}
if status, body := c.call(http.MethodPost, "/api/auth/github", c.guest(), startGitHubBody("verify", nonce, "")); status != http.StatusForbidden {
t.Errorf("游客发起确认 = %d %v,期望 403", status, body)
}
// 只绑定了 GitHub 的账号在两台设备上登录。
octocat := c.guest()
if status, body := c.finish(octocat, c.exchangeCode(octocat, "login", nonce, "", "code-7"), nonce); status != http.StatusOK {
t.Fatalf("用 GitHub 注册 = %d %v", status, body)
}
other := c.guest()
status, body := c.finish(other, c.exchangeCode(other, "login", nonce, "", "code-7"), nonce)
if status != http.StatusOK || body["memberToken"] == nil {
t.Fatalf("另一台设备用 GitHub 登录 = %d %v", status, body)
}
other = body["memberToken"].(string)
const credentials = `{"username":"octocat","password":"correct horse"}`
if status, body := c.call(http.MethodPost, "/api/account", octocat, credentials); status != http.StatusForbidden {
t.Errorf("没有用 GitHub 确认就补设 = %d %v,期望 403", status, body)
}
if status, body := c.finish(octocat, c.exchangeCode(octocat, "verify", nonce, "", "code-42"), nonce); status != http.StatusUnauthorized || errorCode(body) != "INVALID_CREDENTIALS" {
t.Errorf("用别的 GitHub 确认 = %d %v,期望 INVALID_CREDENTIALS", status, body)
}
if status, body := c.call(http.MethodPost, "/api/account", octocat, credentials); status != http.StatusForbidden {
t.Errorf("用别的 GitHub 确认之后补设 = %d %v,期望 403", status, body)
}
status, body = c.finish(octocat, c.exchangeCode(octocat, "verify", nonce, "", "code-7"), nonce)
if status != http.StatusOK || body["memberToken"] != nil || !equalJSON(body["account"], `{"github":true}`) {
t.Fatalf("用绑定的 GitHub 确认 = %d %v,期望成员 token 不变", status, body)
}
if status, body := c.call(http.MethodPost, "/api/account", other, credentials); status != http.StatusForbidden {
t.Errorf("另一台设备用这次确认补设 = %d %v,期望 403", status, body)
}
if status, body := c.call(http.MethodPost, "/api/account", octocat, credentials); status != http.StatusCreated || !equalJSON(body["account"], `{"username":"octocat","github":true}`) {
t.Errorf("确认之后补设 = %d %v", status, body)
}
if status, _ := c.call(http.MethodGet, "/api/me", other, ""); status != http.StatusUnauthorized {
t.Errorf("补设之后另一台设备的 token = %d,期望 401", status)
}
if status, _ := c.call(http.MethodGet, "/api/me", octocat, ""); status != http.StatusOK {
t.Errorf("补设之后发出请求的 token = %d,期望 200", status)
}
}
// 每次授权和兑换都读后台最新的配置,所有实例立即生效。授权进行中换了 OAuth App,GitHub 不认原来签发的授权码,回调失败;
// 关闭之后,GitHub 相关的接口返回 NOT_FOUND,回调跳到发起授权的客户端,带上 error=failed。
func TestGitHubOAuthAppChanges(t *testing.T) {
newDatabase, newRedis := startPostgres(t), startValkey(t)
contractEnv(t, newDatabase(t), newRedis(t))
fake := newFakeGitHub(t)
endpoint := func(cfg *serveConfig) { cfg.github.Endpoint = fake.endpoint }
first, second := startService(t, endpoint), startService(t, endpoint)
clientOf := func(svc service) githubClient {
client := svc.server.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return githubClient{t: t, server: svc.server, client: client, github: fake}
}
a, b := clientOf(first), clientOf(second)
const (
nonce = "nonce-0123456789"
app = `{"intent":"login","nonce":"` + nonce + `","client":"app"}`
disabled = `{"code":"NOT_FOUND","message":"站点没有开启 GitHub 登录"}`
)
a.configure("client-1")
alice := b.guest()
stale := b.authorize(alice, "login", nonce, "")
b.github.approve(stale, "code-42")
a.configure("client-2")
if got := b.authorize(alice, "login", nonce, "").Get("client_id"); got != "client-2" {
t.Errorf("另一个实例上换了 OAuth App 之后,授权地址的 client_id = %q,期望 client-2", got)
}
if got := b.callback(url.Values{"code": {"code-42"}, "state": {stale.Get("state")}}.Encode()); got != "/auth/github#error=failed" {
t.Errorf("授权进行中换了 OAuth App,回调 = %q,期望 GitHub 不认原来签发的授权码", got)
}
if status, body := b.finish(alice, b.exchangeCode(alice, "login", nonce, "", "code-42"), nonce); status != http.StatusOK || !equalJSON(body["account"], `{"github":true}`) {
t.Fatalf("换了 OAuth App 之后用 GitHub 注册 = %d %v", status, body)
}
bob := b.guest()
exchange := b.exchangeCode(bob, "login", nonce, "", "code-7")
pending := b.start(bob, app)
b.github.approve(pending, "code-7")
if status, body := a.call(http.MethodDelete, "/api/admin/credentials/github", "admin-secret", ""); status != http.StatusNoContent {
t.Fatalf("关闭 GitHub 登录 = %d %v", status, body)
}
if status, body := b.call(http.MethodGet, "/api/site", "", ""); status != http.StatusOK || body["githubLogin"] != false {
t.Errorf("关闭之后另一个实例上的站点 = %d %v,期望 githubLogin 为 false", status, body)
}
if status, body := b.finish(bob, exchange, nonce); status != http.StatusNotFound || !equalJSON(body["error"], disabled) {
t.Errorf("关闭之后兑换 = %d %v,期望 NOT_FOUND", status, body)
}
if got := b.callback(url.Values{"code": {"code-7"}, "state": {pending.Get("state")}}.Encode()); got != "moe.dihe.alisten:/auth/github#error=failed" {
t.Errorf("关闭之后 App 发起的授权回调 = %q,期望跳回 App 并带上 error=failed", got)
}
if got := b.callback(url.Values{"code": {"code-7"}, "state": {"forged"}}.Encode()); got != "/auth/github#error=failed" {
t.Errorf("关闭之后不存在的 state 回调 = %q,期望 error=failed:重新发起也没有用,不提示过期", got)
}
if status, body := b.call(http.MethodPost, "/api/auth/github", bob, startGitHubBody("login", nonce, "")); status != http.StatusNotFound || !equalJSON(body["error"], disabled) {
t.Errorf("关闭之后发起授权 = %d %v,期望 NOT_FOUND", status, body)
}
if status, body := b.call(http.MethodPost, "/api/account/github/removals", alice, `{"currentPassword":"correct horse"}`); status != http.StatusNotFound || !equalJSON(body["error"], disabled) {
t.Errorf("关闭之后解绑 = %d %v,期望 NOT_FOUND", status, body)
}
if status, body := b.call(http.MethodGet, "/api/me", bob, ""); status != http.StatusOK || body["account"] != nil {
t.Errorf("关闭之后兑换失败的游客 = %d %v,期望没有注册账号", status, body)
}
checkNoGitHubSecrets(t, first)
checkNoGitHubSecrets(t, second)
}
func equalJSON(got any, want string) bool {
var w any
if err := json.Unmarshal([]byte(want), &w); err != nil {
panic(err)
}
a, _ := json.Marshal(got)
b, _ := json.Marshal(w)
return string(a) == string(b)
}
+14 -3
View File
@@ -19,13 +19,16 @@ import (
"github.com/dihemoe/alisten/internal/adapter/in/api"
"github.com/dihemoe/alisten/internal/adapter/in/worker"
"github.com/dihemoe/alisten/internal/adapter/out/github"
"github.com/dihemoe/alisten/internal/adapter/out/postgres"
"github.com/dihemoe/alisten/internal/adapter/out/queue"
"github.com/dihemoe/alisten/internal/adapter/out/redis"
"github.com/dihemoe/alisten/internal/adapter/out/shortlink"
"github.com/dihemoe/alisten/internal/adapter/out/storage"
"github.com/dihemoe/alisten/internal/app/admin"
"github.com/dihemoe/alisten/internal/app/catalog"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/listen"
"github.com/dihemoe/alisten/internal/app/lobby"
"github.com/dihemoe/alisten/internal/app/media"
"github.com/dihemoe/alisten/internal/app/playlist"
@@ -33,6 +36,7 @@ import (
"github.com/dihemoe/alisten/internal/app/prepare"
"github.com/dihemoe/alisten/internal/app/realtime"
"github.com/dihemoe/alisten/internal/app/site"
playlistlink "github.com/dihemoe/alisten/internal/domain/playlist"
"github.com/dihemoe/alisten/internal/domain/track"
)
@@ -74,13 +78,17 @@ func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instanc
Bus: redis.NewBus(rdb, instance, version, metrics, tracer, base.With("component", "bus")),
}
runtimeLogger := base.With("component", "realtime")
host, gateway := realtime.New(measure(media.NewSnapshots(store, objects, runtimeLogger), metrics), store, store, music, preparer, cluster, runtimeLogger)
host, gateway := realtime.New(measure(media.NewSnapshots(store, objects, runtimeLogger), metrics), store, store, store, music, preparer, cluster, runtimeLogger)
registerRuntimeMetrics(metrics, host, gateway)
rooms := lobby.New(store, leases)
codes := redis.NewCodes(rdb)
accounts := identity.NewAccounts(store, store, redis.NewAccountQuota(rdb), codes, codes, redis.NewDevices(rdb), gateway)
// 每个请求的期限由 GitHub 适配器设置。
oauth := github.New(&http.Client{Transport: traceHTTP(http.DefaultTransport, tracer)}, cfg.github)
services := api.Services{
Identity: identity.New(store, store, store, gateway),
Accounts: identity.NewAccounts(store, store, redis.NewAccountQuota(rdb), redis.NewDevices(rdb), gateway),
Playlists: playlist.New(store, store, store, music, redis.NewAccountQuota(rdb)),
Accounts: accounts,
Playlists: playlist.New(store, store, store, music, redis.NewAccountQuota(rdb), shortlink.New(playlistlink.AllowedRedirectHost, playlistlink.Recognized)),
Lobby: rooms,
Admin: admin.New(cfg.adminToken, rooms, store, store, store, src.neteaseLogin, src.qqLogin, src.bilibiliLogin, store, gateway),
Gateway: gateway,
@@ -88,8 +96,11 @@ func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instanc
Media: media.NewLibrary(store, store, objects),
Playback: media.NewPlayback(src.playback, store),
Site: site.New(store),
Listens: listen.New(store),
GitHub: identity.NewGitHub(accounts, store, oauth, codes),
StorageURL: cfg.storageURL,
PublicURL: cfg.publicOrigin,
}
httpLogger := base.With("component", "http")
return public{
+9 -9
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"}}
{"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"}}
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM", "github": false}, "recordListens": true}
-- request --
POST /api/account
@@ -62,7 +62,7 @@ GET /api/me
Authorization: Bearer $bob
-- response --
200
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}}
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}, "recordListens": true}
-- request --
PATCH /api/me
@@ -78,7 +78,7 @@ GET /api/me
Authorization: Bearer $bob
-- response --
200
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}}
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "recordListens": true}
-- request --
PATCH /api/me
@@ -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"}}
{"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"}}
{"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"}
+29
View File
@@ -96,3 +96,32 @@ Authorization: Bearer admin-secret
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
-- request --
GET /api/admin/credentials/github
Authorization: Bearer admin-secret
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
-- request --
PUT /api/admin/credentials/github
Authorization: Bearer admin-secret
{"clientId": "Ov23liExample0000001", "clientSecret": "0123456789abcdef0123456789abcdef01234567"}
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
-- request --
DELETE /api/admin/credentials/github
Authorization: Bearer admin-secret
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
-- request --
GET /api/site
-- response --
200
{"brandName": "Alisten", "registrationOpen": true, "githubLogin": false}
+135
View File
@@ -0,0 +1,135 @@
后台配置 GitHub OAuth App:读取只给出 Client ID、Client Secret 是否已设置和要填进 GitHub 的回调地址,任何响应都不返回 Client Secret;
Client ID 和 Client Secret 一起保存,都必填;清除之后关闭 GitHub 登录。
-- request --
GET /api/admin/credentials/github
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
-- request --
PUT /api/admin/credentials/github
Authorization: Bearer wrong-token
{"clientId": "Ov23liExample0000001", "clientSecret": "0123456789abcdef0123456789abcdef01234567"}
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
-- request --
DELETE /api/admin/credentials/github
Authorization: Bearer wrong-token
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
-- request --
GET /api/admin/credentials/github
Authorization: Bearer admin-secret
-- response --
200
{"clientId": null, "clientSecretSet": false, "callbackUrl": "https://alisten.example/api/auth/github/callback"}
-- request --
PUT /api/admin/credentials/github
Authorization: Bearer admin-secret
{"clientId": "Ov23liExample0000001"}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "clientSecret:缺少必填字段"}}
-- request --
PUT /api/admin/credentials/github
Authorization: Bearer admin-secret
{"clientSecret": "0123456789abcdef0123456789abcdef01234567"}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "clientId:缺少必填字段"}}
-- request --
PUT /api/admin/credentials/github
Authorization: Bearer admin-secret
{"clientId": "", "clientSecret": "0123456789abcdef0123456789abcdef01234567"}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "clientId:格式不正确"}}
-- request --
PUT /api/admin/credentials/github
Authorization: Bearer admin-secret
{"clientId": "Ov23liExample0000001", "clientSecret": "0123456789abcdef 0123456789abcdef01234567"}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "clientSecret:格式不正确"}}
-- request --
GET /api/admin/credentials/github
Authorization: Bearer admin-secret
-- response --
200
{"clientId": null, "clientSecretSet": false, "callbackUrl": "https://alisten.example/api/auth/github/callback"}
-- request --
PUT /api/admin/credentials/github
Authorization: Bearer admin-secret
{"clientId": "Ov23liExample0000001", "clientSecret": "0123456789abcdef0123456789abcdef01234567"}
-- response --
204
-- request --
GET /api/admin/credentials/github
Authorization: Bearer admin-secret
-- response --
200
{"clientId": "Ov23liExample0000001", "clientSecretSet": true, "callbackUrl": "https://alisten.example/api/auth/github/callback"}
-- request --
GET /api/site
-- response --
200
{"brandName": "Alisten", "registrationOpen": true, "githubLogin": true}
-- request --
PUT /api/admin/credentials/github
Authorization: Bearer admin-secret
{"clientId": "Iv1.0123456789abcdef", "clientSecret": "fedcba9876543210fedcba9876543210fedcba98"}
-- response --
204
-- request --
GET /api/admin/credentials/github
Authorization: Bearer admin-secret
-- response --
200
{"clientId": "Iv1.0123456789abcdef", "clientSecretSet": true, "callbackUrl": "https://alisten.example/api/auth/github/callback"}
-- request --
DELETE /api/admin/credentials/github
Authorization: Bearer admin-secret
-- response --
204
-- request --
GET /api/admin/credentials/github
Authorization: Bearer admin-secret
-- response --
200
{"clientId": null, "clientSecretSet": false, "callbackUrl": "https://alisten.example/api/auth/github/callback"}
-- request --
GET /api/site
-- response --
200
{"brandName": "Alisten", "registrationOpen": true, "githubLogin": false}
-- request --
DELETE /api/admin/credentials/github
Authorization: Bearer admin-secret
-- response --
204
+42
View File
@@ -0,0 +1,42 @@
后台没有配置 GitHub OAuth App 时,站点说明不提供 GitHub 登录,发起授权、兑换和解绑都返回 NOT_FOUND。这里的请求会跟随跳转,回调的跳转目标见 TestGitHubOAuthAppChanges。
-- request --
GET /api/site
-- response --
200
{"brandName": "Alisten", "registrationOpen": true, "githubLogin": false}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
-- request --
POST /api/auth/github
Authorization: Bearer $alice
{"intent": "login", "nonce": "0123456789abcdef"}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "站点没有开启 GitHub 登录"}}
-- request --
POST /api/auth/github/exchange
Authorization: Bearer $alice
{"code": "anything", "nonce": "0123456789abcdef"}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "站点没有开启 GitHub 登录"}}
-- request --
POST /api/account/github/removals
Authorization: Bearer $alice
{"currentPassword": "correct horse"}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "站点没有开启 GitHub 登录"}}
+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}
+178
View File
@@ -0,0 +1,178 @@
歌单导入:只有已注册的账号能导入。导入文件时,曲目表里已有的歌用服务端的元数据,没有的用文件里的元数据(不是平台自己的 https 地址的封面和页面链接被丢弃,链接曲目没有封面);重复的歌只留第一次出现的位置,标题为空的跳过,来源中的第一首排在歌单最前。链接可以识别出平台歌单,目前只能导入网易云的歌单。
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
-- request --
POST /api/me/playlists/imports
Authorization: Bearer $alice
{"name": "游客", "tracks": []}
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "注册账号之后才能使用歌单"}}
-- request --
POST /api/me/playlist-links
Authorization: Bearer $alice
{"link": "https://music.163.com/#/playlist?id=3778678"}
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "注册账号之后才能使用歌单"}}
-- request --
POST /api/me/playlists/platform-imports
Authorization: Bearer $alice
{"name": "游客", "source": "netease", "playlistId": "3778678"}
-- 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/rooms
{"name": "临时", "description": ""}
-- response --
201
Location: /api/rooms/$room
{"id": "$room"}
-- request --
POST /api/rooms/$room/tickets
Authorization: Bearer $alice
{}
-- response --
201
{"ticket": "$ticket"}
-- stream alice --
Authorization: Bearer $ticket
-- event alice --
event: snapshot
data: {"room": {"term": 1, "seq": 0, "room": {"id": "$room", "name": "临时", "description": "", "kind": "ephemeral"},
"playMode": "sequential", "nowPlaying": null, "pending": null, "items": [],
"members": {"sessions": [], "onlineCount": 0}}}
-- request --
POST /api/room/queue
Authorization: Bearer $ticket
{"track": {"source": "netease", "id": "186016"}}
-- response --
201
Location: /api/room/queue/1
{"entry": {"id": "1", "track": {"source": "netease", "id": "186016"},
"meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000},
"by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}
-- event alice --
event: room.activity
id: 1
data: {"kind": "event", "type": "room.activity", "term": 1, "seq": 1, "data": {"kind": "added", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
"entry": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000},
"by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"},
"message": "点了《晴天》"}}
-- event alice --
event: queue.updated
id: 2
data: {"kind": "event", "type": "queue.updated", "term": 1, "seq": 2, "data": {"items": [],
"pending": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000},
"by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}}
-- event alice --
event: queue.updated
id: 3
data: {"kind": "event", "type": "queue.updated", "term": 1, "seq": 3, "data": {"pending": null, "items": []}}
-- event alice --
event: playback.updated
id: 4
data: {"kind": "event", "type": "playback.updated", "term": 1, "seq": 4, "data": {"nowPlaying": {
"entry": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269500},
"by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "ready"},
"mediaUrl": "/media/netease/186016", "startedAtMs": 1, "durationMs": 269500, "gain": 1, "lyric": "[00:01.00]故事的小黄花"}}}
-- request --
POST /api/me/playlists/imports
Authorization: Bearer $alice
{"name": " 文件里的歌单 ", "tracks": [
{"track": {"source": "netease", "id": "186016"}, "title": "改过的标题", "artist": "改过的歌手", "durationMs": 1000, "coverUrl": "https://evil.example/c.jpg"},
{"track": {"source": "netease", "id": "999"}, "title": "文件里的歌", "artist": "歌手", "album": "专辑", "durationMs": 61000, "coverUrl": "http://p1.music.126.net/a.jpg", "pageUrl": "https://evil.example/song"},
{"track": {"source": "link", "id": "https://example.com/song.mp3"}, "title": "链接曲目", "durationMs": 5000, "coverUrl": "https://p1.music.126.net/b.jpg"},
{"track": {"source": "netease", "id": "186016"}, "title": "重复", "durationMs": 1},
{"track": {"source": "netease", "id": "1000"}, "title": " ", "durationMs": 1}
]}
-- response --
201
{"playlist": {"id": "$imported", "name": "文件里的歌单", "favorites": false, "trackCount": 3, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/example.jpg", "shareId": null}, "imported": 3}
-- request --
GET /api/me/playlists/$imported/items
Authorization: Bearer $alice
-- response --
200
{"items": [
{"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269500}},
{"track": {"source": "netease", "id": "999"}, "meta": {"title": "文件里的歌", "artist": "歌手", "album": "专辑", "coverUrl": "", "pageUrl": "https://music.163.com/#/song?id=999", "durationMs": 61000}},
{"track": {"source": "link", "id": "https://example.com/song.mp3"}, "meta": {"title": "链接曲目", "artist": "", "album": "", "coverUrl": "", "pageUrl": "https://example.com/song.mp3", "durationMs": 5000}}
]}
-- request --
POST /api/me/playlists/imports
Authorization: Bearer $alice
{"name": " ", "tracks": []}
-- response --
201
{"playlist": {"id": "$empty", "name": "导入的歌单", "favorites": false, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null, "shareId": null}, "imported": 0}
-- request --
POST /api/me/playlist-links
Authorization: Bearer $alice
{"link": "https://music.163.com/#/playlist?id=3778678"}
-- response --
200
{"source": "netease", "playlistId": "3778678"}
-- request --
POST /api/me/playlist-links
Authorization: Bearer $alice
{"link": "https://example.com/playlist?id=1"}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "不是网易云或 QQ 音乐的歌单链接"}}
-- request --
POST /api/me/playlists/platform-imports
Authorization: Bearer $alice
{"name": "周杰伦精选", "source": "netease", "playlistId": "3778678"}
-- response --
201
{"playlist": {"id": "$platform", "name": "周杰伦精选", "favorites": false, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/example.jpg", "shareId": null}, "imported": 2}
-- request --
POST /api/me/playlists/platform-imports
Authorization: Bearer $alice
{"name": "QQ", "source": "qq", "playlistId": "1"}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "暂时只能导入网易云的歌单"}}
+269
View File
@@ -0,0 +1,269 @@
歌单分享:已注册的账号可以分享自己的任何歌单,包括“我喜欢”;每次开启都生成新的链接,旧链接随即失效;不需要身份就能读取分享,响应里没有所有者的成员 ID 和用户名;已注册的成员可以把分享的歌单复制成自己的新歌单;停止分享、删除歌单之后分享失效。
-- 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", "github": false}}
-- request --
PATCH /api/me
Authorization: Bearer $alice
{"email": "alice@example.com"}
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}}
-- request --
GET /api/me/playlists
Authorization: Bearer $alice
-- response --
200
{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null, "shareId": null}]}
-- request --
POST /api/me/playlists/$favorites/items
Authorization: Bearer $alice
{"track": {"source": "netease", "id": "186016"}}
-- response --
201
{"item": {"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}}}
-- request --
POST /api/me/playlists/$favorites/items
Authorization: Bearer $alice
{"track": {"source": "netease", "id": "185811"}}
-- response --
201
{"item": {"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}}}
-- request --
POST /api/me/playlists/$favorites/share
{}
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
-- request --
POST /api/me/playlists/$favorites/share
Authorization: Bearer $alice
{}
-- response --
200
{"shareId": "$share"}
-- request --
GET /api/me/playlists
Authorization: Bearer $alice
-- response --
200
{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "shareId": "$share"}]}
-- request --
GET /api/shared-playlists/$share
-- response --
200
{"name": "我喜欢", "favorites": true, "ownerName": "$aliceName", "ownerAvatarHash": "c160f8cc69a4f0bf2b0362752353d060", "items": [
{"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}},
{"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}}
]}
-- request --
POST /api/me/playlists/$favorites/share
Authorization: Bearer $alice
{}
-- response --
200
{"shareId": "$newShare"}
-- request --
GET /api/shared-playlists/$share
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "分享已失效"}}
-- request --
GET /api/shared-playlists/$newShare
-- response --
200
{"name": "我喜欢", "favorites": true, "ownerName": "$aliceName", "ownerAvatarHash": "c160f8cc69a4f0bf2b0362752353d060", "items": [
{"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}},
{"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}}
]}
-- request --
GET /api/shared-playlists/not-a-share
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "分享已失效"}}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$bob", "member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}}
-- request --
GET /api/shared-playlists/$newShare
Authorization: Bearer $bob
-- response --
200
{"name": "我喜欢", "favorites": true, "ownerName": "$aliceName", "ownerAvatarHash": "c160f8cc69a4f0bf2b0362752353d060", "items": [
{"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}},
{"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}}
]}
-- request --
POST /api/shared-playlists/$newShare/copies
{"name": "抄来的"}
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
-- request --
POST /api/shared-playlists/$newShare/copies
Authorization: Bearer $bob
{"name": "抄来的"}
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "注册账号之后才能使用歌单"}}
-- request --
POST /api/me/playlists/$favorites/share
Authorization: Bearer $bob
{}
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "注册账号之后才能使用歌单"}}
-- request --
POST /api/account
Authorization: Bearer $bob
{"username": "bob", "password": "correct horse"}
-- response --
201
{"account": {"username": "bob", "github": false}}
-- request --
POST /api/me/playlists/$favorites/share
Authorization: Bearer $bob
{}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
-- request --
DELETE /api/me/playlists/$favorites/share
Authorization: Bearer $bob
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
-- request --
POST /api/shared-playlists/$newShare/copies
Authorization: Bearer $bob
{"name": " "}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "name:去掉首尾空白后应有 1 到 50 个字符,其中至少一个看得见,不能包含控制字符"}}
-- request --
POST /api/shared-playlists/$newShare/copies
Authorization: Bearer $bob
{"name": " 抄来的 "}
-- response --
201
{"id": "$copy", "name": "抄来的", "favorites": false, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "shareId": null}
-- request --
GET /api/me/playlists/$copy/items
Authorization: Bearer $bob
-- response --
200
{"items": [
{"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}},
{"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}}
]}
-- request --
POST /api/shared-playlists/not-a-share/copies
Authorization: Bearer $bob
{"name": "抄来的"}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "分享已失效"}}
-- request --
DELETE /api/me/playlists/$favorites/share
Authorization: Bearer $alice
-- response --
204
-- request --
DELETE /api/me/playlists/$favorites/share
Authorization: Bearer $alice
-- response --
204
-- request --
GET /api/shared-playlists/$newShare
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "分享已失效"}}
-- request --
POST /api/shared-playlists/$newShare/copies
Authorization: Bearer $bob
{"name": "抄来的"}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "分享已失效"}}
-- request --
POST /api/me/playlists/$copy/share
Authorization: Bearer $bob
{}
-- response --
200
{"shareId": "$copyShare"}
-- request --
DELETE /api/me/playlists/$copy
Authorization: Bearer $bob
-- response --
204
-- request --
GET /api/shared-playlists/$copyShare
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "分享已失效"}}
+7 -7
View File
@@ -31,14 +31,14 @@ Authorization: Bearer $alice
{"username": "alice", "password": "correct horse"}
-- response --
201
{"account": {"username": "alice"}}
{"account": {"username": "alice", "github": false}}
-- request --
GET /api/me/playlists
Authorization: Bearer $alice
-- response --
200
{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null}]}
{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null, "shareId": null}]}
-- request --
POST /api/me/playlists/$favorites/items
@@ -93,7 +93,7 @@ Authorization: Bearer $alice
{"name": " 通勤 "}
-- response --
201
{"id": "$commute", "name": "通勤", "favorites": false, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null}
{"id": "$commute", "name": "通勤", "favorites": false, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null, "shareId": null}
-- request --
POST /api/me/playlists
@@ -224,7 +224,7 @@ GET /api/me/playlists
Authorization: Bearer $alice
-- response --
200
{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/qilixiang.jpg"}, {"id": "$commute", "name": "上班路上", "favorites": false, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/example.jpg"}]}
{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "shareId": null}, {"id": "$commute", "name": "上班路上", "favorites": false, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/example.jpg", "shareId": null}]}
-- request --
POST /api/me/playlist-memberships
@@ -282,14 +282,14 @@ Authorization: Bearer $bob
{"username": "bob", "password": "correct horse"}
-- response --
201
{"account": {"username": "bob"}}
{"account": {"username": "bob", "github": false}}
-- request --
GET /api/me/playlists
Authorization: Bearer $bob
-- response --
200
{"items": [{"id": "$bobFavorites", "name": "我喜欢", "favorites": true, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null}]}
{"items": [{"id": "$bobFavorites", "name": "我喜欢", "favorites": true, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null, "shareId": null}]}
-- request --
POST /api/me/playlists/$bobFavorites/copies
@@ -377,4 +377,4 @@ GET /api/me/playlists
Authorization: Bearer $alice
-- response --
200
{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 1, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/qilixiang.jpg"}]}
{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 1, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "shareId": null}]}
+3 -3
View File
@@ -21,7 +21,7 @@ GET /api/me
Authorization: Bearer $alice
-- response --
200
{"member": {"id": "$aliceId", "name": "$guestName", "avatarHash": ""}}
{"member": {"id": "$aliceId", "name": "$guestName", "avatarHash": ""}, "recordListens": true}
-- request --
PATCH /api/me
@@ -73,11 +73,11 @@ GET /api/me
Authorization: Bearer $alice
-- response --
200
{"member": {"id": "$aliceId", "name": "甲", "avatarHash": ""}}
{"member": {"id": "$aliceId", "name": "甲", "avatarHash": ""}, "recordListens": true}
-- request --
GET /api/me
Authorization: Bearer $bob
-- response --
200
{"member": {"id": "$bobId", "name": "乙", "avatarHash": ""}}
{"member": {"id": "$bobId", "name": "乙", "avatarHash": ""}, "recordListens": true}
+4 -4
View File
@@ -4,7 +4,7 @@
GET /api/site
-- response --
200
{"brandName": "Alisten", "registrationOpen": true}
{"brandName": "Alisten", "registrationOpen": true, "githubLogin": false}
-- request --
PATCH /api/admin/site
@@ -19,7 +19,7 @@ Authorization: Bearer admin-secret
GET /api/site
-- response --
200
{"brandName": "周末听歌会", "registrationOpen": true}
{"brandName": "周末听歌会", "registrationOpen": true, "githubLogin": false}
-- request --
PATCH /api/admin/site
@@ -72,7 +72,7 @@ Authorization: Bearer admin-secret
GET /api/site
-- response --
200
{"brandName": "周末听歌会", "registrationOpen": false}
{"brandName": "周末听歌会", "registrationOpen": false, "githubLogin": false}
-- request --
POST /api/members
@@ -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
+249
View File
@@ -0,0 +1,249 @@
播放历史:开播时记给房间里的成员,开播之后进来的成员也记上正在播放的歌,关闭了记录的成员不记,已有的历史保留;
可以删除一首、清空;游客登录账号时,游客的历史并入账号。读取历史要有成员 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", "github": false}}
-- request --
GET /api/me/listens
Authorization: Bearer $alice
-- response --
200
{"items": []}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$bob", "member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}}
-- request --
DELETE /api/me/listen-recording
Authorization: Bearer $bob
-- response --
204
-- request --
GET /api/me
Authorization: Bearer $bob
-- response --
200
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}, "recordListens": false}
-- request --
POST /api/admin/rooms
Authorization: Bearer admin-secret
{"name": "大厅", "description": ""}
-- response --
201
Location: /api/rooms/$room
{"id": "$room"}
-- request --
POST /api/rooms/$room/tickets
Authorization: Bearer $alice
{}
-- response --
201
{"ticket": "$ticketA"}
-- connect alice --
-- send alice --
{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$room", "ticket": "$ticketA", "protocolVersion": "$protocolVersion"}}
-- receive alice --
{"kind": "response", "type": "hello", "id": "h1", "data": {
"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
"serverTimeMs": 1,
"room": {"term": 1, "seq": 1, "room": {"id": "$room", "name": "大厅", "description": "", "kind": "persistent"},
"playMode": "sequential", "nowPlaying": null, "pending": null, "items": [],
"members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 1}}}}
-- send alice --
{"kind": "request", "type": "queue.add", "id": "a1", "data": {"track": {"source": "netease", "id": "186016"}}}
-- receive alice --
{"kind": "event", "type": "room.activity", "term": 1, "seq": 2, "data": {"kind": "added", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
"entry": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000},
"by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"},
"message": "点了《晴天》"}}
-- receive alice --
{"kind": "event", "type": "queue.updated", "term": 1, "seq": 3, "data": {"items": [],
"pending": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000},
"by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}}
-- receive alice --
{"kind": "response", "type": "queue.add", "id": "a1", "data": {"entry": {"id": "1", "track": {"source": "netease", "id": "186016"},
"meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000},
"by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}}
-- receive alice --
{"kind": "event", "type": "queue.updated", "term": 1, "seq": 4, "data": {"pending": null, "items": []}}
-- receive alice --
{"kind": "event", "type": "playback.updated", "term": 1, "seq": 5, "data": {"nowPlaying": {
"entry": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269500},
"by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "ready"},
"mediaUrl": "/media/netease/186016", "startedAtMs": 1, "durationMs": 269500, "gain": 1, "lyric": "[00:01.00]故事的小黄花"}}}
-- request --
GET /api/me/listens
Authorization: Bearer $alice
-- response --
200
{"items": [{"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269500}, "heardAtMs": 1}]}
-- request --
POST /api/rooms/$room/tickets
Authorization: Bearer $bob
{}
-- response --
201
{"ticket": "$ticketB"}
-- connect bob --
-- send bob --
{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$room", "ticket": "$ticketB", "protocolVersion": "$protocolVersion"}}
-- receive bob --
{"kind": "response", "type": "hello", "id": "h1", "data": {
"member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""},
"serverTimeMs": 1,
"room": {"term": 1, "seq": 6, "room": {"id": "$room", "name": "大厅", "description": "", "kind": "persistent"},
"playMode": "sequential", "pending": null, "items": [],
"nowPlaying": {
"entry": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269500},
"by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "ready"},
"mediaUrl": "/media/netease/186016", "startedAtMs": 1, "durationMs": 269500, "gain": 1, "lyric": "[00:01.00]故事的小黄花"},
"members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, {"id": "$bobId", "name": "$bobName", "avatarHash": ""}], "onlineCount": 2}}}}
-- receive alice --
{"kind": "event", "type": "members.updated", "term": 1, "seq": 6, "data": {
"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, {"id": "$bobId", "name": "$bobName", "avatarHash": ""}], "onlineCount": 2}}
-- request --
GET /api/me/listens
Authorization: Bearer $bob
-- response --
200
{"items": []}
-- request --
PUT /api/me/listen-recording
Authorization: Bearer $bob
{}
-- response --
204
-- request --
GET /api/me
Authorization: Bearer $bob
-- response --
200
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}, "recordListens": true}
-- disconnect bob --
-- receive alice --
{"kind": "event", "type": "members.updated", "term": 1, "seq": 7, "data": {
"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 1}}
-- connect again --
-- send again --
{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$room", "ticket": "$ticketB", "protocolVersion": "$protocolVersion"}}
-- receive again --
{"kind": "response", "type": "hello", "id": "h1", "data": {
"member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""},
"serverTimeMs": 1,
"room": {"term": 1, "seq": 8, "room": {"id": "$room", "name": "大厅", "description": "", "kind": "persistent"},
"playMode": "sequential", "pending": null, "items": [],
"nowPlaying": {
"entry": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269500},
"by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "ready"},
"mediaUrl": "/media/netease/186016", "startedAtMs": 1, "durationMs": 269500, "gain": 1, "lyric": "[00:01.00]故事的小黄花"},
"members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, {"id": "$bobId", "name": "$bobName", "avatarHash": ""}], "onlineCount": 2}}}}
-- receive alice --
{"kind": "event", "type": "members.updated", "term": 1, "seq": 8, "data": {
"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, {"id": "$bobId", "name": "$bobName", "avatarHash": ""}], "onlineCount": 2}}
-- request --
GET /api/me/listens
Authorization: Bearer $bob
-- response --
200
{"items": [{"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269500}, "heardAtMs": 1}]}
-- request --
POST /api/me/listens/removals
Authorization: Bearer $alice
{"track": {"source": "netease", "id": "186016"}}
-- response --
204
-- request --
POST /api/me/listens/removals
Authorization: Bearer $alice
{"track": {"source": "qq", "id": "0039MnYb0qxYhV"}}
-- response --
204
-- request --
GET /api/me/listens
Authorization: Bearer $alice
-- response --
200
{"items": []}
-- request --
POST /api/sessions
Authorization: Bearer $bob
{"username": "alice", "password": "correct horse"}
-- response --
201
{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}, "device": "$bobDevice"}
-- closed again --
4401
-- receive alice --
{"kind": "event", "type": "members.updated", "term": 1, "seq": 9, "data": {
"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 1}}
-- request --
GET /api/me/listens
Authorization: Bearer $alice2
-- response --
200
{"items": [{"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269500}, "heardAtMs": 1}]}
-- request --
DELETE /api/me/listens
Authorization: Bearer $alice2
-- response --
204
-- request --
GET /api/me/listens
Authorization: Bearer $alice
-- response --
200
{"items": []}
-- request --
GET /api/me/listens
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff. Show more