From f103a0cb652089e1cee4b19f5907d673508f9a55 Mon Sep 17 00:00:00 2001 From: Jared Schoeny Date: Tue, 6 Oct 2026 15:57:54 -0600 Subject: [PATCH] Hack page: let admins delete hacks, with an optional redirect (#108) --- package.json | 1 + scripts/send-announcement.mts | 2 +- scripts/send-announcement.test.mts | 1 + src/app/dashboard/archives/actions.ts | 39 +----- src/app/discover/actions.ts | 3 +- src/app/hack/[slug]/metadata.ts | 33 +++++ src/app/hack/[slug]/page.tsx | 6 +- src/app/hack/[slug]/session/page.tsx | 4 +- src/app/hack/actions.ts | 50 ++++++++ src/app/sitemap.ts | 10 +- src/components/Dashboard/ArchivesList.tsx | 7 +- src/components/Hack/DeleteHackModal.tsx | 114 ++++++++++++++++++ src/components/Hack/HackDetailView.tsx | 2 +- src/components/Hack/HackOptionsMenu.tsx | 21 +++- src/types/db.ts | 9 ++ src/utils/hackRedirect.test.ts | 29 +++++ src/utils/hackRedirect.ts | 40 ++++++ .../20261006120000_hack_soft_delete.sql | 98 +++++++++++++++ 18 files changed, 417 insertions(+), 52 deletions(-) create mode 100644 src/components/Hack/DeleteHackModal.tsx create mode 100644 src/utils/hackRedirect.test.ts create mode 100644 src/utils/hackRedirect.ts create mode 100644 supabase/migrations/20261006120000_hack_soft_delete.sql diff --git a/package.json b/package.json index 6f3da63..b544cf6 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "seed:storage": "node --env-file=.env.local --env-file=.env.development.local scripts/seed-storage.mjs", "discord:register": "node scripts/register-discord-commands.mjs", "email:announcement": "node --env-file-if-exists=.env.local --env-file-if-exists=.env.announcements scripts/send-announcement.mts", + "test": "node --test 'src/**/*.test.ts'", "test:announcement": "node --test scripts/send-announcement.test.mts", "postinstall": "patch-package" }, diff --git a/scripts/send-announcement.mts b/scripts/send-announcement.mts index 04f0062..c5b8d5b 100644 --- a/scripts/send-announcement.mts +++ b/scripts/send-announcement.mts @@ -72,7 +72,7 @@ export async function collectRecipients(supabase: SupabaseClient): Pro for (let offset = 0; ; offset += pageSize) { const { data, error } = await supabase.from("hacks") .select("slug,created_by,current_patch,patch_url,approved") - .eq("is_archive", false).order("slug").range(offset, offset + pageSize - 1); + .eq("is_archive", false).is("deleted_at", null).order("slug").range(offset, offset + pageSize - 1); if (error) throw new Error(`Cannot load hacks: ${error.message}`); if (!data) throw new Error("No response when loading hacks."); for (const hack of data) { diff --git a/scripts/send-announcement.test.mts b/scripts/send-announcement.test.mts index 23a1ba4..3e4088e 100644 --- a/scripts/send-announcement.test.mts +++ b/scripts/send-announcement.test.mts @@ -155,6 +155,7 @@ test("recipient selection paginates, excludes empty drafts, and deduplicates cre const url = new URL(new Request(input, init).url); if (url.pathname === "/rest/v1/hacks") { assert.equal(url.searchParams.get("is_archive"), "eq.false"); + assert.equal(url.searchParams.get("deleted_at"), "is.null"); const columns = new Set(["slug", "created_by", "current_patch", "patch_url", "approved"]); const unknown = url.searchParams.get("select")?.split(",").find((column) => !columns.has(column)); if (unknown) return Response.json({ message: `column hacks.${unknown} does not exist`, code: "42703" }, { status: 400 }); diff --git a/src/app/dashboard/archives/actions.ts b/src/app/dashboard/archives/actions.ts index 4fed6fc..bd631dc 100644 --- a/src/app/dashboard/archives/actions.ts +++ b/src/app/dashboard/archives/actions.ts @@ -37,6 +37,7 @@ export async function getArchives(args: { .from("hacks") .select("slug,title,original_author,base_rom,created_at,created_by,approved,permission_from,current_patch,is_archive", { count: "exact" }) .eq("is_archive", true) + .is("deleted_at", null) .order(sortBy, { ascending: sortOrder === "asc" }) .range(offset, offset + limit - 1); @@ -93,41 +94,3 @@ export async function getArchives(args: { totalPages: Math.ceil((count || 0) / limit), } as const; } - -export async function deleteArchive(slug: string) { - const supabase = await createClient(); - const { data: { user } } = await supabase.auth.getUser(); - if (!user) { - return { ok: false, error: "Unauthorized" } as const; - } - - // Only admins can delete archives - const { data: isAdmin } = await supabase.rpc("is_admin"); - if (!isAdmin) { - return { ok: false, error: "Forbidden" } as const; - } - - // Verify it's an Archive hack - const { data: hack } = await supabase - .from("hacks") - .select("slug, is_archive") - .eq("slug", slug) - .maybeSingle(); - - if (!hack) { - return { ok: false, error: "Archive not found" } as const; - } - - if (!hack.is_archive) { - return { ok: false, error: "This is not an Archive hack" } as const; - } - - // Delete the hack (cascade will handle covers and tags) - const { error: deleteError } = await supabase.from("hacks").delete().eq("slug", slug); - - if (deleteError) { - return { ok: false, error: deleteError.message } as const; - } - - return { ok: true } as const; -} diff --git a/src/app/discover/actions.ts b/src/app/discover/actions.ts index ea1d8d0..620a473 100644 --- a/src/app/discover/actions.ts +++ b/src/app/discover/actions.ts @@ -48,7 +48,8 @@ async function generateDiscoverData(): Promise { `slug,title,summary,base_rom,downloads,created_by,current_patch,custom_version_name,original_author,approved_at,is_archive,completion_status,${AI_SELECT}`, ) .eq("approved", true) - .eq("is_archive", false); + .eq("is_archive", false) + .is("deleted_at", null); if (hacksError) throw hacksError; const slugs = rows.map((row) => row.slug); diff --git a/src/app/hack/[slug]/metadata.ts b/src/app/hack/[slug]/metadata.ts index 47ce444..7a391dd 100644 --- a/src/app/hack/[slug]/metadata.ts +++ b/src/app/hack/[slug]/metadata.ts @@ -1,5 +1,6 @@ import { createServiceClient } from "@/utils/supabase/server"; import { unstable_cache as cache } from "next/cache"; +import { notFound, permanentRedirect } from "next/navigation"; import { AI_SELECT, aiDisclosureFromRow, type AiDisclosure } from "@/utils/aiDisclosure"; import { sortOrderedTags, getCoverUrls } from "@/utils/format"; import { Database } from "@/types/db"; @@ -66,6 +67,7 @@ export interface HackMetadata { }; } +/** null for deleted hacks too; pages that get null should call `hackNotFound`. */ export async function getHackMetadata(slug: string): Promise { const runner = cache( async () => { @@ -75,6 +77,7 @@ export async function getHackMetadata(slug: string): Promise { return runner(); } + +/** + * Ends a hack page render when the hack is missing. A deleted hack with a + * redirect_url sends visitors there (308); anything else 404s. + */ +export async function hackNotFound(slug: string): Promise { + const runner = cache( + async () => { + const supabase = await createServiceClient(); + const { data } = await supabase + .from("hacks") + .select("redirect_url") + .eq("slug", slug) + .not("deleted_at", "is", null) + .maybeSingle(); + return data?.redirect_url ?? null; + }, + [`hack:${slug}:redirect`], + { + revalidate: 14400, // 4 hours + // Shares the metadata tag so deleting a hack refreshes both. + tags: ["hack", `hack:${slug}:metadata`], + } + ); + + const redirectUrl = await runner(); + if (redirectUrl) permanentRedirect(redirectUrl); + notFound(); +} diff --git a/src/app/hack/[slug]/page.tsx b/src/app/hack/[slug]/page.tsx index be82311..c64f5d9 100644 --- a/src/app/hack/[slug]/page.tsx +++ b/src/app/hack/[slug]/page.tsx @@ -1,4 +1,4 @@ -import { getHackDownloads, getHackMetadata } from "@/app/hack/[slug]/metadata"; +import { getHackDownloads, getHackMetadata, hackNotFound } from "@/app/hack/[slug]/metadata"; import { getHackPageMetadata, type HackDetailPageProps, @@ -24,6 +24,7 @@ export async function generateStaticParams() { .select("slug") .eq("approved", true) .eq("is_archive", false) + .is("deleted_at", null) .order("downloads", { ascending: false }) .limit(750); @@ -38,9 +39,10 @@ export async function generateMetadata({ params }: HackDetailPageProps) { export default async function HackDetail({ params }: HackDetailPageProps) { const { slug } = await params; const metadata = await getHackMetadata(slug); + if (!metadata) return hackNotFound(slug); // Archive entries are currently available only through the signed-in route. - if (!metadata || !metadata.hack.approved || isArchiveHack(metadata.hack)) { + if (!metadata.hack.approved || isArchiveHack(metadata.hack)) { notFound(); } diff --git a/src/app/hack/[slug]/session/page.tsx b/src/app/hack/[slug]/session/page.tsx index a64c03f..26dc643 100644 --- a/src/app/hack/[slug]/session/page.tsx +++ b/src/app/hack/[slug]/session/page.tsx @@ -1,4 +1,4 @@ -import { getHackDownloads, getHackMetadata } from "@/app/hack/[slug]/metadata"; +import { getHackDownloads, getHackMetadata, hackNotFound } from "@/app/hack/[slug]/metadata"; import { getHackPageMetadata, type HackDetailPageProps, @@ -47,7 +47,7 @@ export default async function HackSessionDetail({ getHackDownloads(slug), ]); if (!metadata) { - notFound(); + return hackNotFound(slug); } if (!user) { diff --git a/src/app/hack/actions.ts b/src/app/hack/actions.ts index d2605f5..3b8280c 100644 --- a/src/app/hack/actions.ts +++ b/src/app/hack/actions.ts @@ -23,6 +23,7 @@ import { isCoverKeyFor, newPatchKey } from "@/utils/storageKeys"; import { MAX_COVERS, SUMMARY_MAX, TITLE_MAX } from "@/data/hackLimits"; import { baseRoms } from "@/data/baseRoms"; import type { PatchFormat } from "@/utils/patching"; +import { parseHackRedirect } from "@/utils/hackRedirect"; export async function updateHack(args: { slug: string; @@ -371,6 +372,7 @@ export async function approveHack(slug: string, verified?: boolean) { .from("hacks") .select("slug, approved, title, created_by, submitted_at, current_patch, is_archive") .eq("slug", slug) + .is("deleted_at", null) .maybeSingle(); if (hErr) return { ok: false, error: hErr.message } as const; if (!hack) return { ok: false, error: "Hack not found" } as const; @@ -485,6 +487,7 @@ export async function createHackReviewThread(slug: string) { .from("hacks") .select("slug, title, created_by, assigned_admin, is_archive") .eq("slug", slug) + .is("deleted_at", null) .maybeSingle(); if (hackError) return { ok: false, error: hackError.message } as const; if (!hack) return { ok: false, error: "Hack not found" } as const; @@ -540,3 +543,50 @@ export async function createHackReviewThread(slug: string) { } } + +/** + * Admin-only soft delete. The row and its files stay, but the hack is gone + * everywhere: its page 404s for everyone, or permanently redirects to + * `redirectUrl`. Restoring is a manual database edit (see the + * hack_soft_delete migration). + */ +export async function deleteHack(args: { slug: string; redirectUrl?: string }) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { ok: false, error: "Unauthorized" } as const; + + const { data: isAdmin } = await supabase.rpc("is_admin"); + if (!isAdmin) return { ok: false, error: "Forbidden" } as const; + + const target = parseHackRedirect(args.redirectUrl ?? "", args.slug); + if (!target.ok) return target; + + // The service role: RLS won't let anyone write a deleted_at. + const service = await createServiceClient(); + const { data: hack, error } = await service + .from("hacks") + .update({ + deleted_at: new Date().toISOString(), + deleted_by: user.id, + redirect_url: target.url, + }) + .eq("slug", args.slug) + .is("deleted_at", null) + .select("approved, created_by") + .maybeSingle(); + if (error) return { ok: false, error: error.message } as const; + if (!hack) return { ok: false, error: "Hack not found" } as const; + + revalidateTag(`hack:${args.slug}:metadata`); + revalidateTag(`hack:${args.slug}:downloads`); + revalidatePath(`/hack/${args.slug}`); + if (hack.approved) { + revalidateDiscoverCatalog(); + // The creator's other pages list this one under "other hacks by this creator". + const { data: siblings } = await service.from("hacks").select("slug").eq("created_by", hack.created_by).neq("slug", args.slug); + for (const { slug } of siblings ?? []) revalidateTag(`hack:${slug}:metadata`); + } + return { ok: true } as const; +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 62acbd6..d742ece 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -10,10 +10,14 @@ export default async function Sitemap(): Promise { const supabase = await createClient(); - const { data: hacks } = await supabase.from("hacks").select("slug,updated_at,approved"); + // Archive entries 404 for signed-out visitors. RLS already hides deleted hacks. + const { data: hacks } = await supabase + .from("hacks") + .select("slug,updated_at") + .eq("approved", true) + .eq("is_archive", false); if (hacks) { - const approvedHacks = hacks.filter((hack) => hack.approved); - routes.push(...approvedHacks.map((hack) => ({ + routes.push(...hacks.map((hack) => ({ url: `/hack/${hack.slug}`, changeFrequency: "weekly" as const, lastModified: hack.updated_at || new Date().toISOString(), diff --git a/src/components/Dashboard/ArchivesList.tsx b/src/components/Dashboard/ArchivesList.tsx index 79b6418..0699951 100644 --- a/src/components/Dashboard/ArchivesList.tsx +++ b/src/components/Dashboard/ArchivesList.tsx @@ -3,7 +3,8 @@ import React from "react"; import Link from "next/link"; import { FiExternalLink, FiEdit2, FiTrash2, FiChevronLeft, FiChevronRight, FiArrowDown, FiSearch, FiLoader, FiDownload, FiInfo, FiBarChart2 } from "react-icons/fi"; -import { getArchives, deleteArchive } from "@/app/dashboard/archives/actions"; +import { getArchives } from "@/app/dashboard/archives/actions"; +import { deleteHack } from "@/app/hack/actions"; import { baseRoms } from "@/data/baseRoms"; import Select from "@/components/Primitives/Select"; @@ -68,13 +69,13 @@ export default function ArchivesList({ initialData, isAdmin = false }: { initial }, [loadArchives]); async function handleDelete(slug: string) { - if (!confirm(`Are you sure you want to delete the archive "${slug}"? This action cannot be undone.`)) { + if (!confirm(`Delete the archive "${slug}"? Its page will 404 for everyone, and only a database edit can bring it back.`)) { return; } setDeletingSlug(slug); try { - const result = await deleteArchive(slug); + const result = await deleteHack({ slug }); if (!result.ok) { alert(result.error || "Failed to delete archive"); return; diff --git a/src/components/Hack/DeleteHackModal.tsx b/src/components/Hack/DeleteHackModal.tsx new file mode 100644 index 0000000..4e7a0f1 --- /dev/null +++ b/src/components/Hack/DeleteHackModal.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; +import Modal from "@/components/Primitives/Modal"; +import { deleteHack } from "@/app/hack/actions"; +import { parseHackRedirect } from "@/utils/hackRedirect"; + +interface DeleteHackModalProps { + slug: string; + onClose: () => void; +} + +/** Admin confirmation for soft-deleting a hack, with an optional redirect for its old URL. */ +export default function DeleteHackModal({ slug, onClose }: DeleteHackModalProps) { + const router = useRouter(); + const [redirectUrl, setRedirectUrl] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [isDeleting, setIsDeleting] = useState(false); + const [serverError, setServerError] = useState(null); + + const parsed = parseHackRedirect(redirectUrl, slug); + const error = (!parsed.ok && parsed.error) || serverError; + const canDelete = parsed.ok && confirmation.trim() === slug && !isDeleting; + + const handleDelete = async () => { + if (!canDelete) return; + setIsDeleting(true); + setServerError(null); + try { + const result = await deleteHack({ slug, redirectUrl }); + if (!result.ok) { + setServerError(result.error); + return; + } + toast.success("Hack deleted"); + router.push("/dashboard"); + } catch { + setServerError("Couldn't delete the hack. Try again."); + } finally { + setIsDeleting(false); + } + }; + + return ( + +
+

+ It disappears from Discover and the creator’s dashboard, and its page 404s for everyone, admins included. + Its files stay, but only a database edit can bring it back. +

+ + + + + + {error &&

{error}

} + +
+ + +
+
+
+ ); +} diff --git a/src/components/Hack/HackDetailView.tsx b/src/components/Hack/HackDetailView.tsx index a364c73..9bc3afd 100644 --- a/src/components/Hack/HackDetailView.tsx +++ b/src/components/Hack/HackDetailView.tsx @@ -301,7 +301,7 @@ export default function HackDetailView({ metadata, downloads, canEdit, canUpload {(!editing || editor.notOwner) && (
- + {isAdmin && !hack.approved && !isDraft && ( diff --git a/src/components/Hack/HackOptionsMenu.tsx b/src/components/Hack/HackOptionsMenu.tsx index deb79d9..9c50fc9 100644 --- a/src/components/Hack/HackOptionsMenu.tsx +++ b/src/components/Hack/HackOptionsMenu.tsx @@ -1,10 +1,11 @@ "use client"; import React, { useMemo, useState } from "react"; -import { FiMoreVertical, FiEdit2, FiBarChart2 } from "react-icons/fi"; +import { FiMoreVertical, FiEdit2, FiBarChart2, FiTrash2 } from "react-icons/fi"; import { TbVersions } from "react-icons/tb"; import { Menu, MenuButton, MenuItem, MenuItems, MenuSeparator } from "@headlessui/react"; import ReportModal from "@/components/Hack/ReportModal"; +import DeleteHackModal from "@/components/Hack/DeleteHackModal"; interface HackOptionsMenuProps { slug: string; @@ -12,6 +13,8 @@ interface HackOptionsMenuProps { canUploadPatch: boolean; /** Where Edit goes: the in-place editor for regular hacks, the form for archives. */ editHref: string; + /** Admins only: adds Delete at the bottom. */ + canDelete?: boolean; children?: React.ReactNode; } @@ -20,9 +23,11 @@ export default function HackOptionsMenu({ canEdit, editHref, canUploadPatch, + canDelete = false, children, }: HackOptionsMenuProps) { const [showReportModal, setShowReportModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); const hasRenderableChildren = useMemo(() => { return React.Children.toArray(children).some(Boolean); @@ -102,11 +107,25 @@ export default function HackOptionsMenu({ {children} } + {canDelete && <> + + setShowDeleteModal(true)} + className="flex items-center gap-2 w-full px-3 py-2 text-left text-sm font-medium text-error data-focus:bg-error-soft" + > + + Delete + + } {showReportModal && ( setShowReportModal(false)} /> )} + {showDeleteModal && ( + setShowDeleteModal(false)} /> + )} ); } diff --git a/src/types/db.ts b/src/types/db.ts index 0b8f1ed..db41388 100644 --- a/src/types/db.ts +++ b/src/types/db.ts @@ -225,6 +225,8 @@ export type Database = { created_by: string current_patch: number | null custom_version_name: string | null + deleted_at: string | null + deleted_by: string | null description: string downloads: number estimated_release: string | null @@ -235,6 +237,7 @@ export type Database = { patches_download_permission: Database["public"]["Enums"]["Patches Download Permission"] permission_from: string | null published: boolean + redirect_url: string | null rejected: boolean rejected_at: string | null rejected_by: string | null @@ -273,6 +276,8 @@ export type Database = { created_by: string current_patch?: number | null custom_version_name?: string | null + deleted_at?: string | null + deleted_by?: string | null description: string downloads?: number estimated_release?: string | null @@ -283,6 +288,7 @@ export type Database = { patches_download_permission?: Database["public"]["Enums"]["Patches Download Permission"] permission_from?: string | null published?: boolean + redirect_url?: string | null rejected?: boolean rejected_at?: string | null rejected_by?: string | null @@ -321,6 +327,8 @@ export type Database = { created_by?: string current_patch?: number | null custom_version_name?: string | null + deleted_at?: string | null + deleted_by?: string | null description?: string downloads?: number estimated_release?: string | null @@ -331,6 +339,7 @@ export type Database = { patches_download_permission?: Database["public"]["Enums"]["Patches Download Permission"] permission_from?: string | null published?: boolean + redirect_url?: string | null rejected?: boolean rejected_at?: string | null rejected_by?: string | null diff --git a/src/utils/hackRedirect.test.ts b/src/utils/hackRedirect.test.ts new file mode 100644 index 0000000..271b3df --- /dev/null +++ b/src/utils/hackRedirect.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseHackRedirect } from "./hackRedirect.ts"; + +const site = "https://hackdex.app"; + +test("blank means no redirect", () => { + assert.deepEqual(parseHackRedirect(" ", "old", site), { ok: true, url: null }); +}); + +test("accepts site paths and http(s) links, trimmed", () => { + assert.deepEqual(parseHackRedirect(" /hack/new ", "old", site), { ok: true, url: "/hack/new" }); + assert.deepEqual(parseHackRedirect("https://example.com/x", "old", site), { ok: true, url: "https://example.com/x" }); +}); + +test("rejects other schemes and protocol-relative links", () => { + for (const input of ["javascript:alert(1)", "example.com", "//evil.com", "/\\evil.com", "ftp://x.com"]) { + assert.equal(parseHackRedirect(input, "old", site).ok, false, input); + } +}); + +test("rejects the hack's own page, however it's written", () => { + for (const input of ["/hack/old", "/hack/old/", "/HACK/Old?x=1", "https://hackdex.app/hack/old", "/hack/old/session", "/hack/old/versions"]) { + assert.equal(parseHackRedirect(input, "old", site).ok, false, input); + } + // Same path on another site, or a hack whose slug only starts the same, is fine. + assert.equal(parseHackRedirect("/hack/older", "old", site).ok, true); + assert.equal(parseHackRedirect("https://example.com/hack/old", "old", site).ok, true); +}); diff --git a/src/utils/hackRedirect.ts b/src/utils/hackRedirect.ts new file mode 100644 index 0000000..8ddc8ab --- /dev/null +++ b/src/utils/hackRedirect.ts @@ -0,0 +1,40 @@ +export type HackRedirectResult = { ok: true; url: string | null } | { ok: false; error: string }; + +/** + * Checks the redirect an admin enters when deleting a hack. Accepts a full + * http(s) link or a site path like `/hack/new-slug`; blank means none. Rejects + * the hack's own pages, which would loop or dead-end. + */ +export function parseHackRedirect( + input: string, + slug: string, + siteUrl = process.env.NEXT_PUBLIC_SITE_URL, +): HackRedirectResult { + const url = input.trim(); + if (!url) return { ok: true, url: null }; + + // A path, but not `//host` or `/\host`, which browsers read as another site. + const isPath = /^\/(?![/\\])/.test(url); + if (!isPath && !/^https?:\/\//i.test(url)) { + return { ok: false, error: "Use a full link (https://…) or a path starting with /" }; + } + + let target: URL; + let site: URL | null = null; + try { + site = siteUrl ? new URL(siteUrl) : null; + target = new URL(url, site ?? "https://hackdex.invalid"); + } catch { + return { ok: false, error: "That isn't a valid link" }; + } + + // Every page under /hack/ 404s or redirects once it's deleted, and /session loops back here. + const onThisSite = isPath || target.host === site?.host; + const ownPath = `/hack/${slug}`.toLowerCase(); + const path = target.pathname.replace(/\/+$/, "").toLowerCase(); + if (onThisSite && (path === ownPath || path.startsWith(`${ownPath}/`))) { + return { ok: false, error: "That's this hack's own page" }; + } + + return { ok: true, url }; +} diff --git a/supabase/migrations/20261006120000_hack_soft_delete.sql b/supabase/migrations/20261006120000_hack_soft_delete.sql new file mode 100644 index 0000000..de02f1f --- /dev/null +++ b/supabase/migrations/20261006120000_hack_soft_delete.sql @@ -0,0 +1,98 @@ +-- Soft delete for hacks. An admin deletes a hack from its page; the row and its files stay, but the +-- hack is gone everywhere: its URL 404s for everyone (admins included), or permanently redirects to +-- redirect_url when one is set. +-- +-- There's no UI to restore one. To bring a hack back: +-- update public.hacks set deleted_at = null, deleted_by = null, redirect_url = null where slug = ''; +-- then clear the caches: GET /api/refresh/ and /api/discover/refresh (both as an admin). +alter table public.hacks + add column deleted_at timestamptz, + add column deleted_by uuid references auth.users (id) on delete set null, + -- Absolute http(s) URL or site path. Only used once deleted_at is set. + add column redirect_url text; + +-- Deleted hacks don't exist for API callers, admins included. Child tables whose policies look the +-- hack up (covers, tags, patches) follow along. The app deletes and reads deleted rows with the +-- service role, and restores happen in the SQL editor, so neither needs a way through. +create policy "Deleted hacks are hidden from everyone." +on public.hacks as restrictive for all to anon, authenticated +using (deleted_at is null) +with check (deleted_at is null); + +-- These two are readable by anyone and never look the hack up, so they'd still list a deleted hack's +-- patch ids. The subquery runs under the hacks policies, so rows follow their hack's visibility. +create policy "Patch groups follow their hack." +on public.patch_groups as restrictive for select to anon, authenticated +using (exists (select 1 from public.hacks h where h.slug = patch_groups.hack_slug)); + +create policy "Patcher patches follow their hack." +on public.hack_patcher_patches as restrictive for select to anon, authenticated +using (exists (select 1 from public.hacks h where h.slug = hack_patcher_patches.hack_slug)); + +-- SECURITY DEFINER skips the hacks policy, so it has to check on its own. +create or replace function public.is_archive_hack_for_archiver(hack_slug text) +returns boolean +language sql +stable +security definer +as $$ + select exists ( + select 1 + from public.hacks h + where h.slug = hack_slug + and h.is_archive = true + and h.deleted_at is null + ); +$$; + +-- Same as 20261003034744_v2_redesign.sql, plus the three delete columns. +create or replace function public.hacks_update_guard() +returns trigger +language plpgsql +as $function$ +begin + if current_user in ('postgres', 'dashboard_user', 'service_role') then + return new; + end if; + + if public.is_admin() then + return new; + end if; + + if tg_op = 'INSERT' then + if new.downloads <> 0 or new.is_archive or new.rejected or num_nonnulls( + new.current_patch, new.original_author, new.permission_from, new.submitted_at, + new.approved_at, new.approved_by, new.rejected_at, new.rejected_reason, new.rejected_by, new.assigned_admin, + new.ai_graphics, new.ai_music, new.ai_story, new.ai_translation, new.ai_events, new.ai_code, new.ai_note, new.ai_disclosed_at, + new.deleted_at, new.deleted_by, new.redirect_url + ) > 0 then + raise exception 'only Hackdex can set these hack fields'; + end if; + return new; + end if; + + if new.slug is distinct from old.slug then + raise exception 'non-admins cannot change slug'; + end if; + if new.created_by is distinct from old.created_by then + raise exception 'non-admins cannot change created_by'; + end if; + if new.approved is distinct from old.approved then + raise exception 'non-admins cannot change approved'; + end if; + if ( + new.downloads, new.is_archive, new.current_patch, new.original_author, new.permission_from, new.submitted_at, + new.approved_at, new.approved_by, new.rejected, new.rejected_at, new.rejected_reason, new.rejected_by, new.assigned_admin, + new.ai_graphics, new.ai_music, new.ai_story, new.ai_translation, new.ai_events, new.ai_code, new.ai_note, new.ai_disclosed_at, + new.deleted_at, new.deleted_by, new.redirect_url + ) is distinct from ( + old.downloads, old.is_archive, old.current_patch, old.original_author, old.permission_from, old.submitted_at, + old.approved_at, old.approved_by, old.rejected, old.rejected_at, old.rejected_reason, old.rejected_by, old.assigned_admin, + old.ai_graphics, old.ai_music, old.ai_story, old.ai_translation, old.ai_events, old.ai_code, old.ai_note, old.ai_disclosed_at, + old.deleted_at, old.deleted_by, old.redirect_url + ) then + raise exception 'only Hackdex can change these hack fields'; + end if; + return new; +end; +$function$;