From cb120290e56e0cb75d14aa3f1fa157f5ec87355c Mon Sep 17 00:00:00 2001 From: Jared Schoeny Date: Sat, 29 Aug 2026 11:51:11 -0600 Subject: [PATCH] Serve anonymous hack pages from cache without dropping owner tools (#86) * Prerender public hack pages so anonymous visits can be served from cache. Signed-in owners and admins still get the session-gated page at the same URL via a cookie-only rewrite. Co-authored-by: Cursor * Keep the site notice off the static hack page cache. Signed-in visits to /session redirect to the canonical hack URL; signed-out visits 404. Co-authored-by: Cursor * Prerender the top 750 public hack pages at build time. Almost the whole catalog is visited within a week, so first-hit generation after deploys is wasted compared to a slightly longer SSG step. Co-authored-by: Cursor * Show the public hack page when session auth is missing. An expired or leftover cookie was rewriting visitors onto /session and 404ing approved hacks that anonymous users can see. Co-authored-by: Cursor * Note that forks must replace the hardcoded Supabase project ID. Middleware cookie matchers cannot use env vars, so other communities need to swap in their own project ref. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- src/app/api/notice/route.ts | 29 ++ src/app/api/refresh/[slug]/route.ts | 3 +- src/app/hack/[slug]/hack-page-shared.ts | 93 ++++ src/app/hack/[slug]/page.tsx | 637 ++---------------------- src/app/hack/[slug]/session/page.tsx | 100 ++++ src/app/hack/actions.ts | 2 + src/components/Hack/HackDetailView.tsx | 636 +++++++++++++++++++++++ src/components/NoticeBanner.tsx | 46 +- src/middleware.ts | 110 +++- 9 files changed, 1018 insertions(+), 638 deletions(-) create mode 100644 src/app/api/notice/route.ts create mode 100644 src/app/hack/[slug]/hack-page-shared.ts create mode 100644 src/app/hack/[slug]/session/page.tsx create mode 100644 src/components/Hack/HackDetailView.tsx diff --git a/src/app/api/notice/route.ts b/src/app/api/notice/route.ts new file mode 100644 index 0000000..44893c4 --- /dev/null +++ b/src/app/api/notice/route.ts @@ -0,0 +1,29 @@ +import { get } from "@vercel/edge-config"; + +const NOTICE_KEY = + process.env.NEXT_PUBLIC_NOTICE_KEY ?? "global_notice_message"; + +export const dynamic = "force-static"; +export const revalidate = 60; + +export async function GET() { + let message: string | null = null; + + try { + const value = await get(NOTICE_KEY); + if (typeof value === "string" && value.trim().length > 0) { + message = value.trim(); + } + } catch { + // Fail silently if Edge Config is unavailable or misconfigured. + } + + return Response.json( + { message }, + { + headers: { + "Cache-Control": "s-maxage=60, stale-while-revalidate", + }, + }, + ); +} diff --git a/src/app/api/refresh/[slug]/route.ts b/src/app/api/refresh/[slug]/route.ts index 82f1352..9f09e74 100644 --- a/src/app/api/refresh/[slug]/route.ts +++ b/src/app/api/refresh/[slug]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { revalidateTag } from "next/cache"; +import { revalidatePath, revalidateTag } from "next/cache"; import { checkUserRoles } from "@/utils/user"; import { createClient } from "@/utils/supabase/server"; @@ -17,5 +17,6 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ slug revalidateTag(`hack:${slug}:metadata`); revalidateTag(`hack:${slug}:downloads`); + revalidatePath(`/hack/${slug}`); return NextResponse.redirect(new URL(`/hack/${slug}`, req.url)); } \ No newline at end of file diff --git a/src/app/hack/[slug]/hack-page-shared.ts b/src/app/hack/[slug]/hack-page-shared.ts new file mode 100644 index 0000000..163c39c --- /dev/null +++ b/src/app/hack/[slug]/hack-page-shared.ts @@ -0,0 +1,93 @@ +import { getHackMetadata } from "@/app/hack/[slug]/actions"; +import { baseRoms } from "@/data/baseRoms"; +import { isArchiveHack } from "@/utils/hack"; +import type { Metadata } from "next"; + +export interface HackDetailPageProps { + params: Promise<{ slug: string }>; +} + +export function getHackPageUrl(slug: string) { + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, ""); + return siteUrl ? `${siteUrl}/hack/${slug}` : `/hack/${slug}`; +} + +export async function getHackPageMetadata( + slug: string, + includeRestricted: boolean, +): Promise { + const metadata = await getHackMetadata(slug); + if ( + !metadata || + (!includeRestricted && + (!metadata.hack.approved || isArchiveHack(metadata.hack))) + ) { + return { title: "Hack not found" }; + } + + const { hack, profile } = metadata; + const author = hack.original_author + ? hack.original_author + : profile?.username + ? `@${profile.username}` + : undefined; + + if (!hack.approved) { + return { + title: hack.title, + description: "This hack is pending approval by an admin.", + }; + } + + const isArchive = isArchiveHack(hack); + const baseRomName = + baseRoms.find((rom) => rom.id === hack.base_rom)?.name ?? "Pokémon"; + const pageUrl = getHackPageUrl(slug); + const title = isArchive + ? `${hack.title} | Archive` + : `${hack.title} | ROM hack download`; + const description = isArchive + ? `Archive entry for ${hack.title}, a fan-made ROM hack for ${baseRomName}. ${hack.summary}` + : `Play ${hack.title}, a fan-made ROM hack for ${baseRomName}. ${hack.summary}`; + + return { + title, + description, + keywords: [ + hack.title, + `${hack.title} rom hack`, + `${hack.title} patch`, + `${hack.title} patcher`, + `${hack.title} patched rom`, + `${hack.title} rom download`, + `${hack.title} download patch`, + baseRomName, + "Pokemon rom hack", + "Pokemon patch file", + `${baseRomName} rom hack`, + `${baseRomName} patch file`, + ], + alternates: { + canonical: pageUrl, + }, + openGraph: { + title, + description, + url: pageUrl, + authors: author ? [author] : undefined, + type: "article", + publishedTime: new Date(hack.created_at).toISOString(), + modifiedTime: hack.updated_at + ? new Date(hack.updated_at).toISOString() + : undefined, + images: hack.box_art + ? [ + { + url: hack.box_art, + alt: `${hack.title} ROM hack box art`, + }, + ] + : undefined, + }, + }; +} diff --git a/src/app/hack/[slug]/page.tsx b/src/app/hack/[slug]/page.tsx index 34393cd..63a8ad4 100644 --- a/src/app/hack/[slug]/page.tsx +++ b/src/app/hack/[slug]/page.tsx @@ -1,623 +1,58 @@ -import { baseRoms, PLATFORM_NAMES } from "@/data/baseRoms"; -import type { Metadata } from "next"; -import Link from "next/link"; +import { getHackDownloads, getHackMetadata } from "@/app/hack/[slug]/actions"; +import { + getHackPageMetadata, + type HackDetailPageProps, +} from "@/app/hack/[slug]/hack-page-shared"; +import HackDetailView from "@/components/Hack/HackDetailView"; +import { createServiceClient } from "@/utils/supabase/server"; +import { isArchiveHack } from "@/utils/hack"; import { notFound } from "next/navigation"; -import Gallery from "@/components/Hack/Gallery"; -import HackActions from "@/components/Hack/HackActions"; -import Markdown from "@/components/Markdown/Markdown"; -import Image from "next/image"; -import { FaDiscord, FaTwitter, FaGithub, FaTriangleExclamation, FaArrowUpRightFromSquare } from "react-icons/fa6"; -import { FiAlertTriangle, FiInfo, FiMail } from "react-icons/fi"; -import PokeCommunityIcon from "@/components/Icons/PokeCommunityIcon"; -import { createClient, createServiceClient } from "@/utils/supabase/server"; -import HackOptionsMenu from "@/components/Hack/HackOptionsMenu"; -import DownloadsBadge from "@/components/Hack/DownloadsBadge"; -import HackShareButton from "@/components/Hack/HackShareButton"; -import type { CreativeWork, WithContext } from "schema-dts"; -import serialize from "serialize-javascript"; -import { headers } from "next/headers"; -import { MenuItem } from "@headlessui/react"; -import { FaCircleCheck } from "react-icons/fa6"; -import { RiArchiveStackFill } from "react-icons/ri"; -import { TbProgressCheck } from "react-icons/tb"; -import { isArchiveHack, checkEditPermission, checkPatchEditPermission } from "@/utils/hack"; -import Avatar from "@/components/Account/Avatar"; -import CollapsibleCard from "@/components/Primitives/CollapsibleCard"; -import CollapsibleTags from "@/components/Hack/CollapsibleTags"; -import { getHackMetadata, getHackDownloads } from "@/app/hack/[slug]/actions"; -import CreateReviewThreadMenuItem from "@/components/Hack/CreateReviewThreadMenuItem"; -import { getHackReviewThread } from "@/utils/hack-review"; - -interface HackDetailProps { - params: Promise<{ slug: string }>; -} +export const dynamic = "error"; export async function generateStaticParams() { + if ( + !process.env.NEXT_PUBLIC_SUPABASE_URL || + !process.env.SUPABASE_SECRET_KEY + ) { + return []; + } + const supabase = await createServiceClient(); const { data: hacks } = await supabase .from("hacks") .select("slug") .eq("approved", true) + .eq("is_archive", false) .order("downloads", { ascending: false }) - .limit(100); // Pre-render top 100 most popular hacks + .limit(750); - return (hacks || []).map((hack) => ({ - slug: hack.slug, - })); + return (hacks || []).map(({ slug }) => ({ slug })); } -export async function generateMetadata({ params }: HackDetailProps): Promise { +export async function generateMetadata({ params }: HackDetailPageProps) { + const { slug } = await params; + return getHackPageMetadata(slug, false); +} + +export default async function HackDetail({ params }: HackDetailPageProps) { const { slug } = await params; const metadata = await getHackMetadata(slug); - if (!metadata) return { title: "Hack not found" }; - const { hack, profile } = metadata; - const author = hack.original_author ? hack.original_author : (profile?.username ? `@${profile.username}` : undefined); - - if (!hack.approved) return { - title: hack.title, - description: 'This hack is pending approval by an admin.', - } satisfies Metadata; - - const isArchive = isArchiveHack(hack); - const baseRomName = baseRoms.find((r) => r.id === hack.base_rom)?.name ?? "Pokémon"; - const pageUrl = `/hack/${slug}`; - const title = isArchive ? `${hack.title} | Archive` : `${hack.title} | ROM hack download`; - const description = isArchive - ? `Archive entry for ${hack.title}, a fan-made ROM hack for ${baseRomName}. ${hack.summary}` - : `Play ${hack.title}, a fan-made ROM hack for ${baseRomName}. ${hack.summary}`; - - const keywords: string[] = [ - hack.title, - `${hack.title} rom hack`, - `${hack.title} patch`, - `${hack.title} patcher`, - `${hack.title} patched rom`, - `${hack.title} rom download`, - `${hack.title} download patch`, - baseRomName, - "Pokemon rom hack", - "Pokemon patch file", - `${baseRomName} rom hack`, - `${baseRomName} patch file`, - ]; - - return { - title, - description, - keywords, - alternates: { - canonical: pageUrl, - }, - openGraph: { - title, - description, - url: pageUrl, - authors: author ? [author] : undefined, - type: "article", - publishedTime: new Date(hack.created_at).toISOString(), - modifiedTime: hack.updated_at ? new Date(hack.updated_at).toISOString() : undefined, - images: hack.box_art ? [ - { - url: hack.box_art, - alt: `${hack.title} ROM hack box art`, - }, - ] : undefined, - }, - } satisfies Metadata; -} - -export default async function HackDetail({ params }: HackDetailProps) { - const { slug } = await params; - const [metadata, downloads] = await Promise.all([ - getHackMetadata(slug), - getHackDownloads(slug), - ]); - - if (!metadata) return notFound(); - - const { hack, images, tags, profile, otherHacks, patch, displayVersion } = metadata; - const baseRom = baseRoms.find((r) => r.id === hack.base_rom); - const author = hack.original_author ? hack.original_author : (profile?.username ? `@${profile.username}` : "Unknown"); - - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - const { - canEdit, - canEditAsArchiver, - isInformationalArchive, - isDownloadableArchive, - isArchive, - } = await checkEditPermission(hack, user?.id as string, supabase); - const { - canEdit: canUploadPatch, - } = await checkPatchEditPermission(hack, user?.id as string, supabase); - - let isAdmin = false; - const { data: admin } = await supabase.rpc("is_admin"); - if (admin) { - isAdmin = true; - } else if (!hack.approved || isArchive) { - if (isArchive && !canEditAsArchiver) { - return notFound(); - } else if (!canEdit) { - return notFound(); - } + // Archive entries are currently available only through the signed-in route. + if (!metadata || !metadata.hack.approved || isArchiveHack(metadata.hack)) { + notFound(); } - const hasReviewThread = - isAdmin && !isArchive - ? Boolean(await getHackReviewThread(hack.slug)) - : false; - - // Extract patch info from cached metadata - const patchFilename = patch?.filename || null; - const patchVersion = displayVersion; - const patchId = patch?.id || null; - const lastUpdated = patch ? new Date(patch.created_at).toLocaleDateString() : null; - const patchCreatedAt = patch?.created_at || null; - const patchChangelog = patch?.changelog || null; - const hasMissingPatch = !hack.approved && patchId === null; - const hasMissingScreenshots = !hack.approved && images.length === 0; - - // Build canonical URL, sameAs, dates, and JSON-LD - const hdrs = await headers(); - const siteBase = process.env.NEXT_PUBLIC_SITE_URL ? process.env.NEXT_PUBLIC_SITE_URL.replace(/\/$/, "") : ""; - const proto = siteBase ? "" : (hdrs.get("x-forwarded-proto") || "https"); - const host = siteBase ? "" : (hdrs.get("host") || ""); - const baseUrl = siteBase || (proto && host ? `${proto}://${host}` : ""); - const pageUrl = baseUrl ? `${baseUrl}/hack/${hack.slug}` : `/hack/${hack.slug}`; - - const authorName = hack.original_author || profile?.username || "Unknown"; - - const sameAs: string[] = []; - const social = hack.social_links as { discord?: string; twitter?: string; pokecommunity?: string; github?: string } | null; - if (social?.discord) sameAs.push(social.discord); - if (social?.twitter) sameAs.push(social.twitter); - if (social?.pokecommunity) sameAs.push(social.pokecommunity); - - const dateCreated = new Date(hack.created_at).toISOString(); - const modifiedRaw = patchCreatedAt || (hack.updated_at as string) || (hack.created_at as string); - const dateModified = new Date(modifiedRaw).toISOString(); - // Add common tags to keywords - const commonTags = ["Pokémon", "ROM Hack", "Patch", "BPS", "Romhack", "Pokemon", "Mod", "Game", "Hack"]; - if (baseRom) commonTags.push(PLATFORM_NAMES[baseRom.platform], baseRom.platform, baseRom.name); - const keywords = tags.length ? [...tags, ...commonTags] : commonTags; - - const jsonLd: WithContext = { - '@context': 'https://schema.org', - '@type': 'CreativeWork', - name: hack.title, - description: hack.summary || undefined, - url: pageUrl || undefined, - mainEntityOfPage: pageUrl || undefined, - image: images.length ? images : undefined, - thumbnailUrl: images.length ? images[0] : hack.box_art || undefined, - author: { '@type': 'Person', name: authorName }, - sameAs: sameAs.length ? sameAs : undefined, - genre: "Game Mod", - dateCreated, - dateModified, - keywords: keywords, - version: patchVersion || undefined, - inLanguage: 'en', - isAccessibleForFree: true, - isBasedOn: baseRom ? { - '@type': 'VideoGame', - name: baseRom.name, - gamePlatform: PLATFORM_NAMES[baseRom.platform], - } : undefined, - }; + const downloads = await getHackDownloads(slug); return ( -
- {/* Honeypot links - hidden from users and screen readers */} -