diff --git a/src/app/api/download/[slug]/[ext]/route.ts b/src/app/api/download/[slug]/[ext]/route.ts new file mode 100644 index 0000000..b4b5e3a --- /dev/null +++ b/src/app/api/download/[slug]/[ext]/route.ts @@ -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 }); +} + diff --git a/src/app/api/patches/[id]/download/route.ts b/src/app/api/patches/[id]/download/route.ts index 7ca5a4b..2295479 100644 --- a/src/app/api/patches/[id]/download/route.ts +++ b/src/app/api/patches/[id]/download/route.ts @@ -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") diff --git a/src/app/hack/[slug]/actions.ts b/src/app/hack/[slug]/actions.ts new file mode 100644 index 0000000..0a7be8a --- /dev/null +++ b/src/app/hack/[slug]/actions.ts @@ -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" }; + } +} + diff --git a/src/app/hack/[slug]/page.tsx b/src/app/hack/[slug]/page.tsx index c7728d7..454f543 100644 --- a/src/app/hack/[slug]/page.tsx +++ b/src/app/hack/[slug]/page.tsx @@ -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 (
+ {/* Honeypot links - hidden from users and screen readers */} +