add: login status detection in frontend

This commit is contained in:
alikia2x (寒寒) 2025-06-01 17:21:09 +08:00
parent 9dd06fa7bc
commit fa5ab258da
Signed by: alikia2x
GPG Key ID: 56209E0CCD8420C6
13 changed files with 117 additions and 101 deletions

View File

@ -4,7 +4,7 @@ import { SlidingWindow } from "@core/mq/slidingWindow.ts";
import { getCaptchaConfigMaxDuration, getCurrentCaptchaDifficulty } from "@/lib/auth/captchaDifficulty.ts"; import { getCaptchaConfigMaxDuration, getCurrentCaptchaDifficulty } from "@/lib/auth/captchaDifficulty.ts";
import { sqlCred } from "@core/db/dbNew.ts"; import { sqlCred } from "@core/db/dbNew.ts";
import { redis } from "@core/db/redis.ts"; import { redis } from "@core/db/redis.ts";
import { verify } from 'hono/jwt'; import { verify } from "hono/jwt";
import { JwtTokenInvalid, JwtTokenExpired } from "hono/utils/jwt/types"; import { JwtTokenInvalid, JwtTokenExpired } from "hono/utils/jwt/types";
import { getJWTsecret } from "@/lib/auth/getJWTsecret.ts"; import { getJWTsecret } from "@/lib/auth/getJWTsecret.ts";
import { lockManager } from "@core/mq/lockManager.ts"; import { lockManager } from "@core/mq/lockManager.ts";
@ -23,7 +23,8 @@ export const captchaMiddleware = async (c: Context, next: Next) => {
if (!authHeader) { if (!authHeader) {
const response: ErrorResponse = { const response: ErrorResponse = {
message: "'Authorization' header is missing.", message: "'Authorization' header is missing.",
code: "UNAUTHORIZED" code: "UNAUTHORIZED",
errors: []
}; };
return c.json<ErrorResponse>(response, 401); return c.json<ErrorResponse>(response, 401);
} }
@ -32,7 +33,8 @@ export const captchaMiddleware = async (c: Context, next: Next) => {
if (!authIsBearer || authHeader.length < 8) { if (!authIsBearer || authHeader.length < 8) {
const response: ErrorResponse = { const response: ErrorResponse = {
message: "'Authorization' header is invalid.", message: "'Authorization' header is invalid.",
code: "INVALID_HEADER" code: "INVALID_HEADER",
errors: []
}; };
return c.json<ErrorResponse>(response, 400); return c.json<ErrorResponse>(response, 400);
} }
@ -60,47 +62,48 @@ export const captchaMiddleware = async (c: Context, next: Next) => {
if (consumed) { if (consumed) {
const response: ErrorResponse = { const response: ErrorResponse = {
message: "Token has already been used.", message: "Token has already been used.",
code: "INVALID_CREDENTIALS" code: "INVALID_CREDENTIALS",
errors: []
}; };
return c.json<ErrorResponse>(response, 401); return c.json<ErrorResponse>(response, 401);
} }
if (difficulty < requiredDifficulty) { if (difficulty < requiredDifficulty) {
const response: ErrorResponse = { const response: ErrorResponse = {
message: "Token too weak.", message: "Token too weak.",
code: "UNAUTHORIZED" code: "UNAUTHORIZED",
errors: []
}; };
return c.json<ErrorResponse>(response, 401); return c.json<ErrorResponse>(response, 401);
} }
const EXPIRE_FIVE_MINUTES = 300; const EXPIRE_FIVE_MINUTES = 300;
await lockManager.acquireLock(tokenID, EXPIRE_FIVE_MINUTES); await lockManager.acquireLock(tokenID, EXPIRE_FIVE_MINUTES);
} } catch (e) {
catch (e) {
if (e instanceof JwtTokenInvalid) { if (e instanceof JwtTokenInvalid) {
const response: ErrorResponse = { const response: ErrorResponse = {
message: "Failed to verify the token.", message: "Failed to verify the token.",
code: "INVALID_CREDENTIALS" code: "INVALID_CREDENTIALS",
errors: []
}; };
return c.json<ErrorResponse>(response, 400); return c.json<ErrorResponse>(response, 400);
} } else if (e instanceof JwtTokenExpired) {
else if (e instanceof JwtTokenExpired) {
const response: ErrorResponse = { const response: ErrorResponse = {
message: "Token expired.", message: "Token expired.",
code: "INVALID_CREDENTIALS" code: "INVALID_CREDENTIALS",
errors: []
}; };
return c.json<ErrorResponse>(response, 400); return c.json<ErrorResponse>(response, 400);
} } else if (e instanceof ValidationError) {
else if (e instanceof ValidationError) {
const response: ErrorResponse = { const response: ErrorResponse = {
code: "INVALID_QUERY_PARAMS", code: "INVALID_QUERY_PARAMS",
message: "Invalid query parameters", message: "Invalid query parameters",
errors: e.errors errors: e.errors
}; };
return c.json<ErrorResponse>(response, 400); return c.json<ErrorResponse>(response, 400);
} } else {
else {
const response: ErrorResponse = { const response: ErrorResponse = {
message: "Unknown error.", message: "Unknown error.",
code: "UNKNOWN_ERROR" code: "UNKNOWN_ERROR",
errors: []
}; };
return c.json<ErrorResponse>(response, 500); return c.json<ErrorResponse>(response, 500);
} }

View File

@ -34,7 +34,8 @@ export const verifyChallengeHandler = createHandlers(
if (!ans) { if (!ans) {
const response: ErrorResponse = { const response: ErrorResponse = {
message: "Missing required query parameter: ans", message: "Missing required query parameter: ans",
code: "INVALID_QUERY_PARAMS" code: "INVALID_QUERY_PARAMS",
errors: []
}; };
return c.json<ErrorResponse>(response, 400); return c.json<ErrorResponse>(response, 400);
} }
@ -46,26 +47,30 @@ export const verifyChallengeHandler = createHandlers(
code: "ENTITY_NOT_FOUND", code: "ENTITY_NOT_FOUND",
i18n: { i18n: {
key: "backend.error.captcha_not_found" key: "backend.error.captcha_not_found"
} },
errors: []
}; };
return c.json<ErrorResponse>(response, 401); return c.json<ErrorResponse>(response, 401);
} else if (data.error && res.status === 400) { } else if (data.error && res.status === 400) {
const response: ErrorResponse = { const response: ErrorResponse = {
message: data.error, message: data.error,
code: "INVALID_QUERY_PARAMS" code: "INVALID_QUERY_PARAMS",
errors: []
}; };
return c.json<ErrorResponse>(response, 400); return c.json<ErrorResponse>(response, 400);
} else if (data.error) { } else if (data.error) {
const response: ErrorResponse = { const response: ErrorResponse = {
message: data.error, message: data.error,
code: "UNKNOWN_ERROR" code: "UNKNOWN_ERROR",
errors: []
}; };
return c.json<ErrorResponse>(response, 500); return c.json<ErrorResponse>(response, 500);
} }
if (!data.success) { if (!data.success) {
const response: ErrorResponse = { const response: ErrorResponse = {
message: "Incorrect answer", message: "Incorrect answer",
code: "INVALID_CREDENTIALS" code: "INVALID_CREDENTIALS",
errors: []
}; };
return c.json<ErrorResponse>(response, 401); return c.json<ErrorResponse>(response, 401);
} }

View File

@ -1 +1,2 @@
export * from "./register.ts"; export * from "./POST.ts";
export * from "./session/[id]/GET.ts";

View File

@ -0,0 +1,32 @@
import { Context } from "hono";
import { Bindings, BlankEnv } from "hono/types";
import { ErrorResponse } from "src/schema";
import { createHandlers } from "src/utils.ts";
import { sqlCred } from "@core/db/dbNew";
import { DatabaseUserType } from "@core/db/schema";
export const getUserByLoginSessionHandler = createHandlers(
async (c: Context<BlankEnv & { Bindings: Bindings }, "/user/session/:id">) => {
const id = c.req.param("id");
const users = await sqlCred<DatabaseUserType[]>`
SELECT u.*
FROM users u
JOIN login_sessions ls ON u.id = ls.uid
WHERE ls.id = ${id};
`;
if (users.length === 0) {
const response: ErrorResponse = {
message: "Cannot find user",
code: "ENTITY_NOT_FOUND",
errors: []
};
return c.json<ErrorResponse>(response, 404);
}
const user = users[0];
return c.json({
username: user.username,
nickname: user.nickname,
role: user.role
});
}
);

View File

@ -1,23 +1,23 @@
import { rootHandler } from "routes"; import { rootHandler } from "routes";
import { pingHandler } from "routes/ping"; import { pingHandler } from "routes/ping";
import { registerHandler } from "routes/user"; import { getUserByLoginSessionHandler, registerHandler } from "routes/user";
import { videoInfoHandler, getSnapshotsHanlder } from "routes/video"; import { videoInfoHandler, getSnapshotsHanlder } from "routes/video";
import { Hono } from "hono"; import { Hono } from "hono";
import { Variables } from "hono/types"; import { Variables } from "hono/types";
import { createCaptchaSessionHandler, verifyChallengeHandler } from "routes/captcha"; import { createCaptchaSessionHandler, verifyChallengeHandler } from "routes/captcha";
import { getCaptchaDifficultyHandler } from "../routes/captcha/difficulty/GET.ts"; import { getCaptchaDifficultyHandler } from "routes/captcha/difficulty/GET.ts";
export function configureRoutes(app: Hono<{ Variables: Variables }>) { export function configureRoutes(app: Hono<{ Variables: Variables }>) {
app.get("/", ...rootHandler); app.get("/", ...rootHandler);
app.all("/ping", ...pingHandler); app.all("/ping", ...pingHandler);
app.get("/video/:id/snapshots", ...getSnapshotsHanlder); app.get("/video/:id/snapshots", ...getSnapshotsHanlder);
app.post("/user", ...registerHandler);
app.get("/video/:id/info", ...videoInfoHandler); app.get("/video/:id/info", ...videoInfoHandler);
app.post("/user", ...registerHandler);
app.get("/user/session/:id", ...getUserByLoginSessionHandler);
app.post("/captcha/session", ...createCaptchaSessionHandler); app.post("/captcha/session", ...createCaptchaSessionHandler);
app.get("/captcha/:id/result", ...verifyChallengeHandler); app.get("/captcha/:id/result", ...verifyChallengeHandler);
app.get("/captcha/difficulty", ...getCaptchaDifficultyHandler);
app.get("/captcha/difficulty", ...getCaptchaDifficultyHandler)
} }

View File

@ -43,6 +43,12 @@ export interface SignUpResponse {
token: string; token: string;
} }
export interface UserResponse {
username: string;
nickname: string | null;
role: string;
}
export type CaptchaVerificationRawResponse = { export type CaptchaVerificationRawResponse = {
token: string; token: string;
} }

