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;
|
font-weight: 500;
|
||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
background: linear-gradient(120deg, rgba(255, 255, 255, 0.9), rgba(226, 232, 240, 0.85));
|
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 {
|
[data-theme="dark"] .nav-user-inline {
|
||||||
color: #f1f5f9;
|
color: #f1f5f9;
|
||||||
border-color: rgba(59, 130, 246, 0.25);
|
border-color: rgba(59, 130, 246, 0.25);
|
||||||
background: linear-gradient(120deg, rgba(15, 23, 42, 0.85), rgba(30, 41, 59, 0.7));
|
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 {
|
.nav-user-inline__icon {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ interface Props {
|
|||||||
description?: string;
|
description?: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
isWhitelabel?: boolean;
|
isWhitelabel?: boolean;
|
||||||
|
whitelabel?: any;
|
||||||
|
subsite?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
import { metaData } from "../config";
|
import { metaData } from "../config";
|
||||||
@@ -12,24 +14,87 @@ import { JoyUIRootIsland } from "./Components"
|
|||||||
import { useHtmlThemeAttr } from "../hooks/useHtmlThemeAttr";
|
import { useHtmlThemeAttr } from "../hooks/useHtmlThemeAttr";
|
||||||
import { usePrimeReactThemeSwitcher } from "../hooks/usePrimeReactThemeSwitcher";
|
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 { url } = Astro;
|
||||||
|
|
||||||
const trimmedTitle = title?.trim();
|
const trimmedTitle = title?.trim();
|
||||||
const seoTitle = trimmedTitle || metaData.title;
|
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 seoTitleTemplate = isWhitelabel ? "%s" : (trimmedTitle ? `%s | ${metaData.title}` : "%s");
|
||||||
const shareDescription = isWhitelabel ? trimmedTitle : (description ?? metaData.shareDescription ?? metaData.description);
|
const shareDescription = isWhitelabel
|
||||||
const canonicalUrl = url?.href ?? metaData.baseUrl;
|
? (trimmedTitle || (subsiteMeta.shareDescription ?? metaData.shareDescription ?? metaData.description))
|
||||||
const shareImage = new URL(image ?? metaData.ogImage, metaData.baseUrl).toString();
|
: (description ?? metaData.shareDescription ?? metaData.description);
|
||||||
const shareImageAlt = metaData.shareImageAlt ?? metaData.shareTitle ?? metaData.title;
|
// 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
|
<SEO
|
||||||
title={seoTitle}
|
title={seoTitle}
|
||||||
titleTemplate={seoTitleTemplate}
|
titleTemplate={seoTitleTemplate}
|
||||||
titleDefault={metaData.title}
|
titleDefault={metaData.title}
|
||||||
|
canonical={canonicalUrl}
|
||||||
description={shareDescription}
|
description={shareDescription}
|
||||||
charset="UTF-8"
|
charset="UTF-8"
|
||||||
openGraph={{
|
openGraph={{
|
||||||
@@ -41,14 +106,16 @@ const shareImageAlt = metaData.shareImageAlt ?? metaData.shareTitle ?? metaData.
|
|||||||
},
|
},
|
||||||
optional: {
|
optional: {
|
||||||
description: shareDescription,
|
description: shareDescription,
|
||||||
siteName: metaData.name,
|
siteName: subsiteMeta.siteName ?? metaData.name,
|
||||||
locale: "en_US",
|
locale: "en_US",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
extend={{
|
extend={{
|
||||||
link: [
|
link: [
|
||||||
{ rel: "icon", href: "https://codey.lol/images/favicon.png" },
|
// choose subsite favicon if provided, else global config. Allow absolute or relative paths
|
||||||
{ rel: "canonical", href: canonicalUrl },
|
{ rel: 'icon', href: primaryIconHref },
|
||||||
|
// additional icon links from config if present
|
||||||
|
...extraIcons,
|
||||||
],
|
],
|
||||||
meta: [
|
meta: [
|
||||||
{ property: "og:image:alt", content: shareImageAlt },
|
{ property: "og:image:alt", content: shareImageAlt },
|
||||||
|
|||||||
@@ -17,15 +17,17 @@ export default function ReqForm() {
|
|||||||
const [suggestions, setSuggestions] = useState([]);
|
const [suggestions, setSuggestions] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (title !== selectedTitle && selectedOverview) {
|
if (title !== selectedTitle) {
|
||||||
setSelectedOverview("");
|
if (selectedOverview) setSelectedOverview("");
|
||||||
setSelectedTitle("");
|
if (selectedItem) setSelectedItem(null);
|
||||||
|
if (type) setType("");
|
||||||
|
if (selectedTitle) setSelectedTitle("");
|
||||||
}
|
}
|
||||||
}, [title, selectedTitle, selectedOverview]);
|
}, [title, selectedTitle, selectedOverview, selectedItem, type]);
|
||||||
|
|
||||||
const searchTitles = async (event) => {
|
const searchTitles = async (event) => {
|
||||||
const query = event.query;
|
const query = event.query;
|
||||||
if (query.length < 3) {
|
if (query.length < 2) {
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -103,6 +105,15 @@ export default function ReqForm() {
|
|||||||
}, 0);
|
}, 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 (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[60vh] p-4">
|
<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">
|
<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}
|
value={title}
|
||||||
suggestions={suggestions}
|
suggestions={suggestions}
|
||||||
completeMethod={searchTitles}
|
completeMethod={searchTitles}
|
||||||
|
minLength={2}
|
||||||
onChange={(e) => setTitle(typeof e.value === 'string' ? e.value : e.value.label)}
|
onChange={(e) => setTitle(typeof e.value === 'string' ? e.value : e.value.label)}
|
||||||
onSelect={(e) => {
|
onSelect={(e) => {
|
||||||
setType(e.value.mediaType === 'tv' ? 'tv' : 'movie');
|
setType(e.value.mediaType === 'tv' ? 'tv' : 'movie');
|
||||||
@@ -148,6 +160,11 @@ export default function ReqForm() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{selectedItem && selectedTypeLabel && (
|
||||||
|
<div className="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">
|
||||||
|
Selected type: {selectedTypeLabel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedOverview && (
|
{selectedOverview && (
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ export const metaData = {
|
|||||||
description: "CODEY STUFF!",
|
description: "CODEY STUFF!",
|
||||||
shareTitle: "CODEY STUFF",
|
shareTitle: "CODEY STUFF",
|
||||||
shareDescription: "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";
|
export const API_URL = "https://api.codey.lol";
|
||||||
@@ -25,9 +32,19 @@ export const WHITELABELS = {
|
|||||||
'req.boatson.boats': {
|
'req.boatson.boats': {
|
||||||
title: 'Request Media',
|
title: 'Request Media',
|
||||||
name: 'REQ',
|
name: 'REQ',
|
||||||
brandColor: '#12f8f4',
|
brandColor: '', // inherit
|
||||||
siteTitle: 'Request Media',
|
siteTitle: 'Request Media',
|
||||||
logoText: '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"
|
content="index, follow, max-video-preview:-1, max-image-preview:large, max-snippet:-1"
|
||||||
/>
|
/>
|
||||||
<Themes />
|
<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>
|
<script>
|
||||||
import "@scripts/lenisSmoothScroll.js";
|
import "@scripts/lenisSmoothScroll.js";
|
||||||
import "@scripts/main.jsx";
|
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 { Icon } from "astro-icon/components";
|
||||||
import { API_URL } from "../../config";
|
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 whitelabel = Astro.props?.whitelabel ?? null;
|
||||||
const currentPath = Astro.url.pathname;
|
const currentPath = Astro.url.pathname;
|
||||||
|
const activeColor = "#111827";
|
||||||
|
const subsitePathPrefix = "/subsites/req";
|
||||||
|
|
||||||
const links = [
|
const user = await requireAuthHook(Astro);
|
||||||
// Add req-specific nav items here in future
|
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>
|
<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">
|
<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">
|
<div class="max-w-7xl mx-auto">
|
||||||
<a href="/" class="text-xl sm:text-2xl font-semibold" style={`color: ${whitelabel?.brandColor ?? 'var(--brand-color)'}`}>
|
<div class="nav-bar-row flex items-center gap-4 justify-between">
|
||||||
{whitelabel?.logoText ?? 'REQ'}
|
<a
|
||||||
</a>
|
href="/"
|
||||||
<ul class="flex items-center gap-4">
|
class="text-xl sm:text-2xl font-semibold header-text whitespace-nowrap hover:opacity-80 transition-opacity"
|
||||||
<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()">
|
{whitelabel?.logoText ?? 'REQ'}
|
||||||
<Icon name="fa6-solid:circle-half-stroke" class="h-4 w-4 text-[#1c1c1c] dark:text-[#D4D4D4]" />
|
</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>
|
</button>
|
||||||
</li>
|
</div>
|
||||||
</ul>
|
|
||||||
|
<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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -17,8 +17,16 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|||||||
const urlHost = context.url?.hostname || '';
|
const urlHost = context.url?.hostname || '';
|
||||||
const host = (hostHeader || authorityHeader || urlHost).split(':')[0]; // normalize remove port
|
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
|
// 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
|
// When the host header is missing, log all headers for debugging and
|
||||||
// attempt to determine host from forwarded headers or a dev query header.
|
// 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 { getSubsiteByHost } from '../../utils/subsites.js';
|
||||||
|
import {
|
||||||
|
checkRateLimit,
|
||||||
|
recordRequest,
|
||||||
|
getCookieId,
|
||||||
|
generateNonce,
|
||||||
|
createNonceCookie,
|
||||||
|
getClientIp,
|
||||||
|
} from '../../utils/rateLimit.js';
|
||||||
|
|
||||||
export async function GET({ request }) {
|
export async function GET({ request }) {
|
||||||
const host = request.headers.get('host');
|
const host = request.headers.get('host');
|
||||||
@@ -28,27 +16,51 @@ export async function GET({ request }) {
|
|||||||
return new Response('Not found', { status: 404 });
|
return new Response('Not found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let userId = request.headers.get('cookie')?.split(';').find(c => c.trim().startsWith('nonce='))?.split('=')[1];
|
// Rate limit check (5 requests per second, flood protection at 30/10s)
|
||||||
const hadCookie = !!userId;
|
const rateCheck = checkRateLimit(request, {
|
||||||
if (!userId) {
|
limit: 5,
|
||||||
userId = crypto.randomUUID();
|
windowMs: 1000,
|
||||||
|
burstLimit: 30,
|
||||||
|
burstWindowMs: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
let cookieId = getCookieId(request);
|
||||||
|
const hadCookie = !!cookieId;
|
||||||
|
if (!cookieId) {
|
||||||
|
cookieId = generateNonce();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!checkRateLimit(userId)) {
|
if (!rateCheck.allowed) {
|
||||||
const response = new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { status: 429, headers: { 'Content-Type': 'application/json' } });
|
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) {
|
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;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record the request for rate limiting
|
||||||
|
recordRequest(request, 1000);
|
||||||
|
|
||||||
const TMDB_API_KEY = import.meta.env.TMDB_API_KEY;
|
const TMDB_API_KEY = import.meta.env.TMDB_API_KEY;
|
||||||
|
|
||||||
if (!TMDB_API_KEY) {
|
if (!TMDB_API_KEY) {
|
||||||
console.error('TMDB_API_KEY not set');
|
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) {
|
if (!hadCookie) {
|
||||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -57,17 +69,23 @@ export async function GET({ request }) {
|
|||||||
const q = url.searchParams.get('q');
|
const q = url.searchParams.get('q');
|
||||||
|
|
||||||
if (!q || typeof q !== 'string' || !q.trim()) {
|
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) {
|
if (!hadCookie) {
|
||||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (q.length > 100) {
|
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) {
|
if (!hadCookie) {
|
||||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -96,16 +114,21 @@ export async function GET({ request }) {
|
|||||||
overview: item.overview,
|
overview: item.overview,
|
||||||
poster_path: item.poster_path,
|
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) {
|
if (!hadCookie) {
|
||||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching suggestions:', 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) {
|
if (!hadCookie) {
|
||||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||||
}
|
}
|
||||||
return response;
|
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 { getSubsiteByHost } from '../../utils/subsites.js';
|
||||||
|
import {
|
||||||
|
checkRateLimit,
|
||||||
|
recordRequest as recordRateLimitRequest,
|
||||||
|
getCookieId,
|
||||||
|
generateNonce,
|
||||||
|
createNonceCookie,
|
||||||
|
getClientIp,
|
||||||
|
} from '../../utils/rateLimit.js';
|
||||||
|
|
||||||
export async function POST({ request }) {
|
export async function POST({ request }) {
|
||||||
const host = request.headers.get('host');
|
const host = request.headers.get('host');
|
||||||
@@ -37,17 +15,35 @@ export async function POST({ request }) {
|
|||||||
return new Response('Not found', { status: 404 });
|
return new Response('Not found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let userId = request.headers.get('cookie')?.split(';').find(c => c.trim().startsWith('nonce='))?.split('=')[1];
|
// Rate limit check (1 request per 15 seconds, flood protection at 10/30s)
|
||||||
const hadCookie = !!userId;
|
const rateCheck = checkRateLimit(request, {
|
||||||
if (!userId) {
|
limit: 1,
|
||||||
userId = crypto.randomUUID();
|
windowMs: 15_000,
|
||||||
|
burstLimit: 10,
|
||||||
|
burstWindowMs: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
let cookieId = getCookieId(request);
|
||||||
|
const hadCookie = !!cookieId;
|
||||||
|
if (!cookieId) {
|
||||||
|
cookieId = generateNonce();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!checkRateLimit(userId)) {
|
if (!rateCheck.allowed) {
|
||||||
const response = new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { status: 429, headers: { 'Content-Type': 'application/json' } });
|
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) {
|
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;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,9 +51,12 @@ export async function POST({ request }) {
|
|||||||
|
|
||||||
if (!DISCORD_WEBHOOK_URL) {
|
if (!DISCORD_WEBHOOK_URL) {
|
||||||
console.error('DISCORD_WEBHOOK_URL not set');
|
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) {
|
if (!hadCookie) {
|
||||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -67,41 +66,56 @@ export async function POST({ request }) {
|
|||||||
|
|
||||||
// Input validation
|
// Input validation
|
||||||
if (!title || typeof title !== 'string' || !title.trim()) {
|
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) {
|
if (!hadCookie) {
|
||||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (title.length > 200) {
|
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) {
|
if (!hadCookie) {
|
||||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['movie', 'tv'].includes(type)) {
|
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) {
|
if (!hadCookie) {
|
||||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (year && (typeof year !== 'string' || !/^\d{4}$/.test(year))) {
|
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) {
|
if (!hadCookie) {
|
||||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requester && (typeof requester !== 'string' || requester.length > 500)) {
|
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) {
|
if (!hadCookie) {
|
||||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -216,18 +230,24 @@ export async function POST({ request }) {
|
|||||||
throw new Error(`Discord webhook error: ${apiResponse.status}`);
|
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) {
|
if (!hadCookie) {
|
||||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Webhook submission error:', 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) {
|
if (!hadCookie) {
|
||||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||||
}
|
}
|
||||||
return response;
|
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