From 6241eeace638af158090349bf4b675567aebc451 Mon Sep 17 00:00:00 2001 From: Jared Schoeny Date: Wed, 3 Dec 2025 21:11:42 -1000 Subject: [PATCH] Move hack cover storage to S3 --- src/app/hack/[slug]/edit/page.tsx | 6 +-- src/app/hack/[slug]/page.tsx | 8 +--- src/app/hack/actions.ts | 53 +++++++++++++++++++-- src/app/page.tsx | 33 ++++++------- src/components/Discover/DiscoverBrowser.tsx | 35 +++++++------- src/components/Hack/HackEditForm.tsx | 12 ++--- src/components/Hack/HackSubmitForm.tsx | 6 ++- src/utils/minio/server.ts | 1 + 8 files changed, 96 insertions(+), 58 deletions(-) diff --git a/src/app/hack/[slug]/edit/page.tsx b/src/app/hack/[slug]/edit/page.tsx index 04433aa..1a05d79 100644 --- a/src/app/hack/[slug]/edit/page.tsx +++ b/src/app/hack/[slug]/edit/page.tsx @@ -3,6 +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"; interface EditPageProps { params: Promise<{ slug: string }>; @@ -46,10 +47,7 @@ export default async function EditHackPage({ params }: EditPageProps) { .order("position", { ascending: true }); if (covers && covers.length > 0) { coverKeys = covers.map((c: any) => c.url); - const { data: urls } = await supabase.storage - .from('hack-covers') - .createSignedUrls(coverKeys, 60 * 5); - if (urls) signedCoverUrls = urls.map((u) => u.signedUrl); + signedCoverUrls = await getCoverSignedUrls(coverKeys); } const { data: tagRows } = await supabase diff --git a/src/app/hack/[slug]/page.tsx b/src/app/hack/[slug]/page.tsx index fabc18a..356f40e 100644 --- a/src/app/hack/[slug]/page.tsx +++ b/src/app/hack/[slug]/page.tsx @@ -19,6 +19,7 @@ import { MenuItem } from "@headlessui/react"; import { FaCircleCheck } from "react-icons/fa6"; import { sortOrderedTags } from "@/utils/format"; import { FaArchive } from "react-icons/fa"; +import { getCoverSignedUrls } from "@/app/hack/actions"; interface HackDetailProps { params: Promise<{ slug: string }>; @@ -131,12 +132,7 @@ export default async function HackDetail({ params }: HackDetailProps) { .eq("hack_slug", slug) .order("position", { ascending: true }); if (covers && covers.length > 0) { - const { data: imagesData } = await supabase.storage - .from('hack-covers') - .createSignedUrls(covers.map(c => c.url), 60 * 5); - if (imagesData) { - images = imagesData.map(d => d.signedUrl); - } + images = await getCoverSignedUrls(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 c220df8..3ac49c7 100644 --- a/src/app/hack/actions.ts +++ b/src/app/hack/actions.ts @@ -2,7 +2,7 @@ import { createClient } from "@/utils/supabase/server"; import type { TablesInsert } from "@/types/db"; -import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server"; +import { getMinioClient, PATCHES_BUCKET, COVERS_BUCKET } from "@/utils/minio/server"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { APIEmbed } from "discord-api-types/v10"; @@ -158,8 +158,15 @@ export async function saveHackCovers(args: { slug: string; coverUrls: string[] } .eq("hack_slug", args.slug) .in("url", toRemove); if (delErr) return { ok: false, error: delErr.message } as const; - // Best-effort removal of orphaned files - await supabase.storage.from('hack-covers').remove(toRemove); + // Best-effort removal of orphaned files from S3 + const client = getMinioClient(); + for (const key of toRemove) { + try { + await client.removeObject(COVERS_BUCKET, key); + } catch (e) { + // Ignore errors - best effort cleanup + } + } } // Upsert desired rows (insert new and update existing positions/alts) @@ -227,6 +234,46 @@ export async function presignNewPatchVersion(args: { slug: string; version: stri return { ok: true, presignedUrl: url, objectKey } as const; } +export async function presignCoverUpload(args: { slug: string; objectKey: string }) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { ok: false, error: "Unauthorized" } as const; + + // Ensure hack exists and belongs to user + const { data: hack, error: hErr } = await supabase + .from("hacks") + .select("slug, created_by") + .eq("slug", args.slug) + .maybeSingle(); + if (hErr) return { ok: false, error: hErr.message } as const; + if (!hack) return { ok: false, error: "Hack not found" } as const; + if (hack.created_by !== user.id) return { ok: false, error: "Forbidden" } as const; + + const client = getMinioClient(); + // 10 minutes to upload + const url = await client.presignedPutObject(COVERS_BUCKET, args.objectKey, 60 * 10); + + 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(); const { diff --git a/src/app/page.tsx b/src/app/page.tsx index 3071363..be28703 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,6 +5,7 @@ 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"; export const metadata: Metadata = { alternates: { @@ -38,25 +39,21 @@ export default async function Home() { .order("position", { ascending: true }); const coversBySlug = new Map(); - if (coverRows && coverRows.length > 0) { - const { data: imagesData } = await supabase.storage - .from("hack-covers") - .createSignedUrls(coverRows.map((c) => c.url), 60 * 5); - if (imagesData) { - const urlToSignedUrl = new Map(); - imagesData.forEach((d, idx) => { - if (d.signedUrl) urlToSignedUrl.set(coverRows[idx].url, d.signedUrl); - }); + if (coverRows && coverRows.length > 0) { const coverKeys = coverRows.map((c) => c.url); + const signedUrls = await getCoverSignedUrls(coverKeys); + const urlToSignedUrl = new Map(); + coverKeys.forEach((key, idx) => { + urlToSignedUrl.set(key, signedUrls[idx]); + }); - coverRows.forEach((c) => { - const arr = coversBySlug.get(c.hack_slug) || []; - const signed = urlToSignedUrl.get(c.url); - if (signed) { - arr.push(signed); - coversBySlug.set(c.hack_slug, arr); - } - }); - } + coverRows.forEach((c) => { + const arr = coversBySlug.get(c.hack_slug) || []; + const signed = urlToSignedUrl.get(c.url); + if (signed) { + arr.push(signed); + coversBySlug.set(c.hack_slug, arr); + } + }); } // Fetch tags diff --git a/src/components/Discover/DiscoverBrowser.tsx b/src/components/Discover/DiscoverBrowser.tsx index cb0b803..66d855f 100644 --- a/src/components/Discover/DiscoverBrowser.tsx +++ b/src/components/Discover/DiscoverBrowser.tsx @@ -12,6 +12,7 @@ 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"; export default function DiscoverBrowser() { @@ -66,26 +67,22 @@ export default function DiscoverBrowser() { .order("position", { ascending: true }); const coversBySlug = new Map(); if (coverRows && coverRows.length > 0) { - const { data: imagesData } = await supabase.storage - .from('hack-covers') - .createSignedUrls(coverRows.map(c => c.url), 60 * 5); - if (imagesData) { - // Map: storage object url -> signedUrl - const urlToSignedUrl = new Map(); - imagesData.forEach((d, idx) => { - // If creation fails, d.signedUrl might be undefined; filter those out - if (d.signedUrl) urlToSignedUrl.set(coverRows[idx].url, d.signedUrl); - }); + const coverKeys = coverRows.map(c => c.url); + const urls = await getCoverSignedUrls(coverKeys); + // Map: storage object url -> signedUrl + const urlToSignedUrl = new Map(); + coverKeys.forEach((key, idx) => { + if (urls[idx]) urlToSignedUrl.set(key, urls[idx]); + }); - coverRows.forEach((c) => { - const arr = coversBySlug.get(c.hack_slug) || []; - const signed = urlToSignedUrl.get(c.url); - if (signed) { - arr.push(signed); - coversBySlug.set(c.hack_slug, arr); - } - }); - } + coverRows.forEach((c) => { + const arr = coversBySlug.get(c.hack_slug) || []; + const signed = urlToSignedUrl.get(c.url); + if (signed) { + arr.push(signed); + coversBySlug.set(c.hack_slug, arr); + } + }); } const { data: tagRows } = await supabase .from("hack_tags") diff --git a/src/components/Hack/HackEditForm.tsx b/src/components/Hack/HackEditForm.tsx index c693799..e3dc085 100644 --- a/src/components/Hack/HackEditForm.tsx +++ b/src/components/Hack/HackEditForm.tsx @@ -8,8 +8,7 @@ import TagSelector from "@/components/Submit/TagSelector"; import { baseRoms } from "@/data/baseRoms"; import Image from "next/image"; import { createClient } from "@/utils/supabase/client"; -import { updateHack } from "@/app/hack/actions"; -import { saveHackCovers } from "@/app/hack/actions"; +import { updateHack, saveHackCovers, presignCoverUpload } from "@/app/hack/actions"; import SortableCovers from "@/components/Hack/SortableCovers"; interface HackEditFormProps { @@ -65,7 +64,7 @@ export default function HackEditForm({ slug, initial }: HackEditFormProps) { const [coverItems, setCoverItems] = React.useState(() => { const keys = initial.coverKeys || []; const urls = initial.signedCoverUrls || []; - return keys.map((k, i) => ({ type: "existing", key: k, url: urls[i] || supabase.storage.from('hack-covers').getPublicUrl(k).data.publicUrl })); + return keys.map((k, i) => ({ type: "existing", key: k, url: urls[i] || '' })); }); const [coversBaseline, setCoversBaseline] = React.useState<{ keys: string[]; urls: string[] }>(() => ({ keys: initial.coverKeys || [], @@ -198,8 +197,9 @@ export default function HackEditForm({ slug, initial }: HackEditFormProps) { } else { const ext = item.file.name.split('.').pop(); const path = `${slug}/${Date.now()}-${i}.${ext}`; - const { error } = await supabase.storage.from('hack-covers').upload(path, item.file); - if (error) throw error; + const presigned = await presignCoverUpload({ slug, objectKey: path }); + if (!presigned.ok) throw new Error(presigned.error || 'Failed to presign cover upload'); + await fetch(presigned.presignedUrl, { method: 'PUT', body: item.file, headers: { 'Content-Type': item.file.type || 'image/jpeg' } }); keys.push(path); } } @@ -329,7 +329,7 @@ export default function HackEditForm({ slug, initial }: HackEditFormProps) {

Screenshots

{coversChanged && ( - )} diff --git a/src/components/Hack/HackSubmitForm.tsx b/src/components/Hack/HackSubmitForm.tsx index 8ae149d..03ba58e 100644 --- a/src/components/Hack/HackSubmitForm.tsx +++ b/src/components/Hack/HackSubmitForm.tsx @@ -6,6 +6,7 @@ import { baseRoms } from "@/data/baseRoms"; import HackCard from "@/components/HackCard"; import { createClient } from "@/utils/supabase/client"; import { prepareSubmission, presignPatchAndSaveCovers, confirmPatchUpload } from "@/app/submit/actions"; +import { presignCoverUpload } from "@/app/hack/actions"; import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core"; import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; @@ -168,8 +169,9 @@ export default function HackSubmitForm({ const file = newCoverFiles[i]; const fileExt = file.name.split('.').pop(); const path = `${slug}/${Date.now()}-${i}.${fileExt}`; - const { error } = await supabase.storage.from('hack-covers').upload(path, file); - if (error) throw error; + const presigned = await presignCoverUpload({ slug, objectKey: path }); + if (!presigned.ok) throw new Error(presigned.error || 'Failed to presign cover upload'); + await fetch(presigned.presignedUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type || 'image/jpeg' } }); urls.push(path); } return urls; diff --git a/src/utils/minio/server.ts b/src/utils/minio/server.ts index 415001b..c643301 100644 --- a/src/utils/minio/server.ts +++ b/src/utils/minio/server.ts @@ -15,3 +15,4 @@ export function getMinioClient(): Client { } export const PATCHES_BUCKET = process.env.PATCHES_BUCKET!; +export const COVERS_BUCKET = process.env.COVERS_BUCKET!;