Add defenses against scraper bots

This commit is contained in:
Jared Schoeny
2025-11-14 23:33:51 -10:00
parent 03b9de8d29
commit 348bc18f05
8 changed files with 391 additions and 62 deletions

View File

@@ -0,0 +1,32 @@
import { NextRequest } from "next/server";
// Honeypot endpoint - logs bot access attempts
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ slug: string; ext: string }> }
) {
const { slug, ext } = await params;
// Get request information for logging
const ip = req.headers.get("x-forwarded-for") ||
req.headers.get("x-real-ip") ||
"unknown";
const userAgent = req.headers.get("user-agent") || "unknown";
const referer = req.headers.get("referer") || "none";
const timestamp = new Date().toISOString();
// Log bot access attempt (in production, you might want to send this to a logging service)
console.warn("[HONEYPOT] Bot access detected:", {
slug,
ext,
ip,
userAgent,
referer,
timestamp,
path: `/api/download/${slug}/${ext}`,
});
// Return 404 to make it look like the file doesn't exist
return new Response("Not found", { status: 404 });
}

View File

@@ -2,10 +2,40 @@ import { NextRequest } from "next/server";
import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server";
import { createClient } from "@/utils/supabase/server";
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const supabase = await createClient();
// Log suspicious access patterns
const referer = req.headers.get("referer");
const userAgent = req.headers.get("user-agent");
const ip = req.headers.get("x-forwarded-for") ||
req.headers.get("x-real-ip") ||
"unknown";
// Check for suspicious patterns
const suspiciousPatterns = {
noReferer: !referer,
suspiciousUserAgent: userAgent && (
userAgent.includes("bot") ||
userAgent.includes("crawler") ||
userAgent.includes("spider") ||
userAgent.includes("scraper") ||
!userAgent.includes("Mozilla")
),
};
if (suspiciousPatterns.noReferer || suspiciousPatterns.suspiciousUserAgent) {
console.warn("[BOT_DETECTION] Suspicious access to patch download:", {
patchId: id,
ip,
userAgent,
referer,
patterns: suspiciousPatterns,
timestamp: new Date().toISOString(),
});
}
const { data: patch, error } = await supabase
.from("patches")
.select("id, bucket, filename")

View File

@@ -0,0 +1,64 @@
"use server";
import { createClient } from "@/utils/supabase/server";
import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server";
export async function getSignedPatchUrl(slug: string): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
const supabase = await createClient();
// Get user for permission check
const {
data: { user },
} = await supabase.auth.getUser();
// Fetch hack to validate it exists
const { data: hack, error: hackError } = await supabase
.from("hacks")
.select("slug, approved, created_by, current_patch")
.eq("slug", slug)
.maybeSingle();
if (hackError || !hack) {
return { ok: false, error: "Hack not found" };
}
// Check if hack is approved or user has permission (owner or admin)
const canEdit = !!user && user.id === (hack.created_by as string);
let isAdmin = false;
if (!hack.approved && !canEdit) {
const { data: admin } = await supabase.rpc("is_admin");
isAdmin = !!admin;
if (!isAdmin) {
return { ok: false, error: "Hack not found" };
}
}
// Check if patch exists
if (hack.current_patch == null) {
return { ok: false, error: "No patch available" };
}
// Fetch patch info
const { data: patch, error: patchError } = await supabase
.from("patches")
.select("id, bucket, filename")
.eq("id", hack.current_patch as number)
.maybeSingle();
if (patchError || !patch) {
return { ok: false, error: "Patch not found" };
}
// Sign the URL server-side
try {
const client = getMinioClient();
const bucket = patch.bucket || PATCHES_BUCKET;
const signedUrl = await client.presignedGetObject(bucket, patch.filename, 60 * 5);
return { ok: true, url: signedUrl };
} catch (error) {
console.error("Error signing patch URL:", error);
return { ok: false, error: "Failed to generate download URL" };
}
}

View File

