This commit is contained in:
2025-12-02 10:05:43 -05:00
parent 6660b9ffd0
commit c3f0197115
11 changed files with 666 additions and 125 deletions

BIN
public/images/req.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

View File

@@ -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 {

View File

@@ -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 },

View File

@@ -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 && (

View File

@@ -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'
},
};

View File

@@ -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";

View File

@@ -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>

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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
View 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,
};