View File

@ -53,3 +53,12 @@ export interface SnapshotScheduleType {
finished_at?: string; finished_at?: string;
status: string; status: string;
} }
export interface DatabaseUserType {
id: number;
username: string;
nickname: string | null;
password: string;
unq_id: string;
role: string;
}

View File

@ -1,62 +0,0 @@
import type { Client } from "https://deno.land/x/postgres@v0.19.3/mod.ts";
import type { VideoSnapshotType } from "./schema.d.ts";
export async function getVideoSnapshots(
client: Client,
aid: number,
limit: number,
pageOrOffset: number,
reverse: boolean,
mode: "page" | "offset" = "page",
) {
const offset = mode === "page" ? (pageOrOffset - 1) * limit : pageOrOffset;
const queryDesc: string = `
SELECT *
FROM video_snapshot
WHERE aid = $1
ORDER BY created_at DESC
LIMIT $2
OFFSET $3
`;
const queryAsc: string = `
SELECT *
FROM video_snapshot
WHERE aid = $1
ORDER BY created_at
LIMIT $2 OFFSET $3
`;
const query = reverse ? queryAsc : queryDesc;
const queryResult = await client.queryObject<VideoSnapshotType>(query, [aid, limit, offset]);
return queryResult.rows;
}
export async function getVideoSnapshotsByBV(
client: Client,
bv: string,
limit: number,
pageOrOffset: number,
reverse: boolean,
mode: "page" | "offset" = "page",
) {
const offset = mode === "page" ? (pageOrOffset - 1) * limit : pageOrOffset;
const queryAsc = `
SELECT vs.*
FROM video_snapshot vs
JOIN bilibili_metadata bm ON vs.aid = bm.aid
WHERE bm.bvid = $1
ORDER BY vs.created_at
LIMIT $2
OFFSET $3
`;
const queryDesc: string = `
SELECT *
FROM video_snapshot vs
JOIN bilibili_metadata bm ON vs.aid = bm.aid
WHERE bm.bvid = $1
ORDER BY vs.created_at DESC
LIMIT $2 OFFSET $3
`;
const query = reverse ? queryAsc : queryDesc;
const queryResult = await client.queryObject<VideoSnapshotType>(query, [bv, limit, offset]);
return queryResult.rows;
}

