Move hack cover storage to S3

This commit is contained in:
Jared Schoeny
2025-12-03 21:11:42 -10:00
parent 18e83b58c4
commit 6241eeace6
8 changed files with 96 additions and 58 deletions

View File

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

View File

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

View File

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

View File

@@ -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<string, string[]>();
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<string, string>();
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<string, string>();
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

View File

@@ -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<string, string[]>();
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<string, string>();
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<string, string>();
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")

View File

@@ -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<CoverItem[]>(() => {
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) {
<h2 className="text-xl font-semibold tracking-tight">Screenshots</h2>
<div className="flex items-center gap-2">
{coversChanged && (
<button type="button" onClick={() => setCoverItems(coversBaseline.keys.map((k, i) => ({ type: 'existing' as const, key: k, url: coversBaseline.urls[i] || supabase.storage.from('hack-covers').getPublicUrl(k).data.publicUrl })))} className="inline-flex h-8 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 text-[12px] cursor-pointer">
<button type="button" onClick={() => setCoverItems(coversBaseline.keys.map((k, i) => ({ type: 'existing' as const, key: k, url: coversBaseline.urls[i] || '' })))} className="inline-flex h-8 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 text-[12px] cursor-pointer">
Revert
</button>
)}

View File

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

View File

@@ -15,3 +15,4 @@ export function getMinioClient(): Client {
}
export const PATCHES_BUCKET = process.env.PATCHES_BUCKET!;
export const COVERS_BUCKET = process.env.COVERS_BUCKET!;