feat: migrate user fav system from session aud to Logto sub

- Update database schema to rename `UserFav` to `LogtoUserFav` with clearer field names (`logto_uid`, `kaiqiu_uid`).
- Bump `jose` dependency to v6.2.1 for improved JWT verification.
- Configure `@logto/react` to request the correct resource token for API access.
- Implement token verification on the server side using `jose` and `jwtVerify`.
- Update API routes (`/api/fav`) to extract the user ID from the verified JWT `sub` claim instead of the URL `aud` parameter.
- Refactor frontend components (`FavButton`, `FavePlayersPage`) to use `useAuthHeaders` for fetching auth headers instead of manual token claims extraction.
- Clean up unused migration and DAO functions related to the old `aud`-based logic.
This commit is contained in:
kyuuseiryuu 2026-03-16 12:45:12 +09:00
parent ba8dfdf973
commit 9c9b3735cb
13 changed files with 115 additions and 106 deletions

View File

@ -14,6 +14,7 @@
"cheerio": "^1.2.0",
"dayjs": "^1.11.19",
"ics": "^3.8.1",
"jose": "^6.2.1",
"lodash": "^4.17.23",
"mariadb": "^3.5.2",
"react": "^19",
@ -330,7 +331,7 @@
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
"jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="],
"js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="],
@ -496,6 +497,8 @@
"@chevrotain/gast/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"@logto/client/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
"@prisma/adapter-mariadb/mariadb": ["mariadb@3.4.5", "", { "dependencies": { "@types/geojson": "^7946.0.16", "@types/node": "^24.0.13", "denque": "^2.1.0", "iconv-lite": "^0.6.3", "lru-cache": "^10.4.3" } }, "sha512-gThTYkhIS5rRqkVr+Y0cIdzr+GRqJ9sA2Q34e0yzmyhMCwyApf3OKAC1jnF23aSlIOqJuyaUFUcj7O1qZslmmQ=="],
"@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.4.2", "", { "dependencies": { "@prisma/debug": "7.4.2" } }, "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw=="],

View File

@ -18,6 +18,7 @@
"cheerio": "^1.2.0",
"dayjs": "^1.11.19",
"ics": "^3.8.1",
"jose": "^6.2.1",
"lodash": "^4.17.23",
"mariadb": "^3.5.2",
"react": "^19",

View File

