mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-22 16:44:33 -05:00
Move hack cover storage to S3
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,3 +15,4 @@ export function getMinioClient(): Client {
|
||||
}
|
||||
|
||||
export const PATCHES_BUCKET = process.env.PATCHES_BUCKET!;
|
||||
export const COVERS_BUCKET = process.env.COVERS_BUCKET!;
|
||||
|
||||
Reference in New Issue
Block a user