fix: rate limit for shoutbox
This commit is contained in:
parent
0951c4e1d0
commit
3f1049c6f5
4 changed files with 66 additions and 32 deletions
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
28
src/backend/utils/response.ts
Normal file
28
src/backend/utils/response.ts
Normal file
|
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue