diff --git a/src/app/hack/[slug]/actions.ts b/src/app/hack/[slug]/actions.ts index 44e6503..c5ae272 100644 --- a/src/app/hack/[slug]/actions.ts +++ b/src/app/hack/[slug]/actions.ts @@ -11,6 +11,8 @@ import { revalidatePath, revalidateTag } from "next/cache"; import { unstable_cache as cache } from "next/cache"; import { sortOrderedTags, getCoverUrls } from "@/utils/format"; import { Database, Constants } from "@/types/db"; +import { getPatcherSelectablePatches } from "@/utils/patches/patcher-selectable-patches"; +import type { SelectablePatch } from "@/types/patcher"; const PATCHES_DOWNLOAD_PERMISSION_VALUES = Constants.public.Enums[ "Patches Download Permission" @@ -57,6 +59,10 @@ export interface HackMetadata { created_at: string; changelog: string | null; } | null; + patcherSelector: { + selectablePatches: SelectablePatch[]; + defaultPatchId: number | null; + }; } export async function getHackMetadata(slug: string): Promise { @@ -160,6 +166,8 @@ export async function getHackMetadata(slug: string): Promise { return runner(); } -export async function getSignedPatchUrl(slug: string): Promise<{ ok: true; url: string } | { ok: false; error: string }> { +export async function getSignedPatchUrl( + slug: string, + options?: { + patchId?: number; + } +): Promise<{ ok: true; url: string } | { ok: false; error: string }> { const supabase = await createClient(); // Get user for permission check @@ -243,16 +260,20 @@ export async function getSignedPatchUrl(slug: string): Promise<{ ok: true; url: return { ok: false, error: "Archive hacks do not have patch files available" }; } - // Check if patch exists - if (hack.current_patch == null) { - return { ok: false, error: "No patch available" }; + // Get selectable patches and validate selected patch id + const { selectablePatches } = await getPatcherSelectablePatches(supabase, slug, hack.current_patch); + const allowedPatchIds = new Set(selectablePatches.map((patch) => patch.id)); + const selectedPatchId = options?.patchId ?? hack.current_patch; + + if (selectedPatchId === null || !allowedPatchIds.has(selectedPatchId)) { + return { ok: false, error: "Patch not available" }; } // Fetch patch info const { data: patch, error: patchError } = await supabase .from("patches") .select("id, bucket, filename") - .eq("id", hack.current_patch as number) + .eq("id", selectedPatchId) .maybeSingle(); if (patchError || !patch) { @@ -461,7 +482,9 @@ export async function getPatchDownloadUrl(patchId: number): Promise<{ ok: true; return { ok: false, error: "Unauthorized" }; } if (permission === "Current") { - if (hack.current_patch == null || patch.id !== hack.current_patch) { + const { selectablePatches } = await getPatcherSelectablePatches(supabase, hack.slug, hack.current_patch); + const allowedPatchIds = new Set(selectablePatches.map((patch) => patch.id)); + if (hack.current_patch == null && !allowedPatchIds.has(patch.id)) { return { ok: false, error: "Unauthorized" }; } } @@ -815,20 +838,29 @@ export async function publishPatchVersion(slug: string, patchId: number): Promis return { ok: false, error: "Patch not found" }; } - // Check if this patch is newer than current_patch + // Check if hack has any patches in hack_patcher_patches + const { data: patcherPatches, error: ppErr } = await supabase + .from("hack_patcher_patches") + .select("patch_id") + .eq("hack_slug", slug); + if (ppErr) return { ok: false, error: ppErr.message }; + + // Check if this patch is newer than current_patch, but only if there are no patcher patches let willBecomeCurrent = false; const serviceClient = await createServiceClient(); - if (hack.current_patch) { - const { data: currentPatch } = await serviceClient - .from("patches") - .select("created_at") - .eq("id", hack.current_patch) - .maybeSingle(); - if (currentPatch && new Date(patch.created_at) > new Date(currentPatch.created_at)) { + if (patcherPatches.length === 0) { + if (hack.current_patch) { + const { data: currentPatch } = await serviceClient + .from("patches") + .select("created_at") + .eq("id", hack.current_patch) + .maybeSingle(); + if (currentPatch && new Date(patch.created_at) > new Date(currentPatch.created_at)) { + willBecomeCurrent = true; + } + } else { willBecomeCurrent = true; } - } else { - willBecomeCurrent = true; } // Publish the patch @@ -838,7 +870,7 @@ export async function publishPatchVersion(slug: string, patchId: number): Promis .eq("id", patchId); if (updateErr) return { ok: false, error: updateErr.message }; - // If newer than current_patch, update current_patch + // If newer than current_patch and no patcher patches, update current_patch if (willBecomeCurrent) { const { error: updateHackErr } = await supabase .from("hacks") @@ -945,3 +977,60 @@ export async function confirmReuploadPatchVersion( return { ok: true }; } +export async function updatePatcherSelectablePatches( + slug: string, + patchIds: number[], +): Promise<{ ok: true } | { ok: false; error: string }> { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return { ok: false, error: "Unauthorized" }; + + // Fetch hack and verify permissions + const { data: hack, error: hErr } = await supabase + .from("hacks") + .select("slug, created_by, current_patch, original_author, is_archive") + .eq("slug", slug) + .maybeSingle(); + if (hErr || !hack) return { ok: false, error: "Hack not found" }; + + // Check permissions: creator first (optimization), then admin + if (!canEditAsCreator(hack, user.id)) { + const editableAsAdmin = await canEditAsAdmin(hack, user.id, supabase); + if (!editableAsAdmin) { + return { ok: false, error: "Forbidden" }; + } + } + + // Dedupe patch ids + const uniquePatchIds = [...new Set(patchIds)]; + if (uniquePatchIds.length === 0 && hack.current_patch === null) return { ok: false, error: "No patch ids provided" }; + + // Verify patches belong to this hack + const { data: patches, error: pErr } = await supabase + .from("patches") + .select("id, parent_hack, published, archived") + .in("id", uniquePatchIds) + .eq("parent_hack", slug); + if (pErr || patches.length !== uniquePatchIds.length) return { ok: false, error: "One or more patches do not belong to this hack" }; + + // Verify patches are published + const publishedPatches = patches.filter((patch) => patch.published); + if (publishedPatches.length !== uniquePatchIds.length) return { ok: false, error: "One or more patches are not published" }; + + // Verify patches are not archived + const archivedPatches = patches.filter((patch) => patch.archived); + if (archivedPatches.length > 0) return { ok: false, error: "One or more patches are archived" }; + + // Replace patcher patches + const { error: replaceErr } = await supabase.rpc("replace_hack_patcher_patches", { + p_hack_slug: slug, + p_patch_ids: uniquePatchIds, + }); + if (replaceErr) return { ok: false, error: replaceErr.message }; + + revalidateTag(`hack:${slug}:metadata`); + revalidatePath(`/hack/${slug}`); + revalidatePath(`/hack/${slug}/versions`); + + return { ok: true }; +} diff --git a/src/types/db.ts b/src/types/db.ts index f2a3ff1..2764a7c 100644 --- a/src/types/db.ts +++ b/src/types/db.ts @@ -493,6 +493,10 @@ export type Database = { } is_archiver: { Args: never; Returns: boolean } is_claims_admin: { Args: never; Returns: boolean } + replace_hack_patcher_patches: { + Args: { p_hack_slug: string; p_patch_ids: number[] } + Returns: undefined + } set_claim: { Args: { claim: string; uid: string; value: Json } Returns: string diff --git a/src/types/patcher.ts b/src/types/patcher.ts new file mode 100644 index 0000000..655e908 --- /dev/null +++ b/src/types/patcher.ts @@ -0,0 +1,11 @@ +export interface SelectablePatch { + id: number; + version: string; + created_at: string; +}; + +export interface PatcherPatchSelection { + savedPatchIds: number[]; + selectablePatches: SelectablePatch[]; + defaultPatchId: number | null; +}; diff --git a/src/utils/patches/patcher-selectable-patches.ts b/src/utils/patches/patcher-selectable-patches.ts new file mode 100644 index 0000000..29f5bbf --- /dev/null +++ b/src/utils/patches/patcher-selectable-patches.ts @@ -0,0 +1,58 @@ +import type { Database } from "@/types/db"; +import type { PatcherPatchSelection, SelectablePatch } from "@/types/patcher"; +import type { SupabaseClient } from "@supabase/supabase-js"; + +export async function getPatcherSelectablePatches( + supabase: SupabaseClient, + slug: string, + currentPatchId: number | null, +): Promise { + const { data: rows, error } = await supabase + .from("hack_patcher_patches") + .select("patch_id, sort_order, patches!inner(id, version, created_at, published, archived)") + .eq("hack_slug", slug) + .eq("patches.published", true) + .eq("patches.archived", false) + .order("sort_order", { ascending: true }); + if (error) { + console.error(error); + return { + savedPatchIds: [], + selectablePatches: [], + defaultPatchId: null, + }; + } + const savedPatchIds = rows.map((row) => row.patch_id); + let selectablePatches: SelectablePatch[] = rows.map((row) => row.patches).flat().map((patch) => ({ + id: patch.id, + version: patch.version, + created_at: patch.created_at, + })); + if (selectablePatches.length === 0 && currentPatchId !== null) { + const { data: currentPatch, error: currentPatchError } = await supabase + .from("patches") + .select("id, version, created_at, published, archived") + .eq("id", currentPatchId) + .maybeSingle(); + if (currentPatchError) { + console.error(currentPatchError); + return { + savedPatchIds: [], + selectablePatches: [], + defaultPatchId: null, + }; + } + if (currentPatch?.published && !currentPatch?.archived) { + selectablePatches = [currentPatch]; + } + } + const defaultPatchId = (currentPatchId !== null && selectablePatches.some((patch) => patch.id === currentPatchId)) + ? currentPatchId + : selectablePatches[0]?.id ?? null; + + return { + savedPatchIds, + selectablePatches, + defaultPatchId, + }; +} diff --git a/supabase/migrations/20260526054349_replace_hack_patcher_patches_rpc.sql b/supabase/migrations/20260526054349_replace_hack_patcher_patches_rpc.sql new file mode 100644 index 0000000..6a81d2c --- /dev/null +++ b/supabase/migrations/20260526054349_replace_hack_patcher_patches_rpc.sql @@ -0,0 +1,21 @@ +create or replace function public.replace_hack_patcher_patches( + p_hack_slug text, + p_patch_ids bigint[] +) +returns void +language plpgsql +security invoker +set search_path = public +as $$ +begin + delete from public.hack_patcher_patches + where hack_slug = p_hack_slug; + + insert into public.hack_patcher_patches (hack_slug, patch_id, sort_order) + select + p_hack_slug, + patch_id, + ordinality::integer + from unnest(p_patch_ids) with ordinality as t(patch_id, ordinality); +end; +$$;