diff --git a/public/images/req.png b/public/images/req.png new file mode 100644 index 0000000..92ac625 Binary files /dev/null and b/public/images/req.png differ diff --git a/src/assets/styles/nav.css b/src/assets/styles/nav.css index ad9669c..cd8386d 100644 --- a/src/assets/styles/nav.css +++ b/src/assets/styles/nav.css @@ -87,14 +87,14 @@ nav { font-weight: 500; color: #1e293b; background: linear-gradient(120deg, rgba(255, 255, 255, 0.9), rgba(226, 232, 240, 0.85)); - box-shadow: 0 12px 30px rgba(15, 23, 42, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.6); + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.45); } [data-theme="dark"] .nav-user-inline { color: #f1f5f9; border-color: rgba(59, 130, 246, 0.25); background: linear-gradient(120deg, rgba(15, 23, 42, 0.85), rgba(30, 41, 59, 0.7)); - box-shadow: 0 14px 35px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.05); + box-shadow: 0 3px 14px rgba(0, 0, 0, 0.45); } .nav-user-inline__icon { diff --git a/src/components/BaseHead.astro b/src/components/BaseHead.astro index 02a9ae5..0a2bb2e 100644 --- a/src/components/BaseHead.astro +++ b/src/components/BaseHead.astro @@ -4,6 +4,8 @@ interface Props { description?: string; image?: string; isWhitelabel?: boolean; + whitelabel?: any; + subsite?: any; } import { metaData } from "../config"; @@ -12,24 +14,87 @@ import { JoyUIRootIsland } from "./Components" import { useHtmlThemeAttr } from "../hooks/useHtmlThemeAttr"; import { usePrimeReactThemeSwitcher } from "../hooks/usePrimeReactThemeSwitcher"; -const { title, description, image, isWhitelabel } = Astro.props; +const { title, description, image, isWhitelabel, whitelabel, subsite } = Astro.props; const { url } = Astro; const trimmedTitle = title?.trim(); const seoTitle = trimmedTitle || metaData.title; -const shareTitle = isWhitelabel ? (trimmedTitle || (metaData.shareTitle ?? metaData.title)) : (trimmedTitle ? `${trimmedTitle} | ${metaData.title}` : (metaData.shareTitle ?? metaData.title)); +// If a whitelabel/subsite override exists, prefer its shareTitle/shareDescription/ogImage/favicon +const subsiteMeta = whitelabel ?? {}; +const shareTitle = isWhitelabel + ? (trimmedTitle || (subsiteMeta.shareTitle ?? metaData.shareTitle ?? metaData.title)) + : (trimmedTitle ? `${trimmedTitle} | ${metaData.title}` : (metaData.shareTitle ?? metaData.title)); const seoTitleTemplate = isWhitelabel ? "%s" : (trimmedTitle ? `%s | ${metaData.title}` : "%s"); -const shareDescription = isWhitelabel ? trimmedTitle : (description ?? metaData.shareDescription ?? metaData.description); -const canonicalUrl = url?.href ?? metaData.baseUrl; -const shareImage = new URL(image ?? metaData.ogImage, metaData.baseUrl).toString(); -const shareImageAlt = metaData.shareImageAlt ?? metaData.shareTitle ?? metaData.title; +const shareDescription = isWhitelabel + ? (trimmedTitle || (subsiteMeta.shareDescription ?? metaData.shareDescription ?? metaData.description)) + : (description ?? metaData.shareDescription ?? metaData.description); +// Compute canonical URL with these priorities: +// 1. If the whitelabel/subsite provides a baseUrl, use that as the host for the canonical URL +// 2. If a subsite was detected and the request host matches the subsite host, canonical uses that host +// 3. If the request is for a path-based subsite (host is main site), prefer metaData.baseUrl so canonical remains on main site +// 4. Fallback to the request URL or metaData.baseUrl +const currentHost = (Astro.request?.headers?.get('host') || '').split(':')[0]; +let canonicalBase: string | null = null; +if (subsiteMeta?.baseUrl) { + canonicalBase = subsiteMeta.baseUrl; +} else if (subsite?.host) { + // normalize hosts for comparison + const requestedHost = (currentHost || '').toLowerCase(); + const subsiteHost = String(subsite.host || '').toLowerCase(); + if (requestedHost && requestedHost === subsiteHost) { + canonicalBase = `https://${subsite.host}`; + } else { + // keep canonical on the main configured base (path-based subsites should remain under metaData.baseUrl) + canonicalBase = metaData.baseUrl; + } +} +// Decide whether canonical should be the site-root (e.g. https://req.boatson.boats/) +// or include a path. Rules: +// - If canonicalBase comes from a whitelabel/baseUrl or request host matches subsite host -> prefer site-root. +// - Otherwise (path-based subsites), include the pathname/search so canonical remains under the main site path. +const isHostMatchedSubsite = Boolean(subsite?.host && currentHost && currentHost.toLowerCase() === String(subsite.host).toLowerCase()); +const isSubsiteRootCanonical = Boolean(subsiteMeta?.baseUrl || isHostMatchedSubsite); +let canonicalUrl: string; +if (canonicalBase) { + if (isSubsiteRootCanonical) { + // ensure canonicalBase ends with a single '/' + canonicalUrl = canonicalBase.endsWith('/') ? canonicalBase : `${canonicalBase}/`; + } else { + canonicalUrl = new URL((url?.pathname ?? '') + (url?.search ?? ''), canonicalBase).toString(); + } +} else { + canonicalUrl = url?.href ?? metaData.baseUrl; +} +// Prefer the whitelabel/subsite ogImage when this page is for a whitelabel site. +// Otherwise fall back to an explicit image prop or the global ogImage. +const resolvedOgImage = (isWhitelabel && subsiteMeta.ogImage) ? subsiteMeta.ogImage : (image ?? metaData.ogImage); + +// Keep relative/site-root paths as-is (e.g. '/images/req.png') and don't force +// an absolute URL using metaData.baseUrl. Only keep absolute (http/https) +// URLs if the value is already absolute. +function keepRelativeOrAbsolute(val) { + if (!val) return val; + if (/^https?:\/\//i.test(val)) return val; // already absolute + // Normalize to site-root-relative so local testing always resolves under '/' + return val.startsWith('/') ? val : `/${val}`; +} + +const shareImage = keepRelativeOrAbsolute(resolvedOgImage); +const shareImageAlt = subsiteMeta.shareImageAlt ?? metaData.shareImageAlt ?? metaData.shareTitle ?? metaData.title; + +// Build icon links: prefer subsite icons when present. If a subsite provides a single +// `favicon` but no `icons` array, do NOT append the global icons to avoid duplicates. +const primaryIconHref = keepRelativeOrAbsolute(subsiteMeta.favicon ?? metaData.favicon); +// Ensure extraIcons are used only when subsite doesn't provide a single favicon (prevents duplicates) +const extraIcons = subsiteMeta.icons ? subsiteMeta.icons : (subsiteMeta.favicon ? [] : (metaData.icons ?? [])); --- { - if (title !== selectedTitle && selectedOverview) { - setSelectedOverview(""); - setSelectedTitle(""); + if (title !== selectedTitle) { + if (selectedOverview) setSelectedOverview(""); + if (selectedItem) setSelectedItem(null); + if (type) setType(""); + if (selectedTitle) setSelectedTitle(""); } - }, [title, selectedTitle, selectedOverview]); + }, [title, selectedTitle, selectedOverview, selectedItem, type]); const searchTitles = async (event) => { const query = event.query; - if (query.length < 3) { + if (query.length < 2) { setSuggestions([]); return; } @@ -103,6 +105,15 @@ export default function ReqForm() { }, 0); }; + const formatMediaType = (mediaTypeValue) => { + if (!mediaTypeValue) return ""; + if (mediaTypeValue === "tv") return "TV Series"; + if (mediaTypeValue === "movie") return "Movie"; + return mediaTypeValue.charAt(0).toUpperCase() + mediaTypeValue.slice(1); + }; + + const selectedTypeLabel = formatMediaType(selectedItem?.mediaType || type); + return (
@@ -124,6 +135,7 @@ export default function ReqForm() { value={title} suggestions={suggestions} completeMethod={searchTitles} + minLength={2} onChange={(e) => setTitle(typeof e.value === 'string' ? e.value : e.value.label)} onSelect={(e) => { setType(e.value.mediaType === 'tv' ? 'tv' : 'movie'); @@ -148,6 +160,11 @@ export default function ReqForm() {
)} /> + {selectedItem && selectedTypeLabel && ( +
+ Selected type: {selectedTypeLabel} +
+ )}
{selectedOverview && ( diff --git a/src/config.js b/src/config.js index 92e1d7f..423ab69 100644 --- a/src/config.js +++ b/src/config.js @@ -7,7 +7,14 @@ export const metaData = { description: "CODEY STUFF!", shareTitle: "CODEY STUFF", shareDescription: "CODEY STUFF!", - shareImageAlt: "/images/favicon.png", + // alt text for the share/OG image (descriptive; not a filename) + shareImageAlt: "CODEY STUFF logo", + // default favicon / site icon used when no per-subsite override is provided + favicon: "/images/favicon.png", + // additional icons array (optional) for multi-icon support + icons: [ + { rel: 'icon', href: '/images/favicon.png' }, + ], }; export const API_URL = "https://api.codey.lol"; @@ -25,9 +32,19 @@ export const WHITELABELS = { 'req.boatson.boats': { title: 'Request Media', name: 'REQ', - brandColor: '#12f8f4', + brandColor: '', // inherit siteTitle: 'Request Media', logoText: 'Request Media', + // optional meta overrides for whitelabel/subsite + shareTitle: 'Request Media', + shareDescription: 'Request Media', + ogImage: '/images/req.png', + shareImageAlt: 'Request Media logo', + favicon: '/images/req.png', + // optional canonical/base url for this subsite (useful when host-forcing or in prod) + baseUrl: 'https://req.boatson.boats', + // human-readable site name for social meta + siteName: 'Request Media' }, }; diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 32aecbd..a71ea42 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -69,7 +69,7 @@ if (import.meta.env.DEV) { content="index, follow, max-video-preview:-1, max-image-preview:large, max-snippet:-1" /> - + diff --git a/src/middleware.js b/src/middleware.js index fd2270e..9c77235 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -17,8 +17,16 @@ export const onRequest = defineMiddleware(async (context, next) => { const urlHost = context.url?.hostname || ''; const host = (hostHeader || authorityHeader || urlHost).split(':')[0]; // normalize remove port + const requestIp = (headersMap['x-forwarded-for']?.split(',')[0]?.trim()) + || headersMap['x-real-ip'] + || headersMap['cf-connecting-ip'] + || headersMap['forwarded']?.split(';').find(kv => kv.trim().startsWith('for='))?.split('=')[1]?.replace(/"/g, '') + || context.request.headers.get('x-client-ip') + || 'unknown'; + // Debug info for incoming requests - if (import.meta.env.DEV) console.log(`[middleware] incoming: host=${hostHeader} path=${context.url.pathname}`); + if (import.meta.env.DEV) console.log(`[middleware] incoming: host=${hostHeader} ip=${requestIp} path=${context.url.pathname}`); + else console.log(`[middleware] request from ${requestIp}: ${context.url.pathname}`); // When the host header is missing, log all headers for debugging and // attempt to determine host from forwarded headers or a dev query header. diff --git a/src/pages/api/search.js b/src/pages/api/search.js index aefa9a1..2180847 100644 --- a/src/pages/api/search.js +++ b/src/pages/api/search.js @@ -1,24 +1,12 @@ -const rateLimitMap = new Map(); - -function checkRateLimit(userId, limit = 5, windowMs = 1000) { - const now = Date.now(); - const key = userId; - const entry = rateLimitMap.get(key); - - if (!entry || now > entry.resetTime) { - rateLimitMap.set(key, { count: 1, resetTime: now + windowMs }); - return true; - } - - if (entry.count >= limit) { - return false; - } - - entry.count++; - return true; -} - import { getSubsiteByHost } from '../../utils/subsites.js'; +import { + checkRateLimit, + recordRequest, + getCookieId, + generateNonce, + createNonceCookie, + getClientIp, +} from '../../utils/rateLimit.js'; export async function GET({ request }) { const host = request.headers.get('host'); @@ -28,27 +16,51 @@ export async function GET({ request }) { return new Response('Not found', { status: 404 }); } - let userId = request.headers.get('cookie')?.split(';').find(c => c.trim().startsWith('nonce='))?.split('=')[1]; - const hadCookie = !!userId; - if (!userId) { - userId = crypto.randomUUID(); + // Rate limit check (5 requests per second, flood protection at 30/10s) + const rateCheck = checkRateLimit(request, { + limit: 5, + windowMs: 1000, + burstLimit: 30, + burstWindowMs: 10_000, + }); + + let cookieId = getCookieId(request); + const hadCookie = !!cookieId; + if (!cookieId) { + cookieId = generateNonce(); } - if (!checkRateLimit(userId)) { - const response = new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { status: 429, headers: { 'Content-Type': 'application/json' } }); + if (!rateCheck.allowed) { + const errorMsg = rateCheck.isFlooding + ? { error: 'Too many requests - please slow down' } + : { error: 'Rate limit exceeded' }; + const response = new Response(JSON.stringify(errorMsg), { + status: 429, + headers: { + 'Content-Type': 'application/json', + 'Retry-After': '1', + }, + }); if (!hadCookie) { - response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`); + response.headers.set('Set-Cookie', createNonceCookie(cookieId)); } + console.log(`[search] rate limited: ip=${rateCheck.ip} flooding=${rateCheck.isFlooding}`); return response; } + // Record the request for rate limiting + recordRequest(request, 1000); + const TMDB_API_KEY = import.meta.env.TMDB_API_KEY; if (!TMDB_API_KEY) { console.error('TMDB_API_KEY not set'); - const response = new Response(JSON.stringify([]), { status: 500, headers: { 'Content-Type': 'application/json' } }); + const response = new Response(JSON.stringify([]), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); if (!hadCookie) { - response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`); + response.headers.set('Set-Cookie', createNonceCookie(cookieId)); } return response; } @@ -57,17 +69,23 @@ export async function GET({ request }) { const q = url.searchParams.get('q'); if (!q || typeof q !== 'string' || !q.trim()) { - const response = new Response(JSON.stringify([]), { status: 200, headers: { 'Content-Type': 'application/json' } }); + const response = new Response(JSON.stringify([]), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); if (!hadCookie) { - response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`); + response.headers.set('Set-Cookie', createNonceCookie(cookieId)); } return response; } if (q.length > 100) { - const response = new Response(JSON.stringify([]), { status: 200, headers: { 'Content-Type': 'application/json' } }); + const response = new Response(JSON.stringify([]), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); if (!hadCookie) { - response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`); + response.headers.set('Set-Cookie', createNonceCookie(cookieId)); } return response; } @@ -96,16 +114,21 @@ export async function GET({ request }) { overview: item.overview, poster_path: item.poster_path, })); - const response = new Response(JSON.stringify(filtered), { headers: { 'Content-Type': 'application/json' } }); + const response = new Response(JSON.stringify(filtered), { + headers: { 'Content-Type': 'application/json' }, + }); if (!hadCookie) { - response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`); + response.headers.set('Set-Cookie', createNonceCookie(cookieId)); } return response; } catch (error) { console.error('Error fetching suggestions:', error); - const response = new Response(JSON.stringify([]), { status: 500, headers: { 'Content-Type': 'application/json' } }); + const response = new Response(JSON.stringify([]), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); if (!hadCookie) { - response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`); + response.headers.set('Set-Cookie', createNonceCookie(cookieId)); } return response; } diff --git a/src/pages/api/submit.js b/src/pages/api/submit.js index 9492fe3..63067cf 100644 --- a/src/pages/api/submit.js +++ b/src/pages/api/submit.js @@ -1,34 +1,12 @@ -const rateLimitMap = new Map(); - -function checkRateLimit(userId, limit = 1, windowMs = 15000) { // 1 per 15 seconds for submits - const now = Date.now(); - const key = userId; - const entry = rateLimitMap.get(key); - - if (!entry || now > entry.resetTime) { - return true; // Allow, will be recorded later - } - - if (entry.count >= limit) { - return false; - } - - return true; // Allow, will be recorded later -} - -function recordRequest(userId, windowMs = 15000) { - const now = Date.now(); - const key = userId; - const entry = rateLimitMap.get(key); - - if (!entry || now > entry.resetTime) { - rateLimitMap.set(key, { count: 1, resetTime: now + windowMs }); - } else { - entry.count++; - } -} - import { getSubsiteByHost } from '../../utils/subsites.js'; +import { + checkRateLimit, + recordRequest as recordRateLimitRequest, + getCookieId, + generateNonce, + createNonceCookie, + getClientIp, +} from '../../utils/rateLimit.js'; export async function POST({ request }) { const host = request.headers.get('host'); @@ -37,17 +15,35 @@ export async function POST({ request }) { return new Response('Not found', { status: 404 }); } - let userId = request.headers.get('cookie')?.split(';').find(c => c.trim().startsWith('nonce='))?.split('=')[1]; - const hadCookie = !!userId; - if (!userId) { - userId = crypto.randomUUID(); + // Rate limit check (1 request per 15 seconds, flood protection at 10/30s) + const rateCheck = checkRateLimit(request, { + limit: 1, + windowMs: 15_000, + burstLimit: 10, + burstWindowMs: 30_000, + }); + + let cookieId = getCookieId(request); + const hadCookie = !!cookieId; + if (!cookieId) { + cookieId = generateNonce(); } - if (!checkRateLimit(userId)) { - const response = new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { status: 429, headers: { 'Content-Type': 'application/json' } }); + if (!rateCheck.allowed) { + const errorMsg = rateCheck.isFlooding + ? { error: 'Too many requests - please slow down' } + : { error: 'Rate limit exceeded. Please wait before submitting again.' }; + const response = new Response(JSON.stringify(errorMsg), { + status: 429, + headers: { + 'Content-Type': 'application/json', + 'Retry-After': '15', + }, + }); if (!hadCookie) { - response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`); + response.headers.set('Set-Cookie', createNonceCookie(cookieId)); } + console.log(`[submit] rate limited: ip=${rateCheck.ip} flooding=${rateCheck.isFlooding}`); return response; } @@ -55,9 +51,12 @@ export async function POST({ request }) { if (!DISCORD_WEBHOOK_URL) { console.error('DISCORD_WEBHOOK_URL not set'); - const response = new Response(JSON.stringify({ error: 'Webhook not configured' }), { status: 500, headers: { 'Content-Type': 'application/json' } }); + const response = new Response(JSON.stringify({ error: 'Webhook not configured' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); if (!hadCookie) { - response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`); + response.headers.set('Set-Cookie', createNonceCookie(cookieId)); } return response; } @@ -67,41 +66,56 @@ export async function POST({ request }) { // Input validation if (!title || typeof title !== 'string' || !title.trim()) { - const response = new Response(JSON.stringify({ error: 'Title is required and must be a non-empty string' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); + const response = new Response(JSON.stringify({ error: 'Title is required and must be a non-empty string' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); if (!hadCookie) { - response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`); + response.headers.set('Set-Cookie', createNonceCookie(cookieId)); } return response; } if (title.length > 200) { - const response = new Response(JSON.stringify({ error: 'Title must be 200 characters or less' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); + const response = new Response(JSON.stringify({ error: 'Title must be 200 characters or less' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); if (!hadCookie) { - response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`); + response.headers.set('Set-Cookie', createNonceCookie(cookieId)); } return response; } if (!['movie', 'tv'].includes(type)) { - const response = new Response(JSON.stringify({ error: 'Type must be either "movie" or "tv"' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); + const response = new Response(JSON.stringify({ error: 'Type must be either "movie" or "tv"' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); if (!hadCookie) { - response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`); + response.headers.set('Set-Cookie', createNonceCookie(cookieId)); } return response; } if (year && (typeof year !== 'string' || !/^\d{4}$/.test(year))) { - const response = new Response(JSON.stringify({ error: 'Year must be a 4-digit number if provided' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); + const response = new Response(JSON.stringify({ error: 'Year must be a 4-digit number if provided' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); if (!hadCookie) { - response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`); + response.headers.set('Set-Cookie', createNonceCookie(cookieId)); } return response; } if (requester && (typeof requester !== 'string' || requester.length > 500)) { - const response = new Response(JSON.stringify({ error: 'Requester name must be 500 characters or less if provided' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); + const response = new Response(JSON.stringify({ error: 'Requester name must be 500 characters or less if provided' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); if (!hadCookie) { - response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`); + response.headers.set('Set-Cookie', createNonceCookie(cookieId)); } return response; } @@ -216,18 +230,24 @@ export async function POST({ request }) { throw new Error(`Discord webhook error: ${apiResponse.status}`); } - recordRequest(userId); + // Record the request for rate limiting after successful submission + recordRateLimitRequest(request, 15_000); - const response = new Response(JSON.stringify({ success: true }), { headers: { 'Content-Type': 'application/json' } }); + const response = new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json' }, + }); if (!hadCookie) { - response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`); + response.headers.set('Set-Cookie', createNonceCookie(cookieId)); } return response; } catch (error) { console.error('Webhook submission error:', error); - const response = new Response(JSON.stringify({ error: 'Failed to submit' }), { status: 500, headers: { 'Content-Type': 'application/json' } }); + const response = new Response(JSON.stringify({ error: 'Failed to submit' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); if (!hadCookie) { - response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`); + response.headers.set('Set-Cookie', createNonceCookie(cookieId)); } return response; } diff --git a/src/utils/rateLimit.js b/src/utils/rateLimit.js new file mode 100644 index 0000000..861ad84 --- /dev/null +++ b/src/utils/rateLimit.js @@ -0,0 +1,232 @@ +/** + * Robust rate limiter using both IP address and cookie-based tracking. + * Implements sliding window with burst protection and automatic cleanup. + */ + +// Separate maps for IP and cookie tracking +const ipRateLimitMap = new Map(); +const cookieRateLimitMap = new Map(); + +// Global flood protection - track overall request volume per IP +const floodProtectionMap = new Map(); + +// Cleanup old entries every 60 seconds to prevent memory leaks +const CLEANUP_INTERVAL = 60_000; +let lastCleanup = Date.now(); + +function cleanupStaleEntries() { + const now = Date.now(); + if (now - lastCleanup < CLEANUP_INTERVAL) return; + lastCleanup = now; + + for (const [key, entry] of ipRateLimitMap) { + if (now > entry.resetTime + 60_000) ipRateLimitMap.delete(key); + } + for (const [key, entry] of cookieRateLimitMap) { + if (now > entry.resetTime + 60_000) cookieRateLimitMap.delete(key); + } + for (const [key, entry] of floodProtectionMap) { + if (now > entry.resetTime + 60_000) floodProtectionMap.delete(key); + } +} + +/** + * Extract client IP from request headers with proxy support. + * Falls back through common proxy headers. + */ +export function getClientIp(request) { + const headers = request.headers; + + // Try standard proxy headers in order of preference + const xForwardedFor = headers.get('x-forwarded-for'); + if (xForwardedFor) { + // Take first IP (original client), trim whitespace + const ip = xForwardedFor.split(',')[0].trim(); + if (ip && isValidIp(ip)) return normalizeIp(ip); + } + + const xRealIp = headers.get('x-real-ip'); + if (xRealIp && isValidIp(xRealIp)) return normalizeIp(xRealIp); + + const cfConnectingIp = headers.get('cf-connecting-ip'); + if (cfConnectingIp && isValidIp(cfConnectingIp)) return normalizeIp(cfConnectingIp); + + const trueClientIp = headers.get('true-client-ip'); + if (trueClientIp && isValidIp(trueClientIp)) return normalizeIp(trueClientIp); + + // Fallback + return 'unknown'; +} + +/** + * Basic IP validation to prevent header injection attacks. + */ +function isValidIp(ip) { + if (!ip || typeof ip !== 'string') return false; + // Basic sanity check - no weird characters, reasonable length + if (ip.length > 45) return false; // Max IPv6 length + if (!/^[\d.:a-fA-F]+$/.test(ip)) return false; + return true; +} + +/** + * Normalize IP for consistent keying (lowercase, trim). + */ +function normalizeIp(ip) { + return ip.toLowerCase().trim(); +} + +/** + * Extract nonce cookie from request. + */ +export function getCookieId(request) { + const cookieHeader = request.headers.get('cookie'); + if (!cookieHeader) return null; + + const match = cookieHeader.split(';').find(c => c.trim().startsWith('nonce=')); + if (!match) return null; + + const value = match.split('=')[1]?.trim(); + // Validate UUID format loosely + if (!value || value.length < 32 || value.length > 40) return null; + return value; +} + +/** + * Check if an identifier is rate limited. + */ +function isLimited(map, key, limit, windowMs) { + const now = Date.now(); + const entry = map.get(key); + + if (!entry || now > entry.resetTime) { + return false; // Not limited, window expired or new + } + + return entry.count >= limit; +} + +/** + * Record a request for rate limiting. + */ +function recordHit(map, key, windowMs) { + const now = Date.now(); + const entry = map.get(key); + + if (!entry || now > entry.resetTime) { + map.set(key, { count: 1, resetTime: now + windowMs }); + } else { + entry.count++; + } +} + +/** + * Get current count for an identifier. + */ +function getCount(map, key) { + const now = Date.now(); + const entry = map.get(key); + if (!entry || now > entry.resetTime) return 0; + return entry.count; +} + +/** + * Check flood protection - aggressive rate limit for potential abuse. + * This triggers when an IP sends too many requests in a short burst. + */ +function checkFloodProtection(ip, burstLimit = 30, burstWindowMs = 10_000) { + if (ip === 'unknown') return false; // Can't flood-protect unknown IPs effectively + + const now = Date.now(); + const entry = floodProtectionMap.get(ip); + + if (!entry || now > entry.resetTime) { + floodProtectionMap.set(ip, { count: 1, resetTime: now + burstWindowMs }); + return false; + } + + entry.count++; + return entry.count > burstLimit; +} + +/** + * Main rate limit check combining IP and cookie tracking. + * + * @param {Request} request - The incoming request + * @param {Object} options - Rate limit configuration + * @param {number} options.limit - Max requests per window + * @param {number} options.windowMs - Window duration in ms + * @param {number} options.burstLimit - Flood protection burst limit + * @param {number} options.burstWindowMs - Flood protection window + * @returns {{ allowed: boolean, ip: string, cookieId: string|null, isFlooding: boolean }} + */ +export function checkRateLimit(request, options = {}) { + const { + limit = 5, + windowMs = 1000, + burstLimit = 30, + burstWindowMs = 10_000, + } = options; + + cleanupStaleEntries(); + + const ip = getClientIp(request); + const cookieId = getCookieId(request); + + // Check flood protection first (aggressive anti-abuse) + const isFlooding = checkFloodProtection(ip, burstLimit, burstWindowMs); + if (isFlooding) { + return { allowed: false, ip, cookieId, isFlooding: true }; + } + + // Check both IP and cookie limits + // Both must be under limit to allow the request + const ipLimited = ip !== 'unknown' && isLimited(ipRateLimitMap, ip, limit, windowMs); + const cookieLimited = cookieId && isLimited(cookieRateLimitMap, cookieId, limit, windowMs); + + // If either is limited, deny + if (ipLimited || cookieLimited) { + return { allowed: false, ip, cookieId, isFlooding: false }; + } + + return { allowed: true, ip, cookieId, isFlooding: false }; +} + +/** + * Record a successful request for rate limiting tracking. + * Call this after the request is processed. + */ +export function recordRequest(request, windowMs = 1000) { + const ip = getClientIp(request); + const cookieId = getCookieId(request); + + if (ip !== 'unknown') { + recordHit(ipRateLimitMap, ip, windowMs); + } + if (cookieId) { + recordHit(cookieRateLimitMap, cookieId, windowMs); + } +} + +/** + * Generate a new nonce cookie value. + */ +export function generateNonce() { + return crypto.randomUUID(); +} + +/** + * Create Set-Cookie header value for nonce. + */ +export function createNonceCookie(nonce) { + return `nonce=${nonce}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=31536000`; +} + +export default { + checkRateLimit, + recordRequest, + getClientIp, + getCookieId, + generateNonce, + createNonceCookie, +};