mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
feat(api): ✨ Implement rate limiting
Some checks failed
CodeQL Scan / Analyze (javascript-typescript) (push) Failing after 42s
Build Docker Images / lint (push) Successful in 31s
Build Docker Images / check (push) Successful in 1m3s
Build Docker Images / tests (push) Failing after 6s
Build Docker Images / build (server, Dockerfile, ${{ github.repository_owner }}/server) (push) Has been skipped
Build Docker Images / build (worker, Worker.Dockerfile, ${{ github.repository_owner }}/worker) (push) Has been skipped
Deploy Docs to GitHub Pages / build (push) Failing after 13s
Mirror to Codeberg / Mirror (push) Failing after 0s
Deploy Docs to GitHub Pages / Deploy (push) Has been skipped
Nix Build / check (push) Failing after 33m18s
Some checks failed
CodeQL Scan / Analyze (javascript-typescript) (push) Failing after 42s
Build Docker Images / lint (push) Successful in 31s
Build Docker Images / check (push) Successful in 1m3s
Build Docker Images / tests (push) Failing after 6s
Build Docker Images / build (server, Dockerfile, ${{ github.repository_owner }}/server) (push) Has been skipped
Build Docker Images / build (worker, Worker.Dockerfile, ${{ github.repository_owner }}/worker) (push) Has been skipped
Deploy Docs to GitHub Pages / build (push) Failing after 13s
Mirror to Codeberg / Mirror (push) Failing after 0s
Deploy Docs to GitHub Pages / Deploy (push) Has been skipped
Nix Build / check (push) Failing after 33m18s
This commit is contained in:
parent
1993231663
commit
3d3e64edab
|
|
@ -9,6 +9,7 @@ import { User, db } from "@versia/kit/db";
|
||||||
import type { Users } from "@versia/kit/tables";
|
import type { Users } from "@versia/kit/tables";
|
||||||
import { type InferSelectModel, sql } from "drizzle-orm";
|
import { type InferSelectModel, sql } from "drizzle-orm";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
import { ApiError } from "~/classes/errors/api-error";
|
||||||
|
import { rateLimit } from "~/middlewares/rate-limit";
|
||||||
|
|
||||||
const route = createRoute({
|
const route = createRoute({
|
||||||
method: "get",
|
method: "get",
|
||||||
|
|
@ -26,6 +27,7 @@ const route = createRoute({
|
||||||
scopes: ["read:follows"],
|
scopes: ["read:follows"],
|
||||||
permissions: [RolePermission.ManageOwnFollows],
|
permissions: [RolePermission.ManageOwnFollows],
|
||||||
}),
|
}),
|
||||||
|
rateLimit(5),
|
||||||
qsQuery(),
|
qsQuery(),
|
||||||
] as const,
|
] as const,
|
||||||
request: {
|
request: {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { and, eq, isNull } from "drizzle-orm";
|
||||||
import ISO6391 from "iso-639-1";
|
import ISO6391 from "iso-639-1";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
import { ApiError } from "~/classes/errors/api-error";
|
||||||
import { config } from "~/config.ts";
|
import { config } from "~/config.ts";
|
||||||
|
import { rateLimit } from "~/middlewares/rate-limit";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
username: z.string().openapi({
|
username: z.string().openapi({
|
||||||
|
|
@ -55,6 +56,7 @@ const route = createRoute({
|
||||||
scopes: ["write:accounts"],
|
scopes: ["write:accounts"],
|
||||||
challenge: true,
|
challenge: true,
|
||||||
}),
|
}),
|
||||||
|
rateLimit(5),
|
||||||
jsonOrForm(),
|
jsonOrForm(),
|
||||||
] as const,
|
] as const,
|
||||||
request: {
|
request: {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { Users } from "@versia/kit/tables";
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
import { ApiError } from "~/classes/errors/api-error";
|
||||||
import { config } from "~/config.ts";
|
import { config } from "~/config.ts";
|
||||||
|
import { rateLimit } from "~/middlewares/rate-limit";
|
||||||
|
|
||||||
const route = createRoute({
|
const route = createRoute({
|
||||||
method: "get",
|
method: "get",
|
||||||
|
|
@ -19,6 +20,7 @@ const route = createRoute({
|
||||||
auth: false,
|
auth: false,
|
||||||
permissions: [RolePermission.Search],
|
permissions: [RolePermission.Search],
|
||||||
}),
|
}),
|
||||||
|
rateLimit(5),
|
||||||
] as const,
|
] as const,
|
||||||
request: {
|
request: {
|
||||||
query: z.object({
|
query: z.object({
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
import { RolePermission } from "@versia/client/schemas";
|
import { RolePermission } from "@versia/client/schemas";
|
||||||
import { Relationship } from "@versia/kit/db";
|
import { Relationship } from "@versia/kit/db";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
import { ApiError } from "~/classes/errors/api-error";
|
||||||
|
import { rateLimit } from "~/middlewares/rate-limit";
|
||||||
|
|
||||||
const route = createRoute({
|
const route = createRoute({
|
||||||
method: "get",
|
method: "get",
|
||||||
|
|
@ -20,6 +21,7 @@ const route = createRoute({
|
||||||
},
|
},
|
||||||
tags: ["Accounts"],
|
tags: ["Accounts"],
|
||||||
middleware: [
|
middleware: [
|
||||||
|
rateLimit(10),
|
||||||
auth({
|
auth({
|
||||||
auth: true,
|
auth: true,
|
||||||
scopes: ["read:follows"],
|
scopes: ["read:follows"],
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { Users } from "@versia/kit/tables";
|
||||||
import { eq, ilike, not, or, sql } from "drizzle-orm";
|
import { eq, ilike, not, or, sql } from "drizzle-orm";
|
||||||
import stringComparison from "string-comparison";
|
import stringComparison from "string-comparison";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
import { ApiError } from "~/classes/errors/api-error";
|
||||||
|
import { rateLimit } from "~/middlewares/rate-limit";
|
||||||
|
|
||||||
export const route = createRoute({
|
export const route = createRoute({
|
||||||
method: "get",
|
method: "get",
|
||||||
|
|
@ -18,6 +19,7 @@ export const route = createRoute({
|
||||||
},
|
},
|
||||||
tags: ["Accounts"],
|
tags: ["Accounts"],
|
||||||
middleware: [
|
middleware: [
|
||||||
|
rateLimit(5),
|
||||||
auth({
|
auth({
|
||||||
auth: false,
|
auth: false,
|
||||||
permissions: [RolePermission.Search, RolePermission.ViewAccounts],
|
permissions: [RolePermission.Search, RolePermission.ViewAccounts],
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { and, eq, isNull } from "drizzle-orm";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
import { ApiError } from "~/classes/errors/api-error";
|
||||||
import { contentToHtml } from "~/classes/functions/status";
|
import { contentToHtml } from "~/classes/functions/status";
|
||||||
import { config } from "~/config.ts";
|
import { config } from "~/config.ts";
|
||||||
|
import { rateLimit } from "~/middlewares/rate-limit";
|
||||||
|
|
||||||
const route = createRoute({
|
const route = createRoute({
|
||||||
method: "patch",
|
method: "patch",
|
||||||
|
|
@ -21,6 +22,7 @@ const route = createRoute({
|
||||||
},
|
},
|
||||||
tags: ["Accounts"],
|
tags: ["Accounts"],
|
||||||
middleware: [
|
middleware: [
|
||||||
|
rateLimit(5),
|
||||||
auth({
|
auth({
|
||||||
auth: true,
|
auth: true,
|
||||||
permissions: [RolePermission.ManageOwnAccount],
|
permissions: [RolePermission.ManageOwnAccount],
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { Application } from "@versia/kit/db";
|
import { Application } from "@versia/kit/db";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
import { ApiError } from "~/classes/errors/api-error";
|
||||||
|
import { rateLimit } from "~/middlewares/rate-limit";
|
||||||
|
|
||||||
const route = createRoute({
|
const route = createRoute({
|
||||||
method: "post",
|
method: "post",
|
||||||
|
|
@ -17,7 +18,7 @@ const route = createRoute({
|
||||||
url: "https://docs.joinmastodon.org/methods/apps/#create",
|
url: "https://docs.joinmastodon.org/methods/apps/#create",
|
||||||
},
|
},
|
||||||
tags: ["Apps"],
|
tags: ["Apps"],
|
||||||
middleware: [jsonOrForm()],
|
middleware: [jsonOrForm(), rateLimit(4)],
|
||||||
request: {
|
request: {
|
||||||
body: {
|
body: {
|
||||||
content: {
|
content: {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { Emojis } from "@versia/kit/tables";
|
||||||
import { and, eq, isNull, or } from "drizzle-orm";
|
import { and, eq, isNull, or } from "drizzle-orm";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
import { ApiError } from "~/classes/errors/api-error";
|
||||||
import { config } from "~/config.ts";
|
import { config } from "~/config.ts";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
shortcode: CustomEmojiSchema.shape.shortcode,
|
shortcode: CustomEmojiSchema.shape.shortcode,
|
||||||
element: z
|
element: z
|
||||||
|
|
|
||||||
9
app.ts
9
app.ts
|
|
@ -22,6 +22,7 @@ import { agentBans } from "./middlewares/agent-bans.ts";
|
||||||
import { boundaryCheck } from "./middlewares/boundary-check.ts";
|
import { boundaryCheck } from "./middlewares/boundary-check.ts";
|
||||||
import { ipBans } from "./middlewares/ip-bans.ts";
|
import { ipBans } from "./middlewares/ip-bans.ts";
|
||||||
import { logger } from "./middlewares/logger.ts";
|
import { logger } from "./middlewares/logger.ts";
|
||||||
|
import { rateLimit } from "./middlewares/rate-limit.ts";
|
||||||
import { routes } from "./routes.ts";
|
import { routes } from "./routes.ts";
|
||||||
import type { ApiRouteExports, HonoEnv } from "./types/api.ts";
|
import type { ApiRouteExports, HonoEnv } from "./types/api.ts";
|
||||||
|
|
||||||
|
|
@ -87,6 +88,14 @@ export const appFactory = async (): Promise<OpenAPIHono<HonoEnv>> => {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Set default ratelimits to 100 requests per minute
|
||||||
|
app.use("/api/*", rateLimit(100));
|
||||||
|
app.use("/api/v1/media", rateLimit(40));
|
||||||
|
app.use("/api/v1/media/*", rateLimit(40));
|
||||||
|
app.use("/api/v2/media", rateLimit(40));
|
||||||
|
app.use("/api/v1/timelines/*", rateLimit(40));
|
||||||
|
app.use("/api/v1/push/*", rateLimit(10));
|
||||||
|
|
||||||
/* app.use("*", registerMetrics);
|
/* app.use("*", registerMetrics);
|
||||||
app.get("/metrics", printMetrics); */
|
app.get("/metrics", printMetrics); */
|
||||||
// Disabled as federation now checks for this
|
// Disabled as federation now checks for this
|
||||||
|
|
|
||||||
3
bun.lock
3
bun.lock
|
|
@ -32,6 +32,7 @@
|
||||||
"confbox": "^0.2.1",
|
"confbox": "^0.2.1",
|
||||||
"drizzle-orm": "^0.41.0",
|
"drizzle-orm": "^0.41.0",
|
||||||
"hono": "^4.7.5",
|
"hono": "^4.7.5",
|
||||||
|
"hono-rate-limiter": "^0.4.2",
|
||||||
"html-to-text": "^9.0.5",
|
"html-to-text": "^9.0.5",
|
||||||
"ioredis": "^5.6.0",
|
"ioredis": "^5.6.0",
|
||||||
"ip-matching": "^2.1.2",
|
"ip-matching": "^2.1.2",
|
||||||
|
|
@ -854,6 +855,8 @@
|
||||||
|
|
||||||
"hono": ["hono@4.7.5", "", {}, "sha512-fDOK5W2C1vZACsgLONigdZTRZxuBqFtcKh7bUQ5cVSbwI2RWjloJDcgFOVzbQrlI6pCmhlTsVYZ7zpLj4m4qMQ=="],
|
"hono": ["hono@4.7.5", "", {}, "sha512-fDOK5W2C1vZACsgLONigdZTRZxuBqFtcKh7bUQ5cVSbwI2RWjloJDcgFOVzbQrlI6pCmhlTsVYZ7zpLj4m4qMQ=="],
|
||||||
|
|
||||||
|
"hono-rate-limiter": ["hono-rate-limiter@0.4.2", "", { "peerDependencies": { "hono": "^4.1.1" } }, "sha512-AAtFqgADyrmbDijcRTT/HJfwqfvhalya2Zo+MgfdrMPas3zSMD8SU03cv+ZsYwRU1swv7zgVt0shwN059yzhjw=="],
|
||||||
|
|
||||||
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||||
|
|
||||||
"html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
|
"html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,10 @@ export class ApiError extends Error {
|
||||||
|
|
||||||
public static zodSchema = z.object({
|
public static zodSchema = z.object({
|
||||||
error: z.string(),
|
error: z.string(),
|
||||||
details: z.string().or(z.record(z.string(), z.string())).optional(),
|
details: z
|
||||||
|
.string()
|
||||||
|
.or(z.record(z.string(), z.string().or(z.number())))
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
public get schema(): ResponseConfig {
|
public get schema(): ResponseConfig {
|
||||||
|
|
|
||||||
40
middlewares/rate-limit.ts
Normal file
40
middlewares/rate-limit.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type { z } from "@hono/zod-openapi";
|
||||||
|
import { env } from "bun";
|
||||||
|
import type { MiddlewareHandler } from "hono";
|
||||||
|
import { rateLimiter } from "hono-rate-limiter";
|
||||||
|
import type { ApiError } from "~/classes/errors/api-error";
|
||||||
|
import type { HonoEnv } from "~/types/api";
|
||||||
|
|
||||||
|
// Not exported by hono-rate-limiter
|
||||||
|
// So we define it ourselves
|
||||||
|
type RateLimitEnv = HonoEnv & {
|
||||||
|
Variables: {
|
||||||
|
rateLimit: {
|
||||||
|
limit: number;
|
||||||
|
remaining: number;
|
||||||
|
resetTime: Date;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rateLimit = (
|
||||||
|
limit: number,
|
||||||
|
windowMs = 60 * 1000,
|
||||||
|
): MiddlewareHandler<RateLimitEnv> =>
|
||||||
|
env.DISABLE_RATE_LIMIT === "true"
|
||||||
|
? (_, next): Promise<void> => next()
|
||||||
|
: rateLimiter<RateLimitEnv>({
|
||||||
|
keyGenerator: (c): string => c.req.path,
|
||||||
|
message: (c): z.infer<typeof ApiError.zodSchema> => ({
|
||||||
|
error: "Too many requests, please try again later.",
|
||||||
|
details: {
|
||||||
|
limit: c.get("rateLimit").limit,
|
||||||
|
remaining: c.get("rateLimit").remaining,
|
||||||
|
reset: c.get("rateLimit").resetTime.toISOString(),
|
||||||
|
resetInMs:
|
||||||
|
c.get("rateLimit").resetTime.getTime() - Date.now(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
windowMs,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
@ -104,6 +104,7 @@
|
||||||
"confbox": "^0.2.1",
|
"confbox": "^0.2.1",
|
||||||
"drizzle-orm": "^0.41.0",
|
"drizzle-orm": "^0.41.0",
|
||||||
"hono": "^4.7.5",
|
"hono": "^4.7.5",
|
||||||
|
"hono-rate-limiter": "^0.4.2",
|
||||||
"html-to-text": "^9.0.5",
|
"html-to-text": "^9.0.5",
|
||||||
"ioredis": "^5.6.0",
|
"ioredis": "^5.6.0",
|
||||||
"ip-matching": "^2.1.2",
|
"ip-matching": "^2.1.2",
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,14 @@ import { Client as VersiaClient } from "@versia/client";
|
||||||
import { Note, Token, User, db } from "@versia/kit/db";
|
import { Note, Token, User, db } from "@versia/kit/db";
|
||||||
import { Notes, Users } from "@versia/kit/tables";
|
import { Notes, Users } from "@versia/kit/tables";
|
||||||
import { solveChallenge } from "altcha-lib";
|
import { solveChallenge } from "altcha-lib";
|
||||||
|
import { env } from "bun";
|
||||||
import { type InferSelectModel, asc, inArray, like } from "drizzle-orm";
|
import { type InferSelectModel, asc, inArray, like } from "drizzle-orm";
|
||||||
import { appFactory } from "~/app";
|
import { appFactory } from "~/app";
|
||||||
import { searchManager } from "~/classes/search/search-manager";
|
import { searchManager } from "~/classes/search/search-manager";
|
||||||
import { config } from "~/config.ts";
|
import { config } from "~/config.ts";
|
||||||
import { setupDatabase } from "~/drizzle/db";
|
import { setupDatabase } from "~/drizzle/db";
|
||||||
|
|
||||||
|
env.DISABLE_RATE_LIMIT = "true";
|
||||||
await setupDatabase();
|
await setupDatabase();
|
||||||
|
|
||||||
if (config.search.enabled) {
|
if (config.search.enabled) {
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,7 @@ export const checkRouteNeedsChallenge = async (
|
||||||
.where(eq(Challenges.id, challenge_id));
|
.where(eq(Challenges.id, challenge_id));
|
||||||
};
|
};
|
||||||
|
|
||||||
type HonoEnvWithAuth = HonoEnv & {
|
export type HonoEnvWithAuth = HonoEnv & {
|
||||||
Variables: {
|
Variables: {
|
||||||
auth: AuthData & {
|
auth: AuthData & {
|
||||||
user: NonNullable<AuthData["user"]>;
|
user: NonNullable<AuthData["user"]>;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue