Extend fine-grained ACL to settings, content, shop, and radio.
Local Build and Deploy / deploy (push) Successful in 54s

Gate pages and mutations on module PERMS instead of dashboard-only staff checks, add radio view/edit slugs with migration 0015, and expand the operations contract tests.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-15 20:11:55 +02:00
1 parent f2427b3483
commit 3c8a8ff888
32 files changed
+244 -70

No files matched your search

@@ -25,6 +25,8 @@ INSERT INTO `acl_permissions` (`slug`, `title`) VALUES
('admin.catalog.edit', 'Edit catalog'), ('admin.catalog.edit', 'Edit catalog'),
('admin.rcon.execute', 'Execute RCON commands'), ('admin.rcon.execute', 'Execute RCON commands'),
('admin.export', 'Export data'), ('admin.export', 'Export data'),
('admin.radio.view', 'View radio admin'),
('admin.radio.edit', 'Edit radio admin'),
('admin.prefixes.view', 'View prefixes'), ('admin.prefixes.view', 'View prefixes'),
('admin.prefixes.edit', 'Edit prefixes'), ('admin.prefixes.edit', 'Edit prefixes'),
('admin.tickets.view', 'View tickets'), ('admin.tickets.view', 'View tickets'),
@@ -0,0 +1,33 @@
INSERT INTO `acl_permissions` (`slug`, `title`) VALUES
('admin.radio.view', 'View radio admin'),
('admin.radio.edit', 'Edit radio admin')
ON DUPLICATE KEY UPDATE `title` = VALUES(`title`);
-- Rank >= 6 gets radio view (matches other admin.*.view grants from 0012).
INSERT INTO `acl_model_permissions` (`model_type`, `model_id`, `permission_id`)
SELECT 'Role', ar.id, ap.id
FROM `permission_ranks` pr
JOIN `acl_roles` ar ON ar.slug = CONCAT('rank_', pr.id)
JOIN `acl_permissions` ap ON ap.slug = 'admin.radio.view'
WHERE pr.id >= 6
AND NOT EXISTS (
SELECT 1 FROM `acl_model_permissions` amp
WHERE amp.model_type = 'Role'
AND amp.model_id = ar.id
AND amp.permission_id = ap.id
);
-- Highest rank gets radio edit (same pattern as assets.import / permissions.manage).
INSERT INTO `acl_model_permissions` (`model_type`, `model_id`, `permission_id`)
SELECT 'Role', ar.id, ap.id
FROM `permission_ranks` pr
JOIN `acl_roles` ar ON ar.slug = CONCAT('rank_', pr.id)
CROSS JOIN `acl_permissions` ap
WHERE pr.id = (SELECT MAX(id) FROM `permission_ranks`)
AND ap.slug = 'admin.radio.edit'
AND NOT EXISTS (
SELECT 1 FROM `acl_model_permissions` amp
WHERE amp.model_type = 'Role'
AND amp.model_id = ar.id
AND amp.permission_id = ap.id
);
+5 -4
View File
@@ -2,7 +2,8 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { slugify } from "@/lib/format"; import { slugify } from "@/lib/format";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -22,7 +23,7 @@ async function uniqueSlug(title: string): Promise<string> {
} }
export async function createArticle(formData: FormData): Promise<void> { export async function createArticle(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requirePermission(PERMS.NEWS_EDIT);
const title = String(formData.get("title") ?? "") const title = String(formData.get("title") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
@@ -61,7 +62,7 @@ export async function createArticle(formData: FormData): Promise<void> {
} }
export async function updateArticle(formData: FormData): Promise<void> { export async function updateArticle(formData: FormData): Promise<void> {
await requireStaff(); await requirePermission(PERMS.NEWS_EDIT);
const id = BigInt(String(formData.get("id"))); const id = BigInt(String(formData.get("id")));
try { try {
await prisma.websiteArticles.update({ await prisma.websiteArticles.update({
@@ -93,7 +94,7 @@ export async function updateArticle(formData: FormData): Promise<void> {
} }
export async function deleteArticle(formData: FormData): Promise<void> { export async function deleteArticle(formData: FormData): Promise<void> {
await requireStaff(); await requirePermission(PERMS.NEWS_EDIT);
const id = BigInt(String(formData.get("id"))); const id = BigInt(String(formData.get("id")));
try { try {
await prisma.$transaction([ await prisma.$transaction([
+4 -3
View File
@@ -2,7 +2,8 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import type { $Enums } from "@/generated/prisma/client"; import type { $Enums } from "@/generated/prisma/client";
import { requireStaffRateLimited as requireStaff } from "@/lib/admin/guard"; import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -17,7 +18,7 @@ const BAN_TYPES: ReadonlySet<string> = new Set([
const PERMANENT_SECONDS = 100 * 365 * 24 * 3600; const PERMANENT_SECONDS = 100 * 365 * 24 * 3600;
export async function createBan(formData: FormData): Promise<void> { export async function createBan(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requirePermissionRateLimited(PERMS.USERS_BAN);
const userId = Number(formData.get("userId")); const userId = Number(formData.get("userId"));
const reason = const reason =
String(formData.get("reason") ?? "") String(formData.get("reason") ?? "")
@@ -63,7 +64,7 @@ export async function createBan(formData: FormData): Promise<void> {
} }
export async function liftBan(formData: FormData): Promise<void> { export async function liftBan(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requirePermissionRateLimited(PERMS.USERS_BAN);
const id = Number(formData.get("id")); const id = Number(formData.get("id"));
if (id > 0) { if (id > 0) {
await prisma.ban.delete({ where: { id } }); await prisma.ban.delete({ where: { id } });
+4 -3
View File
@@ -1,7 +1,8 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
// emulator_settings: PK is the string column `key`, payload is `value` (VarChar 512). // emulator_settings: PK is the string column `key`, payload is `value` (VarChar 512).
@@ -10,7 +11,7 @@ import { prisma } from "@/lib/prisma";
// keys via upsert. We never migrate or drop them. // keys via upsert. We never migrate or drop them.
export async function updateEmulatorSetting(formData: FormData): Promise<void> { export async function updateEmulatorSetting(formData: FormData): Promise<void> {
await requireStaff(); await requirePermission(PERMS.SETTINGS_EDIT);
const key = String(formData.get("key") ?? "") const key = String(formData.get("key") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
@@ -28,7 +29,7 @@ export async function updateEmulatorSetting(formData: FormData): Promise<void> {
} }
export async function updateEmulatorText(formData: FormData): Promise<void> { export async function updateEmulatorText(formData: FormData): Promise<void> {
await requireStaff(); await requirePermission(PERMS.SETTINGS_EDIT);
const key = String(formData.get("key") ?? "") const key = String(formData.get("key") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
+5 -4
View File
@@ -3,7 +3,8 @@
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -35,7 +36,7 @@ function intOr(raw: FormDataEntryValue | null, fallback: number): number {
} }
export async function createApiKey(formData: FormData): Promise<void> { export async function createApiKey(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requirePermission(PERMS.RADIO_EDIT);
const name = str(formData.get("name")).trim().slice(0, 255); const name = str(formData.get("name")).trim().slice(0, 255);
if (!name) return; if (!name) return;
@@ -77,7 +78,7 @@ export async function createApiKey(formData: FormData): Promise<void> {
} }
export async function toggleApiKey(formData: FormData): Promise<void> { export async function toggleApiKey(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requirePermission(PERMS.RADIO_EDIT);
const id = parseId(formData.get("id")); const id = parseId(formData.get("id"));
if (id == null) return; if (id == null) return;
@@ -109,7 +110,7 @@ export async function toggleApiKey(formData: FormData): Promise<void> {
} }
export async function deleteApiKey(formData: FormData): Promise<void> { export async function deleteApiKey(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requirePermission(PERMS.RADIO_EDIT);
const id = parseId(formData.get("id")); const id = parseId(formData.get("id"));
if (id == null) return; if (id == null) return;
+5 -4
View File
@@ -1,7 +1,8 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -51,7 +52,7 @@ function optUInt(raw: FormDataEntryValue | null): number | null {
// ── AutoDJ playlist CRUD (radio_auto_dj_playlist) ──────────────────────── // ── AutoDJ playlist CRUD (radio_auto_dj_playlist) ────────────────────────
export async function createTrack(formData: FormData): Promise<void> { export async function createTrack(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requirePermission(PERMS.RADIO_EDIT);
const title = str(formData.get("title")).trim().slice(0, 255); const title = str(formData.get("title")).trim().slice(0, 255);
if (!title) return; if (!title) return;
@@ -91,7 +92,7 @@ export async function createTrack(formData: FormData): Promise<void> {
} }
export async function toggleTrack(formData: FormData): Promise<void> { export async function toggleTrack(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requirePermission(PERMS.RADIO_EDIT);
const id = parseId(formData.get("id")); const id = parseId(formData.get("id"));
if (id === null) return; if (id === null) return;
@@ -117,7 +118,7 @@ export async function toggleTrack(formData: FormData): Promise<void> {
} }
export async function deleteTrack(formData: FormData): Promise<void> { export async function deleteTrack(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requirePermission(PERMS.RADIO_EDIT);
const id = parseId(formData.get("id")); const id = parseId(formData.get("id"));
if (id === null) return; if (id === null) return;
+10 -9
View File
@@ -1,7 +1,8 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -36,7 +37,7 @@ function bool(raw: FormDataEntryValue | null): boolean {
* siteSettings cache so the public radio pages pick the change up immediately. * siteSettings cache so the public radio pages pick the change up immediately.
*/ */
export async function saveRadioSetting(formData: FormData): Promise<void> { export async function saveRadioSetting(formData: FormData): Promise<void> {
await requireStaff(); await requirePermission(PERMS.RADIO_EDIT);
const key = str(formData.get("key")).trim().slice(0, 255); const key = str(formData.get("key")).trim().slice(0, 255);
const value = str(formData.get("value")); const value = str(formData.get("value"));
const comment = str(formData.get("comment")).trim().slice(0, 255); const comment = str(formData.get("comment")).trim().slice(0, 255);
@@ -61,7 +62,7 @@ export async function saveRadioSetting(formData: FormData): Promise<void> {
* only touch those (and never wipe unrelated settings). * only touch those (and never wipe unrelated settings).
*/ */
export async function saveRadioSettings(formData: FormData): Promise<void> { export async function saveRadioSettings(formData: FormData): Promise<void> {
await requireStaff(); await requirePermission(PERMS.RADIO_EDIT);
const keysRaw = str(formData.get("__keys")); const keysRaw = str(formData.get("__keys"));
const keys = keysRaw const keys = keysRaw
.split(",") .split(",")
@@ -90,7 +91,7 @@ export async function saveRadioSettings(formData: FormData): Promise<void> {
// ── Radio banners CRUD (radio_banners) ───────────────────────────────────── // ── Radio banners CRUD (radio_banners) ─────────────────────────────────────
export async function createRadioBanner(formData: FormData): Promise<void> { export async function createRadioBanner(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requirePermission(PERMS.RADIO_EDIT);
const imagePath = str(formData.get("imagePath")).trim().slice(0, 255); const imagePath = str(formData.get("imagePath")).trim().slice(0, 255);
if (!imagePath) return; if (!imagePath) return;
@@ -123,7 +124,7 @@ export async function createRadioBanner(formData: FormData): Promise<void> {
} }
export async function updateRadioBanner(formData: FormData): Promise<void> { export async function updateRadioBanner(formData: FormData): Promise<void> {
await requireStaff(); await requirePermission(PERMS.RADIO_EDIT);
const id = parseId(formData.get("id")); const id = parseId(formData.get("id"));
if (id === null) return; if (id === null) return;
@@ -156,7 +157,7 @@ export async function updateRadioBanner(formData: FormData): Promise<void> {
} }
export async function deleteRadioBanner(formData: FormData): Promise<void> { export async function deleteRadioBanner(formData: FormData): Promise<void> {
await requireStaff(); await requirePermission(PERMS.RADIO_EDIT);
const id = parseId(formData.get("id")); const id = parseId(formData.get("id"));
if (id === null) return; if (id === null) return;
try { try {
@@ -170,7 +171,7 @@ export async function deleteRadioBanner(formData: FormData): Promise<void> {
// ── Radio ranks CRUD (radio_ranks) ───────────────────────────────────────── // ── Radio ranks CRUD (radio_ranks) ─────────────────────────────────────────
export async function createRadioRank(formData: FormData): Promise<void> { export async function createRadioRank(formData: FormData): Promise<void> {
await requireStaff(); await requirePermission(PERMS.RADIO_EDIT);
const name = str(formData.get("name")).trim().slice(0, 255); const name = str(formData.get("name")).trim().slice(0, 255);
if (!name) return; if (!name) return;
@@ -197,7 +198,7 @@ export async function createRadioRank(formData: FormData): Promise<void> {
} }
export async function updateRadioRank(formData: FormData): Promise<void> { export async function updateRadioRank(formData: FormData): Promise<void> {
await requireStaff(); await requirePermission(PERMS.RADIO_EDIT);
const id = parseId(formData.get("id")); const id = parseId(formData.get("id"));
if (id === null) return; if (id === null) return;
@@ -225,7 +226,7 @@ export async function updateRadioRank(formData: FormData): Promise<void> {
} }
export async function deleteRadioRank(formData: FormData): Promise<void> { export async function deleteRadioRank(formData: FormData): Promise<void> {
await requireStaff(); await requirePermission(PERMS.RADIO_EDIT);
const id = parseId(formData.get("id")); const id = parseId(formData.get("id"));
if (id === null) return; if (id === null) return;
try { try {
+3 -2
View File
@@ -1,7 +1,8 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -22,7 +23,7 @@ function parseId(raw: FormDataEntryValue | null): bigint | null {
* moderation route. Fails soft if the row is already gone. * moderation route. Fails soft if the row is already gone.
*/ */
export async function deleteShout(formData: FormData): Promise<void> { export async function deleteShout(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requirePermission(PERMS.RADIO_EDIT);
const id = parseId(formData.get("id")); const id = parseId(formData.get("id"));
if (id === null) return; if (id === null) return;
+3 -2
View File
@@ -2,7 +2,8 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -46,7 +47,7 @@ function intStr(raw: FormDataEntryValue | null): string {
} }
export async function savePoints(formData: FormData): Promise<void> { export async function savePoints(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requirePermission(PERMS.RADIO_EDIT);
const currencyRaw = str(formData.get("radio_points_currency")) const currencyRaw = str(formData.get("radio_points_currency"))
.trim() .trim()
+5 -4
View File
@@ -1,12 +1,13 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requireStaffRateLimited as requireStaff } from "@/lib/admin/guard"; import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
export async function updateSetting(formData: FormData): Promise<void> { export async function updateSetting(formData: FormData): Promise<void> {
await requireStaff(); await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
const key = String(formData.get("key") ?? "") const key = String(formData.get("key") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
@@ -18,7 +19,7 @@ export async function updateSetting(formData: FormData): Promise<void> {
} }
export async function createSetting(formData: FormData): Promise<void> { export async function createSetting(formData: FormData): Promise<void> {
await requireStaff(); await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
const key = String(formData.get("key") ?? "") const key = String(formData.get("key") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
@@ -39,7 +40,7 @@ export async function createSetting(formData: FormData): Promise<void> {
} }
export async function deleteSetting(formData: FormData): Promise<void> { export async function deleteSetting(formData: FormData): Promise<void> {
await requireStaff(); await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
const key = String(formData.get("key") ?? "") const key = String(formData.get("key") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
+5 -4
View File
@@ -2,7 +2,8 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { formPositiveBigInt } from "@/lib/form-data"; import { formPositiveBigInt } from "@/lib/form-data";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logServerError } from "@/lib/server-log"; import { logServerError } from "@/lib/server-log";
@@ -30,7 +31,7 @@ function reqUInt(formData: FormData, key: string): number {
} }
export async function createShopArticle(formData: FormData): Promise<void> { export async function createShopArticle(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requirePermission(PERMS.SHOP_EDIT);
const name = String(formData.get("name") ?? "") const name = String(formData.get("name") ?? "")
.normalize("NFC") .normalize("NFC")
@@ -90,7 +91,7 @@ export async function createShopArticle(formData: FormData): Promise<void> {
} }
export async function updateShopArticle(formData: FormData): Promise<void> { export async function updateShopArticle(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requirePermission(PERMS.SHOP_EDIT);
const id = formPositiveBigInt(formData, "id"); const id = formPositiveBigInt(formData, "id");
if (!id) return; if (!id) return;
@@ -152,7 +153,7 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
} }
export async function deleteShopArticle(formData: FormData): Promise<void> { export async function deleteShopArticle(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requirePermission(PERMS.SHOP_EDIT);
const id = formPositiveBigInt(formData, "id"); const id = formPositiveBigInt(formData, "id");
if (!id) return; if (!id) return;
+8 -7
View File
@@ -2,7 +2,8 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -30,7 +31,7 @@ async function writeSetting(key: string, value: string): Promise<void> {
} }
export async function saveTheme(formData: FormData): Promise<void> { export async function saveTheme(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requirePermission(PERMS.SETTINGS_EDIT);
try { try {
for (const mode of ["light", "dark"] as const) { for (const mode of ["light", "dark"] as const) {
@@ -96,7 +97,7 @@ export async function saveTheme(formData: FormData): Promise<void> {
} }
export async function applyPreset(formData: FormData): Promise<void> { export async function applyPreset(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requirePermission(PERMS.SETTINGS_EDIT);
const name = String(formData.get("preset") ?? "").normalize("NFC"); const name = String(formData.get("preset") ?? "").normalize("NFC");
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below // eslint-disable-next-line security/detect-object-injection -- guarded by null check below
const preset = PRESETS[name]; const preset = PRESETS[name];
@@ -120,7 +121,7 @@ export async function applyPreset(formData: FormData): Promise<void> {
} }
export async function saveCustomTheme(formData: FormData): Promise<void> { export async function saveCustomTheme(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requirePermission(PERMS.SETTINGS_EDIT);
const name = String(formData.get("name") ?? "") const name = String(formData.get("name") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
@@ -141,7 +142,7 @@ export async function saveCustomTheme(formData: FormData): Promise<void> {
} }
export async function applyCustomTheme(formData: FormData): Promise<void> { export async function applyCustomTheme(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requirePermission(PERMS.SETTINGS_EDIT);
const id = String(formData.get("id") ?? "") const id = String(formData.get("id") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
@@ -167,7 +168,7 @@ export async function applyCustomTheme(formData: FormData): Promise<void> {
} }
export async function renameCustomTheme(formData: FormData): Promise<void> { export async function renameCustomTheme(formData: FormData): Promise<void> {
await requireStaff(); await requirePermission(PERMS.SETTINGS_EDIT);
const id = String(formData.get("id") ?? "") const id = String(formData.get("id") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
@@ -186,7 +187,7 @@ export async function renameCustomTheme(formData: FormData): Promise<void> {
} }
export async function deleteCustomTheme(formData: FormData): Promise<void> { export async function deleteCustomTheme(formData: FormData): Promise<void> {
await requireStaff(); await requirePermission(PERMS.SETTINGS_EDIT);
const id = String(formData.get("id") ?? "") const id = String(formData.get("id") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
+4 -3
View File
@@ -1,12 +1,13 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
export async function addWord(formData: FormData): Promise<void> { export async function addWord(formData: FormData): Promise<void> {
await requireStaff(); await requirePermission(PERMS.WORDFILTER_EDIT);
const word = String(formData.get("word") ?? "") const word = String(formData.get("word") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
@@ -23,7 +24,7 @@ export async function addWord(formData: FormData): Promise<void> {
} }
export async function deleteWord(formData: FormData): Promise<void> { export async function deleteWord(formData: FormData): Promise<void> {
await requireStaff(); await requirePermission(PERMS.WORDFILTER_EDIT);
const raw = String(formData.get("id") ?? "").normalize("NFC"); const raw = String(formData.get("id") ?? "").normalize("NFC");
if (!raw) return; if (!raw) return;
+7 -1
View File
@@ -1,9 +1,10 @@
import { Newspaper } from "lucide-react"; import { Newspaper } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { deleteArticle, updateArticle } from "@/actions/admin-articles"; import { deleteArticle, updateArticle } from "@/actions/admin-articles";
import { ArticleForm } from "@/components/admin/article-form"; import { ArticleForm } from "@/components/admin/article-form";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -15,6 +16,11 @@ export default async function EditArticle({
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
searchParams: Promise<{ error?: string }>; searchParams: Promise<{ error?: string }>;
}) { }) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.NEWS_VIEW, session.user.rank)) {
redirect("/admin");
}
const { id } = await params; const { id } = await params;
const { error } = await searchParams; const { error } = await searchParams;
const t = await getTranslations("pages.admin.articles"); const t = await getTranslations("pages.admin.articles");
+7
View File
@@ -1,14 +1,21 @@
import { Newspaper } from "lucide-react"; import { Newspaper } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { createArticle } from "@/actions/admin-articles"; import { createArticle } from "@/actions/admin-articles";
import { ArticleForm } from "@/components/admin/article-form"; import { ArticleForm } from "@/components/admin/article-form";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function NewArticle({ export default async function NewArticle({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ error?: string }>; searchParams: Promise<{ error?: string }>;
}) { }) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.NEWS_EDIT, session.user.rank)) {
redirect("/admin");
}
const { error } = await searchParams; const { error } = await searchParams;
const t = await getTranslations("pages.admin.articles"); const t = await getTranslations("pages.admin.articles");
+7
View File
@@ -1,8 +1,10 @@
import { Newspaper } from "lucide-react"; import { Newspaper } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { deleteArticle } from "@/actions/admin-articles"; import { deleteArticle } from "@/actions/admin-articles";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -12,6 +14,11 @@ export default async function AdminArticles({
}: { }: {
searchParams: Promise<{ error?: string }>; searchParams: Promise<{ error?: string }>;
}) { }) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.NEWS_VIEW, session.user.rank)) {
redirect("/admin");
}
const { error } = await searchParams; const { error } = await searchParams;
const t = await getTranslations("pages.admin.articles"); const t = await getTranslations("pages.admin.articles");
const articles = await prisma.websiteArticles const articles = await prisma.websiteArticles
+7
View File
@@ -1,7 +1,9 @@
import { Ban } from "lucide-react"; import { Ban } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { createBan, liftBan } from "@/actions/admin-bans"; import { createBan, liftBan } from "@/actions/admin-bans";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -11,6 +13,11 @@ function fromUnix(ts: number): string {
} }
export default async function AdminBans() { export default async function AdminBans() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.BANS_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.bans"); const t = await getTranslations("pages.admin.bans");
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
let bans: Awaited<ReturnType<typeof prisma.ban.findMany>> = []; let bans: Awaited<ReturnType<typeof prisma.ban.findMany>> = [];
+7
View File
@@ -1,9 +1,11 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { import {
updateEmulatorSetting, updateEmulatorSetting,
updateEmulatorText, updateEmulatorText,
} from "@/actions/admin-emulator"; } from "@/actions/admin-emulator";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -11,6 +13,11 @@ export const dynamic = "force-dynamic";
const TEXTS_LIMIT = 300; const TEXTS_LIMIT = 300;
export default async function AdminEmulator() { export default async function AdminEmulator() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.emulator"); const t = await getTranslations("pages.admin.emulator");
let settings: { key: string; value: string }[] = []; let settings: { key: string; value: string }[] = [];
-3
View File
@@ -2,7 +2,6 @@ import { Code } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { requireStaff } from "@/lib/admin/guard";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import { EmbedSnippet } from "./embed-snippet"; import { EmbedSnippet } from "./embed-snippet";
@@ -11,8 +10,6 @@ export const dynamic = "force-dynamic";
export const metadata = { title: "Radio Embed" }; export const metadata = { title: "Radio Embed" };
export default async function AdminRadioEmbedPage() { export default async function AdminRadioEmbedPage() {
await requireStaff();
const t = await getTranslations("pages.admin.radio"); const t = await getTranslations("pages.admin.radio");
let streamUrl = ""; let streamUrl = "";
+15
View File
@@ -0,0 +1,15 @@
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function RadioAdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.RADIO_VIEW, session.user.rank)) {
redirect("/admin");
}
return children;
}
-3
View File
@@ -2,7 +2,6 @@ import { Radio } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { DiagnosticRow, StatusCard } from "@/components/admin/dashboard"; import { DiagnosticRow, StatusCard } from "@/components/admin/dashboard";
import { requireStaff } from "@/lib/admin/guard";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -166,8 +165,6 @@ function shortUrl(url: string): string {
} }
export default async function AdminRadioMonitoringPage() { export default async function AdminRadioMonitoringPage() {
await requireStaff();
const t = await getTranslations("pages.admin.radio"); const t = await getTranslations("pages.admin.radio");
let nowPlayingUrl: string | null = null; let nowPlayingUrl: string | null = null;
+7
View File
@@ -1,14 +1,21 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { import {
createSetting, createSetting,
deleteSetting, deleteSetting,
updateSetting, updateSetting,
} from "@/actions/admin-settings"; } from "@/actions/admin-settings";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function AdminSettings() { export default async function AdminSettings() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.settings"); const t = await getTranslations("pages.admin.settings");
let settings: Awaited<ReturnType<typeof prisma.websiteSetting.findMany>> = []; let settings: Awaited<ReturnType<typeof prisma.websiteSetting.findMany>> = [];
+7 -1
View File
@@ -1,8 +1,9 @@
import { Package } from "lucide-react"; import { Package } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { deleteShopArticle, updateShopArticle } from "@/actions/admin-shop"; import { deleteShopArticle, updateShopArticle } from "@/actions/admin-shop";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -12,6 +13,11 @@ export default async function EditShopArticle({
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}) { }) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.shop"); const t = await getTranslations("pages.admin.shop");
const { id } = await params; const { id } = await params;
+7
View File
@@ -1,9 +1,16 @@
import { Package } from "lucide-react"; import { Package } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { createShopArticle } from "@/actions/admin-shop"; import { createShopArticle } from "@/actions/admin-shop";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function NewShopArticle() { export default async function NewShopArticle() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SHOP_EDIT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.shop"); const t = await getTranslations("pages.admin.shop");
return ( return (
+7
View File
@@ -1,6 +1,8 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -25,6 +27,11 @@ function statusState(
} }
export default async function AdminShop() { export default async function AdminShop() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.shop"); const t = await getTranslations("pages.admin.shop");
let packages: Awaited< let packages: Awaited<
+7
View File
@@ -1,3 +1,4 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { import {
applyCustomTheme, applyCustomTheme,
@@ -7,6 +8,7 @@ import {
saveTheme, saveTheme,
} from "@/actions/admin-theme"; } from "@/actions/admin-theme";
import { RenameTheme } from "@/components/admin/theme/rename-theme"; import { RenameTheme } from "@/components/admin/theme/rename-theme";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import { listCustomThemes } from "@/lib/theme-custom-store"; import { listCustomThemes } from "@/lib/theme-custom-store";
import { FONTS, PRESETS } from "@/lib/theme-presets"; import { FONTS, PRESETS } from "@/lib/theme-presets";
@@ -317,6 +319,11 @@ export default async function AdminTheme({
deletedTheme?: string; deletedTheme?: string;
}>; }>;
}) { }) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.theme"); const t = await getTranslations("pages.admin.theme");
const sp = await searchParams; const sp = await searchParams;
+7
View File
@@ -1,7 +1,9 @@
import { Filter } from "lucide-react"; import { Filter } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { addWord, deleteWord } from "@/actions/admin-wordfilter"; import { addWord, deleteWord } from "@/actions/admin-wordfilter";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -9,6 +11,11 @@ export const dynamic = "force-dynamic";
type WordRow = { id: bigint; word: string }; type WordRow = { id: bigint; word: string };
export default async function AdminWordFilter() { export default async function AdminWordFilter() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.WORDFILTER_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.wordfilter"); const t = await getTranslations("pages.admin.wordfilter");
let words: WordRow[] = []; let words: WordRow[] = [];
+28
View File
@@ -19,6 +19,13 @@ const ROUTES: Array<[string, string]> = [
["online", "PERMS.USERS_VIEW"], ["online", "PERMS.USERS_VIEW"],
["commandocentrum", "PERMS.RCON_EXECUTE"], ["commandocentrum", "PERMS.RCON_EXECUTE"],
["users/edit/[id]", "PERMS.USERS_EDIT"], ["users/edit/[id]", "PERMS.USERS_EDIT"],
["settings", "PERMS.SETTINGS_VIEW"],
["theme", "PERMS.SETTINGS_VIEW"],
["emulator", "PERMS.SETTINGS_VIEW"],
["bans", "PERMS.BANS_VIEW"],
["wordfilter", "PERMS.WORDFILTER_VIEW"],
["articles", "PERMS.NEWS_VIEW"],
["shop", "PERMS.SHOP_VIEW"],
]; ];
describe("admin operations route contract", () => { describe("admin operations route contract", () => {
@@ -28,6 +35,12 @@ describe("admin operations route contract", () => {
expect(readFileSync(path, "utf8"), path).toContain(permission); expect(readFileSync(path, "utf8"), path).toContain(permission);
}); });
it("guards radio section via layout", () => {
const path = "src/app/admin/radio/layout.tsx";
expect(existsSync(path), path).toBe(true);
expect(readFileSync(path, "utf8"), path).toContain("PERMS.RADIO_VIEW");
});
it.each([ it.each([
["analytics/export", "PERMS.ANALYTICS_EXPORT"], ["analytics/export", "PERMS.ANALYTICS_EXPORT"],
["devops/health", "PERMS.DEVOPS_VIEW"], ["devops/health", "PERMS.DEVOPS_VIEW"],
@@ -55,4 +68,19 @@ describe("admin operations route contract", () => {
expect(source).toContain("PERMS.RCON_EXECUTE"); expect(source).toContain("PERMS.RCON_EXECUTE");
expect(source).not.toContain("requireStaff()"); expect(source).not.toContain("requireStaff()");
}); });
it.each([
["admin-settings.ts", "PERMS.SETTINGS_EDIT"],
["admin-theme.ts", "PERMS.SETTINGS_EDIT"],
["admin-emulator.ts", "PERMS.SETTINGS_EDIT"],
["admin-bans.ts", "PERMS.USERS_BAN"],
["admin-wordfilter.ts", "PERMS.WORDFILTER_EDIT"],
["admin-articles.ts", "PERMS.NEWS_EDIT"],
["admin-shop.ts", "PERMS.SHOP_EDIT"],
["admin-radio-autodj.ts", "PERMS.RADIO_EDIT"],
])("guards %s mutations with %s", (file, permission) => {
const source = readFileSync(`src/actions/${file}`, "utf8");
expect(source).toContain(permission);
expect(source).not.toMatch(/await requireStaff\(\)/);
});
}); });
+15 -13
View File
@@ -1,32 +1,34 @@
import { readFileSync } from "node:fs"; import { readdirSync, readFileSync } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
describe("production ACL migration", () => { describe("production ACL migration", () => {
const migrationPath = resolve( const migrationsDir = resolve(process.cwd(), "prisma/migrations");
process.cwd(), const allSql = readdirSync(migrationsDir)
"prisma/migrations/0012_seed_acl_permissions.sql", .filter((name) => name.endsWith(".sql"))
); .map((name) => readFileSync(resolve(migrationsDir, name), "utf8"))
.join("\n");
it("seeds every permission used by the application", () => { it("seeds every permission used by the application", () => {
const sql = readFileSync(migrationPath, "utf8");
for (const slug of Object.values(PERMS)) { for (const slug of Object.values(PERMS)) {
expect(sql, `missing ACL seed for ${slug}`).toContain(`'${slug}'`); expect(allSql, `missing ACL seed for ${slug}`).toContain(`'${slug}'`);
} }
}); });
it("assigns dashboard access after seeding permissions", () => { it("assigns dashboard access after seeding permissions", () => {
const sql = readFileSync(migrationPath, "utf8"); const seedSql = readFileSync(
const seedPosition = sql.indexOf("INSERT INTO `acl_permissions`"); resolve(migrationsDir, "0012_seed_acl_permissions.sql"),
const assignmentPosition = sql.indexOf( "utf8",
);
const seedPosition = seedSql.indexOf("INSERT INTO `acl_permissions`");
const assignmentPosition = seedSql.indexOf(
"INSERT INTO `acl_model_permissions`", "INSERT INTO `acl_model_permissions`",
); );
expect(seedPosition).toBeGreaterThanOrEqual(0); expect(seedPosition).toBeGreaterThanOrEqual(0);
expect(assignmentPosition).toBeGreaterThan(seedPosition); expect(assignmentPosition).toBeGreaterThan(seedPosition);
expect(sql).toContain("ap.slug = 'admin.dashboard'"); expect(seedSql).toContain("ap.slug = 'admin.dashboard'");
expect(sql).toContain("ap.slug = 'mod.dashboard'"); expect(seedSql).toContain("ap.slug = 'mod.dashboard'");
}); });
}); });
+10
View File
@@ -39,6 +39,16 @@ export async function requirePermission(permission: string): Promise<StaffUser>
}; };
} }
export async function requirePermissionRateLimited(
permission: string,
): Promise<StaffUser> {
const staff = await requirePermission(permission);
const ip = await clientIp();
if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok)
redirectSafe("/admin?error=ratelimit", "/admin");
return staff;
}
export async function requireStaffRateLimited(): Promise<StaffUser> { export async function requireStaffRateLimited(): Promise<StaffUser> {
const staff = await requireStaff(); const staff = await requireStaff();
const ip = await clientIp(); const ip = await clientIp();
+3
View File
@@ -26,6 +26,9 @@ export const PERMS = {
CATALOG_EDIT: "admin.catalog.edit", CATALOG_EDIT: "admin.catalog.edit",
RCON_EXECUTE: "admin.rcon.execute", RCON_EXECUTE: "admin.rcon.execute",
EXPORT: "admin.export", EXPORT: "admin.export",
// ── Radio Module ──
RADIO_VIEW: "admin.radio.view",
RADIO_EDIT: "admin.radio.edit",
PREFIXES_VIEW: "admin.prefixes.view", PREFIXES_VIEW: "admin.prefixes.view",
PREFIXES_EDIT: "admin.prefixes.edit", PREFIXES_EDIT: "admin.prefixes.edit",
TICKETS_VIEW: "admin.tickets.view", TICKETS_VIEW: "admin.tickets.view",