Add API rate limit (#23)

This commit is contained in:
KM Koushik
2024-05-30 20:01:20 +10:00
committed by GitHub
parent d7b8a9cca6
commit 8b61223153
6 changed files with 86 additions and 45 deletions

View File

@@ -23,6 +23,7 @@
"@hono/swagger-ui": "^0.2.1", "@hono/swagger-ui": "^0.2.1",
"@hono/zod-openapi": "^0.10.0", "@hono/zod-openapi": "^0.10.0",
"@hookform/resolvers": "^3.3.4", "@hookform/resolvers": "^3.3.4",
"@isaacs/ttlcache": "^1.4.1",
"@prisma/client": "^5.11.0", "@prisma/client": "^5.11.0",
"@t3-oss/env-nextjs": "^0.9.2", "@t3-oss/env-nextjs": "^0.9.2",
"@tanstack/react-query": "^5.25.0", "@tanstack/react-query": "^5.25.0",

View File

@@ -40,6 +40,10 @@ export const env = createEnv({
GOOGLE_CLIENT_SECRET: z.string(), GOOGLE_CLIENT_SECRET: z.string(),
SES_QUEUE_LIMIT: z.string().transform((str) => parseInt(str, 10)), SES_QUEUE_LIMIT: z.string().transform((str) => parseInt(str, 10)),
AWS_DEFAULT_REGION: z.string().default("us-east-1"), AWS_DEFAULT_REGION: z.string().default("us-east-1"),
API_RATE_LIMIT: z
.string()
.transform((str) => parseInt(str, 10))
.default(2),
}, },
/** /**
@@ -72,6 +76,7 @@ export const env = createEnv({
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
SES_QUEUE_LIMIT: process.env.SES_QUEUE_LIMIT, SES_QUEUE_LIMIT: process.env.SES_QUEUE_LIMIT,
AWS_DEFAULT_REGION: process.env.AWS_DEFAULT_REGION, AWS_DEFAULT_REGION: process.env.AWS_DEFAULT_REGION,
API_RATE_LIMIT: process.env.API_RATE_LIMIT,
}, },
/** /**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially

View File

@@ -1,20 +1,30 @@
import TTLCache from "@isaacs/ttlcache";
import { Context } from "hono"; import { Context } from "hono";
import { hashToken } from "../auth"; import { hashToken } from "../auth";
import { db } from "../db"; import { db } from "../db";
import { UnsendApiError } from "./api-error"; import { UnsendApiError } from "./api-error";
import { env } from "~/env";
const rateLimitCache = new TTLCache({
ttl: 1000, // 1 second
max: env.API_RATE_LIMIT,
});
/** /**
* Gets the team from the token. Also will check if the token is valid. * Gets the team from the token. Also will check if the token is valid.
*/ */
export const getTeamFromToken = async (c: Context) => { export const getTeamFromToken = async (c: Context) => {
const authHeader = c.req.header("Authorization"); const authHeader = c.req.header("Authorization");
if (!authHeader) { if (!authHeader) {
throw new UnsendApiError({ throw new UnsendApiError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "No Authorization header provided", message: "No Authorization header provided",
}); });
} }
const token = authHeader.split(" ")[1]; const token = authHeader.split(" ")[1];
if (!token) { if (!token) {
throw new UnsendApiError({ throw new UnsendApiError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
@@ -22,6 +32,8 @@ export const getTeamFromToken = async (c: Context) => {
}); });
} }
checkRateLimit(token);
const hashedToken = hashToken(token); const hashedToken = hashToken(token);
const team = await db.team.findFirst({ const team = await db.team.findFirst({
@@ -55,3 +67,18 @@ export const getTeamFromToken = async (c: Context) => {
return team; return team;
}; };
const checkRateLimit = (token: string) => {
let rateLimit = rateLimitCache.get<number>(token);
rateLimit = rateLimit ?? 0;
if (rateLimit >= 2) {
throw new UnsendApiError({
code: "RATE_LIMITED",
message: `Rate limit exceeded, ${env.API_RATE_LIMIT} requests per second`,
});
}
rateLimitCache.set(token, rateLimit + 1);
};

View File

@@ -1,6 +1,6 @@
{ {
"name": "unsend", "name": "unsend",
"version": "0.0.5", "version": "0.0.6",
"description": "", "description": "",
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",
@@ -8,7 +8,8 @@
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint . --max-warnings 0", "lint": "eslint . --max-warnings 0",
"build": "rm -rf dist && tsup index.ts --format esm,cjs --dts" "build": "rm -rf dist && tsup index.ts --format esm,cjs --dts",
"publish-sdk": "pnpm run build && pnpm publish"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",

View File

@@ -3,7 +3,7 @@ import { cn } from "..";
export const Spinner: React.FC< export const Spinner: React.FC<
React.SVGProps<SVGSVGElement> & { innerSvgClass?: string } React.SVGProps<SVGSVGElement> & { innerSvgClass?: string }
> = (props) => { > = ({ innerSvgClass, ...props }) => {
return ( return (
<svg <svg
version="1.1" version="1.1"
@@ -18,7 +18,7 @@ export const Spinner: React.FC<
<g <g
strokeWidth="200" strokeWidth="200"
strokeLinecap="round" strokeLinecap="round"
className={cn("stroke-primary-foreground", props.innerSvgClass)} className={cn("stroke-primary-foreground", innerSvgClass)}
fill="none" fill="none"
id="spinner" id="spinner"
> >

89
pnpm-lock.yaml generated
View File

@@ -112,6 +112,9 @@ importers:
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^3.3.4 specifier: ^3.3.4
version: 3.3.4(react-hook-form@7.51.3) version: 3.3.4(react-hook-form@7.51.3)
'@isaacs/ttlcache':
specifier: ^1.4.1
version: 1.4.1
'@prisma/client': '@prisma/client':
specifier: ^5.11.0 specifier: ^5.11.0
version: 5.11.0(prisma@5.11.0) version: 5.11.0(prisma@5.11.0)
@@ -2091,6 +2094,11 @@ packages:
wrap-ansi: 8.1.0 wrap-ansi: 8.1.0
wrap-ansi-cjs: /wrap-ansi@7.0.0 wrap-ansi-cjs: /wrap-ansi@7.0.0
/@isaacs/ttlcache@1.4.1:
resolution: {integrity: sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==}
engines: {node: '>=12'}
dev: false
/@jridgewell/gen-mapping@0.3.5: /@jridgewell/gen-mapping@0.3.5:
resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
@@ -6141,7 +6149,7 @@ packages:
eslint: 8.57.0 eslint: 8.57.0
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0) eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) eslint-plugin-import: 2.29.1(eslint@8.57.0)
eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.0) eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.0)
eslint-plugin-react: 7.34.0(eslint@8.57.0) eslint-plugin-react: 7.34.0(eslint@8.57.0)
eslint-plugin-react-hooks: 4.6.0(eslint@8.57.0) eslint-plugin-react-hooks: 4.6.0(eslint@8.57.0)
@@ -6199,7 +6207,7 @@ packages:
enhanced-resolve: 5.16.0 enhanced-resolve: 5.16.0
eslint: 8.57.0 eslint: 8.57.0
eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0)(eslint@8.57.0)
fast-glob: 3.3.2 fast-glob: 3.3.2
get-tsconfig: 4.7.3 get-tsconfig: 4.7.3
is-core-module: 2.13.1 is-core-module: 2.13.1
@@ -6333,41 +6341,6 @@ packages:
ignore: 5.3.1 ignore: 5.3.1
dev: true dev: true
/eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0):
resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==}
engines: {node: '>=4'}
peerDependencies:
'@typescript-eslint/parser': '*'
eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8
peerDependenciesMeta:
'@typescript-eslint/parser':
optional: true
dependencies:
'@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.4.2)
array-includes: 3.1.7
array.prototype.findlastindex: 1.2.4
array.prototype.flat: 1.3.2
array.prototype.flatmap: 1.3.2
debug: 3.2.7
doctrine: 2.1.0
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
hasown: 2.0.2
is-core-module: 2.13.1
is-glob: 4.0.3
minimatch: 3.1.2
object.fromentries: 2.0.7
object.groupby: 1.0.2
object.values: 1.1.7
semver: 6.3.1
tsconfig-paths: 3.15.0
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
- supports-color
dev: true
/eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.2.0)(eslint@8.57.0): /eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.2.0)(eslint@8.57.0):
resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==} resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -6403,6 +6376,40 @@ packages:
- supports-color - supports-color
dev: true dev: true
/eslint-plugin-import@2.29.1(eslint@8.57.0):
resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==}
engines: {node: '>=4'}
peerDependencies:
'@typescript-eslint/parser': '*'
eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8
peerDependenciesMeta:
'@typescript-eslint/parser':
optional: true
dependencies:
array-includes: 3.1.7
array.prototype.findlastindex: 1.2.4
array.prototype.flat: 1.3.2
array.prototype.flatmap: 1.3.2
debug: 3.2.7
doctrine: 2.1.0
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
hasown: 2.0.2
is-core-module: 2.13.1
is-glob: 4.0.3
minimatch: 3.1.2
object.fromentries: 2.0.7
object.groupby: 1.0.2
object.values: 1.1.7
semver: 6.3.1
tsconfig-paths: 3.15.0
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
- supports-color
dev: true
/eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@6.21.0)(eslint@8.57.0)(typescript@5.4.2): /eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@6.21.0)(eslint@8.57.0)(typescript@5.4.2):
resolution: {integrity: sha512-QIT7FH7fNmd9n4se7FFKHbsLKGQiw885Ds6Y/sxKgCZ6natwCsXdgPOADnYVxN2QrRweF0FZWbJ6S7Rsn7llug==} resolution: {integrity: sha512-QIT7FH7fNmd9n4se7FFKHbsLKGQiw885Ds6Y/sxKgCZ6natwCsXdgPOADnYVxN2QrRweF0FZWbJ6S7Rsn7llug==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -8265,8 +8272,8 @@ packages:
highlight.js: 10.7.3 highlight.js: 10.7.3
dev: false dev: false
/lru-cache@10.2.0: /lru-cache@10.2.2:
resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==} resolution: {integrity: sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==}
engines: {node: 14 || >=16.14} engines: {node: 14 || >=16.14}
/lru-cache@5.1.1: /lru-cache@5.1.1:
@@ -9565,7 +9572,7 @@ packages:
resolution: {integrity: sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==} resolution: {integrity: sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==}
engines: {node: '>=16 || 14 >=14.17'} engines: {node: '>=16 || 14 >=14.17'}
dependencies: dependencies:
lru-cache: 10.2.0 lru-cache: 10.2.2
minipass: 7.0.4 minipass: 7.0.4
dev: true dev: true
@@ -9573,7 +9580,7 @@ packages:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'} engines: {node: '>=16 || 14 >=14.18'}
dependencies: dependencies:
lru-cache: 10.2.0 lru-cache: 10.2.2
minipass: 7.0.4 minipass: 7.0.4
/path-to-regexp@0.1.7: /path-to-regexp@0.1.7: