From c70aeda41250308303d3f389879fbcbf57004f62 Mon Sep 17 00:00:00 2001 From: kyuuseiryuu Date: Wed, 18 Mar 2026 01:18:34 +0900 Subject: [PATCH] feat(auth): add Kaiqiu account binding and update WebSocket architecture - Add `UserBind` model to Prisma schema for linking Logto and Kaiqiu user IDs. - Implement `/api/account/bind` endpoint (GET/PUT) to handle Kaiqiu account binding and retrieval. - Refactor `KaiqiuService` to include a `login` method that performs browser automation-like login on kaiqiuwang.cc using `cheerio` and `fetch`. - Update `UserCenter` page to include a new `BindKaiqiuAccount` component. - Restructure `WebSocketService`: - Change from a simple global connection map to a per-user client tracking system (`#userClients`). - Update topic naming conventions (e.g., `ONLINE_MEMBER_CHANGE` -> `MEMBER_CHANGE`). - Add client-side broadcast capabilities for user-specific events like `MY_CLIENT_ONLINE`. - Add support for dynamic subscription handling (SUB/UNSUB) via WebSocket messages. - Update `verifyLogtoToken` to accept either `Headers` or a raw token string for flexibility in WebSocket auth. - Minor fixes: typo corrections in `WSTopic` enum and commented out debug logs. BREAKING CHANGE: WebSocket payload structure has changed. The `ws.data` property now contains a `WsPaylaod` object with a `user` field (previously it was a JSON string of the JWT payload). The `WSTopic` names have been updated (e.g., `ONLINE_MEMBER_CHANGE` is now `MEMBER_CHANGE`), requiring updates to any client code subscribing to these topics. --- __test__/kaiqiu-login.test.ts | 8 ++ .../migration.sql | 7 + prisma/schema.prisma | 5 + src/components/BindKaiqiuAccount.tsx | 86 ++++++++++++ src/components/EventCard.tsx | 2 +- src/components/WebSocketHandler/Handler.tsx | 0 .../WebSocketHandler/useAuthSocket.ts | 23 ++++ src/index.tsx | 52 ++++++-- src/page/UserCenter.tsx | 2 + src/services/KaiqiuService.ts | 78 +++++++++++ src/services/WebsocketService.ts | 123 +++++++++++++----- src/types/index.ts | 8 +- src/utils/common.ts | 10 +- src/utils/server.ts | 8 +- 14 files changed, 363 insertions(+), 49 deletions(-) create mode 100644 __test__/kaiqiu-login.test.ts create mode 100644 prisma/migrations/20260317152301_add_user_bind_table/migration.sql create mode 100644 src/components/BindKaiqiuAccount.tsx create mode 100644 src/components/WebSocketHandler/Handler.tsx create mode 100644 src/components/WebSocketHandler/useAuthSocket.ts diff --git a/__test__/kaiqiu-login.test.ts b/__test__/kaiqiu-login.test.ts new file mode 100644 index 0000000..5fc3d70 --- /dev/null +++ b/__test__/kaiqiu-login.test.ts @@ -0,0 +1,8 @@ +import { test, expect } from 'bun:test'; +import { KaiqiuService } from '../src/services/KaiqiuService'; + +test('login success', async () => { + const { html, ...result } = await KaiqiuService.login('', ''); + console.debug(result); + expect(result.cookies).toBeDefined(); +}); \ No newline at end of file diff --git a/prisma/migrations/20260317152301_add_user_bind_table/migration.sql b/prisma/migrations/20260317152301_add_user_bind_table/migration.sql new file mode 100644 index 0000000..5c2b661 --- /dev/null +++ b/prisma/migrations/20260317152301_add_user_bind_table/migration.sql @@ -0,0 +1,7 @@ +-- CreateTable +CREATE TABLE `UserBind` ( + `logto_uid` VARCHAR(191) NOT NULL, + `kaiqiu_uid` VARCHAR(191) NOT NULL, + + UNIQUE INDEX `UserBind_logto_uid_key`(`logto_uid`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f412e3c..dff1318 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -23,4 +23,9 @@ model EventSubs { logto_uid String event_id String @@id([logto_uid, event_id]) +} + +model UserBind { + logto_uid String @unique + kaiqiu_uid String } \ No newline at end of file diff --git a/src/components/BindKaiqiuAccount.tsx b/src/components/BindKaiqiuAccount.tsx new file mode 100644 index 0000000..f29ffe3 --- /dev/null +++ b/src/components/BindKaiqiuAccount.tsx @@ -0,0 +1,86 @@ +import { useRequest } from "ahooks"; +import { useAuthHeaders } from "../hooks/useAuthHeaders"; +import { Alert, App, Button, Drawer, Flex, Form, Input, Spin, Typography } from "antd"; +import { LinkOutlined } from "@ant-design/icons"; +import { useCallback, useState } from "react"; + +export const BindKaiqiuAccount = () => { + const headers = useAuthHeaders(); + const isBindReq = useRequest<{ + isBinded: boolean; + uid?: string; + }, []>(async () => { + return fetch('/api/account/bind', { headers }).then(res => res.json()); + }, { manual: false, debounceWait: 300 }); + const [open, setOpen] = useState(false); + const [form] = Form.useForm<{ password: string, username: string }>(); + const { modal, message } = App.useApp(); + const bindRequest = useRequest(async (data) => { + const result = await fetch('/api/account/bind', { + method: 'PUT', + body: JSON.stringify(data), + headers, + }).then(res => res.json()); + return result; + }, { manual: true }); + const handleBind = useCallback(async () => { + const data = await form.validateFields().catch(() => Promise.reject()); + const result = await bindRequest.runAsync(data); + if (!result.success) { + message.error(result.message); + return; + } + await isBindReq.runAsync(); + message.success('绑定成功'); + setOpen(false); + }, [form, headers, isBindReq, message]); + const handleForgotPass = useCallback(() => { + modal.info({ + title: '忘记密码', + content: ( +
+ 打开微信小程序 - 我的 - 修改资料 - 用户安全 - 重置密码 +
+ ), + }); + }, [modal]); + if (isBindReq.data?.isBinded === undefined) return null; + if (isBindReq.data?.isBinded) { + return ( + UID: {isBindReq.data?.uid ?? '-'} + ); + } + return ( + <> + + setOpen(false)} + > + + + +
+ + + + + + +
+
+ + +
+
+ + ); +} \ No newline at end of file diff --git a/src/components/EventCard.tsx b/src/components/EventCard.tsx index 2aceb5a..2422d51 100644 --- a/src/components/EventCard.tsx +++ b/src/components/EventCard.tsx @@ -59,7 +59,7 @@ export function EventCard(props: EventCardProps) { const statistic = getStatisticProps(); setMessageFormat(statistic.format); setStatisticType(statistic.type); - console.debug('format: %s', day.format(statistic.format), statistic); + // console.debug('format: %s', day.format(statistic.format), statistic); }, [getStatisticProps]) useEffect(() => { const timeout = day.toDate().getTime() - Date.now(); diff --git a/src/components/WebSocketHandler/Handler.tsx b/src/components/WebSocketHandler/Handler.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/components/WebSocketHandler/useAuthSocket.ts b/src/components/WebSocketHandler/useAuthSocket.ts new file mode 100644 index 0000000..d63f5c0 --- /dev/null +++ b/src/components/WebSocketHandler/useAuthSocket.ts @@ -0,0 +1,23 @@ +import { useLogto } from "@logto/react"; +import { useRef } from "react" +import { useRequest } from "ahooks"; +import { LOGTO_RESOURCE } from "../../utils/constants"; + +export const useAuthSocket = () => { + const wsRef = useRef(null); + const { isAuthenticated, getAccessToken } = useLogto(); + const initWs = useRequest(async () => { + if (!isAuthenticated) return; + if (wsRef.current) { + if (wsRef.current.readyState === WebSocket.OPEN) { + return wsRef.current; + } + } + const token = await getAccessToken(LOGTO_RESOURCE); + const url = `${window.origin}/ws?token=${token}`.replace(/^http/, 'ws'); + const ws = new WebSocket(url); + wsRef.current = ws; + return ws; + }, { manual: false, refreshDeps: [isAuthenticated, getAccessToken], debounceWait: 300 }); + return initWs; +} \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx index 9e14bee..8655173 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,3 @@ -import { serve } from "bun"; import { getMatchInfo, verifyLogtoToken, xcxApi } from "./utils/server"; import ics from 'ics'; import index from "./index.html"; @@ -9,14 +8,16 @@ import { KaiqiuService } from "./services/KaiqiuService"; import dayjs from "dayjs"; import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; -import type { IEventInfo } from "./types"; +import type { IEventInfo, WsPaylaod } from "./types"; import { EventSubscribeService } from "./services/EventSubscribeService"; import { WebSocketService } from "./services/WebsocketService"; +import type { JWTPayload } from "jose"; +import { prisma } from "./prisma/db"; dayjs.extend(utc); dayjs.extend(timezone); -const server = serve({ +const server = Bun.serve({ port: process.env.PORT || 3000, routes: { // Serve index.html for all unmatched routes. @@ -226,18 +227,52 @@ const server = serve({ return Response.json(data); } }, + '/api/account/bind': { + async GET(req) { + const { sub } = await verifyLogtoToken(req.headers); + if (!sub) return Response.json(false); + const record = await prisma.userBind.findFirst({ + where: { logto_uid: sub }, + }); + return Response.json({ + isBinded: record !== null, + uid: record?.kaiqiu_uid, + }); + }, + async PUT(req) { + const { sub } = await verifyLogtoToken(req.headers); + if (!sub) return Response.json({ message: 'Not login' }, { status: 401 }); + const { username, password } = await req.json(); + const result = await KaiqiuService.login(username, password); + if (!result.success || !result.uid) return Response.json({ + message: '账号或密码错误', + success: false, + }); + const { uid } = result; + await prisma.userBind.create({ + data: { logto_uid: sub, kaiqiu_uid: uid }, + }).catch(); + return Response.json({ + message: '绑定成功', + success: true, + }); + } + }, '/ws': { async GET(req, server) { - const user = await verifyLogtoToken(req.headers).catch(() => undefined); - if (!user?.sub) return new Response("Unauthorized", { - status: 401, + const token = new URL(req.url).searchParams.get('token'); + if (!token) return new Response('Not valid token', { status: 401 }); + const user = await verifyLogtoToken(token) as Required; + if (!user.sub) return new Response('Not valid token', { status: 401 }); + console.debug('ws connect', user.sub); + server.upgrade(req, { + data: { user }, }); - server.upgrade(req, { data: JSON.stringify(user) as any }); - return new Response('Upgraded'); } } }, websocket: { + data: {} as WsPaylaod, open(ws) { WebSocketService.addConnection(ws); }, @@ -245,6 +280,7 @@ const server = serve({ WebSocketService.processMessage(ws, message); }, close(ws, code, reason) { + console.debug('close ws', code, reason) WebSocketService.removeConnection(ws); }, }, diff --git a/src/page/UserCenter.tsx b/src/page/UserCenter.tsx index 624843d..6f4a22b 100644 --- a/src/page/UserCenter.tsx +++ b/src/page/UserCenter.tsx @@ -7,6 +7,7 @@ import { AUTH_CALLBACK_URL, USER_CENTER_URL } from "../utils/front"; import useAutoLogin from "../hooks/useAutoLogin"; import { LOGTO_DOMAIN } from "../utils/common"; import { useAppVersion } from "../hooks/useAppVersion"; +import { BindKaiqiuAccount } from "../components/BindKaiqiuAccount"; enum modifyRoutes { username = '/account/username', @@ -81,6 +82,7 @@ export const UserCenter = () => { {user?.username ?? user?.name ?? '未设置'} + 修改信息 diff --git a/src/services/KaiqiuService.ts b/src/services/KaiqiuService.ts index d4cbebc..e693321 100644 --- a/src/services/KaiqiuService.ts +++ b/src/services/KaiqiuService.ts @@ -159,4 +159,82 @@ export class KaiqiuService { } } + public static async login(username: string, password: string) { + const loginPageRes = await fetch('https://kaiqiuwang.cc/home/do.php?ac=668g&&ref'); + const cookies = loginPageRes.headers.getSetCookie().map(c => c.split(';')[0]).join('; '); + console.debug('init cookies: ', cookies); + let $ = cheerio.load(await loginPageRes.text()); + const loginsubmit = $('#loginsubmit').val()?.toString() || ''; + const formhash = $('#loginform > input[type=hidden]').val()?.toString() || ''; + const cookietime = $('#cookietime').val()?.toString() || ''; + const refer = "https://kaiqiuwang.cc/home/do.php?ac=668g&&ref"; + const formData = new FormData(); + formData.append('loginsubmit', loginsubmit); + formData.append('cookietime', cookietime); + formData.append('formhash', formhash); + formData.append('username', username); + formData.append('password', password); + formData.append('refer', refer); + const loginRes = await fetch("https://kaiqiuwang.cc/home/do.php?ac=668g&&ref", { + "headers": { + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", + "accept-language": "zh-CN,zh;q=0.9", + "cache-control": "no-cache", + "content-type": "application/x-www-form-urlencoded", + "pragma": "no-cache", + "priority": "u=0, i", + "sec-ch-ua": "\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Brave\";v=\"146\"", + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": "\"Windows\"", + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "sec-fetch-site": "same-origin", + "sec-fetch-user": "?1", + "sec-gpc": "1", + "upgrade-insecure-requests": "1", + "cookie": cookies, + "Referer": "https://kaiqiuwang.cc/home/do.php?ac=668g" + }, + "body": new URLSearchParams(Object.fromEntries(formData.entries())), + "method": "POST" + }); + const PHPSESSID = cookies.split('; ').filter(cookie => cookie.startsWith('PHPSESSID'))[0]; + const authCookies = [ + PHPSESSID, + loginRes.headers + .getSetCookie() + .map(c => c.split(';')[0]) + .filter(cookie => !cookie?.includes('uchome_auth=deleted')) + .join('; ') + ].join('; '); + console.debug('auth cookies', authCookies); + const networkPageReq = await fetch("https://kaiqiuwang.cc/home/network.php", { + "headers": { + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", + "accept-language": "zh-CN,zh;q=0.9", + "cache-control": "no-cache", + "pragma": "no-cache", + "priority": "u=0, i", + "sec-ch-ua": "\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Brave\";v=\"146\"", + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": "\"Windows\"", + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "sec-fetch-site": "none", + "sec-fetch-user": "?1", + "sec-gpc": "1", + "upgrade-insecure-requests": "1", + "cookie": authCookies, + }, + "method": "GET", + "redirect": 'follow', + }); + const html = await networkPageReq.text(); + $ = cheerio.load(html); + const success = $('#header_content > div.user_info > div.login_ext').text().includes('欢迎'); + const href = $('#header_content > div.user_info > div.login_ext > p > a.loginName').attr('href') || ''; + const uid = /\b(?\d+)\.html/.exec(href)?.groups?.uid; + return { uid, success, cookies: authCookies }; + } + } \ No newline at end of file diff --git a/src/services/WebsocketService.ts b/src/services/WebsocketService.ts index 058f19a..1acebe4 100644 --- a/src/services/WebsocketService.ts +++ b/src/services/WebsocketService.ts @@ -1,52 +1,111 @@ -import type { JWTPayload } from "jose"; import { fromCustomMessage, toCustomMessage, WSTopic } from "../utils/common"; import { EventSubscribeService } from "./EventSubscribeService"; +import type { WsPaylaod } from "../types"; const publicTopics = [ - WSTopic.ONLINE_MEMBER_CHANGE, + WSTopic.MEMBER_CHANGE, ]; +type BunServerWebSocket = Bun.ServerWebSocket; + export class WebSocketService { - static #connections = new Set(); - static #userSubKeys = new Map>(); - - static async addConnection(ws: Bun.ServerWebSocket) { - const user = JSON.parse(ws.data ?? '{}') as Required; - const subEvets = await EventSubscribeService.getEvents(user.sub).then(e => e.map(v => `event:${e}`)); + static #connections = new Set(); + static #userSubTopics = new Map>(); + static #userClients = new Map>(); + + static async addConnection(ws: BunServerWebSocket) { this.#connections.add(ws); + const user = ws.data.user; + let isNewMember = false; + if (!this.#userClients.has(user.sub)) { + this.#userClients.set(user.sub, new Set()); + isNewMember = true; + } + this.#userClients.get(user.sub)?.add(ws); + await this.#initSubscribe(ws); + const message = toCustomMessage(WSTopic.MEMBER_CHANGE, this.#userClients.size); + if (isNewMember) { + this.broadcast(WSTopic.MEMBER_CHANGE, message); + } else { + ws.send(message); + } + this.userClientsBroadcast( + ws.data.user.sub, + WSTopic.MY_CLIENT_ONLINE, + toCustomMessage(WSTopic.MY_CLIENT_ONLINE, this.#userClients.get(user.sub)?.size), + ); + } + + static async #initSubscribe(ws: BunServerWebSocket) { + const user = ws.data.user + const subEvets = await EventSubscribeService.getEvents(user.sub).then(e => e.map(v => `event:${v}`)); this.userSub(ws, user.sub, publicTopics); this.userSub(ws, user.sub, subEvets); - const message = toCustomMessage(WSTopic.ONLINE_MEMBER_CHANGE, this.#connections.size); - ws.send(message); - ws.publish(WSTopic.ONLINE_MEMBER_CHANGE, message); } - static removeConnection(ws: Bun.ServerWebSocket) { - const user = JSON.parse(ws.data ?? '{}') as Required; - this.userUnSub(ws, user.sub, [...this.#userSubKeys.get(user.sub) ?? []]) - this.userUnSub(ws, user.sub, publicTopics); - this.#connections.delete(ws); - ws.publish(WSTopic.ONLINE_MEMBER_CHANGE, toCustomMessage(WSTopic.ONLINE_MEMBER_CHANGE, this.#connections.size)); - } - static userSub(ws: Bun.ServerWebSocket, user: string, keys: string[]) { - if (!this.#userSubKeys.has(user)) { - this.#userSubKeys.set(user, new Set()); + + static publish(ws: BunServerWebSocket, topic: string, message: string, withSelf?: boolean) { + ws.publish(topic, message); + if (withSelf) { + ws.send(message); } - console.debug('User %s subscribe keys: %s', user, keys.join(',')); - keys.forEach(key => { - this.#userSubKeys.get(user)?.add(key); - ws.subscribe(key); + } + + static async userClientsBroadcast(user: string, topic: string, message: string) { + this.#userClients.get(user)?.forEach(ws => ws.send(message)); + } + + static removeConnection(ws: BunServerWebSocket) { + this.#connections.delete(ws); + console.debug('Someone disconnected. User: ', ws.data.user.sub); + this.#userClients.get(ws.data.user.sub)?.delete(ws); + if (this.#userClients.get(ws.data.user.sub)?.size === 0) { + this.#userClients.delete(ws.data.user.sub); + this.publish(ws, WSTopic.MEMBER_CHANGE, toCustomMessage(WSTopic.MEMBER_CHANGE, this.#userClients.size)); + } + this.userClientsBroadcast( + ws.data.user.sub, + WSTopic.MY_CLIENT_ONLINE, + toCustomMessage(WSTopic.MY_CLIENT_ONLINE, this.#userClients.get(ws.data.user.sub)?.size), + ); + } + + static userSub(ws: BunServerWebSocket, user: string, topics: string[]) { + if (!this.#userSubTopics.has(user)) { + this.#userSubTopics.set(user, new Set()); + } + if (!topics.length) return; + console.debug('User %s subscribe keys: %s', user, topics.join(',')); + topics.forEach(topic => { + this.#userSubTopics.get(user)?.add(topic); + ws.subscribe(topic); }); } - static userUnSub(ws: Bun.ServerWebSocket, user: string, keys: string[]) { - console.debug('User %s subscribe keys: %s', user, keys.join(',')); - keys.forEach(key => { - this.#userSubKeys.get(user)?.delete(key); - ws.unsubscribe(key); + + static broadcast(topic: string, message: string) { + this.#connections.values().forEach(con => { + if (con.isSubscribed(topic)) { + con.send(message); + } }); } - static processMessage(ws: Bun.ServerWebSocket, message: string | Buffer) { + + static processMessage(ws: BunServerWebSocket, message: string | Buffer) { const { clientTopic: action, data } = fromCustomMessage(message.toString()); if (!action) return; - console.debug('Recieve: %s, %s', action, JSON.stringify(data)); + console.debug('Recieve action: %s', action, message); + switch (action) { + case WSTopic.SUB: + ws.subscribe(data.topic); + break; + case WSTopic.UNSUB: + ws.unsubscribe(data.topic); + break; + case WSTopic.SEND: + ws.publish(data.topic, data.message); + break; + default: + this.broadcast("Test", 'This is a broadcast message. Everyone should recieved.'); + break; + } } } \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index fc78a81..16649be 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,5 @@ +import type { JWTPayload } from 'jose'; + export * from './profile'; export * from './tag'; export interface Config { @@ -76,4 +78,8 @@ export interface ClubDetail { article: string; img: string; geo: { lng: number; lat: number; } | null; -} \ No newline at end of file +} + +export type WsPaylaod = { + user: Required; +} diff --git a/src/utils/common.ts b/src/utils/common.ts index e4b8c2b..9fbbc33 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -87,13 +87,17 @@ export function calculate(winerScore: number, loserScore: number) { } } -export function toCustomMessage(clientTopic: string, data: any) { +export function toCustomMessage(clientTopic: string | number, data: any) { return JSON.stringify({ topic: clientTopic, data }); } export enum WSTopic { - UNKONOW = 'UNKONOW', - ONLINE_MEMBER_CHANGE = 'ONLINE_MEMBER_CHANGE', + UNKONOW = 'UNKONW', + MY_CLIENT_ONLINE = 'CLIENT_ONLINE', + MEMBER_CHANGE = 'MEMBER_CHNAGE', + SUB = "SUB", + UNSUB = "UNSUB", + SEND = "PUBLISH", } export function fromCustomMessage(message: string): { diff --git a/src/utils/server.ts b/src/utils/server.ts index 17087e0..084dbc7 100644 --- a/src/utils/server.ts +++ b/src/utils/server.ts @@ -91,7 +91,7 @@ export const extractBearerTokenFromHeaders = (authorization: string | null) => { } if (!authorization.startsWith('Bearer')) { - return null; + return authorization; } return authorization.slice(7); // The length of 'Bearer ' is 7 @@ -99,9 +99,9 @@ export const extractBearerTokenFromHeaders = (authorization: string | null) => { const jwks = createRemoteJWKSet(new URL(`${LOGTO_DOMAIN}/oidc/jwks`)); -export const verifyLogtoToken = async (headers: Headers) => { - const auth = headers.get('Authorization'); - const token = extractBearerTokenFromHeaders(auth) +export const verifyLogtoToken = async (headers: Headers | string) => { + const auth = typeof headers === 'string' ? headers : headers.get('Authorization'); + const token = extractBearerTokenFromHeaders(auth); // console.debug('Authorization', { auth, token }); if (!token) return {}; const { payload } = await jwtVerify(