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

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