mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-03-21 17:54:09 -05:00
Fix permissions issues with archivers and archive hacks
This commit is contained in:
parent
a4c9474f7f
commit
ef55c0a40d
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
import { unstable_cache as cache } from "next/cache";
|
||||
import { createClient, createServiceClient } from "@/utils/supabase/server";
|
||||
import { canEditAsCreator, canEditAsAdminOrArchiver } from "@/utils/hack";
|
||||
import { checkUserRoles } from "@/utils/user";
|
||||
|
||||
interface SeriesDataset {
|
||||
slug: string;
|
||||
|
|
@ -48,16 +50,40 @@ function buildUtcDateLabels(days: number, endExclusiveUtc: Date): string[] {
|
|||
export const getDownloadsSeriesAll = async ({ days = 30 }: { days?: number }): Promise<DownloadsSeriesAll> => {
|
||||
const { startISO, endISO, ttl, dayStamp, startOfTodayUtc } = getUtcBounds(days);
|
||||
|
||||
// Resolve user and owned slugs OUTSIDE cache (cookies not allowed in cache)
|
||||
// Resolve user and accessible slugs OUTSIDE cache (cookies not allowed in cache)
|
||||
const supa = await createClient();
|
||||
const { data: userResp } = await supa.auth.getUser();
|
||||
const user = userResp.user;
|
||||
if (!user) throw new Error("Unauthorized");
|
||||
const { data: hacks } = await supa
|
||||
|
||||
// Get hacks owned by user
|
||||
const { data: ownedHacks } = await supa
|
||||
.from("hacks")
|
||||
.select("slug")
|
||||
.select("slug,created_by,current_patch,original_author,permission_from")
|
||||
.eq("created_by", user.id);
|
||||
const slugs = (hacks ?? []).map((h) => h.slug);
|
||||
const ownedSlugs = (ownedHacks ?? []).map((h) => h.slug);
|
||||
|
||||
// Get archive hacks that user can edit as archiver
|
||||
const { data: allArchiveHacks } = await supa
|
||||
.from("hacks")
|
||||
.select("slug,created_by,current_patch,original_author,permission_from")
|
||||
.not("original_author", "is", null);
|
||||
|
||||
const accessibleArchiveSlugs: string[] = [];
|
||||
if (allArchiveHacks) {
|
||||
const { isAdmin, isArchiver } = await checkUserRoles(supa);
|
||||
for (const hack of allArchiveHacks) {
|
||||
// Skip if already owned
|
||||
if (canEditAsCreator(hack, user.id)) continue;
|
||||
|
||||
// Check if user can edit as archiver (function already checks if it's an archive)
|
||||
if (await canEditAsAdminOrArchiver(hack, user.id, supa, { roles: { isAdmin, isArchiver } })) {
|
||||
accessibleArchiveSlugs.push(hack.slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const slugs = [...ownedSlugs, ...accessibleArchiveSlugs];
|
||||
|
||||
const runner = cache(
|
||||
async () => {
|
||||
|
|
@ -141,14 +167,22 @@ export const getHackInsights = async ({ slug }: { slug: string }): Promise<HackI
|
|||
if (!user) throw new Error("Unauthorized");
|
||||
const { data: hack } = await supa
|
||||
.from("hacks")
|
||||
.select("slug,created_by,current_patch,created_at")
|
||||
.select("slug,created_by,current_patch,created_at,original_author,permission_from")
|
||||
.eq("slug", slug)
|
||||
.maybeSingle();
|
||||
if (!hack) throw new Error("Not found");
|
||||
let isOwner = hack.created_by === user.id;
|
||||
|
||||
// Check if user can access: owner, admin, or archiver for archive hacks
|
||||
const isOwner = canEditAsCreator(hack, user.id);
|
||||
if (!isOwner) {
|
||||
const { data: admin } = await supa.rpc("is_admin");
|
||||
if (!admin) throw new Error("Forbidden");
|
||||
if (admin) {
|
||||
// Admin can access any hack
|
||||
} else if (await canEditAsAdminOrArchiver(hack, user.id, supa)) {
|
||||
// Archiver can access archive hacks (function already checks if it's an archive)
|
||||
} else {
|
||||
throw new Error("Forbidden");
|
||||
}
|
||||
}
|
||||
|
||||
const currentPatchId = (hack.current_patch as number | null) ?? null;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server";
|
||||
import { isInformationalArchiveHack } from "@/utils/hack";
|
||||
|
||||
export async function getSignedPatchUrl(slug: string): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
|
||||
const supabase = await createClient();
|
||||
|
|
@ -34,9 +35,8 @@ export async function getSignedPatchUrl(slug: string): Promise<{ ok: true; url:
|
|||
}
|
||||
}
|
||||
|
||||
// Check if this is an Archive hack (no patch available)
|
||||
const isArchive = hack.original_author != null && hack.current_patch === null;
|
||||
if (isArchive) {
|
||||
// Check if this is an Informational Archive hack (no patch available)
|
||||
if (isInformationalArchiveHack(hack)) {
|
||||
return { ok: false, error: "Archive hacks do not have patch files available" };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { createClient } from "@/utils/supabase/server";
|
|||
import { FaChevronLeft, FaChevronRight } from "react-icons/fa6";
|
||||
import Link from "next/link";
|
||||
import { getCoverSignedUrls } from "@/app/hack/actions";
|
||||
import { checkEditPermission } from "@/utils/hack";
|
||||
|
||||
interface EditPageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
|
|
@ -19,22 +20,16 @@ export default async function EditHackPage({ params }: EditPageProps) {
|
|||
|
||||
const { data: hack } = await supabase
|
||||
.from("hacks")
|
||||
.select("slug,title,summary,description,base_rom,language,box_art,social_links,created_by,current_patch,original_author")
|
||||
.select("slug,title,summary,description,base_rom,language,box_art,social_links,created_by,current_patch,original_author,permission_from")
|
||||
.eq("slug", slug)
|
||||
.maybeSingle();
|
||||
if (!hack) return notFound();
|
||||
|
||||
// Check if user can edit: either they're the creator, or they're admin/archiver editing an Archive hack
|
||||
const canEditAsCreator = hack.created_by === user!.id;
|
||||
const isArchive = hack.original_author != null && hack.current_patch === null;
|
||||
let canEditAsAdminOrArchiver = false;
|
||||
if (isArchive && !canEditAsCreator) {
|
||||
// Admin check automatically included with is_archiver check
|
||||
const { data: isArchiver } = await supabase.rpc("is_archiver");
|
||||
canEditAsAdminOrArchiver = !!isArchiver;
|
||||
}
|
||||
const permission = await checkEditPermission(hack, user!.id, supabase);
|
||||
const { isInformationalArchive, isDownloadableArchive, isArchive } = permission;
|
||||
|
||||
if (!canEditAsCreator && !canEditAsAdminOrArchiver) {
|
||||
if (!permission.canEdit) {
|
||||
redirect(`/hack/${slug}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { createClient } from "@/utils/supabase/server";
|
|||
import HackPatchForm from "@/components/Hack/HackPatchForm";
|
||||
import Link from "next/link";
|
||||
import { FaChevronLeft } from "react-icons/fa6";
|
||||
import { isInformationalArchiveHack, isDownloadableArchiveHack, canEditAsCreator, canEditAsArchiver } from "@/utils/hack";
|
||||
|
||||
interface EditPatchPageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
|
|
@ -18,12 +19,17 @@ export default async function EditPatchPage({ params }: EditPatchPageProps) {
|
|||
|
||||
const { data: hack } = await supabase
|
||||
.from("hacks")
|
||||
.select("slug,base_rom,created_by,title,current_patch")
|
||||
.select("slug,base_rom,created_by,title,current_patch,original_author,permission_from")
|
||||
.eq("slug", slug)
|
||||
.maybeSingle();
|
||||
if (!hack) return notFound();
|
||||
if (hack.created_by !== user!.id) {
|
||||
redirect(`/hack/${slug}`);
|
||||
if (!canEditAsCreator(hack, user!.id)) {
|
||||
const isInformationalArchive = isInformationalArchiveHack(hack);
|
||||
const isDownloadableArchive = isDownloadableArchiveHack(hack);
|
||||
const isEditableByArchiver = await canEditAsArchiver(hack, user!.id, supabase);
|
||||
|
||||
if (isInformationalArchive) redirect(`/hack/${slug}`);
|
||||
if (isDownloadableArchive && !isEditableByArchiver) redirect(`/hack/${slug}`);
|
||||
}
|
||||
|
||||
const { data: patchRows } = await supabase
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { FaCircleCheck } from "react-icons/fa6";
|
|||
import { sortOrderedTags } from "@/utils/format";
|
||||
import { RiArchiveStackFill } from "react-icons/ri";
|
||||
import { getCoverSignedUrls } from "@/app/hack/actions";
|
||||
import { isInformationalArchiveHack, isDownloadableArchiveHack, isArchiveHack, checkEditPermission } from "@/utils/hack";
|
||||
|
||||
interface HackDetailProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
|
|
@ -46,7 +47,7 @@ export async function generateMetadata({ params }: HackDetailProps): Promise<Met
|
|||
const supabase = await createClient();
|
||||
const { data: hack } = await supabase
|
||||
.from("hacks")
|
||||
.select("title,summary,approved,base_rom,box_art,created_by,created_at,updated_at,original_author,current_patch")
|
||||
.select("title,summary,approved,base_rom,box_art,created_by,created_at,updated_at,original_author,current_patch,permission_from")
|
||||
.eq("slug", slug)
|
||||
.maybeSingle();
|
||||
if (!hack) return { title: "Hack not found" };
|
||||
|
|
@ -63,7 +64,7 @@ export async function generateMetadata({ params }: HackDetailProps): Promise<Met
|
|||
description: 'This hack is pending approval by an admin.',
|
||||
} satisfies Metadata;
|
||||
|
||||
const isArchive = hack.original_author != null && hack.current_patch === null;
|
||||
const isArchive = isArchiveHack(hack);
|
||||
const baseRomName = baseRoms.find((r) => r.id === hack.base_rom)?.name ?? "Pokémon";
|
||||
const pageUrl = `/hack/${slug}`;
|
||||
const title = isArchive ? `${hack.title} | Archive` : `${hack.title} | ROM hack download`;
|
||||
|
|
@ -116,15 +117,12 @@ export default async function HackDetail({ params }: HackDetailProps) {
|
|||
const supabase = await createClient();
|
||||
const { data: hack, error } = await supabase
|
||||
.from("hacks")
|
||||
.select("slug,title,summary,description,base_rom,created_at,updated_at,downloads,current_patch,box_art,social_links,created_by,approved,original_author")
|
||||
.select("slug,title,summary,description,base_rom,created_at,updated_at,downloads,current_patch,box_art,social_links,created_by,approved,original_author,permission_from")
|
||||
.eq("slug", slug)
|
||||
.maybeSingle();
|
||||
if (error || !hack) return notFound();
|
||||
const baseRom = baseRoms.find((r) => r.id === hack.base_rom);
|
||||
|
||||
// Detect if this is an Archive hack
|
||||
const isArchive = hack.original_author != null && hack.current_patch === null;
|
||||
|
||||
let images: string[] = [];
|
||||
const { data: covers } = await supabase
|
||||
.from("hack_covers")
|
||||
|
|
@ -158,8 +156,14 @@ export default async function HackDetail({ params }: HackDetailProps) {
|
|||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
const canEdit = !!user && user.id === (hack.created_by as string);
|
||||
const canUploadPatch = (!!user && user.id === (hack.created_by as string) && !isArchive);
|
||||
const {
|
||||
canEdit,
|
||||
canEditAsAdminOrArchiver,
|
||||
isInformationalArchive,
|
||||
isDownloadableArchive,
|
||||
isArchive,
|
||||
} = await checkEditPermission(hack, user?.id as string, supabase);
|
||||
const canUploadPatch = canEdit && !isInformationalArchive;
|
||||
|
||||
let isAdmin = false;
|
||||
if ((!hack.approved && !canEdit) || isArchive) {
|
||||
|
|
@ -171,6 +175,10 @@ export default async function HackDetail({ params }: HackDetailProps) {
|
|||
}
|
||||
}
|
||||
|
||||
if (isArchive && !isAdmin && !canEditAsAdminOrArchiver) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
// Get patch info, but don't sign URL yet (happens on user interaction)
|
||||
let patchFilename: string | null = null;
|
||||
let patchVersion = isArchive ? "Archive" : "";
|
||||
|
|
@ -254,7 +262,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
|
|||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: serialize(jsonLd, { isJSON: true }) }}
|
||||
/>
|
||||
{!isArchive && (
|
||||
{!isInformationalArchive && (
|
||||
<HackActions
|
||||
title={hack.title}
|
||||
version={patchVersion || "Pre-release"}
|
||||
|
|
@ -267,7 +275,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
|
|||
/>
|
||||
)}
|
||||
|
||||
{isArchive && (
|
||||
{isInformationalArchive && (
|
||||
<div className="flex flex-row items-center gap-4 mx-6 mt-6 rounded-lg border-2 border-rose-500/40 bg-rose-50 dark:bg-rose-900/10 p-4 md:pl-6">
|
||||
<div className="flex items-center gap-4 md:gap-6">
|
||||
<div className="flex-shrink-0">
|
||||
|
|
@ -434,7 +442,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isArchive ? (
|
||||
{isInformationalArchive ? (
|
||||
<div className="card overflow-hidden p-4 mt-4 text-sm text-foreground/60">
|
||||
<p>
|
||||
This is an archive entry for <span className="font-semibold">{hack.title}</span> preserved for informational purposes.
|
||||
|
|
@ -449,7 +457,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
|
|||
) : (
|
||||
<div className="card overflow-hidden p-4 mt-4 text-sm text-foreground/60">
|
||||
<p>
|
||||
This page provides the official patch file for <span className="font-semibold">{hack.title}</span>. You can safely download the patched ROM for this hack
|
||||
This page provides {isDownloadableArchive ? "an archived" : "the official"} patch file for <span className="font-semibold">{hack.title}</span>{isDownloadableArchive ? " with permission from the original creator" : ""}. You can safely download the patched ROM for this hack
|
||||
using our built-in patcher.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { createClient } from "@/utils/supabase/server";
|
|||
import { notFound, redirect } from "next/navigation";
|
||||
import HackStatsClient from "@/components/Hack/Stats/HackStatsClient";
|
||||
import { getDownloadsSeriesAll, getHackInsights } from "@/app/dashboard/actions";
|
||||
import { isArchiveHack, canEditAsAdminOrArchiver } from "@/utils/hack";
|
||||
|
||||
export default async function HackStatsPage({ params: { slug } }: { params: { slug: string } }) {
|
||||
const supa = await createClient();
|
||||
|
|
@ -11,15 +12,16 @@ export default async function HackStatsPage({ params: { slug } }: { params: { sl
|
|||
|
||||
const { data: hack } = await supa
|
||||
.from("hacks")
|
||||
.select("slug,created_by,title")
|
||||
.select("slug,created_by,title,original_author,current_patch,permission_from")
|
||||
.eq("slug", slug)
|
||||
.maybeSingle();
|
||||
if (!hack) notFound();
|
||||
|
||||
let isOwner = hack.created_by === user.id;
|
||||
if (!isOwner) {
|
||||
const { data: admin } = await supa.rpc("is_admin");
|
||||
if (!admin) notFound();
|
||||
const isArchive = isArchiveHack(hack);
|
||||
const isEditableByArchiver = await canEditAsAdminOrArchiver(hack, user.id, supa);
|
||||
if (!isOwner && !isArchive && !isEditableByArchiver) notFound();
|
||||
}
|
||||
|
||||
const allSeries = await getDownloadsSeriesAll({ days: 30 });
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { revalidatePath } from "next/cache";
|
|||
import { redirect } from "next/navigation";
|
||||
import { APIEmbed } from "discord-api-types/v10";
|
||||
import { sendDiscordMessageEmbed } from "@/utils/discord";
|
||||
import { checkEditPermission, checkPatchEditPermission } from "@/utils/hack";
|
||||
|
||||
export async function updateHack(args: {
|
||||
slug: string;
|
||||
|
|
@ -28,23 +29,14 @@ export async function updateHack(args: {
|
|||
|
||||
const { data: hack, error: hErr } = await supabase
|
||||
.from("hacks")
|
||||
.select("slug, created_by, current_patch, original_author")
|
||||
.select("slug, created_by, current_patch, original_author, permission_from")
|
||||
.eq("slug", args.slug)
|
||||
.maybeSingle();
|
||||
if (hErr) return { ok: false, error: hErr.message } as const;
|
||||
if (!hack) return { ok: false, error: "Hack not found" } as const;
|
||||
|
||||
// Check if user can edit: either they're the creator, or they're admin/archiver editing an Archive hack
|
||||
const canEditAsCreator = hack.created_by === user.id;
|
||||
const isArchive = hack.original_author != null && hack.current_patch === null;
|
||||
let canEditAsAdminOrArchiver = false;
|
||||
if (isArchive && !canEditAsCreator) {
|
||||
const { data: isAdmin } = await supabase.rpc("is_admin");
|
||||
const { data: isArchiver } = await supabase.rpc("is_archiver");
|
||||
canEditAsAdminOrArchiver = !!isAdmin || !!isArchiver;
|
||||
}
|
||||
|
||||
if (!canEditAsCreator && !canEditAsAdminOrArchiver) {
|
||||
const permission = await checkEditPermission(hack, user.id, supabase);
|
||||
if (!permission.canEdit) {
|
||||
return { ok: false, error: "Forbidden" } as const;
|
||||
}
|
||||
|
||||
|
|
@ -128,12 +120,16 @@ export async function saveHackCovers(args: { slug: string; coverUrls: string[] }
|
|||
|
||||
const { data: hack, error: hErr } = await supabase
|
||||
.from("hacks")
|
||||
.select("slug, created_by")
|
||||
.select("slug, created_by, current_patch, original_author, permission_from")
|
||||
.eq("slug", args.slug)
|
||||
.maybeSingle();
|
||||
if (hErr) return { ok: false, error: hErr.message } as const;
|
||||
if (!hack) return { ok: false, error: "Hack not found" } as const;
|
||||
if (hack.created_by !== user.id) return { ok: false, error: "Forbidden" } as const;
|
||||
|
||||
const permission = await checkEditPermission(hack, user.id, supabase);
|
||||
if (!permission.canEdit) {
|
||||
return { ok: false, error: "Forbidden" } as const;
|
||||
}
|
||||
|
||||
// Fetch current covers to compute removals and preserve alt text
|
||||
const { data: currentRows, error: cErr } = await supabase
|
||||
|
|
@ -204,15 +200,22 @@ export async function presignNewPatchVersion(args: { slug: string; version: stri
|
|||
} = await supabase.auth.getUser();
|
||||
if (!user) return { ok: false, error: "Unauthorized" } as const;
|
||||
|
||||
// Ensure hack exists and belongs to user
|
||||
// Ensure hack exists and user has permission
|
||||
const { data: hack, error: hErr } = await supabase
|
||||
.from("hacks")
|
||||
.select("slug, created_by")
|
||||
.select("slug, created_by, current_patch, original_author, permission_from")
|
||||
.eq("slug", args.slug)
|
||||
.maybeSingle();
|
||||
if (hErr) return { ok: false, error: hErr.message } as const;
|
||||
if (!hack) return { ok: false, error: "Hack not found" } as const;
|
||||
if (hack.created_by !== user.id) return { ok: false, error: "Forbidden" } as const;
|
||||
|
||||
const permission = await checkPatchEditPermission(hack, user.id, supabase);
|
||||
if (permission.error) {
|
||||
return { ok: false, error: permission.error } as const;
|
||||
}
|
||||
if (!permission.canEdit) {
|
||||
return { ok: false, error: "Forbidden" } as const;
|
||||
}
|
||||
|
||||
// Enforce unique version per hack
|
||||
const { data: existing } = await supabase
|
||||
|
|
@ -241,15 +244,19 @@ export async function presignCoverUpload(args: { slug: string; objectKey: string
|
|||
} = await supabase.auth.getUser();
|
||||
if (!user) return { ok: false, error: "Unauthorized" } as const;
|
||||
|
||||
// Ensure hack exists and belongs to user
|
||||
// Ensure hack exists and user has permission
|
||||
const { data: hack, error: hErr } = await supabase
|
||||
.from("hacks")
|
||||
.select("slug, created_by")
|
||||
.select("slug, created_by, current_patch, original_author, permission_from")
|
||||
.eq("slug", args.slug)
|
||||
.maybeSingle();
|
||||
if (hErr) return { ok: false, error: hErr.message } as const;
|
||||
if (!hack) return { ok: false, error: "Hack not found" } as const;
|
||||
if (hack.created_by !== user.id) return { ok: false, error: "Forbidden" } as const;
|
||||
|
||||
const permission = await checkEditPermission(hack, user.id, supabase);
|
||||
if (!permission.canEdit) {
|
||||
return { ok: false, error: "Forbidden" } as const;
|
||||
}
|
||||
|
||||
const client = getMinioClient();
|
||||
// 10 minutes to upload
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server";
|
|||
import { sendDiscordMessageEmbed } from "@/utils/discord";
|
||||
import { APIEmbed } from "discord-api-types/v10";
|
||||
import { slugify } from "@/utils/format";
|
||||
import { checkEditPermission, checkPatchEditPermission } from "@/utils/hack";
|
||||
|
||||
type HackInsert = TablesInsert<"hacks">;
|
||||
|
||||
|
|
@ -119,15 +120,19 @@ export async function saveHackCovers(args: { slug: string; coverUrls: string[] }
|
|||
} = await supabase.auth.getUser();
|
||||
if (!user) return { ok: false, error: "Unauthorized" } as const;
|
||||
|
||||
// Ensure hack exists and belongs to user (created_by) to prevent spoof
|
||||
// Ensure hack exists and user has permission
|
||||
const { data: hack, error: hErr } = await supabase
|
||||
.from("hacks")
|
||||
.select("slug, created_by")
|
||||
.select("slug, created_by, current_patch, original_author, permission_from")
|
||||
.eq("slug", args.slug)
|
||||
.maybeSingle();
|
||||
if (hErr) return { ok: false, error: hErr.message } as const;
|
||||
if (!hack) return { ok: false, error: "Hack not found" } as const;
|
||||
if (hack.created_by !== user.id) return { ok: false, error: "Forbidden" } as const;
|
||||
|
||||
const permission = await checkEditPermission(hack, user.id, supabase);
|
||||
if (!permission.canEdit) {
|
||||
return { ok: false, error: "Forbidden" } as const;
|
||||
}
|
||||
|
||||
// Insert covers (overwrite positions)
|
||||
if (args.coverUrls && args.coverUrls.length > 0) {
|
||||
|
|
@ -154,15 +159,22 @@ export async function presignPatchAndSaveCovers(args: {
|
|||
} = await supabase.auth.getUser();
|
||||
if (!user) return { ok: false, error: "Unauthorized" } as const;
|
||||
|
||||
// Ensure hack exists and belongs to user (created_by) to prevent spoof
|
||||
// Ensure hack exists and user has permission
|
||||
const { data: hack, error: hErr } = await supabase
|
||||
.from("hacks")
|
||||
.select("slug, created_by")
|
||||
.select("slug, created_by, current_patch, original_author, permission_from")
|
||||
.eq("slug", args.slug)
|
||||
.maybeSingle();
|
||||
if (hErr) return { ok: false, error: hErr.message } as const;
|
||||
if (!hack) return { ok: false, error: "Hack not found" } as const;
|
||||
if (hack.created_by !== user.id) return { ok: false, error: "Forbidden" } as const;
|
||||
|
||||
const permission = await checkPatchEditPermission(hack, user.id, supabase);
|
||||
if (permission.error) {
|
||||
return { ok: false, error: permission.error } as const;
|
||||
}
|
||||
if (!permission.canEdit) {
|
||||
return { ok: false, error: "Forbidden" } as const;
|
||||
}
|
||||
|
||||
// Insert covers (overwrite positions)
|
||||
if (args.coverUrls && args.coverUrls.length > 0) {
|
||||
|
|
@ -192,12 +204,19 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string
|
|||
|
||||
const { data: hack, error: hErr } = await supabase
|
||||
.from("hacks")
|
||||
.select("slug, created_by, title")
|
||||
.select("slug, created_by, title, current_patch, original_author, permission_from")
|
||||
.eq("slug", args.slug)
|
||||
.maybeSingle();
|
||||
if (hErr) return { ok: false, error: hErr.message } as const;
|
||||
if (!hack) return { ok: false, error: "Hack not found" } as const;
|
||||
if (hack.created_by !== user.id) return { ok: false, error: "Forbidden" } as const;
|
||||
|
||||
const permission = await checkPatchEditPermission(hack, user.id, supabase);
|
||||
if (permission.error) {
|
||||
return { ok: false, error: permission.error } as const;
|
||||
}
|
||||
if (!permission.canEdit) {
|
||||
return { ok: false, error: "Forbidden" } as const;
|
||||
}
|
||||
|
||||
// Enforce unique version per hack defensively (avoid race with presign step)
|
||||
const { data: existing, error: vErr } = await supabase
|
||||
|
|
|
|||
173
src/utils/hack.ts
Normal file
173
src/utils/hack.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { checkUserRoles } from "@/utils/user";
|
||||
|
||||
type HackWithArchiveFields = {
|
||||
original_author: string | null;
|
||||
current_patch: number | null;
|
||||
permission_from?: string | null;
|
||||
created_by: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a hack is an informational archive (has original_author but no patch)
|
||||
*/
|
||||
export function isInformationalArchiveHack(hack: HackWithArchiveFields): boolean {
|
||||
return hack.original_author != null && hack.current_patch === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a hack is a downloadable archive (has original_author, patch, and permission_from)
|
||||
*/
|
||||
export function isDownloadableArchiveHack(hack: HackWithArchiveFields): boolean {
|
||||
return hack.original_author != null && hack.current_patch !== null && hack.permission_from != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a hack is any type of archive (informational or downloadable)
|
||||
*/
|
||||
export function isArchiveHack(hack: HackWithArchiveFields): boolean {
|
||||
return isInformationalArchiveHack(hack) || isDownloadableArchiveHack(hack);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user can edit a hack as the creator
|
||||
*/
|
||||
export function canEditAsCreator(hack: HackWithArchiveFields, userId: string): boolean {
|
||||
return hack.created_by === userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user can edit a hack as admin or archiver (for archive hacks only)
|
||||
* Requires a Supabase client to check RPC functions
|
||||
*/
|
||||
export async function canEditAsAdminOrArchiver(
|
||||
hack: HackWithArchiveFields,
|
||||
userId: string,
|
||||
supabase: SupabaseClient<any>,
|
||||
options?: {
|
||||
roles?: {
|
||||
isAdmin: boolean;
|
||||
isArchiver: boolean;
|
||||
}
|
||||
},
|
||||
): Promise<boolean> {
|
||||
if (!isArchiveHack(hack) || canEditAsCreator(hack, userId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options?.roles) {
|
||||
return options.roles.isAdmin || options.roles.isArchiver;
|
||||
}
|
||||
|
||||
const { isAdmin, isArchiver } = await checkUserRoles(supabase);
|
||||
return isAdmin || isArchiver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user can edit a hack as archiver (for downloadable archives only)
|
||||
* Requires a Supabase client to check RPC functions
|
||||
*/
|
||||
export async function canEditAsArchiver(
|
||||
hack: HackWithArchiveFields,
|
||||
userId: string,
|
||||
supabase: SupabaseClient<any>,
|
||||
options?: {
|
||||
roles?: {
|
||||
isArchiver: boolean;
|
||||
}
|
||||
},
|
||||
): Promise<boolean> {
|
||||
if (!isDownloadableArchiveHack(hack) || canEditAsCreator(hack, userId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options?.roles) {
|
||||
return options.roles.isArchiver;
|
||||
}
|
||||
|
||||
const { isArchiver } = await checkUserRoles(supabase);
|
||||
return isArchiver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user can edit a hack (as creator, admin, or archiver)
|
||||
* Returns an object with permission details
|
||||
*/
|
||||
export async function checkEditPermission(
|
||||
hack: HackWithArchiveFields,
|
||||
userId: string,
|
||||
supabase: SupabaseClient<any>
|
||||
): Promise<{
|
||||
canEdit: boolean;
|
||||
canEditAsCreator: boolean;
|
||||
canEditAsAdminOrArchiver: boolean;
|
||||
isInformationalArchive: boolean;
|
||||
isDownloadableArchive: boolean;
|
||||
isArchive: boolean;
|
||||
}> {
|
||||
const canEditAsCreatorValue = canEditAsCreator(hack, userId);
|
||||
const isInformationalArchiveValue = isInformationalArchiveHack(hack);
|
||||
const isDownloadableArchiveValue = isDownloadableArchiveHack(hack);
|
||||
const isArchiveValue = isArchiveHack(hack);
|
||||
|
||||
let canEditAsAdminOrArchiverValue = false;
|
||||
if (isArchiveValue && !canEditAsCreatorValue) {
|
||||
canEditAsAdminOrArchiverValue = await canEditAsAdminOrArchiver(hack, userId, supabase);
|
||||
}
|
||||
|
||||
return {
|
||||
canEdit: canEditAsCreatorValue || canEditAsAdminOrArchiverValue,
|
||||
canEditAsCreator: canEditAsCreatorValue,
|
||||
canEditAsAdminOrArchiver: canEditAsAdminOrArchiverValue,
|
||||
isInformationalArchive: isInformationalArchiveValue,
|
||||
isDownloadableArchive: isDownloadableArchiveValue,
|
||||
isArchive: isArchiveValue,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user can edit a hack for patch operations (blocks informational archives)
|
||||
* Returns an object with permission details
|
||||
*/
|
||||
export async function checkPatchEditPermission(
|
||||
hack: HackWithArchiveFields,
|
||||
userId: string,
|
||||
supabase: SupabaseClient<any>
|
||||
): Promise<{
|
||||
canEdit: boolean;
|
||||
canEditAsCreator: boolean;
|
||||
canEditAsArchiver: boolean;
|
||||
isInformationalArchive: boolean;
|
||||
isDownloadableArchive: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const canEditAsCreatorValue = canEditAsCreator(hack, userId);
|
||||
const isInformationalArchiveValue = isInformationalArchiveHack(hack);
|
||||
const isDownloadableArchiveValue = isDownloadableArchiveHack(hack);
|
||||
|
||||
// Informational archives cannot have patches
|
||||
if (isInformationalArchiveValue) {
|
||||
return {
|
||||
canEdit: false,
|
||||
canEditAsCreator: canEditAsCreatorValue,
|
||||
canEditAsArchiver: false,
|
||||
isInformationalArchive: isInformationalArchiveValue,
|
||||
isDownloadableArchive: isDownloadableArchiveValue,
|
||||
error: "Informational archives cannot have patch files",
|
||||
};
|
||||
}
|
||||
|
||||
let canEditAsArchiverValue = false;
|
||||
if (isDownloadableArchiveValue && !canEditAsCreatorValue) {
|
||||
canEditAsArchiverValue = await canEditAsArchiver(hack, userId, supabase);
|
||||
}
|
||||
|
||||
return {
|
||||
canEdit: canEditAsCreatorValue || canEditAsArchiverValue,
|
||||
canEditAsCreator: canEditAsCreatorValue,
|
||||
canEditAsArchiver: canEditAsArchiverValue,
|
||||
isInformationalArchive: isInformationalArchiveValue,
|
||||
isDownloadableArchive: isDownloadableArchiveValue,
|
||||
};
|
||||
}
|
||||
|
||||
17
src/utils/user.ts
Normal file
17
src/utils/user.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
/**
|
||||
* Check if a user is admin or archiver
|
||||
*/
|
||||
export async function checkUserRoles(
|
||||
supabase: SupabaseClient<any>
|
||||
): Promise<{
|
||||
isAdmin: boolean;
|
||||
isArchiver: boolean;
|
||||
}> {
|
||||
const { data: claims } = await supabase.rpc("get_my_claims");
|
||||
return {
|
||||
isAdmin: claims?.claims_admin ?? false,
|
||||
isArchiver: claims?.archiver ?? false,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
-- Archivers can view covers for archive hacks (including unapproved ones)
|
||||
CREATE POLICY "Archivers can view covers for archive hacks." ON "public"."hack_covers" FOR SELECT USING (("public"."is_archiver"() AND "public"."is_archive_hack_for_archiver"("hack_slug")));
|
||||
|
||||
-- Archivers can view tags for archive hacks (including unapproved ones)
|
||||
CREATE POLICY "Archivers can view tags for archive hacks." ON "public"."hack_tags" FOR SELECT USING (("public"."is_archiver"() AND "public"."is_archive_hack_for_archiver"("hack_slug")));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user