View File

@ -2,7 +2,7 @@
font-family: "MiSans VF"; font-family: "MiSans VF";
font-style: normal; font-style: normal;
font-weight: 150 700; font-weight: 150 700;
font-display: swap; font-display: optional;
src: url("MiSans VF.woff2") format("woff2"); src: url("MiSans VF.woff2") format("woff2");
} }

View File

@ -1,9 +1,21 @@
import { Header } from "@/components/shell/Header"; import { Header } from "@/components/shell/Header";
import { fetcher } from "@/lib/net";
import { UserResponse } from "@backend/src/schema";
import { cookies } from "next/headers";
export default function Home() { export default async function Home() {
const cookieStore = await cookies();
const sessionID = cookieStore.get("session_id");
let user: undefined | UserResponse = undefined;
if (sessionID) {
try {
user = await fetcher<UserResponse>(`${process.env.BACKEND_URL}/user/session/${sessionID.value}`);
} finally {
}
}
return ( return (
<> <>
<Header /> <Header user={user} />
<main className="flex flex-col items-center justify-center h-full flex-grow gap-8 px-4"> <main className="flex flex-col items-center justify-center h-full flex-grow gap-8 px-4">
<h1 className="text-4xl font-medium text-center"></h1> <h1 className="text-4xl font-medium text-center"></h1>
<p>BV号或AV号~</p> <p>BV号或AV号~</p>

View File

@ -1,7 +1,7 @@
import { Dispatch, JSX, SetStateAction } from "react"; import { Dispatch, JSX, SetStateAction } from "react";
import { ApiRequestError, fetcher } from "@/lib/net"; import { ApiRequestError, fetcher } from "@/lib/net";
import type { CaptchaVerificationRawResponse, ErrorResponse, SignUpResponse } from "@backend/src/schema"; import type { CaptchaVerificationRawResponse, ErrorResponse, SignUpResponse } from "@backend/src/schema";
import Link from "next/link"; import { Link } from "@/i18n/navigation";
import { LocalizedMessage } from "./SignUpForm"; import { LocalizedMessage } from "./SignUpForm";
import { ErrorDialog } from "./ErrorDialog"; import { ErrorDialog } from "./ErrorDialog";
import { string, object, ValidationError } from "yup"; import { string, object, ValidationError } from "yup";

View File

@ -16,8 +16,13 @@ import { InfoIcon } from "@/components/icons/InfoIcon";
import { HomeIcon } from "@/components/icons/HomeIcon"; import { HomeIcon } from "@/components/icons/HomeIcon";
import { TextButton } from "@/components/ui/Buttons/TextButton"; import { TextButton } from "@/components/ui/Buttons/TextButton";
import { Link } from "@/i18n/navigation"; import { Link } from "@/i18n/navigation";
import { UserResponse } from "@backend/src/schema";
export const HeaderDestop = () => { interface HeaderProps {
user?: UserResponse;
}
export const HeaderDestop = ({ user }: HeaderProps) => {
return ( return (
<div className="hidden md:flex relative top-0 left-0 w-full h-28 z-20 justify-between"> <div className="hidden md:flex relative top-0 left-0 w-full h-28 z-20 justify-between">
<div className="w-[305px] xl:ml-8 inline-flex items-center"> <div className="w-[305px] xl:ml-8 inline-flex items-center">
@ -37,14 +42,19 @@ export const HeaderDestop = () => {
className="inline-flex relative gap-6 h-full lg:right-12 className="inline-flex relative gap-6 h-full lg:right-12
text-xl font-medium items-center w-[15rem] min-w-[8rem] mr-4 lg:mr-0 lg:w-[305px] justify-end" text-xl font-medium items-center w-[15rem] min-w-[8rem] mr-4 lg:mr-0 lg:w-[305px] justify-end"
> >
<a href="/signup"></a> {user ? (
<Link href="/my/profile">{user.nickname || user.username}</Link>
) : (
<Link href="/signup"></Link>
)}
<a href="/about"></a> <a href="/about"></a>
</div> </div>
</div> </div>
); );
}; };
export const HeaderMobile = () => { export const HeaderMobile = ({ user }: HeaderProps) => {
const [showDrawer, setShowDrawer] = useState(false); const [showDrawer, setShowDrawer] = useState(false);
const [showsearchBox, setShowsearchBox] = useState(false); const [showsearchBox, setShowsearchBox] = useState(false);
@ -125,11 +135,11 @@ export const HeaderMobile = () => {
); );
}; };
export const Header = () => { export const Header = (props: HeaderProps) => {
return ( return (
<> <>
<HeaderDestop /> <HeaderDestop {...props} />
<HeaderMobile /> <HeaderMobile {...props} />
</> </>
); );
}; };