Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

implement rate limit #195

Open
wants to merge 5 commits into
base: dev
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.sample
Original file line number Diff line number Diff line change
@@ -16,4 +16,9 @@ CRON_SECRET=
# mainnet or sepolia
# Note: Not everything is supported on sepolia
# Default: mainnet
NEXT_PUBLIC_NETWORK=mainnet
NEXT_PUBLIC_NETWORK=mainnet
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
# Rate limiting configuration
RATE_LIMIT_REQUESTS=50
RATE_LIMIT_WINDOW="60 s"
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -13,7 +13,8 @@
"format:check": "prettier --check \"**/*.{ts,tsx,json}\"",
"format:fix": "prettier --write \"**/*.{ts,tsx,json}\"",
"prepare": "husky",
"postinstall": "prisma generate"
"postinstall": "prisma generate",
"test:ratelimit": "ts-node --project tsconfig.server.json src/scripts/test-rate-limit.ts"
},
"files": [
"CHANGELOG.md",
@@ -34,6 +35,8 @@
"@tanstack/query-core": "5.28.0",
"@types/mixpanel-browser": "2.49.0",
"@types/mustache": "4.2.5",
"@upstash/ratelimit": "^2.0.5",
"@upstash/redis": "^1.34.3",
"@vercel/analytics": "1.2.2",
"@vercel/speed-insights": "1.0.12",
"axios": "1.6.7",
@@ -86,6 +89,7 @@
"prettier": "3.3.3",
"prisma": "5.18.0",
"tailwindcss": "3.3.0",
"ts-node": "^10.9.2",
"typescript": "5"
},
"engines": {
2 changes: 2 additions & 0 deletions src/app/api/price/[name]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { NextResponse } from 'next/server';

export const revalidate = 300; // 5 mins
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

// only meant for backend calls
async function initRedis() {
1 change: 1 addition & 0 deletions src/app/api/raffle/luckyWinner/route.ts
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@
import { NextResponse } from 'next/server';

export const dynamic = 'force-dynamic'; // static by default, unless reading the request
export const runtime = 'nodejs';

export async function GET(request: Request) {
const authHeader = request.headers.get('authorization');
@@ -50,7 +51,7 @@
userId: true,
},
});
if (totalExistingWinners.length == raffleParticipants.length) {

Check warning on line 54 in src/app/api/raffle/luckyWinner/route.ts

GitHub Actions / Performs linting, formatting on the application

Expected '===' and instead saw '=='
return NextResponse.json({
success: false,
message: 'No new participants found',
@@ -111,7 +112,7 @@
}

// Check if we were able to select enough winners
if (luckyWinners.length == 0) {

Check warning on line 115 in src/app/api/raffle/luckyWinner/route.ts

GitHub Actions / Performs linting, formatting on the application

Expected '===' and instead saw '=='
return NextResponse.json({
success: false,
message: 'No winner found',
3 changes: 3 additions & 0 deletions src/app/api/raffle/route.ts
Original file line number Diff line number Diff line change
@@ -4,6 +4,9 @@ import { db } from '@/db';
import { getStrategies } from '@/store/strategies.atoms';
import { standariseAddress } from '@/utils';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

export async function POST(req: Request) {
const { address, type } = await req.json();

3 changes: 3 additions & 0 deletions src/app/api/referral/createUser/route.ts
Original file line number Diff line number Diff line change
@@ -3,6 +3,9 @@ import { NextResponse } from 'next/server';
import { db } from '@/db';
import { standariseAddress } from '@/utils';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

function isSixDigitAlphanumeric(str: string) {
const regex = /^[a-zA-Z0-9]{6}$/;
return regex.test(str);
2 changes: 2 additions & 0 deletions src/app/api/stats/[address]/route.ts
Original file line number Diff line number Diff line change
@@ -3,6 +3,8 @@ import { standariseAddress } from '@/utils';
import { NextResponse } from 'next/server';

export const revalidate = 0;
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

export async function GET(_req: Request, context: any) {
const { params } = context;
2 changes: 2 additions & 0 deletions src/app/api/stats/route.ts
Original file line number Diff line number Diff line change
@@ -2,6 +2,8 @@ import { getStrategies } from '@/store/strategies.atoms';
import { NextResponse } from 'next/server';

export const revalidate = 1800;
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

export async function GET(_req: Request) {
const strategies = getStrategies();
2 changes: 2 additions & 0 deletions src/app/api/strategies/route.ts
Original file line number Diff line number Diff line change
@@ -11,6 +11,8 @@
import { STRKFarmStrategyAPIResult } from '@/store/strkfarm.atoms';

export const revalidate = 3600; // 1 hr
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

const allPoolsAtom = atom<PoolInfo[]>((get) => {
const pools: PoolInfo[] = [];
@@ -25,7 +27,7 @@
const hasRequiredPools = minProtocolsRequired.every((p) => {
if (!allPools) return false;
return allPools.some(
(pool) => pool.protocol.name === p && pool.type == PoolType.Lending,

Check warning on line 30 in src/app/api/strategies/route.ts

GitHub Actions / Performs linting, formatting on the application

Expected '===' and instead saw '=='
);
});
const MAX_RETRIES = 120;
@@ -90,7 +92,7 @@
};
}

export async function GET(req: Request) {

Check warning on line 95 in src/app/api/strategies/route.ts

GitHub Actions / Performs linting, formatting on the application

'req' is defined but never used. Allowed unused args must match /^_/u
const allPools = await getPools(MY_STORE);
const strategies = getStrategies();

2 changes: 2 additions & 0 deletions src/app/api/tnc/getUser/[address]/route.ts
Original file line number Diff line number Diff line change
@@ -2,6 +2,8 @@ import { NextResponse } from 'next/server';

import { db } from '@/db';
import { standariseAddress } from '@/utils';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

export async function GET(req: Request, context: any) {
const { params } = context;
3 changes: 3 additions & 0 deletions src/app/api/tnc/signUser/route.ts
Original file line number Diff line number Diff line change
@@ -8,6 +8,9 @@
import Mixpanel from 'mixpanel';
const mixpanel = Mixpanel.init('118f29da6a372f0ccb6f541079cad56b');

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

export async function POST(req: Request) {
const { address, signature } = await req.json();

@@ -94,7 +97,7 @@

if (!isValid) {
try {
const cls = await provider.getClassAt(address, 'pending');

Check warning on line 100 in src/app/api/tnc/signUser/route.ts

GitHub Actions / Performs linting, formatting on the application

'cls' is assigned a value but never used. Allowed unused vars must match /^_/u
// means account is deployed
return NextResponse.json({
success: false,
@@ -178,7 +181,7 @@
}),
});
console.debug('verifyMessageHash resp', resp);
if (Number(resp[0]) == 0) {

Check warning on line 184 in src/app/api/tnc/signUser/route.ts

GitHub Actions / Performs linting, formatting on the application

Expected '===' and instead saw '=='
throw new Error('Invalid signature');
}
return true;
2 changes: 2 additions & 0 deletions src/app/api/users/ognft/[address]/route.ts
Original file line number Diff line number Diff line change
@@ -4,6 +4,8 @@ import { standariseAddress } from '../../../../../utils';
import OGNFTUsersJson from '../../../../../../public/og_nft_eligible_users.json';

export const revalidate = 3600;
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

export async function GET(req: Request, context: any) {
try {
5 changes: 5 additions & 0 deletions src/lib/redis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Redis } from '@upstash/redis';

const redis = Redis.fromEnv();

export default redis;
56 changes: 56 additions & 0 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server';
import { Ratelimit } from '@upstash/ratelimit';
import redis from './lib/redis';

export const config = {
matcher: ['/api/:path*'],
};

const RATE_LIMIT_REQUESTS = parseInt(
process.env.RATE_LIMIT_REQUESTS || '20',
10,
);
const RATE_LIMIT_WINDOW = process.env.RATE_LIMIT_WINDOW || '10 s';

const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(
RATE_LIMIT_REQUESTS,
RATE_LIMIT_WINDOW as `${number} s`,
),
analytics: true,
prefix: '@upstash/ratelimit',
});

export async function middleware(request: NextRequest) {
const ip = request.headers.get('x-forwarded-for') || '127.0.0.1';
const identifier = ip;

let success, limit, remaining, reset;
try {
const result = await ratelimit.limit(identifier);
success = result.success;
limit = result.limit;
remaining = result.remaining;
reset = result.reset;
} catch (error) {
console.log(error);
return NextResponse.json(
{ message: 'Internal Server Error' },
{ status: 500 },
);
}

const response = success
? NextResponse.next()
: NextResponse.json(
{ message: 'Rate limit exceeded', limit, remaining, reset },
{ status: 429 },
);

response.headers.set('X-RateLimit-Limit', limit.toString());
response.headers.set('X-RateLimit-Remaining', remaining.toString());
response.headers.set('X-RateLimit-Reset', reset.toString());

return response;
}
30 changes: 30 additions & 0 deletions src/scripts/test-rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
async function testRateLimit(url: string, attempts: number) {
console.log(`Testing rate limit for ${url}`);
for (let i = 0; i < attempts; i++) {
const response = await fetch(url);
const remaining = response.headers.get('X-RateLimit-Remaining');
console.log(
`Attempt ${i + 1}: Status ${response.status}, Remaining: ${remaining}`,
);
await new Promise((resolve) => setTimeout(resolve, 100)); // Wait 100ms between requests
}
console.log('\n');
}

async function runTests() {
const baseUrl = 'http://localhost:3000/api';
await testRateLimit(`${baseUrl}/price`, 25);
await testRateLimit(`${baseUrl}/raffle`, 25);
await testRateLimit(`${baseUrl}/raffle/luckyWinner`, 25);
await testRateLimit(`${baseUrl}/referral/createUser`, 25);
await testRateLimit(`${baseUrl}/stats`, 25);
await testRateLimit(`${baseUrl}/stats/[address]`, 25);
await testRateLimit(`${baseUrl}/strategies`, 25);
await testRateLimit(`${baseUrl}/tnc/getUser`, 25);
await testRateLimit(`${baseUrl}/tnc/getUser/[address]`, 25);
await testRateLimit(`${baseUrl}/tnc/signUser`, 25);
await testRateLimit(`${baseUrl}/users/ognft`, 25);
await testRateLimit(`${baseUrl}/users/ognft/[address]`, 25);
}

runTests().catch(console.error);
5 changes: 5 additions & 0 deletions src/types/redis.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
declare module 'lib/redis' {
import { Redis } from '@upstash/redis';
const redis: Redis;
export default redis;
}
11 changes: 11 additions & 0 deletions tsconfig.server.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./",
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["scripts/**/*.ts"]
}
Loading

Unchanged files with check annotations Beta

) : !address ? (
'Connect wallet to check eligibility'
) : isOGNFTEligible.isLoading ||
balanceQueryStatus == 'pending' ? (

Check warning on line 376 in src/app/community/page.tsx

GitHub Actions / Performs linting, formatting on the application

Expected '===' and instead saw '=='
<Spinner size="sm" />
) : hasNFT ? (
'Claimed'
export default function Home() {
const [tabIndex, setTabIndex] = useState(0);
const { address } = useAccount();

Check warning on line 49 in src/app/page.tsx

GitHub Actions / Performs linting, formatting on the application

'address' is assigned a value but never used. Allowed unused vars must match /^_/u
const searchParams = useSearchParams();
const size = useWindowSize();
const router = useRouter();
<Link href={banner.link} isExternal>
<ChakraImage
src={
(!isMobile && size.width > 450) || size.width == 0

Check warning on line 129 in src/app/page.tsx

GitHub Actions / Performs linting, formatting on the application

Expected '===' and instead saw '=='
? banner.desktop
: banner.mobile
}

Check warning on line 24 in src/app/slinks/page.tsx

GitHub Actions / Performs linting, formatting on the application

'metadata' is assigned a value but never used. Allowed unused vars must match /^_/u