diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts new file mode 100644 index 00000000..95eb64cb --- /dev/null +++ b/lib/rate-limit.ts @@ -0,0 +1,32 @@ +import { createServerClient } from "./supabase"; + +/** + * DB-based rate limiter (serverless-safe, atomic). + * Uses a Supabase RPC function that counts + inserts in a single transaction. + * Returns true if the request is allowed, false if rate limited. + */ +export async function checkRateLimit( + ip: string, + endpoint: string, + maxRequests = 5, + windowMs = 60_000, +): Promise { + const supabase = createServerClient(); + if (!supabase) return true; // fail open if DB unavailable + + const key = `${endpoint}:${ip}`; + + const { data, error } = await supabase.rpc("check_rate_limit", { + p_key: key, + p_max_requests: maxRequests, + p_window_ms: windowMs, + }); + + if (error) return true; // fail open on error + return data as boolean; +} + +/** Extract client IP from request headers (Vercel x-forwarded-for). */ +export function getClientIp(req: Request): string { + return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; +} diff --git a/lib/supabase.ts b/lib/supabase.ts index 25646197..e347c2c8 100644 --- a/lib/supabase.ts +++ b/lib/supabase.ts @@ -523,6 +523,24 @@ export interface Database { }; Relationships: []; }; + rate_limits: { + Row: { + id: number; + key: string; + created_at: string; + }; + Insert: { + id?: never; + key: string; + created_at?: string; + }; + Update: { + id?: never; + key?: string; + created_at?: string; + }; + Relationships: []; + }; trade_history: { Row: { id: number; @@ -724,6 +742,10 @@ export interface Database { Args: { sid: number; caddr: string }; Returns: void; }; + check_rate_limit: { + Args: { p_key: string; p_max_requests: number; p_window_ms: number }; + Returns: boolean; + }; }; Enums: { [_ in never]: never; diff --git a/package-lock.json b/package-lock.json index 443ba0b0..042ceaaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "plotlink", - "version": "0.1.53", + "version": "0.1.54", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plotlink", - "version": "0.1.53", + "version": "0.1.54", "workspaces": [ "packages/*" ], diff --git a/package.json b/package.json index fb7002aa..3a3abcae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "0.1.53", + "version": "0.1.54", "private": true, "workspaces": [ "packages/*" diff --git a/src/app/api/airdrop/checkin/route.ts b/src/app/api/airdrop/checkin/route.ts index 5d7d8a80..b92f0906 100644 --- a/src/app/api/airdrop/checkin/route.ts +++ b/src/app/api/airdrop/checkin/route.ts @@ -12,8 +12,14 @@ import { createServerClient } from "../../../../../lib/supabase"; import { AIRDROP_CONFIG } from "../../../../../lib/airdrop/config"; import { getStreakBoost, dropOneTier, getNextTier } from "../../../../../lib/airdrop/streak"; import { verifyWalletOwnership } from "../../../../../lib/airdrop/verify-wallet"; +import { checkRateLimit, getClientIp } from "../../../../../lib/rate-limit"; export async function POST(req: Request) { + const ip = getClientIp(req); + if (!(await checkRateLimit(ip, "airdrop/checkin"))) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }); + } + const supabase = createServerClient(); if (!supabase) { return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); diff --git a/src/app/api/airdrop/leaderboard/route.ts b/src/app/api/airdrop/leaderboard/route.ts index d2e476b7..626f5de7 100644 --- a/src/app/api/airdrop/leaderboard/route.ts +++ b/src/app/api/airdrop/leaderboard/route.ts @@ -63,5 +63,7 @@ export async function GET(req: NextRequest) { userRank = idx >= 0 ? idx + 1 : null; } - return NextResponse.json({ entries, userRank }); + return NextResponse.json({ entries, userRank }, { + headers: { "Cache-Control": "public, s-maxage=30, stale-while-revalidate=15" }, + }); } diff --git a/src/app/api/airdrop/points/route.ts b/src/app/api/airdrop/points/route.ts index 524a4ae0..7772da90 100644 --- a/src/app/api/airdrop/points/route.ts +++ b/src/app/api/airdrop/points/route.ts @@ -110,5 +110,7 @@ export async function GET(req: NextRequest) { referredUsersCount: referredUsersCount ?? 0, }, estimatedAirdrop, + }, { + headers: { "Cache-Control": "public, s-maxage=10, stale-while-revalidate=5" }, }); } diff --git a/src/app/api/airdrop/referral-code/route.ts b/src/app/api/airdrop/referral-code/route.ts index 6653887d..9689a4da 100644 --- a/src/app/api/airdrop/referral-code/route.ts +++ b/src/app/api/airdrop/referral-code/route.ts @@ -10,6 +10,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { nanoid } from "nanoid"; import { createServerClient } from "../../../../../lib/supabase"; import { verifyWalletOwnership } from "../../../../../lib/airdrop/verify-wallet"; +import { checkRateLimit, getClientIp } from "../../../../../lib/rate-limit"; export async function GET(req: NextRequest) { const supabase = createServerClient(); @@ -36,6 +37,11 @@ export async function GET(req: NextRequest) { } export async function POST(req: Request) { + const ip = getClientIp(req); + if (!(await checkRateLimit(ip, "airdrop/referral-code"))) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }); + } + const supabase = createServerClient(); if (!supabase) { return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); diff --git a/src/app/api/airdrop/register-referral/route.ts b/src/app/api/airdrop/register-referral/route.ts index 41523a23..2b6e486f 100644 --- a/src/app/api/airdrop/register-referral/route.ts +++ b/src/app/api/airdrop/register-referral/route.ts @@ -12,6 +12,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { createServerClient } from "../../../../../lib/supabase"; import { verifyWalletOwnership } from "../../../../../lib/airdrop/verify-wallet"; +import { checkRateLimit, getClientIp } from "../../../../../lib/rate-limit"; export async function GET(req: NextRequest) { const supabase = createServerClient(); @@ -52,6 +53,11 @@ export async function GET(req: NextRequest) { } export async function POST(req: Request) { + const ip = getClientIp(req); + if (!(await checkRateLimit(ip, "airdrop/register-referral"))) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }); + } + const supabase = createServerClient(); if (!supabase) { return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); diff --git a/src/app/api/airdrop/snapshots/route.ts b/src/app/api/airdrop/snapshots/route.ts index df3bb1e8..cc26f5d1 100644 --- a/src/app/api/airdrop/snapshots/route.ts +++ b/src/app/api/airdrop/snapshots/route.ts @@ -33,5 +33,7 @@ export async function GET() { mcapEnd: s.mcap_end, totalPlEarned: s.total_pl_earned, })), + }, { + headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=60" }, }); } diff --git a/src/app/api/airdrop/status/route.ts b/src/app/api/airdrop/status/route.ts index 94825e85..61570523 100644 --- a/src/app/api/airdrop/status/route.ts +++ b/src/app/api/airdrop/status/route.ts @@ -87,5 +87,7 @@ export async function GET() { totalPointsEarned, totalParticipants, lockerId: AIRDROP_CONFIG.LOCKER_ID, + }, { + headers: { "Cache-Control": "public, s-maxage=60, stale-while-revalidate=30" }, }); } diff --git a/supabase/migrations/00037_rate_limits.sql b/supabase/migrations/00037_rate_limits.sql new file mode 100644 index 00000000..58e3da2c --- /dev/null +++ b/supabase/migrations/00037_rate_limits.sql @@ -0,0 +1,51 @@ +-- Rate limiting table for serverless-safe request throttling. +-- Stores recent request timestamps per IP+endpoint key. +CREATE TABLE IF NOT EXISTS rate_limits ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + key TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_rate_limits_key_created ON rate_limits (key, created_at DESC); + +-- Auto-purge entries older than 5 minutes +CREATE OR REPLACE FUNCTION purge_old_rate_limits() RETURNS TRIGGER AS $$ +BEGIN + DELETE FROM rate_limits WHERE created_at < now() - interval '5 minutes'; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_purge_rate_limits + AFTER INSERT ON rate_limits + FOR EACH STATEMENT + EXECUTE FUNCTION purge_old_rate_limits(); + +-- Atomic rate limit check: counts recent requests, inserts if under limit. +-- Returns true if allowed, false if rate limited. +-- Atomic rate limit check: uses advisory lock to serialize per key, +-- counts recent requests, inserts if under limit. +-- Returns true if allowed, false if rate limited. +CREATE OR REPLACE FUNCTION check_rate_limit( + p_key TEXT, + p_max_requests INT DEFAULT 5, + p_window_ms INT DEFAULT 60000 +) RETURNS BOOLEAN AS $$ +DECLARE + v_count INT; + v_window_start TIMESTAMPTZ; + v_lock_id BIGINT; +BEGIN + v_lock_id := hashtext(p_key); + PERFORM pg_advisory_xact_lock(v_lock_id); + v_window_start := now() - (p_window_ms || ' milliseconds')::interval; + SELECT count(*) INTO v_count + FROM rate_limits + WHERE key = p_key AND created_at >= v_window_start; + IF v_count >= p_max_requests THEN + RETURN false; + END IF; + INSERT INTO rate_limits (key) VALUES (p_key); + RETURN true; +END; +$$ LANGUAGE plpgsql;