diff --git a/lib/platformIcons.ts b/lib/platformIcons.ts index 40c402a..06601f0 100644 --- a/lib/platformIcons.ts +++ b/lib/platformIcons.ts @@ -18,6 +18,10 @@ import { SiCodeforces, SiKaggle, SiGeeksforgeeks, + SiBehance, + SiSubstack, + SiCodepen, + SiYcombinator, } from "react-icons/si"; import type { ComponentType, SVGProps } from "react"; @@ -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>> = diff --git a/lib/platforms.ts b/lib/platforms.ts index a41056d..3321f10 100644 --- a/lib/platforms.ts +++ b/lib/platforms.ts @@ -20,8 +20,11 @@ export type Platform = | "codechef" | "kaggle" | "geeksforgeeks" - | "website"; - + | "website" + | "behance" + | "substack" + | "codepen" + | "hackernews"; // ─── URL Validation Patterns ───────────────────────────────────────────────── @@ -42,10 +45,19 @@ const PLATFORM_PATTERNS: Record = { 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, diff --git a/lib/rateLimit.ts b/lib/rateLimit.ts index 919f051..c8c0fa4 100644 --- a/lib/rateLimit.ts +++ b/lib/rateLimit.ts @@ -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(); - -// 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(); - // 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 { - // 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 { - 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 { - // 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' + ); } \ No newline at end of file