Gate translations, RCON, and user mutations on SETTINGS_EDIT, RCON_EXECUTE, and USERS_EDIT instead of dashboard/rank checks; redirect the legacy user-edit URL to the guarded canonical page. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
bae543baf6
commit
f2427b3483
8 files changed
+127
-284
No files matched your search
@@ -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 { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
@@ -28,8 +29,8 @@ function toInt(value: FormDataEntryValue | null, min = 0): number | null {
|
|||||||
* user from the session and logs the action. emulator-owned users.id is Int.
|
* user from the session and logs the action. emulator-owned users.id is Int.
|
||||||
*/
|
*/
|
||||||
export async function updateUser(formData: FormData): Promise<void> {
|
export async function updateUser(formData: FormData): Promise<void> {
|
||||||
// Never trust the client: re-check staff inside the action.
|
// Never trust the client: re-check USERS_EDIT inside the action.
|
||||||
const staff = await requireStaff();
|
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||||
|
|
||||||
const userId = Number(formData.get("id"));
|
const userId = Number(formData.get("id"));
|
||||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||||
@@ -95,6 +96,6 @@ export async function updateUser(formData: FormData): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath(`/admin/users/${userId}`);
|
revalidatePath(`/admin/users/${userId}`);
|
||||||
revalidatePath(`/admin/users/${userId}/edit`);
|
revalidatePath(`/admin/users/edit/${userId}`);
|
||||||
redirect(`/admin/users/${userId}`);
|
redirect(`/admin/users/show/${userId}`);
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,7 @@ import {
|
|||||||
getClientTranslationFile,
|
getClientTranslationFile,
|
||||||
} from "@/lib/client-translation-files";
|
} from "@/lib/client-translation-files";
|
||||||
import { patchJson5 } from "@/lib/json5-patch";
|
import { patchJson5 } from "@/lib/json5-patch";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
|
|
||||||
@@ -41,10 +42,8 @@ const saveTranslationsSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const saveTranslations = adminAction(
|
export const saveTranslations = adminAction(
|
||||||
{ schema: saveTranslationsSchema },
|
{ permission: PERMS.SETTINGS_EDIT, schema: saveTranslationsSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
if (ctx.session.user.rank < 7) throw new ActionError("Forbidden");
|
|
||||||
|
|
||||||
const filePath = path.join(
|
const filePath = path.join(
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
"messages",
|
"messages",
|
||||||
@@ -68,10 +67,8 @@ const saveClientTranslationsSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const saveClientTranslations = adminAction(
|
export const saveClientTranslations = adminAction(
|
||||||
{ schema: saveClientTranslationsSchema },
|
{ permission: PERMS.SETTINGS_EDIT, schema: saveClientTranslationsSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
if (ctx.session.user.rank < 7) throw new ActionError("Forbidden");
|
|
||||||
|
|
||||||
const file = getClientTranslationFile(ctx.data.fileId);
|
const file = getClientTranslationFile(ctx.data.fileId);
|
||||||
if (!file) throw new ActionError("Unknown file");
|
if (!file) throw new ActionError("Unknown file");
|
||||||
if (file.readOnly) throw new ActionError("File is read-only");
|
if (file.readOnly) throw new ActionError("File is read-only");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import { Activity } from "lucide-react";
|
import { Activity } from "lucide-react";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import {
|
import {
|
||||||
alertUser,
|
alertUser,
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
OnlineUsersWidget,
|
OnlineUsersWidget,
|
||||||
StatusCard,
|
StatusCard,
|
||||||
} from "@/components/admin/dashboard";
|
} from "@/components/admin/dashboard";
|
||||||
|
import { canAccess, getAdminContext, 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 { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
@@ -61,6 +63,11 @@ function uptime(seconds: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function CommandoCentrum() {
|
export default async function CommandoCentrum() {
|
||||||
|
const { session, permissions } = await getAdminContext();
|
||||||
|
if (!canAccess(permissions, PERMS.RCON_EXECUTE, session.user.rank)) {
|
||||||
|
redirect("/admin");
|
||||||
|
}
|
||||||
|
|
||||||
const t = await getTranslations("pages.admin.commandocentrum");
|
const t = await getTranslations("pages.admin.commandocentrum");
|
||||||
|
|
||||||
const [
|
const [
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -17,6 +17,8 @@ const ROUTES: Array<[string, string]> = [
|
|||||||
["devops", "PERMS.DEVOPS_VIEW"],
|
["devops", "PERMS.DEVOPS_VIEW"],
|
||||||
["devops/errors", "PERMS.DEVOPS_VIEW"],
|
["devops/errors", "PERMS.DEVOPS_VIEW"],
|
||||||
["online", "PERMS.USERS_VIEW"],
|
["online", "PERMS.USERS_VIEW"],
|
||||||
|
["commandocentrum", "PERMS.RCON_EXECUTE"],
|
||||||
|
["users/edit/[id]", "PERMS.USERS_EDIT"],
|
||||||
];
|
];
|
||||||
|
|
||||||
describe("admin operations route contract", () => {
|
describe("admin operations route contract", () => {
|
||||||
@@ -29,6 +31,7 @@ describe("admin operations route contract", () => {
|
|||||||
it.each([
|
it.each([
|
||||||
["analytics/export", "PERMS.ANALYTICS_EXPORT"],
|
["analytics/export", "PERMS.ANALYTICS_EXPORT"],
|
||||||
["devops/health", "PERMS.DEVOPS_VIEW"],
|
["devops/health", "PERMS.DEVOPS_VIEW"],
|
||||||
|
["users/actions", "PERMS.USERS_EDIT"],
|
||||||
])("provides and guards /api/admin/%s", (route, permission) => {
|
])("provides and guards /api/admin/%s", (route, permission) => {
|
||||||
const path = `src/app/api/admin/${route}/route.ts`;
|
const path = `src/app/api/admin/${route}/route.ts`;
|
||||||
expect(existsSync(path), path).toBe(true);
|
expect(existsSync(path), path).toBe(true);
|
||||||
@@ -40,4 +43,16 @@ describe("admin operations route contract", () => {
|
|||||||
expect(source).toContain("PERMS.MODERATION_EDIT");
|
expect(source).toContain("PERMS.MODERATION_EDIT");
|
||||||
expect(source).toContain("adminAction");
|
expect(source).toContain("adminAction");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("guards translation writes with SETTINGS_EDIT", () => {
|
||||||
|
const source = readFileSync("src/actions/translations.ts", "utf8");
|
||||||
|
expect(source).toContain("PERMS.SETTINGS_EDIT");
|
||||||
|
expect(source).not.toContain("rank < 7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards commandocentrum mutations with RCON_EXECUTE", () => {
|
||||||
|
const source = readFileSync("src/actions/commandocentrum.ts", "utf8");
|
||||||
|
expect(source).toContain("PERMS.RCON_EXECUTE");
|
||||||
|
expect(source).not.toContain("requireStaff()");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -22,6 +22,23 @@ export async function requireStaff(): Promise<StaffUser> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff gate plus a specific ACL slug. Use for FormData server actions that
|
||||||
|
* must not stop at admin.dashboard alone (RCON, user edits, settings writes).
|
||||||
|
*/
|
||||||
|
export async function requirePermission(permission: string): Promise<StaffUser> {
|
||||||
|
const { session, permissions } = await getAdminContext();
|
||||||
|
if (!canAccess(permissions, PERMS.ADMIN_DASHBOARD, session.user.rank))
|
||||||
|
redirectSafe("/", "/");
|
||||||
|
if (!canAccess(permissions, permission, session.user.rank))
|
||||||
|
redirectSafe("/admin", "/admin");
|
||||||
|
return {
|
||||||
|
id: session.user.id,
|
||||||
|
rank: session.user.rank,
|
||||||
|
username: session.user.username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
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();
|
||||||
|
|||||||
Reference in new issue
Block a user