Skip to content
Merged
Show file tree
Hide file tree
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
32 changes: 32 additions & 0 deletions lib/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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";
}
22 changes: 22 additions & 0 deletions lib/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "plotlink",
"version": "0.1.53",
"version": "0.1.54",
"private": true,
"workspaces": [
"packages/*"
Expand Down
6 changes: 6 additions & 0 deletions src/app/api/airdrop/checkin/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
4 changes: 3 additions & 1 deletion src/app/api/airdrop/leaderboard/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
});
}
2 changes: 2 additions & 0 deletions src/app/api/airdrop/points/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
});
}
6 changes: 6 additions & 0 deletions src/app/api/airdrop/referral-code/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 });
Expand Down
6 changes: 6 additions & 0 deletions src/app/api/airdrop/register-referral/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 });
Expand Down
2 changes: 2 additions & 0 deletions src/app/api/airdrop/snapshots/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
});
}
2 changes: 2 additions & 0 deletions src/app/api/airdrop/status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
});
}
51 changes: 51 additions & 0 deletions supabase/migrations/00037_rate_limits.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading