Skip to content
Open
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
9 changes: 9 additions & 0 deletions lib/platformIcons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import {
SiCodeforces,
SiKaggle,
SiGeeksforgeeks,
SiBehance,
SiSubstack,
SiCodepen,
SiYcombinator,
} from "react-icons/si";

import type { ComponentType, SVGProps } from "react";
Expand All @@ -42,6 +46,11 @@ export const PLATFORMS = {
codeforces: { icon: SiCodeforces, name: "Codeforces" },
kaggle: { icon: SiKaggle, name: "Kaggle" },
geeksforgeeks: { icon: SiGeeksforgeeks, name: "GeeksforGeeks" },
// New platforms
behance: { icon: SiBehance, name: "Behance" },
substack: { icon: SiSubstack, name: "Substack" },
codepen: { icon: SiCodepen, name: "CodePen" },
hackernews: { icon: SiYcombinator, name: "Hacker News" },
} as const;

export const PLATFORM_ICONS: Record<string, ComponentType<SVGProps<SVGSVGElement>>> =
Expand Down
24 changes: 18 additions & 6 deletions lib/platforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ export type Platform =
| "codechef"
| "kaggle"
| "geeksforgeeks"
| "website";

| "website"
| "behance"
| "substack"
| "codepen"
| "hackernews";

// ─── URL Validation Patterns ─────────────────────────────────────────────────

Expand All @@ -42,10 +45,19 @@ const PLATFORM_PATTERNS: Record<Platform, RegExp> = {
instagram: /^https?:\/\/(www\.)?instagram\.com\/(?:[A-Za-z0-9._]{1,30}|p\/[A-Za-z0-9_-]+|reel\/[A-Za-z0-9_-]+|reels\/[A-Za-z0-9_-]+)\/?(\?.*)?$/i,
discord: /^https?:\/\/(www\.)?discord\.com\/(users\/\d+|invite\/[A-Za-z0-9_-]+)\/?(\?.*)?$/i,
twitch: /^https?:\/\/(www\.)?twitch\.tv\/[A-Za-z0-9_]{4,25}\/?(\?.*)?$/i,
hashnode: /^https?:\/\/([A-Za-z0-9_-]+\.hashnode\.(com|dev)|hashnode\.(com|dev)\/[A-Za-z0-9_@-]+)\/?(\?.*)?$/i,
devto: /^https?:\/\/(www\.)?dev\.to\/[A-Za-z0-9_-]+\/?(\?.*)?$/i,
medium: /^https?:\/\/(www\.)?medium\.com\/@?[A-Za-z0-9_.-]+\/?(\?.*)?$/i,
dribbble: /^https?:\/\/(www\.)?dribbble\.com\/[A-Za-z0-9_-]+\/?(\?.*)?$/i,

// Updated patterns for existing platforms (more uniform, no trailing query params allowed)
hashnode: /^https?:\/\/[\w-]+\.hashnode\.dev\/?$|^https?:\/\/(www\.)?hashnode\.com\/@?[\w.-]+\/?$/i,
devto: /^https?:\/\/(www\.)?dev\.to\/[\w.-]+\/?$/i,
medium: /^https?:\/\/(www\.)?medium\.com\/@?[\w.-]+\/?$/i,
dribbble: /^https?:\/\/(www\.)?dribbble\.com\/[\w.-]+\/?$/i,

// New platforms
behance: /^https?:\/\/(www\.)?behance\.net\/[\w.-]+\/?$/i,
substack: /^https?:\/\/[\w-]+\.substack\.com\/?$/i,
codepen: /^https?:\/\/(www\.)?codepen\.io\/[\w.-]+\/?$/i,
hackernews: /^https?:\/\/news\.ycombinator\.com\/user\?id=[\w.-]+$/i,

codechef: /^https?:\/\/(www\.)?codechef\.com\/users\/[A-Za-z0-9_.-]+\/?(\?.*)?$/i,
codeforces: /^https?:\/\/(www\.)?codeforces\.com\/profile\/[A-Za-z0-9_.-]+\/?(\?.*)?$/i,
kaggle: /^https?:\/\/(www\.)?kaggle\.com\/[A-Za-z0-9_.-]+\/?(\?.*)?$/i,
Expand Down
281 changes: 55 additions & 226 deletions lib/rateLimit.ts
Original file line number Diff line number Diff line change
@@ -1,237 +1,66 @@
/**
* Dual-mode sliding-window rate limiter for Next.js API routes.
*
* ## Modes
*
* ### In-Memory (default / local dev)
* Used when `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` are **not** set.
* Stores timestamps in a `Map` local to the process. Works perfectly for single-instance
* deployments (local dev, single container) but **does not share state** across
* multiple serverless function instances.
*
* ### Redis / Upstash (production)
* Used when both `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` are set.
* Uses a Redis sorted-set sliding-window algorithm so rate-limit state is shared
* across every instance, edge function, or server replica. This prevents users from
* bypassing limits by hitting a different server.
*
* ## Public interface
* Both modes expose the same async signature so call sites are identical:
*
* ```ts
* const allowed = await checkRateLimit(key, limit, windowMs);
* if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
* ```
*
* ## Middleware helper
* For convenience, `rateLimit(limit, windowMs)` returns a middleware function that
* extracts the client IP from `x-forwarded-for` and handles the Next.js response.
* Use it in API routes or middleware:
*
* ```ts
* const rateLimitMiddleware = rateLimit(10, 60_000);
* const response = await rateLimitMiddleware(req);
* if (response) return response; // rate limited
* // ... proceed with your route logic
* ```
*/

import { NextRequest, NextResponse } from 'next/server';

// ─── In-Memory Backend ────────────────────────────────────────────────────────

type WindowEntry = {
timestamps: number[];
windowMs: number;
};

const store = new Map<string, WindowEntry>();

// Periodic full-store sweep so keys added by one-off or rotating IPs do not
// accumulate indefinitely. A sweep removes every key whose window has fully
// expired, bounding Map growth to the number of distinct keys seen within one
// rolling window rather than the lifetime of the process.
let requestsSinceCleanup = 0;
const CLEANUP_INTERVAL = 500; // sweep after every N requests

function sweepExpiredKeys(): void {
const now = Date.now();
for (const [key, entry] of store.entries()) {
const cutoff = now - entry.windowMs;
if (entry.timestamps.every((t) => t <= cutoff)) {
store.delete(key);
}
}
interface RateLimitRecord {
count: number;
ts: number;
}

function checkRateLimitMemory(
key: string,
limit: number,
windowMs: number,
): boolean {
const now = Date.now();
const cutoff = now - windowMs;

requestsSinceCleanup++;
if (requestsSinceCleanup >= CLEANUP_INTERVAL) {
requestsSinceCleanup = 0;
sweepExpiredKeys();
}

let entry = store.get(key);
if (!entry) {
entry = { timestamps: [], windowMs };
store.set(key, entry);
} else {
entry.windowMs = windowMs;
}
// In-process store — works for single-instance deployments.
// For multi-instance Vercel Edge, swap to @upstash/ratelimit (see .env.example).
const rateLimitMap = new Map<string, RateLimitRecord>();

// Evict timestamps outside the current window.
entry.timestamps = entry.timestamps.filter((t) => t > cutoff);

if (entry.timestamps.length >= limit) {
return false;
}

entry.timestamps.push(now);
return true;
}

// ─── Redis Backend ────────────────────────────────────────────────────────────
// Clean up stale entries every 5 minutes to avoid memory leaks
setInterval(() => {
const now = Date.now();
for (const [key, record] of rateLimitMap.entries()) {
if (now - record.ts > 5 * 60_000) rateLimitMap.delete(key);
}
}, 5 * 60_000);

/**
* Checks and records a request in Redis using a sorted-set sliding window.
*
* Algorithm:
* 1. Remove members (timestamps) outside the current window with ZREMRANGEBYSCORE.
* 2. Count remaining members with ZCARD.
* 3. If under limit, add the current timestamp with ZADD and refresh TTL with EXPIRE.
* 4. Return whether the request is allowed.
*
* All four commands are pipelined in a single round-trip via MULTI/EXEC so the
* operation is atomic and race-condition-safe.
* Returns a 429 NextResponse if the caller exceeds the limit, otherwise null.
* @param req The incoming NextRequest
* @param key Unique key (e.g. `username:${ip}` or `links:${userId}`)
* @param limit Max requests allowed in the window
* @param windowMs Window size in milliseconds
*/
async function checkRateLimitRedis(
key: string,
limit: number,
windowMs: number,
): Promise<boolean> {
// Lazy-import so the module is only loaded when Redis is actually needed.
// This keeps cold-start overhead zero in in-memory mode.
const { Redis } = await import("@upstash/redis");

const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

const now = Date.now();
const windowStart = now - windowMs;
const redisKey = `ratelimit:${key}`;
const ttlSeconds = Math.ceil(windowMs / 1000);

// Pipeline all commands in one round-trip.
const pipeline = redis.pipeline();
// 1. Remove expired entries
pipeline.zremrangebyscore(redisKey, 0, windowStart);
// 2. Count remaining entries in the window
pipeline.zcard(redisKey);

const [, count] = await pipeline.exec<[number, number]>();

if (count >= limit) {
return false;
}

// 3. Record this request (use timestamp as both score and unique member)
// Append a random suffix to the member to allow multiple requests at the exact same ms.
const member = `${now}-${Math.random().toString(36).slice(2, 8)}`;
const addPipeline = redis.pipeline();
addPipeline.zadd(redisKey, { score: now, member });
addPipeline.expire(redisKey, ttlSeconds);
await addPipeline.exec();

return true;
}

// ─── Public API ───────────────────────────────────────────────────────────────

/**
* Returns `true` when the request is allowed, `false` when the rate limit is exceeded.
*
* Automatically selects the Redis backend when `UPSTASH_REDIS_REST_URL` and
* `UPSTASH_REDIS_REST_TOKEN` are present in the environment; otherwise falls
* back to the in-memory backend.
*
* @param key Unique identifier for this rate-limit bucket (e.g. `"register:1.2.3.4"`)
* @param limit Maximum number of requests allowed in the window
* @param windowMs Rolling window duration in milliseconds
*/
export async function checkRateLimit(
key: string,
limit: number,
windowMs: number,
): Promise<boolean> {
const useRedis =
Boolean(process.env.UPSTASH_REDIS_REST_URL) &&
Boolean(process.env.UPSTASH_REDIS_REST_TOKEN);

if (useRedis) {
try {
return await checkRateLimitRedis(key, limit, windowMs);
} catch (err) {
// If Redis is unavailable, fall back to in-memory rather than
// blocking all traffic. Log the error so it surfaces in monitoring.
console.error("[rateLimit] Redis error, falling back to in-memory:", err);
return checkRateLimitMemory(key, limit, windowMs);
}
}

return checkRateLimitMemory(key, limit, windowMs);
export function rateLimit(
req: NextRequest,
key: string,
limit: number,
windowMs: number
): NextResponse | null {
const now = Date.now();
const record = rateLimitMap.get(key);

if (!record || now - record.ts > windowMs) {
rateLimitMap.set(key, { count: 1, ts: now });
return null; // allowed
}

if (record.count >= limit) {
return NextResponse.json(
{ error: 'Too many requests. Please slow down.' },
{
status: 429,
headers: {
'Retry-After': String(Math.ceil(windowMs / 1000)),
'X-RateLimit-Limit': String(limit),
'X-RateLimit-Remaining': '0',
},
}
);
}

record.count++;
return null; // allowed
}

/**
* Middleware-style rate limiter for Next.js API routes.
*
* Extracts the client IP from `x-forwarded-for` (or falls back to `"unknown"`)
* and uses the shared `checkRateLimit` logic to enforce a sliding window.
*
* Returns a `NextResponse` with a 429 status if the limit is exceeded, otherwise `null`.
* When a response is returned, it includes:
* - `Retry-After` (seconds until the window resets, estimated)
* - `X-RateLimit-Limit` (the configured limit)
* - `X-RateLimit-Remaining` (always `"0"` when blocked)
*
* @param limit Maximum requests allowed in the window
* @param windowMs Window duration in milliseconds
*/
export function rateLimit(limit: number, windowMs: number) {
return async function (req: NextRequest): Promise<NextResponse | null> {
// Extract client IP
const forwarded = req.headers.get("x-forwarded-for");
const ip = forwarded ? forwarded.split(",")[0].trim() : "unknown";

const allowed = await checkRateLimit(ip, limit, windowMs);

if (!allowed) {
// Approximate time until the window expires; we don't have the exact reset time
// for sliding windows, so we use the window duration as a safe estimate.
const retryAfter = Math.ceil(windowMs / 1000);

return NextResponse.json(
{ error: "Too many requests. Please try again later." },
{
status: 429,
headers: {
"Retry-After": String(retryAfter),
"X-RateLimit-Limit": String(limit),
"X-RateLimit-Remaining": "0",
},
}
);
}

// Request allowed – proceed
return null;
};
/** Extracts the best available IP from the request headers */
export function getIp(req: NextRequest): string {
return (
req.headers.get('x-forwarded-for')?.split(',')[0].trim() ??
req.headers.get('x-real-ip') ??
'unknown'
);
}
Loading