@@ -10,7 +10,6 @@ import Image from "next/image";
import { FaDiscord, FaTwitter, FaTriangleExclamation } from "react-icons/fa6";
import PokeCommunityIcon from "@/components/Icons/PokeCommunityIcon";
import { createClient, createServiceClient } from "@/utils/supabase/server";
import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server";
import HackOptionsMenu from "@/components/Hack/HackOptionsMenu";
import DownloadsBadge from "@/components/Hack/DownloadsBadge";
import type { CreativeWork, WithContext } from "schema-dts";
@@ -160,9 +159,8 @@ export default async function HackDetail({ params }: HackDetailProps) {
}
}
// Resolve a short-lived signed patch URL (if current_patch exists)
// Get patch info, but don't sign URL yet (happens on user interaction)
let patchFilename: string | null = null;
let signedPatchUrl = "";
let patchVersion = "";
let patchId: number | null = null;
let lastUpdated: string | null = null;
@@ -174,9 +172,6 @@ export default async function HackDetail({ params }: HackDetailProps) {
.eq("id", hack.current_patch as number)
.maybeSingle();
if (patch) {
const client = getMinioClient();
const bucket = patch.bucket || PATCHES_BUCKET;
signedPatchUrl = await client.presignedGetObject(bucket, patch.filename, 60 * 5);
patchFilename = patch.filename;
patchVersion = patch.version || "";
patchId = patch.id;
@@ -236,6 +231,13 @@ export default async function HackDetail({ params }: HackDetailProps) {
return (
<div className="mx-auto max-w-screen-lg w-full pb-28">
{/* Honeypot links - hidden from users and screen readers */}
<div style={{ display: 'none' }} aria-hidden="true">
<a href={`/api/download/${hack.slug}/${hack.slug}.bps`} tabIndex={-1} aria-hidden="true" />
<a href={`/api/download/${hack.slug}/patch.bps`} tabIndex={-1} aria-hidden="true" />
<a href={`/api/download/${hack.slug}/download.bps`} tabIndex={-1} aria-hidden="true" />
<a href={`/api/download/${hack.slug}/rom.${baseRom?.platform?.toLowerCase() || 'gba'}`} tabIndex={-1} aria-hidden="true" />
</div>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: serialize(jsonLd, { isJSON: true }) }}
@@ -246,7 +248,6 @@ export default async function HackDetail({ params }: HackDetailProps) {
author={author}
baseRomId={baseRom?.id || ""}
platform={baseRom?.platform}
patchUrl={signedPatchUrl}
patchFilename={patchFilename}
patchId={patchId ?? undefined}
hackSlug={hack.slug}

17
src/app/robots.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { MetadataRoute } from "next";
export default function Robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
disallow: [
"/_next/",
"/api/",
"/account/",
"/auth/",
"/roms/",
],
},
sitemap: `${process.env.NEXT_PUBLIC_SITE_URL!}/sitemap.xml`,
};
}

View File