@ -1,7 +0,0 @@
-- CreateTable
CREATE TABLE `UserFav` (
`aud` VARCHAR(191) NOT NULL,
`uid` VARCHAR(191) NOT NULL,
PRIMARY KEY (`aud`, `uid`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -0,0 +1,7 @@
-- CreateTable
CREATE TABLE `LogtoUserFav` (
`logto_uid` VARCHAR(191) NOT NULL,
`kaiqiu_uid` VARCHAR(191) NOT NULL,
PRIMARY KEY (`logto_uid`, `kaiqiu_uid`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -13,8 +13,8 @@ datasource db {
provider = "mysql"
}
model UserFav {
aud String
uid String
@@id([aud, uid])
model LogtoUserFav {
logto_uid String
kaiqiu_uid String
@@id([logto_uid, kaiqiu_uid])
}

View File

@ -4,6 +4,7 @@ import { useFavPlayerStore, type FavPlayer } from "../store/useFavPlayerStore";
import styled from "styled-components";
import { useRequest } from "ahooks";
import { useLogto } from "@logto/react";
import { useAuthHeaders } from "../hooks/useAuthHeaders";
interface Props {
user?: FavPlayer;
@ -21,30 +22,30 @@ const StyledContainer = styled.div`
`;
export function FavButton(props: Props) {
const { isAuthenticated, getIdTokenClaims } = useLogto();
const { isAuthenticated } = useLogto();
const { fav, unFav, isFav } = useFavPlayerStore(store => store);
const headers = useAuthHeaders();
const favReq = useRequest(async () => {
if (!isAuthenticated) return;
const claims = await getIdTokenClaims();
await fetch(`/api/fav/${claims?.aud}/${props.user?.uid}`, { method: 'PUT' });
}, { manual: true, refreshDeps: [props, isAuthenticated] });
await fetch(`/api/fav/${props.user?.uid}`, { method: 'PUT', headers });
}, { manual: true });
const unFavReq = useRequest(async () => {
if (!isAuthenticated) return;
const claims = await getIdTokenClaims();
await fetch(`/api/fav/${claims?.aud}/${props.user?.uid}`, { method: 'DELETE' });
}, { manual: true, refreshDeps: [props, isAuthenticated] });
await fetch(`/api/fav/${props.user?.uid}`, { method: 'DELETE', headers });
}, { manual: true });
const [value, setValue] = useState(isFav(props.user?.uid) ? 1 : 0);
useEffect(() => {
if (!props.user) return;
if (!isAuthenticated) return;
const id = setTimeout(async () => {
const claims = await getIdTokenClaims();
const aud = claims?.aud;
const res: { isFav: boolean } = await (await fetch(`/api/fav/${aud}/${props.user?.uid}`)).json();
console.debug('Token', headers);
const res: { isFav: boolean } = await fetch(`/api/fav/${props.user?.uid}`, {
headers,
}).then(res => res.json());
setValue(res.isFav ? 1 : 0);
}, 300);
return () => clearTimeout(id);
}, []);
}, [isAuthenticated, headers]);
const handleFavClick = useCallback((value: number) => {
if (!props.user) return;
setValue(value);

View File

@ -1,30 +1,30 @@
import { prisma } from "../prisma/db";
export async function updateUserFav(aud: string, uid: string) {
const size = await prisma.userFav.count({ where: { aud, uid }});
export async function updateUserFav(logto_uid: string, kaiqiu_uid: string) {
const size = await prisma.logtoUserFav.count({ where: { logto_uid, kaiqiu_uid }});
const isUnFav = size === 0;
if (isUnFav) {
await prisma.userFav.create({
await prisma.logtoUserFav.create({
data: {
aud,
uid,
logto_uid,
kaiqiu_uid,
},
});
}
}
export async function getUserFavList(aud: string) {
const result = await prisma.userFav.findMany({
where: { aud },
select: { uid: true },
export async function getUserFavList(logto_uid: string) {
const result = await prisma.logtoUserFav.findMany({
where: { logto_uid },
select: { kaiqiu_uid: true },
});
return result.map(e => e.uid);
return result.map(e => e.kaiqiu_uid);
}
export async function deleteUserFav(aud: string, uid: string) {
await prisma.userFav.deleteMany({ where: { aud, uid }});
export async function deleteUserFav(logto_uid: string, kaiqiu_uid: string) {
await prisma.logtoUserFav.deleteMany({ where: { logto_uid, kaiqiu_uid }});
}
export async function isUserFav(aud: string, uid: string) {
return (await prisma.userFav.count({ where: { aud, uid }})) > 0;
export async function isUserFav(logto_uid: string, kaiqiu_uid: string) {
return (await prisma.logtoUserFav.count({ where: { kaiqiu_uid, logto_uid }})) > 0;
}

View File

@ -12,12 +12,14 @@ import zhCN from 'antd/locale/zh_CN';
import { RouterProvider } from "react-router";
import { LogtoProvider, type LogtoConfig } from '@logto/react';
import { route } from "./routes";
import { LOGTO_RESOURCE } from "./utils/constants";
const elem = document.getElementById("root")!;
const config: LogtoConfig = {
endpoint: 'https://logto.ksr.la/',
appId: 'iq0oceaeqazpcned7hzil',
resources: [LOGTO_RESOURCE],
};
const app = (
<StrictMode>

View File

@ -0,0 +1,16 @@
import { useLogto } from "@logto/react"
import { useEffect, useState } from "react";
import { LOGTO_RESOURCE } from "../utils/constants";
export const useAuthHeaders = (): HeadersInit => {
const { isAuthenticated, getAccessToken } = useLogto();
const [headers, setHeaders] = useState<HeadersInit>({});
useEffect(() => {
if (isAuthenticated) {
getAccessToken(LOGTO_RESOURCE).then(token => {
setHeaders({ Authorization: `Bearer ${token}` })
});
}
}, [isAuthenticated]);
return headers;
}

View File

@ -1,5 +1,5 @@
import { serve } from "bun";
import { getMatchInfo, xcxApi } from "./utils/server";
import { getMatchInfo, verifyLogtoToken, xcxApi } from "./utils/server";
import ics from 'ics';
import index from "./index.html";
import { getUidScore } from "./services/uidScoreStore";
@ -151,23 +151,27 @@ const server = serve({
return Response.json(profile);
},
},
"/api/fav/:aud": {
"/api/fav": {
async GET(req) {
const list = await listFavPlayers(req.params.aud);
const payload = await verifyLogtoToken(req.headers);
const list = await listFavPlayers(`${payload.sub}`);
return Response.json(list.filter(Boolean));
}
},
"/api/fav/:aud/:uid": {
"/api/fav/:uid": {
async GET(req) {
const isFav = await checkIsUserFav(req.params.aud, req.params.uid);
const payload = await verifyLogtoToken(req.headers);
const isFav = await checkIsUserFav(`${payload.sub}`, req.params.uid);
return Response.json({ isFav });
},
async PUT(req) {
await favPlayer(req.params.aud, req.params.uid);
const payload = await verifyLogtoToken(req.headers);
await favPlayer(`${payload.sub}`, req.params.uid);
return Response.json({ ok: 'ok' });
},
async DELETE(req) {
await unFavPlayer(req.params.aud, req.params.uid);
const payload = await verifyLogtoToken(req.headers);
await unFavPlayer(`${payload.sub}`, req.params.uid);
return Response.json({ ok: 'ok' });
}
},

View File

@ -7,6 +7,7 @@ import { DeleteOutlined, LoginOutlined, SyncOutlined, UploadOutlined } from "@an
import { useRequest } from "ahooks";
import type { XCXProfile } from "../types";
import useAutoLogin from "../hooks/useAutoLogin";
import { useAuthHeaders } from "../hooks/useAuthHeaders";
enum SortType {
DEFAULT = '注册时间',
@ -24,10 +25,11 @@ export function FavePlayersPage() {
const [sortType, setSortType] = useState<SortType>(SortType.DEFAULT);
const [showType, setShowType] = useState(ShowType.LOCAL);
const [claims, setClaims] = useState<IdTokenClaims>();
const headers = useAuthHeaders();
const { login } = useAutoLogin();
const localList = Object.values(favMap);
const favListRequest = useRequest<XCXProfile[], [string]>(async (aud: string) => {
const res = await fetch(`/api/fav/${aud}`);
const res = await fetch(`/api/fav`, { headers });
const data = await res.json();
return data;
}, { manual: true, cacheKey: 'favListRequest', cacheTime: 3 * 60 * 1000 });
@ -61,7 +63,7 @@ export function FavePlayersPage() {
const claims = await getIdTokenClaims()!;
const aud = claims?.aud;
const jobs = list.map(async u => {
await fetch(`/api/fav/${aud}/${u.uid}`, { method: 'PUT' });
await fetch(`/api/fav/${u.uid}`, { method: 'PUT', headers });
});
message.open({ key: 'sync', content: '同步中...', type: 'loading' });
await Promise.all(jobs);

View File

@ -1,3 +1,3 @@
export const LOGTO_RESOURCE = 'https://tt.ksr.la';
export const CLUB_SELECTOR_KEY = 'CLUB_SELECTOR';
export const STORE_PAGE_LIST_KEY = 'events-page-keys';

View File

@ -1,9 +1,10 @@
import type { IEventInfo, Player } from "../types";
import type { Player } from "../types";
import * as cheerio from "cheerio";
import { XCXAPI } from "../services/xcxApi";
import { BASE_URL } from "./common";
import { RedisClient } from "bun";
import dayjs from "dayjs";
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { LOGTO_RESOURCE } from "./constants";
const REQUIRED_ENVS = [
process.env.KAIQIUCC_TOKEN,
@ -40,62 +41,6 @@ export const htmlRequestHeaders = {
"cookie": "SECKEY_ABVK=oTGgqH4ypGPFVdQ3J9K7PoAOPdZ+8R7CsUzi75gelcg%3D; uchome_sendmail=1"
}
/**
* @param tagid ID
*/
export async function listEvent(tagid: string): Promise<IEventInfo[]> {
const key = `my-kaiqiuwang:club-events:${tagid}`;
let html = await redis.get(key).catch(() => '');
if (!html) {
html = await fetchEventListHTML(tagid);
redis.setex(key, 60 * 60 * REDIS_CACHE_HOUR, html);
}
return await parseEventList(html);
}
/**
* @param tagid ID
* @return HTML
*/
export async function fetchEventListHTML(tagid: string) {
const url = `${BASE_URL}/home/space.php?do=mtag&tagid=${tagid}&view=event`;
const resp = await fetch(url, { headers: htmlRequestHeaders });
return resp.text() ?? '';
}
export async function parseEventList(html: string) {
const $ = cheerio.load(html);
const blockList = $('div.event_list > ol > li');
const list: IEventInfo[] = [];
for (const block of blockList) {
const titleEl = $(block).find('.event_title');
const title = titleEl.text();
const eventPath = $(titleEl).find('a').attr('href') ?? '';
const place = $(block).find('ul li:nth-of-type(2)').text().replace('比赛地点: \\t', '').trim();
const eventURL = `${BASE_URL}/home/${eventPath}`;
const matchId = /\S+-(\d+).html$/.exec(eventPath)?.[1] ?? '';
let eventPage = await redis.get(`my-kaiqiuwang:match:${matchId}`) ?? '';
if (!eventPage) {
eventPage = await fetch(eventURL, { headers: htmlRequestHeaders }).then(res => res.text() ?? '');
await redis.setex(`my-kaiqiuwang:match:${matchId}`, 60 * 60 * 10, eventPage)
}
const $eventPage = cheerio.load(eventPage);
const eventContent = $eventPage('.event_content').text().replace(/(\r|\n)/g, ',').split(',').filter(Boolean).join(' ');
const { y, M, D, H, m} = /比赛开始:.*?(?<y>\d{4})年(?<M>\d{2})月(?<D>\d{2})日 \w+ (?<H>\d{2}):(?<m>\d{2})/
.exec(eventContent)?.groups ?? {};
const startDate = y ? `${y}-${M}-${D} ${H}:${m}` : '';
const event: IEventInfo = {
title,
info: [`比赛时间:${startDate}`, place],
url: eventURL,
startDate,
matchId,
isFinished: dayjs(startDate).isBefore(),
}
list.push(event);
}
return list;
}
/**
*
@ -139,3 +84,38 @@ export async function getMatchInfo(matchId: string) {
}
return parseEventInfo(html);
}
export const extractBearerTokenFromHeaders = (authorization: string | null) => {
if (!authorization) {
return null;
}
if (!authorization.startsWith('Bearer')) {
return null;
}
return authorization.slice(7); // The length of 'Bearer ' is 7
};
const jwks = createRemoteJWKSet(new URL('https://logto.ksr.la/oidc/jwks'));
export const verifyLogtoToken = async (headers: Headers) => {
const auth = headers.get('Authorization');
const token = extractBearerTokenFromHeaders(auth)
// console.debug('Authorization', { auth, token });
if (!token) return {};
const { payload } = await jwtVerify(
// The raw Bearer Token extracted from the request header
token,
jwks,
{
// Expected issuer of the token, issued by the Logto server
issuer: 'https://logto.ksr.la/oidc',
// Expected audience token, the resource indicator of the current API
audience: LOGTO_RESOURCE,
}
);
// console.debug('Payload', payload);
// Sub is the user ID, used for user identification
return payload
}