Fix permissions issues with archivers and archive hacks

This commit is contained in:
Jared Schoeny 2025-12-07 00:00:53 -10:00
parent a4c9474f7f
commit ef55c0a40d
11 changed files with 333 additions and 66 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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