@@ -7,6 +7,7 @@ import { baseRoms } from "@/data/baseRoms";
import BinFile from "rom-patcher-js/rom-patcher-js/modules/BinFile.js";
import BPS from "rom-patcher-js/rom-patcher-js/modules/RomPatcher.format.bps.js";
import type { DownloadEventDetail } from "@/types/util";
import { getSignedPatchUrl } from "@/app/hack/[slug]/actions";
interface HackActionsProps {
title: string;
@@ -14,7 +15,6 @@ interface HackActionsProps {
author: string;
baseRomId: string;
platform?: "GBA" | "GBC" | "GB" | "NDS";
patchUrl: string;
patchFilename: string | null;
patchId?: number;
hackSlug: string;
@@ -26,7 +26,6 @@ const HackActions: React.FC<HackActionsProps> = ({
author,
baseRomId,
platform,
patchUrl,
patchFilename,
patchId,
hackSlug,
@@ -36,15 +35,35 @@ const HackActions: React.FC<HackActionsProps> = ({
const [status, setStatus] = React.useState<"idle" | "ready" | "patching" | "done" | "downloading">("idle");
const [error, setError] = React.useState<string | null>(null);
const [patchBlob, setPatchBlob] = React.useState<Blob | null>(null);
const [patchUrl, setPatchUrl] = React.useState<string | null>(null);
const [termsAgreed, setTermsAgreed] = React.useState(false);
const baseRomName = React.useMemo(() => baseRoms.find(r => r.id === baseRomId)?.name || null, [baseRomId]);
// Basic client-side bot detection
React.useEffect(() => {
if (typeof window === 'undefined') return;
if (typeof localStorage === 'undefined') {
setError("Browser features not available");
return;
}
// Check for basic browser features
if (!window.navigator || !window.navigator.userAgent) {
setError("Invalid browser environment");
return;
}
}, []);
React.useEffect(() => {
if ((isLinked(baseRomId) && hasPermission(baseRomId)) || hasCached(baseRomId)) {
if (status !== "downloading" && status !== "patching" && status !== "done") {
setStatus("ready");
if (termsAgreed && patchUrl) {
setStatus("ready");
} else {
setStatus("idle");
}
}
}
}, [baseRomId, isLinked, hasPermission, hasCached, status]);
}, [baseRomId, isLinked, hasPermission, hasCached, status, termsAgreed, patchUrl]);
React.useEffect(() => {
let timeoutId: NodeJS.Timeout | undefined;
@@ -58,43 +77,21 @@ const HackActions: React.FC<HackActionsProps> = ({
}
}, [error]);
// Pre-download patch on mount (or when patchUrl changes) and cache as Blob
// When patch URL is fetched and terms are agreed, automatically proceed with patching if ROM is ready
React.useEffect(() => {
let aborted = false;
async function prefetchPatch() {
try {
setPatchBlob(null);
if (!patchUrl) return;
// indicate downloading while we fetch the patch blob
setStatus((prev) => (prev === "patching" || prev === "done" ? prev : "downloading"));
const res = await fetch(patchUrl);
if (!res.ok) throw new Error("Failed to fetch patch");
const blob = await res.blob();
if (aborted) return;
setPatchBlob(blob);
// restore status after download: ready if base rom uploaded/linked, else idle
setStatus((prev) => {
if (prev === "patching" || prev === "done") return prev;
const romReady = !!file || (isLinked(baseRomId) && (hasPermission(baseRomId) || hasCached(baseRomId)));
return romReady ? "ready" : "idle";
});
} catch {
if (!aborted) {
setPatchBlob(null);
// on error, fall back to current readiness state
setStatus((prev) => {
if (prev === "patching" || prev === "done") return prev;
const romReady = !!file || (isLinked(baseRomId) && (hasPermission(baseRomId) || hasCached(baseRomId)));
return romReady ? "ready" : "idle";
});
}
if (termsAgreed && patchUrl && patchBlob && status === "idle") {
const romReady = !!file || (isLinked(baseRomId) && (hasPermission(baseRomId) || hasCached(baseRomId)));
if (romReady) {
// Automatically start patching
setStatus("ready");
// Use setTimeout to avoid calling onPatch during render
const timeoutId = setTimeout(() => {
onPatch();
}, 0);
return () => clearTimeout(timeoutId);
}
}
prefetchPatch();
return () => {
aborted = true;
};
}, [patchUrl]);
}, [termsAgreed, patchUrl, patchBlob, file, baseRomId, isLinked, hasPermission, hasCached, status]);
async function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0] ?? null;
@@ -117,9 +114,56 @@ const HackActions: React.FC<HackActionsProps> = ({
}
}
async function onAgreeToTerms() {
try {
setError(null);
setStatus("downloading");
// Fetch signed URL from server
const result = await getSignedPatchUrl(hackSlug);
if (!result.ok) {
setError(result.error);
setStatus("idle");
return;
}
setPatchUrl(result.url);
setTermsAgreed(true);
// Download patch blob
const res = await fetch(result.url);
if (!res.ok) throw new Error("Failed to fetch patch");
const blob = await res.blob();
setPatchBlob(blob);
// Update status based on ROM readiness
const romReady = !!file || (isLinked(baseRomId) && (hasPermission(baseRomId) || hasCached(baseRomId)));
if (romReady) {
setStatus("ready");
} else {
setStatus("idle");
}
} catch (e: any) {
setError(e?.message || "Failed to fetch patch URL");
setStatus("idle");
}
}
async function onPatch() {
try {
setError(null);
// If terms not agreed yet, trigger agreement flow
if (!termsAgreed || !patchUrl || !patchBlob) {
await onAgreeToTerms();
return;
}
// Prevent multiple patching attempts
if (status === "patching" || status === "done") {
return;
}
let baseFile = file;
if (!baseFile) {
if (!isLinked(baseRomId) && !hasCached(baseRomId)) return;
@@ -213,6 +257,7 @@ const HackActions: React.FC<HackActionsProps> = ({
onClickLink={() => (isLinked(baseRomId) ? ensurePermission(baseRomId, true) : linkRom(baseRomId))}
supported={supported}
onUploadChange={onSelectFile}
termsAgreed={termsAgreed}
/>
);
};

View File

@@ -1,6 +1,7 @@
"use client";
import React from "react";
import Link from "next/link";
import { platformAccept } from "@/utils/idb";
import type { Platform } from "@/data/baseRoms";
@@ -19,6 +20,7 @@ interface StickyActionBarProps {
onClickLink: () => void;
supported: boolean;
onUploadChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
termsAgreed: boolean;
}
export default function StickyActionBar({
@@ -36,6 +38,7 @@ export default function StickyActionBar({
onClickLink,
supported,
onUploadChange,
termsAgreed,
}: StickyActionBarProps) {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
@@ -86,17 +89,21 @@ export default function StickyActionBar({
<div className="truncate text-sm md:text-xs text-foreground/60">By {author}</div>
</div>
<div className="flex w-full md:w-auto flex-col md:flex-row md:flex-wrap items-stretch md:items-center gap-2 mb-4 md:mb-0">
<span className={`rounded-full mx-auto md:mx-0 px-2 py-0.5 text-xs ring-1 ${
status === "downloading"
? "bg-[var(--surface-2)] text-foreground/85 ring-[var(--border)]"
: romReady
? "bg-emerald-600/60 text-white ring-emerald-700/80 dark:bg-emerald-500/25 dark:text-emerald-100 dark:ring-emerald-400/90"
: isLinked
? "bg-amber-600/60 text-white ring-amber-700/80 dark:bg-amber-500/50 dark:text-amber-100 dark:ring-amber-400/90"
: "bg-red-600/60 text-white ring-red-700/80 dark:bg-red-500/50 dark:text-red-100 dark:ring-red-400/90"
}`}>
{status === "downloading" ? "Downloading..." : romReady ? (filename ?? ".bps file ready") : isLinked ? "Permission needed" : "Base ROM needed"}
</span>
{!termsAgreed || status === "downloading" ? (
<p className="rounded-full mx-auto md:mx-0 px-2 py-0.5 text-xs">
By downloading this patch, you agree to the <Link href="/terms" target="_blank" className="underline">Terms of Service</Link>.
</p>
) : (
<span className={`rounded-full mx-auto md:mx-0 px-2 py-0.5 text-xs ring-1 transition-opacity duration-300 ${
romReady
? "bg-emerald-600/60 text-white ring-emerald-700/80 dark:bg-emerald-500/25 dark:text-emerald-100 dark:ring-emerald-400/90"
: isLinked
? "bg-amber-600/60 text-white ring-amber-700/80 dark:bg-amber-500/50 dark:text-amber-100 dark:ring-amber-400/90"
: "bg-red-600/60 text-white ring-red-700/80 dark:bg-red-500/50 dark:text-red-100 dark:ring-red-400/90"
}`}>
{romReady ? (filename ?? ".bps file ready") : isLinked ? "Permission needed" : "Base ROM needed"}
</span>
)}
{!romReady && !isLinked && (
<label className="inline-flex items-center gap-2 text-xs text-foreground/80">
<input ref={uploadInputRef} type="file" accept={platformAccept(baseRomPlatform)} onChange={onUploadChange} className="hidden" />
@@ -128,14 +135,16 @@ export default function StickyActionBar({
<button
onClick={onPatch}
data-ready={romReady}
disabled={!mounted || (status !== "ready" && status !== "done") || !patchAgainReady}
className={`shine-wrap btn-premium max-md:data-[ready=false]:hidden! h-11 md:h-9 w-full md:w-auto md:min-w-[7.5rem] text-base md:text-sm font-semibold cursor-pointer disabled:cursor-not-allowed disabled:opacity-70 ${romReady && status !== 'downloading' && status !== 'ready' ? "mt-6 md:mt-0" : ""}`}
disabled={!mounted || (status !== "ready" && status !== "done" && status !== "idle") || !patchAgainReady}
className={`shine-wrap btn-premium max-md:data-[ready=false]:hidden! h-11 md:h-9 w-full md:min-w-[7.5rem] ${!termsAgreed || status === 'downloading' ? "md:w-32" : "md:w-auto"} text-base md:text-sm font-semibold cursor-pointer disabled:cursor-not-allowed disabled:opacity-70 ${romReady && status !== 'downloading' && status !== 'ready' && termsAgreed ? "mt-6 md:mt-0" : ""}`}
>
<span>{status === "patching" ? "Patching…" : (
<span>{
status === "patching" ? "Patching…" :
status === "downloading" ? "Downloading…" :
status === "done" ? (
patchAgainReady ? "Patch Again" : "Patched"
) : "Patch Now"
)}</span>
) : termsAgreed ? "Patch Now" : "I Agree"
}</span>
</button>
</div>
</div>

131
src/utils/bot-detection.ts Normal file
View File

@@ -0,0 +1,131 @@
/**
* Bot detection utilities
* These are basic heuristics to help identify bot traffic
*/
export interface BotDetectionResult {
isLikelyBot: boolean;
reasons: string[];
confidence: number; // 0-1
}
/**
* Check if a user agent string looks suspicious
*/
export function checkUserAgent(userAgent: string | null): BotDetectionResult {
if (!userAgent) {
return {
isLikelyBot: true,
reasons: ["Missing user agent"],
confidence: 0.8,
};
}
const reasons: string[] = [];
let confidence = 0;
// Common bot patterns
const botPatterns = [
/bot/i,
/crawler/i,
/spider/i,
/scraper/i,
/curl/i,
/wget/i,
/python/i,
/java/i,
/go-http/i,
/httpclient/i,
/scrapy/i,
/headless/i,
];
for (const pattern of botPatterns) {
if (pattern.test(userAgent)) {
reasons.push(`User agent matches bot pattern: ${pattern}`);
confidence += 0.3;
}
}
// Missing common browser indicators
if (!userAgent.includes("Mozilla") && !userAgent.includes("Chrome") && !userAgent.includes("Safari") && !userAgent.includes("Firefox")) {
reasons.push("Missing browser indicators");
confidence += 0.4;
}
// Very short user agents are suspicious
if (userAgent.length < 20) {
reasons.push("Unusually short user agent");
confidence += 0.2;
}
return {
isLikelyBot: confidence > 0.5,
reasons,
confidence: Math.min(confidence, 1),
};
}
/**
* Check request headers for suspicious patterns
*/
export function checkRequestHeaders(headers: Headers): BotDetectionResult {
const reasons: string[] = [];
let confidence = 0;
// Missing referer is suspicious (though not always a bot)
const referer = headers.get("referer");
if (!referer) {
reasons.push("Missing referer header");
confidence += 0.2;
}
// Missing accept header
const accept = headers.get("accept");
if (!accept) {
reasons.push("Missing accept header");
confidence += 0.3;
}
// Missing accept-language
const acceptLanguage = headers.get("accept-language");
if (!acceptLanguage) {
reasons.push("Missing accept-language header");
confidence += 0.2;
}
// Check user agent
const userAgent = headers.get("user-agent");
const uaCheck = checkUserAgent(userAgent);
if (uaCheck.isLikelyBot) {
reasons.push(...uaCheck.reasons);
confidence += uaCheck.confidence * 0.5;
}
return {
isLikelyBot: confidence > 0.5,
reasons,
confidence: Math.min(confidence, 1),
};
}
/**
* Log bot detection result (for monitoring)
*/
export function logBotDetection(
path: string,
result: BotDetectionResult,
additionalInfo?: Record<string, unknown>
) {
if (result.isLikelyBot) {
console.warn("[BOT_DETECTION]", {
path,
isLikelyBot: result.isLikelyBot,
confidence: result.confidence,
reasons: result.reasons,
...additionalInfo,
timestamp: new Date().toISOString(),
});
}
}