mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-10-20 23:02:33 -05:00
Hack page: let admins delete hacks, with an optional redirect (#108)
Some checks failed
Deploy Supabase Migrations to Production / migrate (push) Has been cancelled
Some checks failed
Deploy Supabase Migrations to Production / migrate (push) Has been cancelled
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
114
src/components/Hack/DeleteHackModal.tsx
Normal file
114
src/components/Hack/DeleteHackModal.tsx
Normal 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’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’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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
29
src/utils/hackRedirect.test.ts
Normal file
29
src/utils/hackRedirect.test.ts
Normal 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
40
src/utils/hackRedirect.ts
Normal 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 };
|
||||
}
|
||||
98
supabase/migrations/20261006120000_hack_soft_delete.sql
Normal file
98
supabase/migrations/20261006120000_hack_soft_delete.sql
Normal 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$;
|
||||
Reference in New Issue
Block a user