From c3f01971156b11ddeed38400f3b965b09ab2ccbd Mon Sep 17 00:00:00 2001 From: codey Date: Tue, 2 Dec 2025 10:05:43 -0500 Subject: [PATCH] misc --- public/images/req.png | Bin 0 -> 859 bytes src/assets/styles/nav.css | 4 +- src/components/BaseHead.astro | 87 +++++++++-- src/components/req/ReqForm.jsx | 27 +++- src/config.js | 21 ++- src/layouts/Base.astro | 2 +- src/layouts/subsites/reqNav.astro | 183 +++++++++++++++++++++-- src/middleware.js | 10 +- src/pages/api/search.js | 97 ++++++++----- src/pages/api/submit.js | 128 ++++++++++------- src/utils/rateLimit.js | 232 ++++++++++++++++++++++++++++++ 11 files changed, 666 insertions(+), 125 deletions(-) create mode 100644 public/images/req.png create mode 100644 src/utils/rateLimit.js diff --git a/public/images/req.png b/public/images/req.png new file mode 100644 index 0000000000000000000000000000000000000000..92ac6254080fd1261c0ebb63bab01102fa638fab GIT binary patch literal 859 zcmV-h1ElKS(JD&@KYiG8S)RSwov|bSb`|^^&!}n7xiBQ zjPEJ(XDcG|u@0&!`00=rEuS*Jb0jZqvg)g&)Hg$ZCy4u3j8^=t@$HsRh5SdsPXf)z zW?o0w1wR&wuY)*{nFRHr6@X>o4EZSRni*AiT>#^K-SNpvy>ZwcC0^*2;JYC1yIA2h z0Nwq))QS8BMAv?EsD2ajz{Sr2lj9O!1ppJ*h5TE{S>zMIZWkX8c`@V<8k#-h6yB%= zcZK}-^3jUZA^#NA2LWw|cxEmq?nkITA#n&8pnOT_$5C#}jiPNe-!gbPGY0@Ze}K?A zNPtS};RF&+2zns89&&p5J61qcn_=sxG?c+EPuIy_(aI>uH@|j ztSLb+J}xcK7wVoy4K3}pNJoH6fccU<9@ObZ&2*BJp*WH-11yj^n`AU 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, +};