Implement patcher version server actions

This commit is contained in:
Jared Schoeny 2026-06-19 23:01:25 -06:00
parent 1a479a324b
commit 5ff983f417
5 changed files with 200 additions and 17 deletions

View File

@ -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<HackMetadata | null> {
@ -160,6 +166,8 @@ export async function getHackMetadata(slug: string): Promise<HackMetadata | null
}
}
const { selectablePatches, defaultPatchId } = await getPatcherSelectablePatches(supabase, slug, hack.current_patch);
return {
hack,
images,
@ -172,6 +180,10 @@ export async function getHackMetadata(slug: string): Promise<HackMetadata | null
} : null,
otherHacks,
patch,
patcherSelector: {
selectablePatches,
defaultPatchId,
}
};
},
[`hack:${slug}:metadata`],
@ -207,7 +219,12 @@ export async function getHackDownloads(slug: string): Promise<number | null> {
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 };
}

View File

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

11
src/types/patcher.ts Normal file
View File

@ -0,0 +1,11 @@
export interface SelectablePatch {
id: number;
version: string;
created_at: string;
};
export interface PatcherPatchSelection {
savedPatchIds: number[];
selectablePatches: SelectablePatch[];
defaultPatchId: number | null;
};

View File

@ -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<Database>,
slug: string,
currentPatchId: number | null,
): Promise<PatcherPatchSelection> {
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,
};
}

View File

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