mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-22 16:44:33 -05:00
Add defenses against scraper bots
This commit is contained in:
32
src/app/api/download/[slug]/[ext]/route.ts
Normal file
32
src/app/api/download/[slug]/[ext]/route.ts
Normal 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 });
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
64
src/app/hack/[slug]/actions.ts
Normal file
64
src/app/hack/[slug]/actions.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
17
src/app/robots.ts
Normal 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`,
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
131
src/utils/bot-detection.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user