diff --git a/package.json b/package.json index e4577e7..d0a4a8c 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "dotenv": "^16.4.5", "drizzle-kit": "^0.23.1", "drizzle-orm": "^0.32.1", + "rate-limiter-flexible": "^5.0.3", "solid-js": "^1.8.19", "typescript": "^5.5.4", "zod": "^3.23.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd84fee..f4f3262 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: drizzle-orm: specifier: ^0.32.1 version: 0.32.1(@types/better-sqlite3@7.6.11)(better-sqlite3@11.1.2) + rate-limiter-flexible: + specifier: ^5.0.3 + version: 5.0.3 solid-js: specifier: ^1.8.19 version: 1.8.19 @@ -3091,6 +3094,9 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} + rate-limiter-flexible@5.0.3: + resolution: {integrity: sha512-lWx2y8NBVlTOLPyqs+6y7dxfEpT6YFqKy3MzWbCy95sTTOhOuxufP2QvRyOHpfXpB9OUJPbVLybw3z3AVAS5fA==} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -7066,6 +7072,8 @@ snapshots: range-parser@1.2.1: {} + rate-limiter-flexible@5.0.3: {} + rc@1.2.8: dependencies: deep-extend: 0.6.0 diff --git a/src/backend/utils/response.ts b/src/backend/utils/response.ts new file mode 100644 index 0000000..da689ea --- /dev/null +++ b/src/backend/utils/response.ts @@ -0,0 +1,28 @@ +export class HttpResponse { + private constructor() {} + + static json(body: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(body), { + ...init, + headers: { + 'Content-Type': 'application/json', + ...init?.headers, + }, + }) + } + + static error(status: number) { + return new Response(null, { status }) + } + + static redirect(url: string, init?: ResponseInit) { + return new Response(null, { + status: 301, + ...init, + headers: { + Location: url, + ...init?.headers, + }, + }) + } +} diff --git a/src/pages/api/shoutbox.ts b/src/pages/api/shoutbox.ts index 930ffa5..5c1c5c6 100644 --- a/src/pages/api/shoutbox.ts +++ b/src/pages/api/shoutbox.ts @@ -1,10 +1,12 @@ import type { APIRoute } from 'astro' import { z } from 'zod' import { fromError } from 'zod-validation-error' +import { RateLimiterMemory } from 'rate-limiter-flexible' import { createShout, fetchShouts } from '~/backend/service/shoutbox' import { getRequestIp } from '~/backend/utils/request' import { verifyCsrfToken } from '~/backend/utils/csrf' +import { HttpResponse } from '~/backend/utils/response' const schema = z.object({ _csrf: z.string(), @@ -12,6 +14,9 @@ const schema = z.object({ private: z.literal('').optional(), }) +const rateLimitPerIp = new RateLimiterMemory({ points: 3, duration: 300 }) +const rateLimitGlobal = new RateLimiterMemory({ points: 100, duration: 3600 }) + export const POST: APIRoute = async (ctx) => { const contentType = ctx.request.headers.get('content-type') const isFormSubmit = contentType === 'application/x-www-form-urlencoded' @@ -25,27 +30,30 @@ export const POST: APIRoute = async (ctx) => { const body = await schema.safeParseAsync(bodyRaw) if (body.error) { - return new Response(JSON.stringify({ + return HttpResponse.json({ error: fromError(body.error).message, - }), { - status: 400, - headers: { - 'Content-Type': 'application/json', - }, - }) + }, { status: 400 }) } const ip = getRequestIp(ctx) if (!verifyCsrfToken(ip, body.data._csrf)) { - return new Response(JSON.stringify({ + return HttpResponse.json({ error: 'csrf token is invalid', - }), { - status: 400, - headers: { - 'Content-Type': 'application/json', - }, - }) + }, { status: 400 }) + } + + const remainingLocal = await rateLimitPerIp.get(ip) + const remainingGlobal = await rateLimitGlobal.get('GLOBAL') + if (remainingLocal?.remainingPoints === 0) { + return HttpResponse.json({ + error: 'too many requests', + }, { status: 400 }) + } + if (remainingGlobal?.remainingPoints === 0) { + return HttpResponse.json({ + error: `too many requests (globally), please retry after ${Math.ceil(remainingGlobal.msBeforeNext) / 60_000} minutes`, + }, { status: 400 }) } const result = await createShout({ @@ -54,23 +62,16 @@ export const POST: APIRoute = async (ctx) => { text: body.data.message, }) + await rateLimitPerIp.penalty(ip, 1) + await rateLimitGlobal.penalty('GLOBAL', 1) + if (isFormSubmit) { - return new Response(null, { - status: 301, - headers: { - Location: typeof result === 'string' ? `/?shout_error=${result}` : '/', - }, - }) + return HttpResponse.redirect(typeof result === 'string' ? `/?shout_error=${result}` : '/') } - return new Response(JSON.stringify( + return HttpResponse.json( typeof result === 'string' ? { error: result } : { ok: true }, - ), { - status: 200, - headers: { - 'Content-Type': 'application/json', - }, - }) + ) } export const GET: APIRoute = async (ctx) => { @@ -81,9 +82,5 @@ export const GET: APIRoute = async (ctx) => { const data = fetchShouts(page, getRequestIp(ctx)) - return new Response(JSON.stringify(data), { - headers: { - 'Content-Type': 'application/json', - }, - }) + return HttpResponse.json(data) }