Hack page: let admins delete hacks, with an optional redirect (#108)
Some checks failed
Deploy Supabase Migrations to Production / migrate (push) Has been cancelled

This commit is contained in:
Jared Schoeny
2026-10-06 15:57:54 -06:00
committed by GitHub
parent 039432b073
commit f103a0cb65
18 changed files with 417 additions and 52 deletions

View File

@@ -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"
},

View File

@@ -72,7 +72,7 @@ export async function collectRecipients(supabase: SupabaseClient<Database>): 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) {

View File

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

View File

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

View File

@@ -48,7 +48,8 @@ async function generateDiscoverData(): Promise<DiscoverData> {
`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);

View File

@@ -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<HackMetadata | null> {
const runner = cache(
async () => {
@@ -75,6 +77,7 @@ export async function getHackMetadata(slug: string): Promise<HackMetadata | null
.from("hacks")
.select(`slug,title,summary,description,base_rom,created_at,updated_at,current_patch,custom_version_name,box_art,social_links,created_by,approved,original_author,permission_from,language,is_archive,completion_status,verification_contact_info,submitted_at,show_emulators,${AI_SELECT}`)
.eq("slug", slug)
.is("deleted_at", null)
.maybeSingle();
if (error || !hack) return null;
@@ -136,6 +139,7 @@ export async function getHackMetadata(slug: string): Promise<HackMetadata | null
.eq("created_by", hack.created_by)
.eq("approved", true)
.eq("is_archive", false)
.is("deleted_at", null)
.neq("slug", hack.slug)
.order("downloads", { ascending: false })
.limit(10);
@@ -228,3 +232,32 @@ export async function getHackDownloads(slug: string): Promise<number | null> {
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<never> {
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();
}

View File

@@ -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();
}

View File

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

View File

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

View File

@@ -10,10 +10,14 @@ export default async function Sitemap(): Promise<MetadataRoute.Sitemap> {
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(),

View File

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

View File

@@ -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<string | null>(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 (
<Modal visible title="Delete this hack?" onClose={onClose}>
<div className="flex flex-col gap-4">
<p className="text-sm text-text-2">
It disappears from Discover and the creator&rsquo;s dashboard, and its page 404s for everyone, admins included.
Its files stay, but only a database edit can bring it back.
</p>
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">
Redirect to <span className="text-xs font-normal text-text-3">(optional)</span>
</span>
<input
type="text"
inputMode="url"
value={redirectUrl}
onChange={(e) => {
setRedirectUrl(e.target.value);
setServerError(null);
}}
placeholder="/hack/new-slug or https://…"
aria-invalid={!!error}
className="h-10 w-full rounded-control border border-line bg-surface-2 px-3 text-sm text-text outline-none transition-[border-color,box-shadow] placeholder:text-text-3 focus:border-line-strong focus:ring-2 focus:ring-accent/40 aria-invalid:border-error/60"
/>
<span className="text-xs text-text-3">Visitors to this hack&rsquo;s page go here instead of a 404.</span>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">
Type <code className="rounded bg-surface-2 px-1 py-0.5 font-mono text-[13px]">{slug}</code> to confirm
</span>
<input
type="text"
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleDelete();
}}
autoComplete="off"
autoCapitalize="off"
spellCheck={false}
className="h-10 w-full rounded-control border border-line bg-surface-2 px-3 font-mono text-sm text-text outline-none transition-[border-color,box-shadow] focus:border-line-strong focus:ring-2 focus:ring-accent/40"
/>
</label>
{error && <p className="rounded-control bg-error-soft px-3 py-2 text-sm text-error">{error}</p>}
<div className="flex flex-col-reverse gap-2 pt-1 sm:flex-row">
<button
type="button"
onClick={onClose}
disabled={isDeleting}
className="inline-flex h-11 w-full items-center justify-center rounded-control border border-line-strong px-4 text-sm font-medium text-text-2 outline-none transition-colors hover:enabled:border-text-3 hover:enabled:text-text focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={handleDelete}
disabled={!canDelete}
className="inline-flex h-11 w-full items-center justify-center rounded-control bg-error px-4 text-sm font-semibold text-white outline-none transition-[background-color,opacity] hover:enabled:bg-error/90 focus-visible:ring-2 focus-visible:ring-error/40 focus-visible:ring-offset-2 focus-visible:ring-offset-surface disabled:cursor-not-allowed disabled:opacity-60"
>
{isDeleting ? "Deleting…" : "Delete hack"}
</button>
</div>
</div>
</Modal>
);
}

View File

@@ -301,7 +301,7 @@ export default function HackDetailView({ metadata, downloads, canEdit, canUpload
{(!editing || editor.notOwner) && (
<div className="flex flex-none items-center justify-end gap-2 md:pt-2">
<HackShareButton title={hack.title} url={pageUrl} author={hack.original_author || profile?.username || null} />
<HackOptionsMenu slug={hack.slug} canEdit={canEdit} canUploadPatch={canUploadPatch} editHref={isArchive ? `/hack/${hack.slug}/edit` : `/hack/${hack.slug}?edit=1`}>
<HackOptionsMenu slug={hack.slug} canEdit={canEdit} canUploadPatch={canUploadPatch} editHref={isArchive ? `/hack/${hack.slug}/edit` : `/hack/${hack.slug}?edit=1`} canDelete={isAdmin}>
{isAdmin && !hack.approved && !isDraft && (
<MenuItem as="a" href={`/hack/${hack.slug}/approve`} className="block w-full px-3 py-2 text-left text-sm font-medium text-ready data-focus:bg-surface-2">
<FaCircleCheck className="mb-0.5 mr-2 inline-block align-middle" size={12} />

View File

@@ -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({
<MenuSeparator className="my-1 h-px bg-line" />
{children}
</>}
{canDelete && <>
<MenuSeparator className="my-1 h-px bg-line" />
<MenuItem
as="button"
onClick={() => 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"
>
<FiTrash2 className="h-4 w-4" />
Delete
</MenuItem>
</>}
</MenuItems>
</Menu>
{showReportModal && (
<ReportModal slug={slug} onClose={() => setShowReportModal(false)} />
)}
{showDeleteModal && (
<DeleteHackModal slug={slug} onClose={() => setShowDeleteModal(false)} />
)}
</>
);
}

View File

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

View File

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

40
src/utils/hackRedirect.ts Normal file
View File

@@ -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/<slug> 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 };
}

View File

@@ -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 = '<slug>';
-- then clear the caches: GET /api/refresh/<slug> 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$;