From 6b2b035050359edef90ebaa4969d1bd6e3030feb Mon Sep 17 00:00:00 2001 From: alikia2x Date: Sun, 9 Nov 2025 04:10:52 +0800 Subject: [PATCH] add: captcha middleware --- bun.lock | 6 + packages/elysia/middlewares/captcha.ts | 59 +++++ packages/elysia/package.json | 1 + packages/elysia/routes/song/info.ts | 202 +++++++++--------- .../app/components/SearchResults.tsx | 23 +- packages/temp_frontend/app/root.tsx | 23 +- .../temp_frontend/app/routes/search/index.tsx | 4 +- .../app/routes/song/[id]/add.tsx | 63 ++++++ 8 files changed, 261 insertions(+), 120 deletions(-) create mode 100644 packages/elysia/middlewares/captcha.ts create mode 100644 packages/temp_frontend/app/routes/song/[id]/add.tsx diff --git a/bun.lock b/bun.lock index bab41b6..4d0cbbf 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/packages/elysia/middlewares/captcha.ts b/packages/elysia/middlewares/captcha.ts new file mode 100644 index 0000000..ce17dbb --- /dev/null +++ b/packages/elysia/middlewares/captcha.ts @@ -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; diff --git a/packages/elysia/package.json b/packages/elysia/package.json index 7e53a85..37366c7 100644 --- a/packages/elysia/package.json +++ b/packages/elysia/package.json @@ -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", diff --git a/packages/elysia/routes/song/info.ts b/packages/elysia/routes/song/info.ts index ecf9ee5..876b6d6 100644 --- a/packages/elysia/routes/song/info.ts +++ b/packages/elysia/routes/song/info.ts @@ -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); diff --git a/packages/temp_frontend/app/components/SearchResults.tsx b/packages/temp_frontend/app/components/SearchResults.tsx index a21ac2b..4aef7ca 100644 --- a/packages/temp_frontend/app/components/SearchResults.tsx +++ b/packages/temp_frontend/app/components/SearchResults.tsx @@ -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 ( +
+

没有找到相关结果

+
+ ); + } return ( -
-

没有找到相关结果

+
+

+ 没有找到相关结果。 尝试 + + 收录 + + ? +

); } diff --git a/packages/temp_frontend/app/root.tsx b/packages/temp_frontend/app/root.tsx index 051a01e..a49eb19 100644 --- a/packages/temp_frontend/app/root.tsx +++ b/packages/temp_frontend/app/root.tsx @@ -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 ( -
-

{message}

-

{details}

- {stack && ( -
-					{stack}
-				
- )} -
+ ); } diff --git a/packages/temp_frontend/app/routes/search/index.tsx b/packages/temp_frontend/app/routes/search/index.tsx index 3db760e..ef7e7da 100644 --- a/packages/temp_frontend/app/routes/search/index.tsx +++ b/packages/temp_frontend/app/routes/search/index.tsx @@ -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(import.meta.env.VITE_API_URL!); @@ -81,7 +81,7 @@ export default function SearchResult() { <Search query={query} setQuery={setQuery} onSearch={handleSearch} className="mb-6" /> - <SearchResults results={data} /> + <SearchResults results={data} query={query} /> </LayoutWithouSearch> ); } diff --git a/packages/temp_frontend/app/routes/song/[id]/add.tsx b/packages/temp_frontend/app/routes/song/[id]/add.tsx new file mode 100644 index 0000000..f18e270 --- /dev/null +++ b/packages/temp_frontend/app/routes/song/[id]/add.tsx @@ -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>; +}