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.
This commit is contained in:
parent
09f3ecaca6
commit
c70aeda412
8
__test__/kaiqiu-login.test.ts
Normal file
8
__test__/kaiqiu-login.test.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
@ -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;
|
||||||
@ -24,3 +24,8 @@ model EventSubs {
|
|||||||
event_id String
|
event_id String
|
||||||
@@id([logto_uid, event_id])
|
@@id([logto_uid, event_id])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model UserBind {
|
||||||
|
logto_uid String @unique
|
||||||
|
kaiqiu_uid String
|
||||||
|
}
|
||||||
86
src/components/BindKaiqiuAccount.tsx
Normal file
86
src/components/BindKaiqiuAccount.tsx
Normal file
@ -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: (
|
||||||
|
<div>
|
||||||
|
打开微信小程序 - 我的 - 修改资料 - 用户安全 - 重置密码
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [modal]);
|
||||||
|
if (isBindReq.data?.isBinded === undefined) return null;
|
||||||
|
if (isBindReq.data?.isBinded) {
|
||||||
|
return (
|
||||||
|
<Typography.Text type="secondary">UID: {isBindReq.data?.uid ?? '-'}</Typography.Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
icon={<LinkOutlined />}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
绑定开球网账号
|
||||||
|
</Button>
|
||||||
|
<Drawer
|
||||||
|
title="绑定开球网账号"
|
||||||
|
placement="bottom"
|
||||||
|
size={500}
|
||||||
|
open={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<Flex gap={12} vertical>
|
||||||
|
<Alert type='success' description="后台不会存储账号密码,仅用于登录开球网获取数据。绑定后修改密码不影响绑定结果" />
|
||||||
|
<Spin spinning={bindRequest.loading}>
|
||||||
|
<Form form={form}>
|
||||||
|
<Form.Item rules={[{ required: true, message: '' }]} label="开球网用户名" name={'username'}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item rules={[{ required: true, message: '' }]} label="开球网密码" name={'password'}>
|
||||||
|
<Input.Password />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Spin>
|
||||||
|
<Button block type="primary" loading={bindRequest.loading} onClick={handleBind}>绑定</Button>
|
||||||
|
<Button block type="link" onClick={handleForgotPass}>忘记密码?</Button>
|
||||||
|
</Flex>
|
||||||
|
</Drawer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -59,7 +59,7 @@ export function EventCard(props: EventCardProps) {
|
|||||||
const statistic = getStatisticProps();
|
const statistic = getStatisticProps();
|
||||||
setMessageFormat(statistic.format);
|
setMessageFormat(statistic.format);
|
||||||
setStatisticType(statistic.type);
|
setStatisticType(statistic.type);
|
||||||
console.debug('format: %s', day.format(statistic.format), statistic);
|
// console.debug('format: %s', day.format(statistic.format), statistic);
|
||||||
}, [getStatisticProps])
|
}, [getStatisticProps])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = day.toDate().getTime() - Date.now();
|
const timeout = day.toDate().getTime() - Date.now();
|
||||||
|
|||||||
0
src/components/WebSocketHandler/Handler.tsx
Normal file
0
src/components/WebSocketHandler/Handler.tsx
Normal file
23
src/components/WebSocketHandler/useAuthSocket.ts
Normal file
23
src/components/WebSocketHandler/useAuthSocket.ts
Normal file
@ -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<WebSocket>(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;
|
||||||
|
}
|
||||||
@ -1,4 +1,3 @@
|
|||||||
import { serve } from "bun";
|
|
||||||
import { getMatchInfo, verifyLogtoToken, xcxApi } from "./utils/server";
|
import { getMatchInfo, verifyLogtoToken, xcxApi } from "./utils/server";
|
||||||
import ics from 'ics';
|
import ics from 'ics';
|
||||||
import index from "./index.html";
|
import index from "./index.html";
|
||||||
@ -9,14 +8,16 @@ import { KaiqiuService } from "./services/KaiqiuService";
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import utc from 'dayjs/plugin/utc';
|
import utc from 'dayjs/plugin/utc';
|
||||||
import timezone from 'dayjs/plugin/timezone';
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
import type { IEventInfo } from "./types";
|
import type { IEventInfo, WsPaylaod } from "./types";
|
||||||
import { EventSubscribeService } from "./services/EventSubscribeService";
|
import { EventSubscribeService } from "./services/EventSubscribeService";
|
||||||
import { WebSocketService } from "./services/WebsocketService";
|
import { WebSocketService } from "./services/WebsocketService";
|
||||||
|
import type { JWTPayload } from "jose";
|
||||||
|
import { prisma } from "./prisma/db";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
const server = serve({
|
const server = Bun.serve({
|
||||||
port: process.env.PORT || 3000,
|
port: process.env.PORT || 3000,
|
||||||
routes: {
|
routes: {
|
||||||
// Serve index.html for all unmatched routes.
|
// Serve index.html for all unmatched routes.
|
||||||
@ -226,18 +227,52 @@ const server = serve({
|
|||||||
return Response.json(data);
|
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': {
|
'/ws': {
|
||||||
async GET(req, server) {
|
async GET(req, server) {
|
||||||
const user = await verifyLogtoToken(req.headers).catch(() => undefined);
|
const token = new URL(req.url).searchParams.get('token');
|
||||||
if (!user?.sub) return new Response("Unauthorized", {
|
if (!token) return new Response('Not valid token', { status: 401 });
|
||||||
status: 401,
|
const user = await verifyLogtoToken(token) as Required<JWTPayload>;
|
||||||
|
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: {
|
websocket: {
|
||||||
|
data: {} as WsPaylaod,
|
||||||
open(ws) {
|
open(ws) {
|
||||||
WebSocketService.addConnection(ws);
|
WebSocketService.addConnection(ws);
|
||||||
},
|
},
|
||||||
@ -245,6 +280,7 @@ const server = serve({
|
|||||||
WebSocketService.processMessage(ws, message);
|
WebSocketService.processMessage(ws, message);
|
||||||
},
|
},
|
||||||
close(ws, code, reason) {
|
close(ws, code, reason) {
|
||||||
|
console.debug('close ws', code, reason)
|
||||||
WebSocketService.removeConnection(ws);
|
WebSocketService.removeConnection(ws);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { AUTH_CALLBACK_URL, USER_CENTER_URL } from "../utils/front";
|
|||||||
import useAutoLogin from "../hooks/useAutoLogin";
|
import useAutoLogin from "../hooks/useAutoLogin";
|
||||||
import { LOGTO_DOMAIN } from "../utils/common";
|
import { LOGTO_DOMAIN } from "../utils/common";
|
||||||
import { useAppVersion } from "../hooks/useAppVersion";
|
import { useAppVersion } from "../hooks/useAppVersion";
|
||||||
|
import { BindKaiqiuAccount } from "../components/BindKaiqiuAccount";
|
||||||
|
|
||||||
enum modifyRoutes {
|
enum modifyRoutes {
|
||||||
username = '/account/username',
|
username = '/account/username',
|
||||||
@ -81,6 +82,7 @@ export const UserCenter = () => {
|
|||||||
<Flex>
|
<Flex>
|
||||||
<Typography.Text>{user?.username ?? user?.name ?? '未设置'}</Typography.Text>
|
<Typography.Text>{user?.username ?? user?.name ?? '未设置'}</Typography.Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
<BindKaiqiuAccount />
|
||||||
<Divider>修改信息</Divider>
|
<Divider>修改信息</Divider>
|
||||||
<Button block icon={<EditOutlined />} onClick={() => handleModifyInfo(modifyRoutes.username)}>修改用户名({user?.username ?? '未设置'})</Button>
|
<Button block icon={<EditOutlined />} onClick={() => handleModifyInfo(modifyRoutes.username)}>修改用户名({user?.username ?? '未设置'})</Button>
|
||||||
<Button block icon={<MailOutlined />} onClick={() => handleModifyInfo(modifyRoutes.email)}>修改 E-Mail</Button>
|
<Button block icon={<MailOutlined />} onClick={() => handleModifyInfo(modifyRoutes.email)}>修改 E-Mail</Button>
|
||||||
|
|||||||
@ -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(?<uid>\d+)\.html/.exec(href)?.groups?.uid;
|
||||||
|
return { uid, success, cookies: authCookies };
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -1,52 +1,111 @@
|
|||||||
import type { JWTPayload } from "jose";
|
|
||||||
import { fromCustomMessage, toCustomMessage, WSTopic } from "../utils/common";
|
import { fromCustomMessage, toCustomMessage, WSTopic } from "../utils/common";
|
||||||
import { EventSubscribeService } from "./EventSubscribeService";
|
import { EventSubscribeService } from "./EventSubscribeService";
|
||||||
|
import type { WsPaylaod } from "../types";
|
||||||
|
|
||||||
const publicTopics = [
|
const publicTopics = [
|
||||||
WSTopic.ONLINE_MEMBER_CHANGE,
|
WSTopic.MEMBER_CHANGE,
|
||||||
];
|
];
|
||||||
|
|
||||||
export class WebSocketService {
|
type BunServerWebSocket = Bun.ServerWebSocket<WsPaylaod>;
|
||||||
static #connections = new Set<Bun.ServerWebSocket>();
|
|
||||||
static #userSubKeys = new Map<string, Set<string>>();
|
|
||||||
|
|
||||||
static async addConnection(ws: Bun.ServerWebSocket) {
|
export class WebSocketService {
|
||||||
const user = JSON.parse(ws.data ?? '{}') as Required<JWTPayload>;
|
static #connections = new Set<BunServerWebSocket>();
|
||||||
const subEvets = await EventSubscribeService.getEvents(user.sub).then(e => e.map(v => `event:${e}`));
|
static #userSubTopics = new Map<string, Set<string>>();
|
||||||
|
static #userClients = new Map<string, Set<BunServerWebSocket>>();
|
||||||
|
|
||||||
|
static async addConnection(ws: BunServerWebSocket) {
|
||||||
this.#connections.add(ws);
|
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, publicTopics);
|
||||||
this.userSub(ws, user.sub, subEvets);
|
this.userSub(ws, user.sub, subEvets);
|
||||||
const message = toCustomMessage(WSTopic.ONLINE_MEMBER_CHANGE, this.#connections.size);
|
}
|
||||||
|
|
||||||
|
static publish(ws: BunServerWebSocket, topic: string, message: string, withSelf?: boolean) {
|
||||||
|
ws.publish(topic, message);
|
||||||
|
if (withSelf) {
|
||||||
ws.send(message);
|
ws.send(message);
|
||||||
ws.publish(WSTopic.ONLINE_MEMBER_CHANGE, message);
|
|
||||||
}
|
}
|
||||||
static removeConnection(ws: Bun.ServerWebSocket) {
|
}
|
||||||
const user = JSON.parse(ws.data ?? '{}') as Required<JWTPayload>;
|
|
||||||
this.userUnSub(ws, user.sub, [...this.#userSubKeys.get(user.sub) ?? []])
|
static async userClientsBroadcast(user: string, topic: string, message: string) {
|
||||||
this.userUnSub(ws, user.sub, publicTopics);
|
this.#userClients.get(user)?.forEach(ws => ws.send(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
static removeConnection(ws: BunServerWebSocket) {
|
||||||
this.#connections.delete(ws);
|
this.#connections.delete(ws);
|
||||||
ws.publish(WSTopic.ONLINE_MEMBER_CHANGE, toCustomMessage(WSTopic.ONLINE_MEMBER_CHANGE, this.#connections.size));
|
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));
|
||||||
}
|
}
|
||||||
static userSub(ws: Bun.ServerWebSocket, user: string, keys: string[]) {
|
this.userClientsBroadcast(
|
||||||
if (!this.#userSubKeys.has(user)) {
|
ws.data.user.sub,
|
||||||
this.#userSubKeys.set(user, new Set<string>());
|
WSTopic.MY_CLIENT_ONLINE,
|
||||||
|
toCustomMessage(WSTopic.MY_CLIENT_ONLINE, this.#userClients.get(ws.data.user.sub)?.size),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
console.debug('User %s subscribe keys: %s', user, keys.join(','));
|
|
||||||
keys.forEach(key => {
|
static userSub(ws: BunServerWebSocket, user: string, topics: string[]) {
|
||||||
this.#userSubKeys.get(user)?.add(key);
|
if (!this.#userSubTopics.has(user)) {
|
||||||
ws.subscribe(key);
|
this.#userSubTopics.set(user, new Set<string>());
|
||||||
|
}
|
||||||
|
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(','));
|
static broadcast(topic: string, message: string) {
|
||||||
keys.forEach(key => {
|
this.#connections.values().forEach(con => {
|
||||||
this.#userSubKeys.get(user)?.delete(key);
|
if (con.isSubscribed(topic)) {
|
||||||
ws.unsubscribe(key);
|
con.send(message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
static processMessage(ws: Bun.ServerWebSocket, message: string | Buffer<ArrayBuffer>) {
|
|
||||||
|
static processMessage(ws: BunServerWebSocket, message: string | Buffer<ArrayBuffer>) {
|
||||||
const { clientTopic: action, data } = fromCustomMessage(message.toString());
|
const { clientTopic: action, data } = fromCustomMessage(message.toString());
|
||||||
if (!action) return;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
import type { JWTPayload } from 'jose';
|
||||||
|
|
||||||
export * from './profile';
|
export * from './profile';
|
||||||
export * from './tag';
|
export * from './tag';
|
||||||
export interface Config {
|
export interface Config {
|
||||||
@ -77,3 +79,7 @@ export interface ClubDetail {
|
|||||||
img: string;
|
img: string;
|
||||||
geo: { lng: number; lat: number; } | null;
|
geo: { lng: number; lat: number; } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WsPaylaod = {
|
||||||
|
user: Required<JWTPayload>;
|
||||||
|
}
|
||||||
|
|||||||
@ -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 });
|
return JSON.stringify({ topic: clientTopic, data });
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum WSTopic {
|
export enum WSTopic {
|
||||||
UNKONOW = 'UNKONOW',
|
UNKONOW = 'UNKONW',
|
||||||
ONLINE_MEMBER_CHANGE = 'ONLINE_MEMBER_CHANGE',
|
MY_CLIENT_ONLINE = 'CLIENT_ONLINE',
|
||||||
|
MEMBER_CHANGE = 'MEMBER_CHNAGE',
|
||||||
|
SUB = "SUB",
|
||||||
|
UNSUB = "UNSUB",
|
||||||
|
SEND = "PUBLISH",
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fromCustomMessage(message: string): {
|
export function fromCustomMessage(message: string): {
|
||||||
|
|||||||
@ -91,7 +91,7 @@ export const extractBearerTokenFromHeaders = (authorization: string | null) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!authorization.startsWith('Bearer')) {
|
if (!authorization.startsWith('Bearer')) {
|
||||||
return null;
|
return authorization;
|
||||||
}
|
}
|
||||||
|
|
||||||
return authorization.slice(7); // The length of 'Bearer ' is 7
|
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`));
|
const jwks = createRemoteJWKSet(new URL(`${LOGTO_DOMAIN}/oidc/jwks`));
|
||||||
|
|
||||||
export const verifyLogtoToken = async (headers: Headers) => {
|
export const verifyLogtoToken = async (headers: Headers | string) => {
|
||||||
const auth = headers.get('Authorization');
|
const auth = typeof headers === 'string' ? headers : headers.get('Authorization');
|
||||||
const token = extractBearerTokenFromHeaders(auth)
|
const token = extractBearerTokenFromHeaders(auth);
|
||||||
// console.debug('Authorization', { auth, token });
|
// console.debug('Authorization', { auth, token });
|
||||||
if (!token) return {};
|
if (!token) return {};
|
||||||
const { payload } = await jwtVerify(
|
const { payload } = await jwtVerify(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user