{message}
-{details}
- {stack && ( -
- {stack}
-
- )}
- 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 ( +
没有找到相关结果
+没有找到相关结果
++ 没有找到相关结果。 尝试 + + 收录 + + ? +
{details}
- {stack && ( -
- {stack}
-
- )}
-