add: captcha middleware
This commit is contained in:
parent
ab5545966e
commit
6b2b035050
6
bun.lock
6
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=="],
|
||||
|
||||
59
packages/elysia/middlewares/captcha.ts
Normal file
59
packages/elysia/middlewares/captcha.ts
Normal 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;
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
}} />
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
63
packages/temp_frontend/app/routes/song/[id]/add.tsx
Normal file
63
packages/temp_frontend/app/routes/song/[id]/add.tsx
Normal 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>;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user