Use public get urls for hack cover images

This commit is contained in:
Jared Schoeny 2025-12-15 16:30:24 -10:00
parent 3888e9e26d
commit 0b355fd93c
7 changed files with 40 additions and 31 deletions

View File

@ -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 repositorys 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`)
### S3compatible storage for patches
### S3compatible 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

View File

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

View File

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

View File

@ -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();

View File

@ -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<string, string[]>();
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<string, string>();
coverKeys.forEach((key, idx) => {
urlToSignedUrl.set(key, signedUrls[idx]);

View File

@ -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<string, string[]>();
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<string, string>();
coverKeys.forEach((key, idx) => {

View File

@ -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}`);
}