misc
This commit is contained in:
BIN
public/images/req.png
Normal file
BIN
public/images/req.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 859 B |
@@ -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 {
|
||||
|
||||
@@ -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 ?? []));
|
||||
|
||||
---
|
||||
<SEO
|
||||
title={seoTitle}
|
||||
titleTemplate={seoTitleTemplate}
|
||||
titleDefault={metaData.title}
|
||||
canonical={canonicalUrl}
|
||||
description={shareDescription}
|
||||
charset="UTF-8"
|
||||
openGraph={{
|
||||
@@ -41,14 +106,16 @@ const shareImageAlt = metaData.shareImageAlt ?? metaData.shareTitle ?? metaData.
|
||||
},
|
||||
optional: {
|
||||
description: shareDescription,
|
||||
siteName: metaData.name,
|
||||
siteName: subsiteMeta.siteName ?? metaData.name,
|
||||
locale: "en_US",
|
||||
},
|
||||
}}
|
||||
extend={{
|
||||
extend={{
|
||||
link: [
|
||||
{ rel: "icon", href: "https://codey.lol/images/favicon.png" },
|
||||
{ rel: "canonical", href: canonicalUrl },
|
||||
// choose subsite favicon if provided, else global config. Allow absolute or relative paths
|
||||
{ rel: 'icon', href: primaryIconHref },
|
||||
// additional icon links from config if present
|
||||
...extraIcons,
|
||||
],
|
||||
meta: [
|
||||
{ property: "og:image:alt", content: shareImageAlt },
|
||||
|
||||
@@ -17,15 +17,17 @@ export default function ReqForm() {
|
||||
const [suggestions, setSuggestions] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<div className="flex items-center justify-center min-h-[60vh] p-4">
|
||||
<div className="w-full max-w-lg p-8 bg-white dark:bg-[#1E1E1E] rounded-3xl shadow-2xl border border-gray-200 dark:border-gray-700">
|
||||
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{selectedItem && selectedTypeLabel && (
|
||||
<div className="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">
|
||||
Selected type: {selectedTypeLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedOverview && (
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ if (import.meta.env.DEV) {
|
||||
content="index, follow, max-video-preview:-1, max-image-preview:large, max-snippet:-1"
|
||||
/>
|
||||
<Themes />
|
||||
<BaseHead title={whitelabel?.siteTitle ?? title} description={description} image={image ?? metaData.ogImage} isWhitelabel={!!whitelabel} />
|
||||
<BaseHead title={whitelabel?.siteTitle ?? title} description={description} image={image ?? metaData.ogImage} isWhitelabel={!!whitelabel} whitelabel={whitelabel} subsite={detectedSubsite} />
|
||||
<script>
|
||||
import "@scripts/lenisSmoothScroll.js";
|
||||
import "@scripts/main.jsx";
|
||||
|
||||
@@ -1,28 +1,185 @@
|
||||
---
|
||||
// Req specific subsite nav placeholder. Keeps markup minimal for now.
|
||||
import { Icon } from "astro-icon/components";
|
||||
import { API_URL } from "../../config";
|
||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
||||
import { padlockIconSvg, userIconSvg, externalLinkIconSvg } from "@/utils/navAssets";
|
||||
import "@/assets/styles/nav.css";
|
||||
|
||||
const whitelabel = Astro.props?.whitelabel ?? null;
|
||||
const currentPath = Astro.url.pathname;
|
||||
const activeColor = "#111827";
|
||||
const subsitePathPrefix = "/subsites/req";
|
||||
|
||||
const links = [
|
||||
// Add req-specific nav items here in future
|
||||
const user = await requireAuthHook(Astro);
|
||||
const isLoggedIn = Boolean(user);
|
||||
const userDisplayName = user?.user ?? null;
|
||||
|
||||
type NavItem = {
|
||||
label: string;
|
||||
href: string;
|
||||
auth?: boolean;
|
||||
guestOnly?: boolean;
|
||||
icon?: "external" | "padlock" | "pirate" | string;
|
||||
onclick?: string;
|
||||
};
|
||||
|
||||
const baseNavItems: NavItem[] = [
|
||||
// { label: "Submit Request", href: "/" },
|
||||
];
|
||||
|
||||
const navItems: NavItem[] = isLoggedIn
|
||||
? [...baseNavItems, { label: "Logout", href: "#logout", onclick: "handleLogout()" }]
|
||||
: baseNavItems;
|
||||
|
||||
const visibleNavItems: NavItem[] = navItems.filter((item) => {
|
||||
if (item.auth && !isLoggedIn) return false;
|
||||
if (item.guestOnly && isLoggedIn) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const normalize = (url) => (url || "/").replace(/\/+$/, "") || "/";
|
||||
const trimmedPath = currentPath.startsWith(subsitePathPrefix)
|
||||
? currentPath.slice(subsitePathPrefix.length) || "/"
|
||||
: currentPath;
|
||||
const normalizedCurrent = normalize(trimmedPath);
|
||||
---
|
||||
|
||||
<script src="/scripts/nav-controls.js" defer data-api-url={API_URL}></script>
|
||||
|
||||
<nav class="w-full px-4 sm:px-6 py-4 bg-transparent sticky top-0 z-50 backdrop-blur-sm bg-white/80 dark:bg-[#121212]/80 border-b border-neutral-200/50 dark:border-neutral-800/50">
|
||||
<div class="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<a href="/" class="text-xl sm:text-2xl font-semibold" style={`color: ${whitelabel?.brandColor ?? 'var(--brand-color)'}`}>
|
||||
{whitelabel?.logoText ?? 'REQ'}
|
||||
</a>
|
||||
<ul class="flex items-center gap-4">
|
||||
<li>
|
||||
<button aria-label="Toggle theme" type="button" class="flex items-center justify-center w-8 h-8 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors" onclick="toggleTheme()">
|
||||
<Icon name="fa6-solid:circle-half-stroke" class="h-4 w-4 text-[#1c1c1c] dark:text-[#D4D4D4]" />
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="nav-bar-row flex items-center gap-4 justify-between">
|
||||
<a
|
||||
href="/"
|
||||
class="text-xl sm:text-2xl font-semibold header-text whitespace-nowrap hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{whitelabel?.logoText ?? 'REQ'}
|
||||
</a>
|
||||
|
||||
<div class="desktop-nav flex items-center">
|
||||
<ul class="desktop-nav-list">
|
||||
{visibleNavItems.map((item) => {
|
||||
const isExternal = item.href?.startsWith("http");
|
||||
const isAuthedPath = item.auth ?? false;
|
||||
const normalizedHref = normalize(item.href);
|
||||
const isActive = !isExternal && (
|
||||
normalizedHref === '/'
|
||||
? normalizedCurrent === '/'
|
||||
: normalizedCurrent === normalizedHref || normalizedCurrent.startsWith(normalizedHref + '/')
|
||||
);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
class={isActive
|
||||
? "flex items-center gap-0 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all duration-200 text-white"
|
||||
: "flex items-center gap-0 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all duration-200 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"}
|
||||
style={isActive ? `background: ${activeColor}` : undefined}
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
||||
onclick={item.onclick}
|
||||
>
|
||||
{item.label}
|
||||
{item.icon === "external" && (
|
||||
<span class="inline-flex ml-0.5" aria-hidden="true" set:html={externalLinkIconSvg}></span>
|
||||
)}
|
||||
{item.icon === "padlock" && (
|
||||
<span class="inline-flex" aria-hidden="true" set:html={padlockIconSvg}></span>
|
||||
)}
|
||||
{item.icon === "pirate" && (
|
||||
<span class="inline-flex ml-1" role="img" aria-label="Pirate flag">🏴☠️</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
aria-label="Toggle theme"
|
||||
type="button"
|
||||
class="flex items-center justify-center w-8 h-8 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
onclick="toggleTheme()"
|
||||
>
|
||||
<Icon
|
||||
name="fa6-solid:circle-half-stroke"
|
||||
class="h-4 w-4 text-[#1c1c1c] dark:text-[#D4D4D4]"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mobile-nav flex items-center gap-2">
|
||||
<button
|
||||
aria-label="Toggle theme"
|
||||
type="button"
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
onclick="toggleTheme()"
|
||||
>
|
||||
<Icon
|
||||
name="fa6-solid:circle-half-stroke"
|
||||
class="h-5 w-5 text-[#1c1c1c] dark:text-[#D4D4D4]"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="mobile-menu-btn"
|
||||
aria-label="Toggle menu"
|
||||
type="button"
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
<svg id="menu-icon" class="w-6 h-6 text-neutral-700 dark:text-neutral-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
<svg id="close-icon" class="w-6 h-6 text-neutral-700 dark:text-neutral-300" style="display: none;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="mobile-menu"
|
||||
class="mobile-menu-dropdown md:hidden"
|
||||
>
|
||||
<ul class="flex flex-col gap-1 py-4">
|
||||
{visibleNavItems.map((item) => {
|
||||
const isExternal = item.href?.startsWith("http");
|
||||
const isAuthedPath = item.auth ?? false;
|
||||
const normalizedHref = normalize(item.href);
|
||||
const isActive = !isExternal && (
|
||||
normalizedHref === '/'
|
||||
? normalizedCurrent === '/'
|
||||
: normalizedCurrent === normalizedHref || normalizedCurrent.startsWith(normalizedHref + '/')
|
||||
);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
class={isActive
|
||||
? "flex items-center gap-0 px-4 py-3 rounded-lg text-base font-medium transition-all duration-200 text-white"
|
||||
: "flex items-center gap-0 px-4 py-3 rounded-lg text-base font-medium transition-all duration-200 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"}
|
||||
style={isActive ? `background: ${activeColor}` : undefined}
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
||||
onclick={item.onclick}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{item.icon === "external" && (
|
||||
<span class="inline-flex ml-0.5" aria-hidden="true" set:html={externalLinkIconSvg}></span>
|
||||
)}
|
||||
{item.icon === "padlock" && (
|
||||
<span class="inline-flex" aria-hidden="true" set:html={padlockIconSvg}></span>
|
||||
)}
|
||||
{item.icon === "pirate" && (
|
||||
<span class="inline-flex ml-1" role="img" aria-label="Pirate flag">🏴☠️</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
232
src/utils/rateLimit.js
Normal file
232
src/utils/rateLimit.js
Normal file
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user