feat: 登录改用 JWT(access token + refresh token),不再用 cookie
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
- 登录返回 15 分钟的 access token(HS256,密钥 JWT_SECRET,未配置则每次启动随机) 和 14 天的 refresh token;请求带 Authorization: Bearer,WebSocket 用 ?token= - refresh token 只存 sha256,放在新表 auth_refresh_token(删除旧的 auth_session); POST /account/refresh 换新的 access token,退出、改密码、封号时作废 - 带了无效或过期的 token 一律 401,前端据此刷新:快过期时提前刷新、并发请求只刷新一次, 401 时刷新后重试一次,refresh token 也失效才退出登录 - 不再用 cookie,去掉 CORS credentials 和写请求的 Origin 校验 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dde976cacb
commit
26827e31d5
21 files changed
+281
-141
No files matched your search
@@ -12,6 +12,8 @@ ENV=dev
|
||||
# MEDIA_ROOT=../media
|
||||
# 允许访问 API 的前端地址,逗号分隔
|
||||
# ALLOWED_ORIGINS=http://localhost:3000
|
||||
# access token 的签名密钥;不配置则每次启动随机生成(重启后前端会自动刷新 token)
|
||||
# JWT_SECRET=
|
||||
|
||||
LLM_API_KEY=
|
||||
LLM_MODEL=deepseek-v4-flash
|
||||
|
||||
@@ -20,7 +20,7 @@ if (user) {
|
||||
is_superuser = 1, is_staff = 1, is_active = 1 WHERE id = $id`,
|
||||
{ hash, raw: password.slice(0, 20), id: user.id },
|
||||
)
|
||||
run(`DELETE FROM auth_session WHERE user_id = $id`, { id: user.id })
|
||||
run(`DELETE FROM auth_refresh_token WHERE user_id = $id`, { id: user.id })
|
||||
console.log(`已将 ${username} 设为超级管理员并重设密码`)
|
||||
} else {
|
||||
run(
|
||||
|
||||
@@ -101,7 +101,7 @@ await pg.close()
|
||||
db.transaction(() => {
|
||||
db.run("PRAGMA defer_foreign_keys = ON")
|
||||
for (const table of [...TABLES].reverse()) db.run(`DELETE FROM ${quote(table)}`)
|
||||
db.run(`DELETE FROM auth_session`)
|
||||
db.run(`DELETE FROM auth_refresh_token`)
|
||||
for (const table of TABLES) {
|
||||
const rows = rowsByTable[table]!
|
||||
if (!rows.length) {
|
||||
|
||||
+4
-17
@@ -1,9 +1,8 @@
|
||||
import { Hono } from "hono"
|
||||
import { cors } from "hono/cors"
|
||||
import { config } from "./config"
|
||||
import { sessionMiddleware } from "./auth"
|
||||
import { authMiddleware } from "./auth"
|
||||
import { HttpError, ValidationError, type Env } from "./http"
|
||||
import { isAllowedOrigin } from "./origin"
|
||||
import account from "./routes/account"
|
||||
import { tutorial, challenge, creation } from "./routes/tasks"
|
||||
import { assets, upload } from "./routes/files"
|
||||
@@ -14,22 +13,10 @@ import { mediaPath } from "./routes/files"
|
||||
// strict: false —— 带不带结尾的斜杠都能匹配(前端两种写法都有)
|
||||
export const app = new Hono<Env>({ strict: false })
|
||||
|
||||
app.use(
|
||||
"/api/*",
|
||||
cors({ origin: config.allowedOrigins, credentials: true }),
|
||||
)
|
||||
// 登录凭证放在 Authorization 头里,浏览器不会自动带上,所以不需要 cookie 和 CSRF 防护
|
||||
app.use("/api/*", cors({ origin: config.allowedOrigins }))
|
||||
|
||||
// 会话 cookie 是 SameSite=Lax,再加一道 Origin 校验防止跨站写请求
|
||||
app.use("/api/*", async (c, next) => {
|
||||
if (!["GET", "HEAD", "OPTIONS"].includes(c.req.method)) {
|
||||
if (!isAllowedOrigin(c.req.header("origin") ?? null, c.req.header("host") ?? null)) {
|
||||
return c.json({ detail: "跨站请求被拒绝" }, 403)
|
||||
}
|
||||
}
|
||||
await next()
|
||||
})
|
||||
|
||||
app.use("/api/*", sessionMiddleware)
|
||||
app.use("/api/*", authMiddleware)
|
||||
|
||||
const api = new Hono<Env>({ strict: false })
|
||||
api.route("/account", account)
|
||||
|
||||
+51
-36
@@ -1,6 +1,6 @@
|
||||
import { pbkdf2 as pbkdf2Cb, randomBytes, timingSafeEqual } from "node:crypto"
|
||||
import { createHash, pbkdf2 as pbkdf2Cb, randomBytes, timingSafeEqual } from "node:crypto"
|
||||
import { promisify } from "node:util"
|
||||
import { getCookie, setCookie, deleteCookie } from "hono/cookie"
|
||||
import { sign, verify } from "hono/jwt"
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
import { config } from "./config"
|
||||
import { get, run, now, toDbTime } from "./db"
|
||||
@@ -54,8 +54,8 @@ export async function setPassword(userId: number, raw: string) {
|
||||
`UPDATE account_user SET password = $password, raw_password = $raw WHERE id = $id`,
|
||||
{ password: await makePassword(raw), raw: raw.slice(0, 20), id: userId },
|
||||
)
|
||||
// 改密码后让该用户的其它会话失效(与 Django 行为一致)
|
||||
run(`DELETE FROM auth_session WHERE user_id = $id`, { id: userId })
|
||||
// 改密码后让该用户的 refresh token 全部失效(与 Django 行为一致)
|
||||
run(`DELETE FROM auth_refresh_token WHERE user_id = $id`, { id: userId })
|
||||
}
|
||||
|
||||
// ── 会话 ──
|
||||
@@ -76,53 +76,68 @@ export async function authenticate(username: string, password: string) {
|
||||
return user as User
|
||||
}
|
||||
|
||||
export function login(c: Ctx, user: User) {
|
||||
const key = randomBytes(24).toString("base64url")
|
||||
const expire = new Date(Date.now() + config.sessionAgeSeconds * 1000)
|
||||
run(`DELETE FROM auth_session WHERE expire_at < $now`, { now: now() })
|
||||
// access token 是短期 JWT(只带用户 id,每次请求仍查一次用户,封号立即生效);
|
||||
// refresh token 是随机串,表 auth_refresh_token 里只存它的 sha256,用来换新的 access token
|
||||
const hashToken = (token: string) => createHash("sha256").update(token).digest("hex")
|
||||
|
||||
function signAccessToken(userId: number) {
|
||||
const exp = Math.floor(Date.now() / 1000) + config.accessTokenSeconds
|
||||
return sign({ sub: userId, exp }, config.jwtSecret, "HS256")
|
||||
}
|
||||
|
||||
export async function issueTokens(user: User) {
|
||||
const refreshToken = randomBytes(32).toString("base64url")
|
||||
const expire = new Date(Date.now() + config.refreshTokenSeconds * 1000)
|
||||
run(`DELETE FROM auth_refresh_token WHERE expire_at < $now`, { now: now() })
|
||||
run(
|
||||
`INSERT INTO auth_session (key, user_id, expire_at) VALUES ($key, $user_id, $expire_at)`,
|
||||
{ key, user_id: user.id, expire_at: toDbTime(expire) },
|
||||
`INSERT INTO auth_refresh_token (token_hash, user_id, expire_at) VALUES ($hash, $user_id, $expire_at)`,
|
||||
{ hash: hashToken(refreshToken), user_id: user.id, expire_at: toDbTime(expire) },
|
||||
)
|
||||
run(`UPDATE account_user SET last_login = $now WHERE id = $id`, {
|
||||
now: now(),
|
||||
id: user.id,
|
||||
})
|
||||
setCookie(c, config.sessionCookie, key, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: config.sessionAgeSeconds,
|
||||
})
|
||||
return { access_token: await signAccessToken(user.id), refresh_token: refreshToken }
|
||||
}
|
||||
|
||||
export function logout(c: Ctx) {
|
||||
const key = getCookie(c, config.sessionCookie)
|
||||
if (key) run(`DELETE FROM auth_session WHERE key = $key`, { key })
|
||||
deleteCookie(c, config.sessionCookie, { path: "/" })
|
||||
export async function refreshAccessToken(refreshToken: string) {
|
||||
const row = get<{ id: number }>(
|
||||
`SELECT u.id FROM auth_refresh_token t JOIN account_user u ON u.id = t.user_id
|
||||
WHERE t.token_hash = $hash AND t.expire_at > $now AND u.is_active = 1`,
|
||||
{ hash: hashToken(refreshToken), now: now() },
|
||||
)
|
||||
if (!row) throw new HttpError(401, "登录已过期,请重新登录")
|
||||
return signAccessToken(row.id)
|
||||
}
|
||||
|
||||
export function userFromSessionKey(key: string | undefined): User | null {
|
||||
if (!key) return null
|
||||
export function revokeRefreshToken(refreshToken: string) {
|
||||
run(`DELETE FROM auth_refresh_token WHERE token_hash = $hash`, { hash: hashToken(refreshToken) })
|
||||
}
|
||||
|
||||
export async function userFromAccessToken(token: string | null | undefined): Promise<User | null> {
|
||||
if (!token) return null
|
||||
let payload
|
||||
try {
|
||||
payload = await verify(token, config.jwtSecret, "HS256")
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return get<User>(
|
||||
`SELECT u.id, u.username, u.role, u.classname, u.is_active
|
||||
FROM auth_session s JOIN account_user u ON u.id = s.user_id
|
||||
WHERE s.key = $key AND s.expire_at > $now AND u.is_active = 1`,
|
||||
{ key, now: now() },
|
||||
`SELECT id, username, role, classname, is_active FROM account_user
|
||||
WHERE id = $id AND is_active = 1`,
|
||||
{ id: Number(payload.sub) },
|
||||
)
|
||||
}
|
||||
|
||||
export function parseCookie(header: string | null, name: string) {
|
||||
if (!header) return undefined
|
||||
for (const part of header.split(";")) {
|
||||
const [k, ...v] = part.trim().split("=")
|
||||
if (k === name) return decodeURIComponent(v.join("="))
|
||||
export const authMiddleware: MiddlewareHandler<Env> = async (c, next) => {
|
||||
const header = c.req.header("authorization")
|
||||
let user: User | null = null
|
||||
if (header?.startsWith("Bearer ")) {
|
||||
user = await userFromAccessToken(header.slice("Bearer ".length))
|
||||
// 带了 token 却无效(过期、被封号)时返回 401,前端据此去刷新 token,而不是当成未登录
|
||||
if (!user) throw new HttpError(401, "登录已过期")
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const sessionMiddleware: MiddlewareHandler<Env> = async (c, next) => {
|
||||
c.set("user", userFromSessionKey(getCookie(c, config.sessionCookie)))
|
||||
c.set("user", user)
|
||||
await next()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomBytes } from "node:crypto"
|
||||
import { resolve } from "node:path"
|
||||
|
||||
const env = process.env
|
||||
@@ -19,8 +20,10 @@ export const config = {
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
sessionCookie: "sessionid",
|
||||
sessionAgeSeconds: 60 * 60 * 24 * 14,
|
||||
// 未配置时每次启动随机生成:重启后 access token 全部失效,前端会用 refresh token 自动换新的
|
||||
jwtSecret: env.JWT_SECRET || randomBytes(32).toString("hex"),
|
||||
accessTokenSeconds: 15 * 60,
|
||||
refreshTokenSeconds: 60 * 60 * 24 * 14,
|
||||
|
||||
// 测试时不调用真实的模型接口
|
||||
llmApiKey: env.NODE_ENV === "test" ? "" : (env.LLM_API_KEY ?? ""),
|
||||
|
||||
+4
-2
@@ -48,8 +48,10 @@ CREATE TABLE IF NOT EXISTS "prompt_message" ("id" integer NOT NULL PRIMARY KEY A
|
||||
CREATE INDEX IF NOT EXISTS "prompt_message_conversation_id_af18dd4b" ON "prompt_message" ("conversation_id");
|
||||
CREATE INDEX IF NOT EXISTS "prompt_message_prompt_level_e461b596" ON "prompt_message" ("prompt_level");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "auth_session" ("key" text NOT NULL PRIMARY KEY, "user_id" bigint NOT NULL REFERENCES "account_user" ("id") ON DELETE CASCADE, "expire_at" datetime NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS "auth_session_user_id" ON "auth_session" ("user_id");
|
||||
-- 登录用 JWT:access token 不落库,refresh token 只存 sha256
|
||||
DROP TABLE IF EXISTS "auth_session";
|
||||
CREATE TABLE IF NOT EXISTS "auth_refresh_token" ("token_hash" text NOT NULL PRIMARY KEY, "user_id" bigint NOT NULL REFERENCES "account_user" ("id") ON DELETE CASCADE, "expire_at" datetime NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS "auth_refresh_token_user_id" ON "auth_refresh_token" ("user_id");
|
||||
`
|
||||
|
||||
export function openDb(path: string): Database {
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@ const server = Bun.serve<WsData>({
|
||||
maxRequestBodySize: 50 * 1024 * 1024,
|
||||
// AI 生成走 WebSocket,普通接口不会太久
|
||||
idleTimeout: 120,
|
||||
fetch(req, server) {
|
||||
const res = upgrade(req, server)
|
||||
async fetch(req, server) {
|
||||
const res = await upgrade(req, server)
|
||||
if (res !== null) return res
|
||||
return app.fetch(req)
|
||||
},
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { config } from "./config"
|
||||
|
||||
// 同源请求,或来自白名单里的前端地址
|
||||
export function isAllowedOrigin(origin: string | null, host: string | null) {
|
||||
if (!origin) return true
|
||||
if (config.allowedOrigins.includes(origin)) return true
|
||||
try {
|
||||
return new URL(origin).host === host
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,10 @@ import { all, get, run, tx, now, iso, likeEscape } from "../db"
|
||||
import {
|
||||
Role,
|
||||
authenticate,
|
||||
login,
|
||||
logout,
|
||||
issueTokens,
|
||||
makePassword,
|
||||
refreshAccessToken,
|
||||
revokeRefreshToken,
|
||||
requireSuper,
|
||||
requireUser,
|
||||
setPassword,
|
||||
@@ -70,13 +71,18 @@ router.post("/login", async (c) => {
|
||||
const payload = await body(c, z.object({ username: z.string(), password: z.string() }))
|
||||
const user = await authenticate(payload.username, payload.password)
|
||||
if (!user) throw new HttpError(401, "账号密码错误")
|
||||
login(c, user)
|
||||
return c.json({ username: user.username, role: user.role })
|
||||
return c.json({ username: user.username, role: user.role, ...(await issueTokens(user)) })
|
||||
})
|
||||
|
||||
router.post("/logout", (c) => {
|
||||
requireUser(c)
|
||||
logout(c)
|
||||
router.post("/refresh", async (c) => {
|
||||
const payload = await body(c, z.object({ refresh_token: z.string() }))
|
||||
return c.json({ access_token: await refreshAccessToken(payload.refresh_token) })
|
||||
})
|
||||
|
||||
// access token 是无状态的,退出只需作废 refresh token;不要求登录,过期了也能退出
|
||||
router.post("/logout", async (c) => {
|
||||
const payload = await body(c, z.object({ refresh_token: z.string().optional() }))
|
||||
if (payload.refresh_token) revokeRefreshToken(payload.refresh_token)
|
||||
return c.json({ message: "退出成功" })
|
||||
})
|
||||
|
||||
@@ -248,7 +254,7 @@ router.put("/active/:id", (c) => {
|
||||
)
|
||||
const active = !user.is_active
|
||||
run(`UPDATE account_user SET is_active = $active WHERE id = $id`, { active, id })
|
||||
if (!active) run(`DELETE FROM auth_session WHERE user_id = $id`, { id })
|
||||
if (!active) run(`DELETE FROM auth_refresh_token WHERE user_id = $id`, { id })
|
||||
return c.json({ message: `${user.username} ${active ? "解封" : "封号"}成功` })
|
||||
})
|
||||
|
||||
|
||||
+7
-8
@@ -1,7 +1,6 @@
|
||||
import type { Server, ServerWebSocket, WebSocketHandler } from "bun"
|
||||
import { config } from "./config"
|
||||
import { all, run, iso, uuidOut } from "./db"
|
||||
import { parseCookie, userFromSessionKey, type User } from "./auth"
|
||||
import { userFromAccessToken, type User } from "./auth"
|
||||
import { canAccessTask, getOrCreateActiveConversation, insertMessage } from "./services/prompt"
|
||||
import {
|
||||
extractCode,
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
streamGuidance,
|
||||
type ChatMessage,
|
||||
} from "./services/llm"
|
||||
import { isAllowedOrigin } from "./origin"
|
||||
|
||||
type Kind = "prompt" | "guidance"
|
||||
|
||||
@@ -34,14 +32,15 @@ type WS = ServerWebSocket<WsData>
|
||||
const PATH_RE = /^\/ws\/(prompt|guidance)\/(\d+)\/?$/
|
||||
|
||||
// 处理 WebSocket 升级请求;返回 undefined 表示已升级,返回 Response 表示拒绝或不是 ws 路径
|
||||
export function upgrade(req: Request, server: Server<WsData>): Response | undefined | null {
|
||||
// 浏览器建 WebSocket 时不能加请求头,access token 放在 ?token= 里
|
||||
export async function upgrade(
|
||||
req: Request,
|
||||
server: Server<WsData>,
|
||||
): Promise<Response | undefined | null> {
|
||||
const url = new URL(req.url)
|
||||
const match = PATH_RE.exec(url.pathname)
|
||||
if (!match) return null
|
||||
if (!isAllowedOrigin(req.headers.get("origin"), req.headers.get("host"))) {
|
||||
return new Response("Forbidden", { status: 403 })
|
||||
}
|
||||
const user = userFromSessionKey(parseCookie(req.headers.get("cookie"), config.sessionCookie))
|
||||
const user = await userFromAccessToken(url.searchParams.get("token"))
|
||||
const taskId = Number(match[2])
|
||||
if (!user || !canAccessTask(user, taskId)) return new Response("Forbidden", { status: 403 })
|
||||
|
||||
|
||||
+44
-10
@@ -17,23 +17,57 @@ describe("权限", () => {
|
||||
expect((await adm("GET", "/api/submission/gradebook/?classname=a")).status).toBe(403)
|
||||
})
|
||||
|
||||
test("封号后会话失效", async () => {
|
||||
test("封号后 access token 和 refresh token 都失效", async () => {
|
||||
const id = makeUser("stu")
|
||||
makeUser("boss", "super")
|
||||
const stu = await client("stu")
|
||||
expect((await stu("GET", "/api/account/profile")).json.username).toBe("stu")
|
||||
await (await client("boss"))("PUT", `/api/account/active/${id}`)
|
||||
expect((await stu("GET", "/api/account/profile")).json.username).toBe("")
|
||||
expect((await stu("GET", "/api/account/profile")).status).toBe(401)
|
||||
const anon = await client()
|
||||
const res = await anon("POST", "/api/account/refresh", { refresh_token: stu.tokens.refresh_token })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe("JWT 登录", () => {
|
||||
test("登录返回两个 token,refresh token 能换新的 access token", async () => {
|
||||
makeUser("stu")
|
||||
const stu = await client("stu")
|
||||
expect(stu.tokens.access_token.split(".")).toHaveLength(3)
|
||||
expect(get(`SELECT token_hash FROM auth_refresh_token`).token_hash).not.toBe(stu.tokens.refresh_token)
|
||||
|
||||
const anon = await client()
|
||||
const res = await anon("POST", "/api/account/refresh", { refresh_token: stu.tokens.refresh_token })
|
||||
expect(res.status).toBe(200)
|
||||
stu.tokens.access_token = res.json.access_token
|
||||
expect((await stu("GET", "/api/account/profile")).json.username).toBe("stu")
|
||||
})
|
||||
|
||||
test("跨站写请求被拒绝", async () => {
|
||||
const { app } = await import("../src/app")
|
||||
const res = await app.request("/api/account/login", {
|
||||
method: "POST",
|
||||
headers: { origin: "https://evil.example", "content-type": "application/json" },
|
||||
body: "{}",
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
test("无效或过期的 access token 返回 401,不带 token 当作未登录", async () => {
|
||||
makeUser("stu")
|
||||
const stu = await client("stu")
|
||||
const { sign } = await import("hono/jwt")
|
||||
const { config } = await import("../src/config")
|
||||
const userId = get(`SELECT id FROM account_user WHERE username = 'stu'`).id
|
||||
stu.tokens.access_token = await sign({ sub: userId, exp: Math.floor(Date.now() / 1000) - 1 }, config.jwtSecret)
|
||||
expect((await stu("GET", "/api/account/profile")).status).toBe(401)
|
||||
stu.tokens.access_token = "garbage"
|
||||
expect((await stu("GET", "/api/account/profile")).status).toBe(401)
|
||||
expect((await (await client())("GET", "/api/account/profile")).json.username).toBe("")
|
||||
})
|
||||
|
||||
test("退出和改密码都会作废 refresh token", async () => {
|
||||
const id = makeUser("stu")
|
||||
makeUser("boss", "super")
|
||||
const anon = await client()
|
||||
const a = await client("stu")
|
||||
expect((await a("POST", "/api/account/logout", { refresh_token: a.tokens.refresh_token })).status).toBe(200)
|
||||
expect((await anon("POST", "/api/account/refresh", { refresh_token: a.tokens.refresh_token })).status).toBe(401)
|
||||
|
||||
const b = await client("stu")
|
||||
await (await client("boss"))("PUT", `/api/account/${id}`, { password: "newpass1" })
|
||||
expect((await anon("POST", "/api/account/refresh", { refresh_token: b.tokens.refresh_token })).status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -48,9 +48,9 @@ export function makeSubmission(userId: number, taskId: number, score = 0, create
|
||||
|
||||
export const uuid = (hex: string) => uuidOut(hex)
|
||||
|
||||
/** 登录后返回一个带 cookie 的请求函数 */
|
||||
/** 登录后返回一个带 access token 的请求函数,tokens 是登录拿到的 token */
|
||||
export async function client(username?: string) {
|
||||
let cookie = ""
|
||||
const tokens = { access_token: "", refresh_token: "" }
|
||||
if (username) {
|
||||
const res = await app.request("/api/account/login", {
|
||||
method: "POST",
|
||||
@@ -58,10 +58,11 @@ export async function client(username?: string) {
|
||||
body: JSON.stringify({ username, password: PASSWORD }),
|
||||
})
|
||||
if (res.status !== 200) throw new Error(`login failed: ${res.status}`)
|
||||
cookie = res.headers.get("set-cookie")!.split(";")[0]!
|
||||
Object.assign(tokens, await res.json())
|
||||
}
|
||||
return async (method: string, path: string, body?: unknown) => {
|
||||
const headers: Record<string, string> = { cookie }
|
||||
const request = async (method: string, path: string, body?: unknown) => {
|
||||
const headers: Record<string, string> = {}
|
||||
if (tokens.access_token) headers.authorization = `Bearer ${tokens.access_token}`
|
||||
if (body !== undefined) headers["content-type"] = "application/json"
|
||||
const res = await app.request(path, {
|
||||
method,
|
||||
@@ -75,4 +76,5 @@ export async function client(username?: string) {
|
||||
} catch {}
|
||||
return { status: res.status, json, text, headers: res.headers }
|
||||
}
|
||||
return Object.assign(request, { tokens })
|
||||
}
|
||||
Reference in new issue
Block a user