diff --git a/README.md b/README.md index 9566eec..d2604ea 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Hackdex is a community hub for discovering and sharing Pokémon romhack patches. - **Discover**: curated hacks with screenshots, tags, versions, and summaries - **Submit**: metadata, screenshots, social links, and a BPS patch file - **Patch in the browser**: Powered by [RomPatcher.js](https://github.com/marcrobledo/RomPatcher.js); linked base roms stay on the user's device -- **Safe delivery**: short-lived signed URLs for assets and downloads; no rom storage required +- **Safe delivery**: public urls for cover images, short-lived signed URLs for patch downloads and other assets; no rom storage required ## Tech stack @@ -28,7 +28,7 @@ Hackdex is a community hub for discovering and sharing Pokémon romhack patches. ## High-level architecture - UI: Next.js App Router with a mix of server and client components -- Data: Supabase tables for hacks, tags, covers, patches; signed URLs for `hack-covers` +- Data: Supabase tables for hacks, tags, patches; cover images stored in `covers` S3-compatible bucket - Patches: stored in an S3-compatible bucket `patches`; downloads use short-lived signed URLs via an API route - Auth: Supabase SSR helpers manage cookies; client SDK for browser calls @@ -66,6 +66,8 @@ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= NEXT_PUBLIC_SITE_URL= NEXT_PUBLIC_SITE_DOMAIN= +NEXT_PUBLIC_HACK_COVERS_DOMAIN= + S3_ENDPOINT= S3_PORT= S3_ACCESS_KEY_ID= @@ -84,12 +86,15 @@ Typical flow: 1) Initialize and start services using the CLI 2) Note the printed API URL and publishable key; set them in `.env.local` as shown above 3) Apply this repository’s migrations in `supabase/migrations` -4) Create a Supabase Storage bucket named `hack-covers` (private is fine; the app uses signed URLs) +4) Create a Supabase Storage bucket named `hack-covers` and make it public. Set the `NEXT_PUBLIC_HACK_COVERS_DOMAIN` environment variable to the public URL of your covers bucket (e.g., `https://your-project.supabase.co/storage/v1/object/public/hack-covers`) -### S3‑compatible storage for patches +### S3‑compatible storage for patches and covers -- Create a bucket named `patches` +- Create a bucket named `patches` and a public bucket named `covers` - Point the `S3_*` environment variables to your S3 endpoint (Minio locally or your vendor) +- Set the `COVERS_BUCKET` environment variable to the name of your covers bucket (e.g., `covers`) +- Set the `PATCHES_BUCKET` environment variable to the name of your patches bucket (e.g., `patches`) +- Set the `NEXT_PUBLIC_HACK_COVERS_DOMAIN` environment variable to the public URL of your covers bucket (e.g., `http://localhost:9000/covers`) ### Install & run diff --git a/src/app/hack/[slug]/edit/page.tsx b/src/app/hack/[slug]/edit/page.tsx index f29d0da..b17fba0 100644 --- a/src/app/hack/[slug]/edit/page.tsx +++ b/src/app/hack/[slug]/edit/page.tsx @@ -3,7 +3,7 @@ import HackForm from "@/components/Hack/HackForm"; import { createClient } from "@/utils/supabase/server"; import { FaChevronLeft, FaChevronRight } from "react-icons/fa6"; import Link from "next/link"; -import { getCoverSignedUrls } from "@/app/hack/actions"; +import { getCoverUrls } from "@/utils/format"; import { checkEditPermission } from "@/utils/hack"; interface EditPageProps { @@ -42,7 +42,7 @@ export default async function EditHackPage({ params }: EditPageProps) { .order("position", { ascending: true }); if (covers && covers.length > 0) { coverKeys = covers.map((c: any) => c.url); - signedCoverUrls = await getCoverSignedUrls(coverKeys); + signedCoverUrls = getCoverUrls(coverKeys); } const { data: tagRows } = await supabase diff --git a/src/app/hack/[slug]/page.tsx b/src/app/hack/[slug]/page.tsx index 660ff63..8db968f 100644 --- a/src/app/hack/[slug]/page.tsx +++ b/src/app/hack/[slug]/page.tsx @@ -18,9 +18,8 @@ import serialize from "serialize-javascript"; import { headers } from "next/headers"; import { MenuItem } from "@headlessui/react"; import { FaCircleCheck } from "react-icons/fa6"; -import { sortOrderedTags } from "@/utils/format"; +import { sortOrderedTags, getCoverUrls } from "@/utils/format"; import { RiArchiveStackFill } from "react-icons/ri"; -import { getCoverSignedUrls } from "@/app/hack/actions"; import { isInformationalArchiveHack, isDownloadableArchiveHack, isArchiveHack, checkEditPermission } from "@/utils/hack"; import Avatar from "@/components/Account/Avatar"; @@ -132,7 +131,7 @@ export default async function HackDetail({ params }: HackDetailProps) { .eq("hack_slug", slug) .order("position", { ascending: true }); if (covers && covers.length > 0) { - images = await getCoverSignedUrls(covers.map(c => c.url)); + images = getCoverUrls(covers.map(c => c.url)); } const { data: tagRows } = await supabase diff --git a/src/app/hack/actions.ts b/src/app/hack/actions.ts index dc75a76..e98ae0d 100644 --- a/src/app/hack/actions.ts +++ b/src/app/hack/actions.ts @@ -265,21 +265,6 @@ export async function presignCoverUpload(args: { slug: string; objectKey: string return { ok: true, presignedUrl: url } as const; } -export async function getCoverSignedUrl(objectKey: string) { - const client = getMinioClient(); - // 5 minutes expiry for viewing - const url = await client.presignedGetObject(COVERS_BUCKET, objectKey, 60 * 5); - return url; -} - -export async function getCoverSignedUrls(objectKeys: string[]) { - const client = getMinioClient(); - // 5 minutes expiry for viewing - const urls = await Promise.all( - objectKeys.map(key => client.presignedGetObject(COVERS_BUCKET, key, 60 * 5)) - ); - return urls; -} export async function approveHack(slug: string) { const supabase = await createClient(); diff --git a/src/app/page.tsx b/src/app/page.tsx index 42288e8..0ebda2b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,8 +4,7 @@ import { FaArrowRightLong } from "react-icons/fa6"; import { createClient } from "@/utils/supabase/server"; import HackCard from "@/components/HackCard"; import Button from "@/components/Button"; -import { sortOrderedTags } from "@/utils/format"; -import { getCoverSignedUrls } from "@/app/hack/actions"; +import { sortOrderedTags, getCoverUrls } from "@/utils/format"; import { HackCardAttributes } from "@/components/HackCard"; export const metadata: Metadata = { @@ -42,7 +41,7 @@ export default async function Home() { const coversBySlug = new Map(); if (coverRows && coverRows.length > 0) { const coverKeys = coverRows.map((c) => c.url); - const signedUrls = await getCoverSignedUrls(coverKeys); + const signedUrls = getCoverUrls(coverKeys); const urlToSignedUrl = new Map(); coverKeys.forEach((key, idx) => { urlToSignedUrl.set(key, signedUrls[idx]); diff --git a/src/components/Discover/DiscoverBrowser.tsx b/src/components/Discover/DiscoverBrowser.tsx index 7d784ae..adc9c92 100644 --- a/src/components/Discover/DiscoverBrowser.tsx +++ b/src/components/Discover/DiscoverBrowser.tsx @@ -11,8 +11,7 @@ import { MdTune } from "react-icons/md"; import { BsSdCardFill } from "react-icons/bs"; import { CATEGORY_ICONS } from "@/components/Icons/tagCategories"; import { useBaseRoms } from "@/contexts/BaseRomContext"; -import { sortOrderedTags, OrderedTag } from "@/utils/format"; -import { getCoverSignedUrls } from "@/app/hack/actions"; +import { sortOrderedTags, OrderedTag, getCoverUrls } from "@/utils/format"; import { HackCardAttributes } from "@/components/HackCard"; const HACKS_PER_PAGE = 9; @@ -82,7 +81,7 @@ export default function DiscoverBrowser() { const coversBySlug = new Map(); if (coverRows && coverRows.length > 0) { const coverKeys = coverRows.map(c => c.url); - const urls = await getCoverSignedUrls(coverKeys); + const urls = getCoverUrls(coverKeys); // Map: storage object url -> signedUrl const urlToSignedUrl = new Map(); coverKeys.forEach((key, idx) => { diff --git a/src/utils/format.ts b/src/utils/format.ts index 1508b9f..41db23f 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -31,3 +31,25 @@ export function slugify(text: string) { .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } + +/** + * Get the public URL for a single cover image + */ +export function getCoverUrl(objectKey: string): string { + const domain = process.env.NEXT_PUBLIC_HACK_COVERS_DOMAIN; + if (!domain) { + throw new Error("NEXT_PUBLIC_HACK_COVERS_DOMAIN environment variable is not set"); + } + return `${domain}/${objectKey}`; +} + +/** + * Get public URLs for multiple cover images + */ +export function getCoverUrls(objectKeys: string[]): string[] { + const domain = process.env.NEXT_PUBLIC_HACK_COVERS_DOMAIN; + if (!domain) { + throw new Error("NEXT_PUBLIC_HACK_COVERS_DOMAIN environment variable is not set"); + } + return objectKeys.map(key => `${domain}/${key}`); +}