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 (
+ <>
+ }
+ onClick={() => setOpen(true)}
+ >
+ 绑定开球网账号
+
+ 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 ?? '未设置'}
+
修改信息
} onClick={() => handleModifyInfo(modifyRoutes.username)}>修改用户名({user?.username ?? '未设置'})
} onClick={() => handleModifyInfo(modifyRoutes.email)}>修改 E-Mail
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(