1
0

add: captcha middleware

This commit is contained in:
alikia2x (寒寒) 2025-11-09 04:10:52 +08:00
parent ab5545966e
commit 6b2b035050
8 changed files with 261 additions and 120 deletions

View File

@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "cvsa",
@ -86,6 +87,7 @@
"dependencies": {
"@alikia/random-key": "^1.1.1",
"@elysiajs/cors": "^1.4.0",
"@elysiajs/jwt": "^1.4.0",
"@elysiajs/openapi": "^1.4.0",
"@elysiajs/server-timing": "^1.4.0",
"@rabbit-company/argon2id": "^2.1.0",
@ -411,6 +413,8 @@
"@elysiajs/eden": ["@elysiajs/eden@1.4.1", "", { "peerDependencies": { "elysia": ">= 1.4.0-exp.0" } }, "sha512-9VXMau/cvafuBa1r19ucKi+l9eesCmeuvD6uYSeq5MFO/URc233JaxZmUlWQ8gztu+pp6L7auTZdkzOQz26O+A=="],
"@elysiajs/jwt": ["@elysiajs/jwt@1.4.0", "", { "dependencies": { "jose": "^6.0.11" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-Z0PvZhQxdDeKZ8HslXzDoXXD83NKExNPmoiAPki3nI2Xvh5wtUrBH+zWOD17yP14IbRo8fxGj3L25MRCAPsgPA=="],
"@elysiajs/openapi": ["@elysiajs/openapi@1.4.10", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-bEsETp/CGcs1CqH3zW6/CAI2g6d0K/g8wUuH7HwXQm0gtP18s9RnljJESuv4of3ePUoYQgy85t+dha+ABv+L/A=="],
"@elysiajs/server-timing": ["@elysiajs/server-timing@1.4.0", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-vDFdHyi8Q43vgA5MaTQMA9v4/bgKrtqPrpVqVuHlMCRQgfOpvYGXPj3okSttyendG5r2bRHfyPG11lTWWIrzrQ=="],
@ -1905,6 +1909,8 @@
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"jose": ["jose@6.1.0", "", {}, "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA=="],
"jotai": ["jotai@2.15.0", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-nbp/6jN2Ftxgw0VwoVnOg0m5qYM1rVcfvij+MZx99Z5IK13eGve9FJoCwGv+17JvVthTjhSmNtT5e1coJnr6aw=="],
"js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],

View File

@ -0,0 +1,59 @@
import { Elysia } from "elysia";
import { jwt } from "@elysiajs/jwt";
import { redis } from "@core/db/redis";
interface JWTPayload {
id: string;
[key: string]: any;
}
export const captchaMiddleware = new Elysia({ name: "captcha" })
.use(
jwt({
name: "captchaJwt",
secret: process.env.JWT_SECRET || "default-secret-key"
})
)
.derive(async ({ request, captchaJwt, set }) => {
const authHeader = request.headers.get("authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
set.status = 401;
throw new Error("Missing or invalid authorization header");
}
const token = authHeader.slice(7);
try {
const payload = (await captchaJwt.verify(token)) as JWTPayload;
if (!payload || !payload.id) {
set.status = 401;
throw new Error("Invalid JWT payload");
}
const redisKey = `captcha:${payload.id}`;
const exists = await redis.exists(redisKey);
if (exists) {
set.status = 400;
throw new Error("Captcha already used or expired");
}
await redis.setex(redisKey, 300, "used");
return {
captchaVerified: true,
userId: payload.id
};
} catch (error) {
if (error instanceof Error) {
set.status = 401;
throw new Error(`JWT verification failed: ${error.message}`);
}
set.status = 500;
throw new Error("Internal server error during captcha verification");
}
});
export default captchaMiddleware;

View File

@ -9,6 +9,7 @@
"dependencies": {
"@alikia/random-key": "^1.1.1",
"@elysiajs/cors": "^1.4.0",
"@elysiajs/jwt": "^1.4.0",
"@elysiajs/openapi": "^1.4.0",
"@elysiajs/server-timing": "^1.4.0",
"@rabbit-company/argon2id": "^2.1.0",

View File

@ -3,6 +3,7 @@ import { dbMain } from "@core/drizzle";
import { relations, singer, songs } from "@core/drizzle/main/schema";
import { eq, and } from "drizzle-orm";
import { bv2av } from "@elysia/lib/av_bv";
import captchaMiddleware from "@elysia/middlewares/captcha";
async function getSongIDFromBiliID(id: string) {
let aid: number;
@ -58,111 +59,110 @@ async function getSingers(id: number) {
return singers.map((singer) => singer.singers);
}
export const songInfoHandler = new Elysia({ prefix: "/song" })
.get(
"/:id/info",
async ({ params, status }) => {
const id = params.id;
const songID = await getSongID(id);
if (!songID) {
return status(404, {
code: "SONG_NOT_FOUND",
message: "Given song cannot be found."
});
}
const info = await getSongInfo(songID);
if (!info) {
return status(404, {
code: "SONG_NOT_FOUND",
message: "Given song cannot be found."
});
}
const singers = await getSingers(info.id);
return {
name: info.name,
aid: info.aid,
producer: info.producer,
duration: info.duration,
singers: singers,
cover: info.image || undefined
};
const songInfoGetHandler = new Elysia({ prefix: "/song" }).get(
"/:id/info",
async ({ params, status }) => {
const id = params.id;
const songID = await getSongID(id);
if (!songID) {
return status(404, {
code: "SONG_NOT_FOUND",
message: "Given song cannot be found."
});
}
const info = await getSongInfo(songID);
if (!info) {
return status(404, {
code: "SONG_NOT_FOUND",
message: "Given song cannot be found."
});
}
const singers = await getSingers(info.id);
return {
name: info.name,
aid: info.aid,
producer: info.producer,
duration: info.duration,
singers: singers,
cover: info.image || undefined
};
},
{
response: {
200: t.Object({
name: t.Union([t.String(), t.Null()]),
aid: t.Union([t.Number(), t.Null()]),
producer: t.Union([t.String(), t.Null()]),
duration: t.Union([t.Number(), t.Null()]),
singers: t.Array(t.String()),
cover: t.Optional(t.String())
}),
404: t.Object({
message: t.String()
})
},
{
response: {
200: t.Object({
name: t.Union([t.String(), t.Null()]),
aid: t.Union([t.Number(), t.Null()]),
producer: t.Union([t.String(), t.Null()]),
duration: t.Union([t.Number(), t.Null()]),
singers: t.Array(t.String()),
cover: t.Optional(t.String())
}),
404: t.Object({
message: t.String()
})
},
detail: {
summary: "Get information of a song",
description:
"This endpoint retrieves detailed information about a song using its unique ID, \
detail: {
summary: "Get information of a song",
description:
"This endpoint retrieves detailed information about a song using its unique ID, \
which can be provided in several formats. \
The endpoint accepts a song ID in either a numerical format as the internal ID in our database\
or as a bilibili video ID (either av or BV format). \
It responds with the song's name, bilibili ID (av), producer, duration, and associated singers."
}
}
)
.patch(
"/:id/info",
async ({ params, status, body }) => {
const id = params.id;
const songID = await getSongID(id);
if (!songID) {
return status(404, {
code: "SONG_NOT_FOUND",
message: "Given song cannot be found."
});
}
const info = await getSongInfo(songID);
if (!info) {
return status(404, {
code: "SONG_NOT_FOUND",
message: "Given song cannot be found."
});
}
if (body.name) {
await dbMain.update(songs).set({ name: body.name }).where(eq(songs.id, songID));
}
if (body.producer) {
await dbMain
.update(songs)
.set({ producer: body.producer })
.where(eq(songs.id, songID))
.returning();
}
const updatedData = await dbMain
.select()
.from(songs)
.where(eq(songs.id, songID));
return {
message: "Successfully updated song info.",
updated: updatedData.length > 0 ? updatedData[0] : null
};
},
{
response: {
200: t.Object({
message: t.String(),
updated: t.Any()
}),
404: t.Object({
message: t.String(),
code: t.String()
})
},
body: t.Object({
name: t.Optional(t.String()),
producer: t.Optional(t.String())
}
);
const songInfoUpdateHandler = new Elysia({ prefix: "/song" }).use(captchaMiddleware).patch(
"/:id/info",
async ({ params, status, body }) => {
const id = params.id;
const songID = await getSongID(id);
if (!songID) {
return status(404, {
code: "SONG_NOT_FOUND",
message: "Given song cannot be found."
});
}
const info = await getSongInfo(songID);
if (!info) {
return status(404, {
code: "SONG_NOT_FOUND",
message: "Given song cannot be found."
});
}
if (body.name) {
await dbMain.update(songs).set({ name: body.name }).where(eq(songs.id, songID));
}
if (body.producer) {
await dbMain
.update(songs)
.set({ producer: body.producer })
.where(eq(songs.id, songID))
.returning();
}
const updatedData = await dbMain.select().from(songs).where(eq(songs.id, songID));
return {
message: "Successfully updated song info.",
updated: updatedData.length > 0 ? updatedData[0] : null
};
},
{
response: {
200: t.Object({
message: t.String(),
updated: t.Any()
}),
404: t.Object({
message: t.String(),
code: t.String()
})
}
);
},
body: t.Object({
name: t.Optional(t.String()),
producer: t.Optional(t.String())
})
}
);
export const songInfoHandler = new Elysia().use(songInfoGetHandler).use(songInfoUpdateHandler);

View File

@ -1,7 +1,9 @@
import type { SearchResult } from "@/routes/search";
import { z } from "zod";
interface SearchResultsProps {
results: SearchResult;
query: string;
}
const formatDateTime = (date: Date): string => {
@ -15,11 +17,26 @@ const formatDateTime = (date: Date): string => {
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
};
export function SearchResults({ results }: SearchResultsProps) {
const biliIDSchema = z.union([z.string().regex(/BV1[0-9A-Za-z]{9}/), z.string().regex(/av[0-9]+/)]);
export function SearchResults({ results, query }: SearchResultsProps) {
if (!results || results.length === 0) {
if (!biliIDSchema.safeParse(query).success) {
return (
<div className="text-center pt-6">
<p className="text-secondary-foreground"></p>
</div>
);
}
return (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400"></p>
<div className="text-center pt-6">
<p className="text-secondary-foreground">
<a href={`/song/${query}/add`} className="text-primary-foreground">
</a>
?
</p>
</div>
);
}

View File

@ -1,8 +1,8 @@
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
import { Toaster } from "@/components/ui/sonner";
import type { Route } from "./+types/root";
import "./app.css";
import { Error as ErrPage } from "./components/Error";
export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
@ -42,27 +42,22 @@ export default function App() {
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let status = 0;
let details = "出错了!";
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details = error.status === 404 ? "The requested page could not be found." : error.statusText || details;
status = error.status
details = error.status === 404 ? "找不到页面" : error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
<ErrPage error={{
status: status || 500,
value: { message: details },
}} />
);
}

View File

@ -7,7 +7,7 @@ import { useSearchParams } from "react-router";
import { SearchBox } from "@/components/Search";
import { SearchResults } from "@/components/SearchResults";
import { Title } from "@/components/Title";
import { Layout, LayoutWithouSearch } from "@/components/Layout";
import { LayoutWithouSearch } from "@/components/Layout";
const app = treaty<App>(import.meta.env.VITE_API_URL!);
@ -81,7 +81,7 @@ export default function SearchResult() {
<LayoutWithouSearch>
<Title title={searchParams.get("q") || ""} />
<Search query={query} setQuery={setQuery} onSearch={handleSearch} className="mb-6" />
<SearchResults results={data} />
<SearchResults results={data} query={query} />
</LayoutWithouSearch>
);
}

View File

@ -0,0 +1,63 @@
import type { Route } from "./+types/info";
import { treaty } from "@elysiajs/eden";
import type { App } from "@elysia/src";
import { useEffect, useState } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { TriangleAlert } from "lucide-react";
import { Title } from "@/components/Title";
import { Search } from "@/components/Search";
import { Error } from "@/components/Error";
import { Layout } from "@/components/Layout";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
const app = treaty<App>(import.meta.env.VITE_API_URL!);
type SongInfo = Awaited<ReturnType<ReturnType<typeof app.song>["info"]["get"]>>["data"];
type SongInfoError = Awaited<ReturnType<ReturnType<typeof app.song>["info"]["get"]>>["error"];
export async function clientLoader({ params }: Route.LoaderArgs) {
return { id: params.id };
}
export default function SongInfo({ loaderData }: Route.ComponentProps) {
const [data, setData] = useState<SongInfo | null>(null);
const [error, setError] = useState<SongInfoError | null>(null);
if (!data && !error) {
return (
<Layout>
<Title title="加载中" />
<Skeleton className="mt-6 w-full aspect-video rounded-lg" />
<div className="mt-6 flex justify-between items-baseline">
<Skeleton className="w-60 h-10 rounded-sm" />
<Skeleton className="w-25 h-10 rounded-sm" />
</div>
</Layout>
);
}
if (error?.status === 404) {
return (
<div className="w-screen min-h-screen flex items-center justify-center">
<Title title="未找到曲目" />
<div className="max-w-md w-full bg-gray-100 dark:bg-neutral-900 rounded-2xl shadow-lg p-6 flex flex-col gap-4 items-center text-center">
<div className="w-16 h-16 flex items-center justify-center rounded-full bg-red-500 text-white text-3xl">
<TriangleAlert size={34} className="-translate-y-0.5" />
</div>
<h1 className="text-3xl font-semibold text-neutral-900 dark:text-neutral-100"></h1>
<a href={`/song/${loaderData.id}/add`} className="text-secondary-foreground">
</a>
</div>
</div>
);
}
if (error) {
return <Error error={error} />;
}
return <Layout></Layout>;
}