mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-09-07 08:26:07 -05:00
Add defenses against scraper bots
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user