This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
+65
-49
@@ -1,70 +1,86 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { isIpBlacklisted, recordRequest } from "@/lib/services/abuse-guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { safeRedirect } from "@/lib/foundation/security";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { isIpBlacklisted, recordRequest } from "@/lib/services/abuse-guard";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
const EXEMPT = ["/banned", "/maintenance", "/login", "/register", "/forgot", "/reset", "/api"];
|
||||
const EXEMPT = [
|
||||
"/banned",
|
||||
"/maintenance",
|
||||
"/login",
|
||||
"/register",
|
||||
"/forgot",
|
||||
"/reset",
|
||||
"/api",
|
||||
];
|
||||
|
||||
function isExempt(path: string): boolean {
|
||||
return EXEMPT.some((p) => path === p || path.startsWith(`${p}/`));
|
||||
return EXEMPT.some((p) => path === p || path.startsWith(`${p}/`));
|
||||
}
|
||||
|
||||
export async function enforceSiteAccess(): Promise<void> {
|
||||
const h = await headers();
|
||||
const path = h.get("x-pathname") ?? "/";
|
||||
const ip = h.get("x-real-client-ip") ?? h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "0.0.0.0";
|
||||
const h = await headers();
|
||||
const path = h.get("x-pathname") ?? "/";
|
||||
const ip =
|
||||
h.get("x-real-client-ip") ??
|
||||
h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
"0.0.0.0";
|
||||
|
||||
void recordRequest(ip).catch(() => {});
|
||||
void recordRequest(ip).catch(() => {});
|
||||
|
||||
if (isExempt(path)) return;
|
||||
if (isExempt(path)) return;
|
||||
|
||||
let target: string | null = null;
|
||||
let checksDegraded = false;
|
||||
let target: string | null = null;
|
||||
let checksDegraded = false;
|
||||
|
||||
try {
|
||||
if (await isIpBlacklisted(ip)) target = "/banned";
|
||||
} catch {
|
||||
checksDegraded = true;
|
||||
}
|
||||
try {
|
||||
if (await isIpBlacklisted(ip)) target = "/banned";
|
||||
} catch {
|
||||
checksDegraded = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
const rank = session?.user?.rank ?? 0;
|
||||
try {
|
||||
const session = await auth();
|
||||
const rank = session?.user?.rank ?? 0;
|
||||
|
||||
try {
|
||||
if (!target && (await siteSettings.getBool("maintenance_enabled", false))) {
|
||||
const minLogin = Number(await siteSettings.get("min_maintenance_login_rank", "7")) || 7;
|
||||
if (rank < minLogin) target = "/maintenance";
|
||||
}
|
||||
} catch {
|
||||
checksDegraded = true;
|
||||
}
|
||||
try {
|
||||
if (
|
||||
!target &&
|
||||
(await siteSettings.getBool("maintenance_enabled", false))
|
||||
) {
|
||||
const minLogin =
|
||||
Number(await siteSettings.get("min_maintenance_login_rank", "7")) ||
|
||||
7;
|
||||
if (rank < minLogin) target = "/maintenance";
|
||||
}
|
||||
} catch {
|
||||
checksDegraded = true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!target && session?.user?.id) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const ban = await prisma.ban.findFirst({
|
||||
where: { userId: Number(session.user.id), banExpire: { gt: now } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (ban) target = "/banned";
|
||||
}
|
||||
} catch {
|
||||
checksDegraded = true;
|
||||
}
|
||||
} catch {
|
||||
checksDegraded = true;
|
||||
}
|
||||
try {
|
||||
if (!target && session?.user?.id) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const ban = await prisma.ban.findFirst({
|
||||
where: { userId: Number(session.user.id), banExpire: { gt: now } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (ban) target = "/banned";
|
||||
}
|
||||
} catch {
|
||||
checksDegraded = true;
|
||||
}
|
||||
} catch {
|
||||
checksDegraded = true;
|
||||
}
|
||||
|
||||
if (target) {
|
||||
redirect(safeRedirect(target, target));
|
||||
}
|
||||
if (target) {
|
||||
redirect(safeRedirect(target, target));
|
||||
}
|
||||
|
||||
if (checksDegraded) {
|
||||
logger.warn("Access guard degraded — some checks skipped", { ip, path });
|
||||
}
|
||||
if (checksDegraded) {
|
||||
logger.warn("Access guard degraded — some checks skipped", { ip, path });
|
||||
}
|
||||
}
|
||||
@@ -3,37 +3,40 @@ import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const tables = [
|
||||
"website_event_types",
|
||||
"website_events",
|
||||
"website_event_registrations",
|
||||
"website_event_prizes",
|
||||
"website_event_winners",
|
||||
"website_polls",
|
||||
"website_poll_questions",
|
||||
"website_poll_votes",
|
||||
"website_banners",
|
||||
"custom_prefixes",
|
||||
"custom_prefix_blacklist",
|
||||
"custom_prefix_settings",
|
||||
"website_event_types",
|
||||
"website_events",
|
||||
"website_event_registrations",
|
||||
"website_event_prizes",
|
||||
"website_event_winners",
|
||||
"website_polls",
|
||||
"website_poll_questions",
|
||||
"website_poll_votes",
|
||||
"website_banners",
|
||||
"custom_prefixes",
|
||||
"custom_prefix_blacklist",
|
||||
"custom_prefix_settings",
|
||||
];
|
||||
|
||||
describe("admin content module port", () => {
|
||||
it("owns every required table through schema and migration contracts", () => {
|
||||
const schema = readFileSync(resolve("prisma/schema.prisma"), "utf8");
|
||||
const migration = readFileSync(
|
||||
resolve("prisma/migrations/0013_admin_events_polls_banners_prefixes.sql"),
|
||||
"utf8",
|
||||
);
|
||||
for (const table of tables) {
|
||||
expect(migration).toContain(`CREATE TABLE IF NOT EXISTS \`${table}\``);
|
||||
if (!table.startsWith("custom_prefix")) expect(schema).toContain(`@@map("${table}")`);
|
||||
}
|
||||
});
|
||||
it("owns every required table through schema and migration contracts", () => {
|
||||
const schema = readFileSync(resolve("prisma/schema.prisma"), "utf8");
|
||||
const migration = readFileSync(
|
||||
resolve("prisma/migrations/0013_admin_events_polls_banners_prefixes.sql"),
|
||||
"utf8",
|
||||
);
|
||||
for (const table of tables) {
|
||||
expect(migration).toContain(`CREATE TABLE IF NOT EXISTS \`${table}\``);
|
||||
if (!table.startsWith("custom_prefix"))
|
||||
expect(schema).toContain(`@@map("${table}")`);
|
||||
}
|
||||
});
|
||||
|
||||
it("provides locale-free routes and matching server actions", () => {
|
||||
for (const module of ["events", "polls", "banners", "prefixes"]) {
|
||||
expect(existsSync(resolve(`src/app/admin/${module}/page.tsx`))).toBe(true);
|
||||
expect(existsSync(resolve(`src/actions/${module}.ts`))).toBe(true);
|
||||
}
|
||||
});
|
||||
it("provides locale-free routes and matching server actions", () => {
|
||||
for (const module of ["events", "polls", "banners", "prefixes"]) {
|
||||
expect(existsSync(resolve(`src/app/admin/${module}/page.tsx`))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(existsSync(resolve(`src/actions/${module}.ts`))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
+38
-31
@@ -4,51 +4,58 @@ export const PER_PAGE_OPTIONS = [10, 20, 50] as const;
|
||||
export const DEFAULT_PER_PAGE = 20;
|
||||
|
||||
export function parseListParams(searchParams: URLSearchParams): ListParams {
|
||||
const search = searchParams.get("search") || "";
|
||||
const perPage = Math.min(
|
||||
Math.max(parseInt(searchParams.get("perPage") || String(DEFAULT_PER_PAGE), 10), 1),
|
||||
100,
|
||||
);
|
||||
const page = Math.max(parseInt(searchParams.get("page") || "1", 10), 1);
|
||||
const sort = searchParams.get("sort") || undefined;
|
||||
const order = (searchParams.get("order") === "asc" ? "asc" : "desc") as "asc" | "desc";
|
||||
const search = searchParams.get("search") || "";
|
||||
const perPage = Math.min(
|
||||
Math.max(
|
||||
parseInt(searchParams.get("perPage") || String(DEFAULT_PER_PAGE), 10),
|
||||
1,
|
||||
),
|
||||
100,
|
||||
);
|
||||
const page = Math.max(parseInt(searchParams.get("page") || "1", 10), 1);
|
||||
const sort = searchParams.get("sort") || undefined;
|
||||
const order = (searchParams.get("order") === "asc" ? "asc" : "desc") as
|
||||
| "asc"
|
||||
| "desc";
|
||||
|
||||
return { search, perPage, page, sort, order };
|
||||
return { search, perPage, page, sort, order };
|
||||
}
|
||||
|
||||
export function calcPagination(total: number, page: number, perPage: number) {
|
||||
const lastPage = Math.max(Math.ceil(total / perPage), 1);
|
||||
return {
|
||||
total,
|
||||
page: Math.min(page, lastPage),
|
||||
perPage,
|
||||
lastPage,
|
||||
offset: (Math.min(page, lastPage) - 1) * perPage,
|
||||
};
|
||||
const lastPage = Math.max(Math.ceil(total / perPage), 1);
|
||||
return {
|
||||
total,
|
||||
page: Math.min(page, lastPage),
|
||||
perPage,
|
||||
lastPage,
|
||||
offset: (Math.min(page, lastPage) - 1) * perPage,
|
||||
};
|
||||
}
|
||||
|
||||
/** Generate CSV content from rows */
|
||||
export function generateCsv(
|
||||
rows: Record<string, unknown>[],
|
||||
columns: { key: string; label: string }[],
|
||||
rows: Record<string, unknown>[],
|
||||
columns: { key: string; label: string }[],
|
||||
): string {
|
||||
const BOM = "\uFEFF";
|
||||
const header = columns.map((c) => escapeCsv(c.label)).join(",");
|
||||
const body = rows
|
||||
.map((row) => columns.map((c) => escapeCsv(String(row[c.key] ?? ""))).join(","))
|
||||
.join("\n");
|
||||
const BOM = "\uFEFF";
|
||||
const header = columns.map((c) => escapeCsv(c.label)).join(",");
|
||||
const body = rows
|
||||
.map((row) =>
|
||||
columns.map((c) => escapeCsv(String(row[c.key] ?? ""))).join(","),
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return `${BOM + header}\n${body}`;
|
||||
return `${BOM + header}\n${body}`;
|
||||
}
|
||||
|
||||
function escapeCsv(value: string): string {
|
||||
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function formatTimestamp(ts: number): string {
|
||||
if (!ts) return "N/A";
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
if (!ts) return "N/A";
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
}
|
||||
+123
-104
@@ -7,47 +7,50 @@ import { prisma } from "./prisma";
|
||||
type PrismaModel = any;
|
||||
|
||||
interface AdminListConfig<TRow> {
|
||||
/** Permission slug required to view this page */
|
||||
permission: string;
|
||||
/** Prisma model name (e.g. 'user', 'ban', 'room') */
|
||||
model: string;
|
||||
/** Search field paths for OR filter. Use dot notation for nested relations (e.g. 'owner.username') */
|
||||
searchFields: string[];
|
||||
/** Default sort when no sort param provided. Default: { id: 'desc' } */
|
||||
defaultSort?: Record<string, "asc" | "desc">;
|
||||
/** Default items per page. Default: 20 */
|
||||
defaultPerPage?: number;
|
||||
/** Prisma include clause for relations */
|
||||
include?: Record<string, unknown>;
|
||||
/** Prisma select clause (mutually exclusive with include) */
|
||||
select?: Record<string, unknown>;
|
||||
/** Fixed WHERE conditions merged with search (e.g. { online: '1' }) */
|
||||
baseWhere?: Record<string, unknown>;
|
||||
/**
|
||||
* Map URL ?filter_* params to Prisma WHERE conditions. Each entry receives
|
||||
* the raw string value from the URL and returns the WHERE fragment to merge.
|
||||
* Returning null/undefined drops the filter.
|
||||
*
|
||||
* @example
|
||||
* filterMap: {
|
||||
* filter_rank: (v) => ({ rank: { gte: Number(v) } }),
|
||||
* filter_online: (v) => ({ online: v }),
|
||||
* }
|
||||
*/
|
||||
filterMap?: Record<string, (value: string) => Record<string, unknown> | null | undefined>;
|
||||
/** Transform raw Prisma row to the shape needed by the table component */
|
||||
mapRow?: (row: PrismaModel) => TRow;
|
||||
/** Permission slug required to view this page */
|
||||
permission: string;
|
||||
/** Prisma model name (e.g. 'user', 'ban', 'room') */
|
||||
model: string;
|
||||
/** Search field paths for OR filter. Use dot notation for nested relations (e.g. 'owner.username') */
|
||||
searchFields: string[];
|
||||
/** Default sort when no sort param provided. Default: { id: 'desc' } */
|
||||
defaultSort?: Record<string, "asc" | "desc">;
|
||||
/** Default items per page. Default: 20 */
|
||||
defaultPerPage?: number;
|
||||
/** Prisma include clause for relations */
|
||||
include?: Record<string, unknown>;
|
||||
/** Prisma select clause (mutually exclusive with include) */
|
||||
select?: Record<string, unknown>;
|
||||
/** Fixed WHERE conditions merged with search (e.g. { online: '1' }) */
|
||||
baseWhere?: Record<string, unknown>;
|
||||
/**
|
||||
* Map URL ?filter_* params to Prisma WHERE conditions. Each entry receives
|
||||
* the raw string value from the URL and returns the WHERE fragment to merge.
|
||||
* Returning null/undefined drops the filter.
|
||||
*
|
||||
* @example
|
||||
* filterMap: {
|
||||
* filter_rank: (v) => ({ rank: { gte: Number(v) } }),
|
||||
* filter_online: (v) => ({ online: v }),
|
||||
* }
|
||||
*/
|
||||
filterMap?: Record<
|
||||
string,
|
||||
(value: string) => Record<string, unknown> | null | undefined
|
||||
>;
|
||||
/** Transform raw Prisma row to the shape needed by the table component */
|
||||
mapRow?: (row: PrismaModel) => TRow;
|
||||
}
|
||||
|
||||
interface AdminListResult<TRow> {
|
||||
rows: TRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
lastPage: number;
|
||||
locale: string;
|
||||
permissions: PermissionSet;
|
||||
rank: number;
|
||||
rows: TRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
lastPage: number;
|
||||
locale: string;
|
||||
permissions: PermissionSet;
|
||||
rank: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,17 +58,22 @@ interface AdminListResult<TRow> {
|
||||
* e.g. 'owner.username' => { owner: { username: { contains: search } } }
|
||||
* e.g. 'username' => { username: { contains: search } }
|
||||
*/
|
||||
function buildFieldCondition(fieldPath: string, search: string): Record<string, unknown> {
|
||||
const parts = fieldPath.split(".");
|
||||
if (parts.length === 1) {
|
||||
return { [parts[0]]: { contains: search } };
|
||||
}
|
||||
// Build nested object from right to left
|
||||
let result: Record<string, unknown> = { [parts[parts.length - 1]]: { contains: search } };
|
||||
for (let i = parts.length - 2; i >= 0; i--) {
|
||||
result = { [parts[i]]: result };
|
||||
}
|
||||
return result;
|
||||
function buildFieldCondition(
|
||||
fieldPath: string,
|
||||
search: string,
|
||||
): Record<string, unknown> {
|
||||
const parts = fieldPath.split(".");
|
||||
if (parts.length === 1) {
|
||||
return { [parts[0]]: { contains: search } };
|
||||
}
|
||||
// Build nested object from right to left
|
||||
let result: Record<string, unknown> = {
|
||||
[parts[parts.length - 1]]: { contains: search },
|
||||
};
|
||||
for (let i = parts.length - 2; i >= 0; i--) {
|
||||
result = { [parts[i]]: result };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,72 +82,83 @@ function buildFieldCondition(fieldPath: string, search: string): Record<string,
|
||||
* Prisma query with pagination, and row mapping.
|
||||
*/
|
||||
export async function fetchAdminList<TRow = PrismaModel>(
|
||||
config: AdminListConfig<TRow>,
|
||||
paramsPromise: Promise<{ locale: string }>,
|
||||
searchParamsPromise: Promise<Record<string, string>>,
|
||||
config: AdminListConfig<TRow>,
|
||||
paramsPromise: Promise<{ locale: string }>,
|
||||
searchParamsPromise: Promise<Record<string, string>>,
|
||||
): Promise<AdminListResult<TRow>> {
|
||||
const { locale } = await paramsPromise;
|
||||
const { session, permissions } = await getAdminContext();
|
||||
const { locale } = await paramsPromise;
|
||||
const { session, permissions } = await getAdminContext();
|
||||
|
||||
if (!canAccess(permissions, config.permission, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
if (!canAccess(permissions, config.permission, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const rawParams = await searchParamsPromise;
|
||||
const sp = new URLSearchParams(rawParams);
|
||||
const parsed = parseListParams(sp);
|
||||
const perPage = config.defaultPerPage ? Number(rawParams.perPage) || config.defaultPerPage : parsed.perPage;
|
||||
const { search, page, sort, order } = parsed;
|
||||
const rawParams = await searchParamsPromise;
|
||||
const sp = new URLSearchParams(rawParams);
|
||||
const parsed = parseListParams(sp);
|
||||
const perPage = config.defaultPerPage
|
||||
? Number(rawParams.perPage) || config.defaultPerPage
|
||||
: parsed.perPage;
|
||||
const { search, page, sort, order } = parsed;
|
||||
|
||||
// Build WHERE clause
|
||||
const searchWhere =
|
||||
search && config.searchFields.length > 0
|
||||
? { OR: config.searchFields.map((field) => buildFieldCondition(field, search)) }
|
||||
: {};
|
||||
// Build WHERE clause
|
||||
const searchWhere =
|
||||
search && config.searchFields.length > 0
|
||||
? {
|
||||
OR: config.searchFields.map((field) =>
|
||||
buildFieldCondition(field, search),
|
||||
),
|
||||
}
|
||||
: {};
|
||||
|
||||
// Apply URL filter_* params via filterMap
|
||||
const filterWhere: Record<string, unknown> = {};
|
||||
if (config.filterMap) {
|
||||
for (const [paramKey, build] of Object.entries(config.filterMap)) {
|
||||
const value = rawParams[paramKey];
|
||||
if (!value) continue;
|
||||
const fragment = build(value);
|
||||
if (fragment) Object.assign(filterWhere, fragment);
|
||||
}
|
||||
}
|
||||
// Apply URL filter_* params via filterMap
|
||||
const filterWhere: Record<string, unknown> = {};
|
||||
if (config.filterMap) {
|
||||
for (const [paramKey, build] of Object.entries(config.filterMap)) {
|
||||
const value = rawParams[paramKey];
|
||||
if (!value) continue;
|
||||
const fragment = build(value);
|
||||
if (fragment) Object.assign(filterWhere, fragment);
|
||||
}
|
||||
}
|
||||
|
||||
const where = {
|
||||
...(config.baseWhere ?? {}),
|
||||
...filterWhere,
|
||||
...searchWhere,
|
||||
};
|
||||
const where = {
|
||||
...(config.baseWhere ?? {}),
|
||||
...filterWhere,
|
||||
...searchWhere,
|
||||
};
|
||||
|
||||
// Build orderBy
|
||||
const orderBy = sort ? { [sort]: order } : (config.defaultSort ?? { id: "desc" as const });
|
||||
// Build orderBy
|
||||
const orderBy = sort
|
||||
? { [sort]: order }
|
||||
: (config.defaultSort ?? { id: "desc" as const });
|
||||
|
||||
// Access Prisma model dynamically
|
||||
const delegate = (prisma as PrismaModel)[config.model];
|
||||
// Access Prisma model dynamically
|
||||
const delegate = (prisma as PrismaModel)[config.model];
|
||||
|
||||
const queryArgs: PrismaModel = {
|
||||
where,
|
||||
orderBy,
|
||||
skip: (page - 1) * perPage,
|
||||
take: perPage,
|
||||
};
|
||||
const queryArgs: PrismaModel = {
|
||||
where,
|
||||
orderBy,
|
||||
skip: (page - 1) * perPage,
|
||||
take: perPage,
|
||||
};
|
||||
|
||||
if (config.include) queryArgs.include = config.include;
|
||||
if (config.select) queryArgs.select = config.select;
|
||||
if (config.include) queryArgs.include = config.include;
|
||||
if (config.select) queryArgs.select = config.select;
|
||||
|
||||
const [rawRows, total] = await Promise.all([delegate.findMany(queryArgs), delegate.count({ where })]);
|
||||
const [rawRows, total] = await Promise.all([
|
||||
delegate.findMany(queryArgs),
|
||||
delegate.count({ where }),
|
||||
]);
|
||||
|
||||
const pagination = calcPagination(total, page, perPage);
|
||||
const rows = config.mapRow ? rawRows.map(config.mapRow) : rawRows;
|
||||
const pagination = calcPagination(total, page, perPage);
|
||||
const rows = config.mapRow ? rawRows.map(config.mapRow) : rawRows;
|
||||
|
||||
return {
|
||||
rows,
|
||||
...pagination,
|
||||
locale,
|
||||
permissions,
|
||||
rank: session.user.rank,
|
||||
};
|
||||
return {
|
||||
rows,
|
||||
...pagination,
|
||||
locale,
|
||||
permissions,
|
||||
rank: session.user.rank,
|
||||
};
|
||||
}
|
||||
@@ -2,42 +2,42 @@ import { existsSync, readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROUTES: Array<[string, string]> = [
|
||||
["moderation", "PERMS.MODERATION_VIEW"],
|
||||
["moderation/actions", "PERMS.MODERATION_EDIT"],
|
||||
["moderation/cfh", "PERMS.MODERATION_VIEW"],
|
||||
["moderation/cfh/[id]", "PERMS.MODERATION_VIEW"],
|
||||
["moderation/team", "PERMS.MODERATION_VIEW"],
|
||||
["logs/audit", "PERMS.LOGS_VIEW"],
|
||||
["logs/chat", "PERMS.LOGS_VIEW"],
|
||||
["logs/commands", "PERMS.LOGS_VIEW"],
|
||||
["logs/trades", "PERMS.LOGS_VIEW"],
|
||||
["analytics", "PERMS.ANALYTICS_VIEW"],
|
||||
["analytics/activity", "PERMS.ANALYTICS_VIEW"],
|
||||
["analytics/economy", "PERMS.ANALYTICS_VIEW"],
|
||||
["devops", "PERMS.DEVOPS_VIEW"],
|
||||
["devops/errors", "PERMS.DEVOPS_VIEW"],
|
||||
["online", "PERMS.USERS_VIEW"],
|
||||
["moderation", "PERMS.MODERATION_VIEW"],
|
||||
["moderation/actions", "PERMS.MODERATION_EDIT"],
|
||||
["moderation/cfh", "PERMS.MODERATION_VIEW"],
|
||||
["moderation/cfh/[id]", "PERMS.MODERATION_VIEW"],
|
||||
["moderation/team", "PERMS.MODERATION_VIEW"],
|
||||
["logs/audit", "PERMS.LOGS_VIEW"],
|
||||
["logs/chat", "PERMS.LOGS_VIEW"],
|
||||
["logs/commands", "PERMS.LOGS_VIEW"],
|
||||
["logs/trades", "PERMS.LOGS_VIEW"],
|
||||
["analytics", "PERMS.ANALYTICS_VIEW"],
|
||||
["analytics/activity", "PERMS.ANALYTICS_VIEW"],
|
||||
["analytics/economy", "PERMS.ANALYTICS_VIEW"],
|
||||
["devops", "PERMS.DEVOPS_VIEW"],
|
||||
["devops/errors", "PERMS.DEVOPS_VIEW"],
|
||||
["online", "PERMS.USERS_VIEW"],
|
||||
];
|
||||
|
||||
describe("admin operations route contract", () => {
|
||||
it.each(ROUTES)("provides and guards /admin/%s", (route, permission) => {
|
||||
const path = `src/app/admin/${route}/page.tsx`;
|
||||
expect(existsSync(path), path).toBe(true);
|
||||
expect(readFileSync(path, "utf8"), path).toContain(permission);
|
||||
});
|
||||
it.each(ROUTES)("provides and guards /admin/%s", (route, permission) => {
|
||||
const path = `src/app/admin/${route}/page.tsx`;
|
||||
expect(existsSync(path), path).toBe(true);
|
||||
expect(readFileSync(path, "utf8"), path).toContain(permission);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["analytics/export", "PERMS.ANALYTICS_EXPORT"],
|
||||
["devops/health", "PERMS.DEVOPS_VIEW"],
|
||||
])("provides and guards /api/admin/%s", (route, permission) => {
|
||||
const path = `src/app/api/admin/${route}/route.ts`;
|
||||
expect(existsSync(path), path).toBe(true);
|
||||
expect(readFileSync(path, "utf8"), path).toContain(permission);
|
||||
});
|
||||
it.each([
|
||||
["analytics/export", "PERMS.ANALYTICS_EXPORT"],
|
||||
["devops/health", "PERMS.DEVOPS_VIEW"],
|
||||
])("provides and guards /api/admin/%s", (route, permission) => {
|
||||
const path = `src/app/api/admin/${route}/route.ts`;
|
||||
expect(existsSync(path), path).toBe(true);
|
||||
expect(readFileSync(path, "utf8"), path).toContain(permission);
|
||||
});
|
||||
|
||||
it("guards moderation mutations separately from page navigation", () => {
|
||||
const source = readFileSync("src/actions/moderation.ts", "utf8");
|
||||
expect(source).toContain("PERMS.MODERATION_EDIT");
|
||||
expect(source).toContain("adminAction");
|
||||
});
|
||||
it("guards moderation mutations separately from page navigation", () => {
|
||||
const source = readFileSync("src/actions/moderation.ts", "utf8");
|
||||
expect(source).toContain("PERMS.MODERATION_EDIT");
|
||||
expect(source).toContain("adminAction");
|
||||
});
|
||||
});
|
||||
@@ -4,83 +4,97 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROOTS = ["src/app/admin", "src/components/admin"];
|
||||
const GRAPHICAL_ALLOWLIST = [
|
||||
"src/app/admin/favicon/favicon-generator.tsx",
|
||||
"src/app/admin/import/clone/import-clone-client.tsx",
|
||||
"src/app/admin/import/furni/import-furni-client.tsx",
|
||||
"src/components/admin/catalog/items-shop-preview.tsx",
|
||||
"src/app/admin/favicon/favicon-generator.tsx",
|
||||
"src/app/admin/import/clone/import-clone-client.tsx",
|
||||
"src/app/admin/import/furni/import-furni-client.tsx",
|
||||
"src/components/admin/catalog/items-shop-preview.tsx",
|
||||
];
|
||||
|
||||
const DATA_COLOR_ALLOWLIST = [
|
||||
"src/app/admin/alerts/page.tsx",
|
||||
"src/app/admin/banners/banners-manager.tsx",
|
||||
"src/app/admin/events/events-table.tsx",
|
||||
"src/app/admin/events/types/event-types-manager.tsx",
|
||||
"src/app/admin/favicon/favicon-generator.tsx",
|
||||
"src/app/admin/help-questions/new/page.tsx",
|
||||
"src/app/admin/help-questions/[id]/page.tsx",
|
||||
"src/app/admin/prefixes/prefixes-client.tsx",
|
||||
"src/app/admin/tags/page.tsx",
|
||||
"src/app/admin/teams/page.tsx",
|
||||
"src/app/admin/theme/page.tsx",
|
||||
"src/app/admin/alerts/page.tsx",
|
||||
"src/app/admin/banners/banners-manager.tsx",
|
||||
"src/app/admin/events/events-table.tsx",
|
||||
"src/app/admin/events/types/event-types-manager.tsx",
|
||||
"src/app/admin/favicon/favicon-generator.tsx",
|
||||
"src/app/admin/help-questions/new/page.tsx",
|
||||
"src/app/admin/help-questions/[id]/page.tsx",
|
||||
"src/app/admin/prefixes/prefixes-client.tsx",
|
||||
"src/app/admin/tags/page.tsx",
|
||||
"src/app/admin/teams/page.tsx",
|
||||
"src/app/admin/theme/page.tsx",
|
||||
];
|
||||
|
||||
const PUBLIC_STRUCTURAL_TOKEN =
|
||||
/var\(--(?:color-(?:background|surface|dropdown|navbar|navbar-text|text|text-muted|primary|primary-hover)|border-subtle|border-color)\)/g;
|
||||
/var\(--(?:color-(?:background|surface|dropdown|navbar|navbar-text|text|text-muted|primary|primary-hover)|border-subtle|border-color)\)/g;
|
||||
const HARDCODED_UI_PALETTE =
|
||||
/(?:text|bg|border|ring|from|to|via)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:[1-9]00|50)(?:\/\d+)?/g;
|
||||
/(?:text|bg|border|ring|from|to|via)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:[1-9]00|50)(?:\/\d+)?/g;
|
||||
|
||||
function sourceFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name);
|
||||
return entry.isDirectory() ? sourceFiles(path) : /\.(?:ts|tsx)$/.test(entry.name) ? [path] : [];
|
||||
});
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name);
|
||||
return entry.isDirectory()
|
||||
? sourceFiles(path)
|
||||
: /\.(?:ts|tsx)$/.test(entry.name)
|
||||
? [path]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
describe("admin theme source audit", () => {
|
||||
it("keeps ordinary admin text theme-aware", () => {
|
||||
const violations: string[] = [];
|
||||
for (const root of ROOTS) {
|
||||
for (const file of sourceFiles(root)) {
|
||||
const normalized = relative(process.cwd(), file).replaceAll("\\", "/");
|
||||
if (GRAPHICAL_ALLOWLIST.includes(normalized)) continue;
|
||||
const source = readFileSync(file, "utf8");
|
||||
const risky = source.match(
|
||||
/(?:text-(?:gray|slate)-(?:400|500|600|700|800|900)|text-white(?:\/\d+)?|\)\]0)/g,
|
||||
);
|
||||
if (risky) violations.push(`${normalized}: ${[...new Set(risky)].join(", ")}`);
|
||||
}
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
it("keeps ordinary admin text theme-aware", () => {
|
||||
const violations: string[] = [];
|
||||
for (const root of ROOTS) {
|
||||
for (const file of sourceFiles(root)) {
|
||||
const normalized = relative(process.cwd(), file).replaceAll("\\", "/");
|
||||
if (GRAPHICAL_ALLOWLIST.includes(normalized)) continue;
|
||||
const source = readFileSync(file, "utf8");
|
||||
const risky = source.match(
|
||||
/(?:text-(?:gray|slate)-(?:400|500|600|700|800|900)|text-white(?:\/\d+)?|\)\]0)/g,
|
||||
);
|
||||
if (risky)
|
||||
violations.push(`${normalized}: ${[...new Set(risky)].join(", ")}`);
|
||||
}
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps admin chrome independent from public structural colors", () => {
|
||||
const violations: string[] = [];
|
||||
for (const root of ROOTS) {
|
||||
for (const file of sourceFiles(root)) {
|
||||
const normalized = relative(process.cwd(), file).replaceAll("\\", "/");
|
||||
if (normalized.endsWith("admin-theme-source-audit.test.ts")) continue;
|
||||
const source = readFileSync(file, "utf8");
|
||||
const risky = [
|
||||
...(source.match(PUBLIC_STRUCTURAL_TOKEN) ?? []),
|
||||
...(source.match(HARDCODED_UI_PALETTE) ?? []),
|
||||
];
|
||||
const filtered = [...DATA_COLOR_ALLOWLIST, ...GRAPHICAL_ALLOWLIST].includes(normalized)
|
||||
? risky.filter((match) => match.startsWith("var("))
|
||||
: risky;
|
||||
if (filtered.length) violations.push(`${normalized}: ${[...new Set(filtered)].join(", ")}`);
|
||||
}
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
it("keeps admin chrome independent from public structural colors", () => {
|
||||
const violations: string[] = [];
|
||||
for (const root of ROOTS) {
|
||||
for (const file of sourceFiles(root)) {
|
||||
const normalized = relative(process.cwd(), file).replaceAll("\\", "/");
|
||||
if (normalized.endsWith("admin-theme-source-audit.test.ts")) continue;
|
||||
const source = readFileSync(file, "utf8");
|
||||
const risky = [
|
||||
...(source.match(PUBLIC_STRUCTURAL_TOKEN) ?? []),
|
||||
...(source.match(HARDCODED_UI_PALETTE) ?? []),
|
||||
];
|
||||
const filtered = [
|
||||
...DATA_COLOR_ALLOWLIST,
|
||||
...GRAPHICAL_ALLOWLIST,
|
||||
].includes(normalized)
|
||||
? risky.filter((match) => match.startsWith("var("))
|
||||
: risky;
|
||||
if (filtered.length)
|
||||
violations.push(
|
||||
`${normalized}: ${[...new Set(filtered)].join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps every import workflow on semantic admin status and overlay colors", () => {
|
||||
const violations = sourceFiles("src/app/admin/import").flatMap((file) => {
|
||||
const source = readFileSync(file, "utf8");
|
||||
const risky = source.match(/rgba?\([^)]*\)|(?:bg-black|text-white)(?:\/\d+)?/g) ?? [];
|
||||
return risky.length
|
||||
? [`${relative(process.cwd(), file).replaceAll("\\", "/")}: ${[...new Set(risky)].join(", ")}`]
|
||||
: [];
|
||||
});
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
it("keeps every import workflow on semantic admin status and overlay colors", () => {
|
||||
const violations = sourceFiles("src/app/admin/import").flatMap((file) => {
|
||||
const source = readFileSync(file, "utf8");
|
||||
const risky =
|
||||
source.match(/rgba?\([^)]*\)|(?:bg-black|text-white)(?:\/\d+)?/g) ?? [];
|
||||
return risky.length
|
||||
? [
|
||||
`${relative(process.cwd(), file).replaceAll("\\", "/")}: ${[...new Set(risky)].join(", ")}`,
|
||||
]
|
||||
: [];
|
||||
});
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -2,20 +2,23 @@ import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("ACL management contract", () => {
|
||||
it("uses normalized ACL persistence and the permissions.manage guard", () => {
|
||||
const source = readFileSync("src/actions/permissions.ts", "utf8");
|
||||
expect(source).toContain("PERMS.PERMISSIONS_MANAGE");
|
||||
expect(source).toContain("adminAction");
|
||||
expect(source).toContain("aclModelPermission");
|
||||
expect(source).not.toContain("websiteHousekeepingPermissions");
|
||||
expect(source).not.toContain("websiteTeams");
|
||||
});
|
||||
it("uses normalized ACL persistence and the permissions.manage guard", () => {
|
||||
const source = readFileSync("src/actions/permissions.ts", "utf8");
|
||||
expect(source).toContain("PERMS.PERMISSIONS_MANAGE");
|
||||
expect(source).toContain("adminAction");
|
||||
expect(source).toContain("aclModelPermission");
|
||||
expect(source).not.toContain("websiteHousekeepingPermissions");
|
||||
expect(source).not.toContain("websiteTeams");
|
||||
});
|
||||
|
||||
it("ships an idempotent ACL completion migration", () => {
|
||||
const sql = readFileSync("prisma/migrations/0014_complete_acl_and_import_permissions.sql", "utf8");
|
||||
expect(sql).toContain("admin.assets.import");
|
||||
expect(sql).toContain("rank_");
|
||||
expect(sql).toContain("'Role'");
|
||||
expect(sql).toContain("'User'");
|
||||
});
|
||||
it("ships an idempotent ACL completion migration", () => {
|
||||
const sql = readFileSync(
|
||||
"prisma/migrations/0014_complete_acl_and_import_permissions.sql",
|
||||
"utf8",
|
||||
);
|
||||
expect(sql).toContain("admin.assets.import");
|
||||
expect(sql).toContain("rank_");
|
||||
expect(sql).toContain("'Role'");
|
||||
expect(sql).toContain("'User'");
|
||||
});
|
||||
});
|
||||
@@ -4,24 +4,29 @@ import { describe, expect, it } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
|
||||
describe("production ACL migration", () => {
|
||||
const migrationPath = resolve(process.cwd(), "prisma/migrations/0012_seed_acl_permissions.sql");
|
||||
const migrationPath = resolve(
|
||||
process.cwd(),
|
||||
"prisma/migrations/0012_seed_acl_permissions.sql",
|
||||
);
|
||||
|
||||
it("seeds every permission used by the application", () => {
|
||||
const sql = readFileSync(migrationPath, "utf8");
|
||||
it("seeds every permission used by the application", () => {
|
||||
const sql = readFileSync(migrationPath, "utf8");
|
||||
|
||||
for (const slug of Object.values(PERMS)) {
|
||||
expect(sql, `missing ACL seed for ${slug}`).toContain(`'${slug}'`);
|
||||
}
|
||||
});
|
||||
for (const slug of Object.values(PERMS)) {
|
||||
expect(sql, `missing ACL seed for ${slug}`).toContain(`'${slug}'`);
|
||||
}
|
||||
});
|
||||
|
||||
it("assigns dashboard access after seeding permissions", () => {
|
||||
const sql = readFileSync(migrationPath, "utf8");
|
||||
const seedPosition = sql.indexOf("INSERT INTO `acl_permissions`");
|
||||
const assignmentPosition = sql.indexOf("INSERT INTO `acl_model_permissions`");
|
||||
it("assigns dashboard access after seeding permissions", () => {
|
||||
const sql = readFileSync(migrationPath, "utf8");
|
||||
const seedPosition = sql.indexOf("INSERT INTO `acl_permissions`");
|
||||
const assignmentPosition = sql.indexOf(
|
||||
"INSERT INTO `acl_model_permissions`",
|
||||
);
|
||||
|
||||
expect(seedPosition).toBeGreaterThanOrEqual(0);
|
||||
expect(assignmentPosition).toBeGreaterThan(seedPosition);
|
||||
expect(sql).toContain("ap.slug = 'admin.dashboard'");
|
||||
expect(sql).toContain("ap.slug = 'mod.dashboard'");
|
||||
});
|
||||
expect(seedPosition).toBeGreaterThanOrEqual(0);
|
||||
expect(assignmentPosition).toBeGreaterThan(seedPosition);
|
||||
expect(sql).toContain("ap.slug = 'admin.dashboard'");
|
||||
expect(sql).toContain("ap.slug = 'mod.dashboard'");
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,14 @@ import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("authorization source contract", () => {
|
||||
it("contains no fixed numeric rank threshold in central authorization files", () => {
|
||||
for (const file of ["src/lib/permissions.ts", "src/lib/proxy-access.ts", "src/lib/admin/guard.ts"]) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
expect(source, file).not.toMatch(/rank\s*[<>]=?\s*\d+/i);
|
||||
}
|
||||
});
|
||||
it("contains no fixed numeric rank threshold in central authorization files", () => {
|
||||
for (const file of [
|
||||
"src/lib/permissions.ts",
|
||||
"src/lib/proxy-access.ts",
|
||||
"src/lib/admin/guard.ts",
|
||||
]) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
expect(source, file).not.toMatch(/rank\s*[<>]=?\s*\d+/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,34 +1,37 @@
|
||||
export interface AuthorizationEvent {
|
||||
kind: "permission.denied" | "permission.load_error";
|
||||
userId: number;
|
||||
username?: string;
|
||||
rank: number;
|
||||
permission?: string;
|
||||
source: string;
|
||||
reason: string;
|
||||
error?: unknown;
|
||||
kind: "permission.denied" | "permission.load_error";
|
||||
userId: number;
|
||||
username?: string;
|
||||
rank: number;
|
||||
permission?: string;
|
||||
source: string;
|
||||
reason: string;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
const clean = (value: string) =>
|
||||
value
|
||||
.replace(/(token|password|secret|cookie|authorization|select|insert|update|delete)[^\s]*/gi, "[REDACTED]")
|
||||
.slice(0, 160);
|
||||
value
|
||||
.replace(
|
||||
/(token|password|secret|cookie|authorization|select|insert|update|delete)[^\s]*/gi,
|
||||
"[REDACTED]",
|
||||
)
|
||||
.slice(0, 160);
|
||||
|
||||
export function authorizationActivity(event: AuthorizationEvent) {
|
||||
const description = [
|
||||
`user=${clean(event.username ?? String(event.userId))}`,
|
||||
`rank=${event.rank}`,
|
||||
event.permission ? `permission=${clean(event.permission)}` : null,
|
||||
`source=${clean(event.source)}`,
|
||||
`reason=${clean(event.reason)}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
return {
|
||||
staffId: event.userId,
|
||||
action: event.kind,
|
||||
description,
|
||||
targetType: "user",
|
||||
targetId: event.userId,
|
||||
};
|
||||
const description = [
|
||||
`user=${clean(event.username ?? String(event.userId))}`,
|
||||
`rank=${event.rank}`,
|
||||
event.permission ? `permission=${clean(event.permission)}` : null,
|
||||
`source=${clean(event.source)}`,
|
||||
`reason=${clean(event.reason)}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
return {
|
||||
staffId: event.userId,
|
||||
action: event.kind,
|
||||
description,
|
||||
targetType: "user",
|
||||
targetId: event.userId,
|
||||
};
|
||||
}
|
||||
@@ -2,29 +2,29 @@ import { describe, expect, it } from "vitest";
|
||||
import { authorizationActivity } from "@/lib/admin/authorization-event";
|
||||
|
||||
describe("authorizationActivity", () => {
|
||||
it("creates a safe rank-aware denial record", () => {
|
||||
const record = authorizationActivity({
|
||||
kind: "permission.denied",
|
||||
userId: 42,
|
||||
username: "admin",
|
||||
rank: 11,
|
||||
permission: "admin.logs.view",
|
||||
source: "/admin/logs",
|
||||
reason: "missing permission",
|
||||
});
|
||||
expect(record.action).toBe("permission.denied");
|
||||
expect(record.description).toContain("rank=11");
|
||||
expect(record.description).toContain("permission=admin.logs.view");
|
||||
});
|
||||
it("creates a safe rank-aware denial record", () => {
|
||||
const record = authorizationActivity({
|
||||
kind: "permission.denied",
|
||||
userId: 42,
|
||||
username: "admin",
|
||||
rank: 11,
|
||||
permission: "admin.logs.view",
|
||||
source: "/admin/logs",
|
||||
reason: "missing permission",
|
||||
});
|
||||
expect(record.action).toBe("permission.denied");
|
||||
expect(record.description).toContain("rank=11");
|
||||
expect(record.description).toContain("permission=admin.logs.view");
|
||||
});
|
||||
|
||||
it("redacts secrets and technical details", () => {
|
||||
const record = authorizationActivity({
|
||||
kind: "permission.load_error",
|
||||
userId: 42,
|
||||
rank: 11,
|
||||
source: "permissions",
|
||||
reason: "token=abc password=hunter2 SELECT * FROM users",
|
||||
});
|
||||
expect(record.description).not.toMatch(/abc|hunter2|SELECT/i);
|
||||
});
|
||||
it("redacts secrets and technical details", () => {
|
||||
const record = authorizationActivity({
|
||||
kind: "permission.load_error",
|
||||
userId: 42,
|
||||
rank: 11,
|
||||
source: "permissions",
|
||||
reason: "token=abc password=hunter2 SELECT * FROM users",
|
||||
});
|
||||
expect(record.description).not.toMatch(/abc|hunter2|SELECT/i);
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,24 @@
|
||||
import {
|
||||
type AuthorizationEvent,
|
||||
authorizationActivity,
|
||||
} from "@/lib/admin/authorization-event";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { authorizationActivity, type AuthorizationEvent } from "@/lib/admin/authorization-event";
|
||||
|
||||
export async function logAuthorizationEvent(event: AuthorizationEvent): Promise<void> {
|
||||
const correlationId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await logStaffActivity({
|
||||
...authorizationActivity(event),
|
||||
description: `${authorizationActivity(event).description}; correlation=${correlationId}`,
|
||||
});
|
||||
if (event.error)
|
||||
logServerError(event.kind, event.error, {
|
||||
correlationId,
|
||||
userId: event.userId,
|
||||
rank: event.rank,
|
||||
permission: event.permission ?? null,
|
||||
source: event.source,
|
||||
});
|
||||
export async function logAuthorizationEvent(
|
||||
event: AuthorizationEvent,
|
||||
): Promise<void> {
|
||||
const correlationId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await logStaffActivity({
|
||||
...authorizationActivity(event),
|
||||
description: `${authorizationActivity(event).description}; correlation=${correlationId}`,
|
||||
});
|
||||
if (event.error)
|
||||
logServerError(event.kind, event.error, {
|
||||
correlationId,
|
||||
userId: event.userId,
|
||||
rank: event.rank,
|
||||
permission: event.permission ?? null,
|
||||
source: event.source,
|
||||
});
|
||||
}
|
||||
@@ -1,40 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decideAuthorization, isDynamicSuperAdmin } from "@/lib/admin/authorization-policy";
|
||||
import {
|
||||
decideAuthorization,
|
||||
isDynamicSuperAdmin,
|
||||
} from "@/lib/admin/authorization-policy";
|
||||
|
||||
describe("isDynamicSuperAdmin", () => {
|
||||
it.each([
|
||||
[7, 7],
|
||||
[11, 11],
|
||||
[2000, 2000],
|
||||
])("accepts highest rank %i", (rank, highest) => {
|
||||
expect(isDynamicSuperAdmin(rank, highest)).toBe(true);
|
||||
});
|
||||
it("demotes the previous highest rank", () => expect(isDynamicSuperAdmin(2000, 2001)).toBe(false));
|
||||
it("fails closed without ranks", () => expect(isDynamicSuperAdmin(1, null)).toBe(false));
|
||||
it.each([
|
||||
[7, 7],
|
||||
[11, 11],
|
||||
[2000, 2000],
|
||||
])("accepts highest rank %i", (rank, highest) => {
|
||||
expect(isDynamicSuperAdmin(rank, highest)).toBe(true);
|
||||
});
|
||||
it("demotes the previous highest rank", () =>
|
||||
expect(isDynamicSuperAdmin(2000, 2001)).toBe(false));
|
||||
it("fails closed without ranks", () =>
|
||||
expect(isDynamicSuperAdmin(1, null)).toBe(false));
|
||||
});
|
||||
|
||||
describe("decideAuthorization", () => {
|
||||
const actor = { id: 1, username: "admin", rank: 11 };
|
||||
it("allows the dynamically highest rank", () =>
|
||||
expect(
|
||||
decideAuthorization({ actor, highestRank: 11, permission: "admin.any", hasPermission: false }).allowed,
|
||||
).toBe(true));
|
||||
it("allows explicit ACL permission below highest", () =>
|
||||
expect(
|
||||
decideAuthorization({ actor, highestRank: 12, permission: "admin.news.view", hasPermission: true })
|
||||
.allowed,
|
||||
).toBe(true));
|
||||
it("denies invalid ranks", () =>
|
||||
expect(
|
||||
decideAuthorization({
|
||||
actor: { ...actor, rank: 0 },
|
||||
highestRank: 11,
|
||||
permission: "admin.any",
|
||||
hasPermission: true,
|
||||
}),
|
||||
).toMatchObject({ allowed: false, reason: "invalid_rank" }));
|
||||
it("denies missing permission", () =>
|
||||
expect(
|
||||
decideAuthorization({ actor, highestRank: 12, permission: "admin.any", hasPermission: false }),
|
||||
).toMatchObject({ allowed: false, reason: "permission_denied" }));
|
||||
const actor = { id: 1, username: "admin", rank: 11 };
|
||||
it("allows the dynamically highest rank", () =>
|
||||
expect(
|
||||
decideAuthorization({
|
||||
actor,
|
||||
highestRank: 11,
|
||||
permission: "admin.any",
|
||||
hasPermission: false,
|
||||
}).allowed,
|
||||
).toBe(true));
|
||||
it("allows explicit ACL permission below highest", () =>
|
||||
expect(
|
||||
decideAuthorization({
|
||||
actor,
|
||||
highestRank: 12,
|
||||
permission: "admin.news.view",
|
||||
hasPermission: true,
|
||||
}).allowed,
|
||||
).toBe(true));
|
||||
it("denies invalid ranks", () =>
|
||||
expect(
|
||||
decideAuthorization({
|
||||
actor: { ...actor, rank: 0 },
|
||||
highestRank: 11,
|
||||
permission: "admin.any",
|
||||
hasPermission: true,
|
||||
}),
|
||||
).toMatchObject({ allowed: false, reason: "invalid_rank" }));
|
||||
it("denies missing permission", () =>
|
||||
expect(
|
||||
decideAuthorization({
|
||||
actor,
|
||||
highestRank: 12,
|
||||
permission: "admin.any",
|
||||
hasPermission: false,
|
||||
}),
|
||||
).toMatchObject({ allowed: false, reason: "permission_denied" }));
|
||||
});
|
||||
@@ -1,26 +1,40 @@
|
||||
export interface AuthorizationActor {
|
||||
id: number;
|
||||
username: string;
|
||||
rank: number;
|
||||
id: number;
|
||||
username: string;
|
||||
rank: number;
|
||||
}
|
||||
export type AuthorizationDenialReason = "invalid_rank" | "permission_denied" | "no_ranks";
|
||||
export type AuthorizationDenialReason =
|
||||
| "invalid_rank"
|
||||
| "permission_denied"
|
||||
| "no_ranks";
|
||||
export type AuthorizationDecision =
|
||||
{ allowed: true; superAdmin: boolean } | { allowed: false; reason: AuthorizationDenialReason };
|
||||
| { allowed: true; superAdmin: boolean }
|
||||
| { allowed: false; reason: AuthorizationDenialReason };
|
||||
|
||||
export function isDynamicSuperAdmin(rank: number, highestRank: number | null): boolean {
|
||||
return Number.isInteger(rank) && rank > 0 && highestRank !== null && rank === highestRank;
|
||||
export function isDynamicSuperAdmin(
|
||||
rank: number,
|
||||
highestRank: number | null,
|
||||
): boolean {
|
||||
return (
|
||||
Number.isInteger(rank) &&
|
||||
rank > 0 &&
|
||||
highestRank !== null &&
|
||||
rank === highestRank
|
||||
);
|
||||
}
|
||||
|
||||
export function decideAuthorization(input: {
|
||||
actor: AuthorizationActor;
|
||||
highestRank: number | null;
|
||||
permission?: string;
|
||||
hasPermission: boolean;
|
||||
actor: AuthorizationActor;
|
||||
highestRank: number | null;
|
||||
permission?: string;
|
||||
hasPermission: boolean;
|
||||
}): AuthorizationDecision {
|
||||
if (!Number.isInteger(input.actor.rank) || input.actor.rank <= 0)
|
||||
return { allowed: false, reason: "invalid_rank" };
|
||||
if (input.highestRank === null) return { allowed: false, reason: "no_ranks" };
|
||||
if (isDynamicSuperAdmin(input.actor.rank, input.highestRank)) return { allowed: true, superAdmin: true };
|
||||
if (!input.permission || input.hasPermission) return { allowed: true, superAdmin: false };
|
||||
return { allowed: false, reason: "permission_denied" };
|
||||
if (!Number.isInteger(input.actor.rank) || input.actor.rank <= 0)
|
||||
return { allowed: false, reason: "invalid_rank" };
|
||||
if (input.highestRank === null) return { allowed: false, reason: "no_ranks" };
|
||||
if (isDynamicSuperAdmin(input.actor.rank, input.highestRank))
|
||||
return { allowed: true, superAdmin: true };
|
||||
if (!input.permission || input.hasPermission)
|
||||
return { allowed: true, superAdmin: false };
|
||||
return { allowed: false, reason: "permission_denied" };
|
||||
}
|
||||
+17
-12
@@ -1,26 +1,31 @@
|
||||
import { isStaff } from "@/lib/admin/is-staff";
|
||||
import { redirectSafe } from "@/lib/foundation/security";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { redirectSafe } from "@/lib/foundation/security";
|
||||
|
||||
export { isStaff };
|
||||
|
||||
export interface StaffUser {
|
||||
id: number;
|
||||
rank: number;
|
||||
username: string;
|
||||
id: number;
|
||||
rank: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export async function requireStaff(): Promise<StaffUser> {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ADMIN_DASHBOARD, session.user.rank)) redirectSafe("/", "/");
|
||||
return { id: session.user.id, rank: session.user.rank, username: session.user.username };
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ADMIN_DASHBOARD, session.user.rank))
|
||||
redirectSafe("/", "/");
|
||||
return {
|
||||
id: session.user.id,
|
||||
rank: session.user.rank,
|
||||
username: session.user.username,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireStaffRateLimited(): Promise<StaffUser> {
|
||||
const staff = await requireStaff();
|
||||
const ip = await clientIp();
|
||||
if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok)
|
||||
redirectSafe("/admin?error=ratelimit", "/admin");
|
||||
return staff;
|
||||
const staff = await requireStaff();
|
||||
const ip = await clientIp();
|
||||
if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok)
|
||||
redirectSafe("/admin?error=ratelimit", "/admin");
|
||||
return staff;
|
||||
}
|
||||
@@ -2,13 +2,13 @@ import { describe, expect, it } from "vitest";
|
||||
import { isStaff } from "./is-staff";
|
||||
|
||||
describe("isStaff", () => {
|
||||
it("is true at or above the min staff rank", () => {
|
||||
expect(isStaff(7, 7)).toBe(true);
|
||||
expect(isStaff(10, 7)).toBe(true);
|
||||
});
|
||||
it("is true at or above the min staff rank", () => {
|
||||
expect(isStaff(7, 7)).toBe(true);
|
||||
expect(isStaff(10, 7)).toBe(true);
|
||||
});
|
||||
|
||||
it("is false below the min staff rank", () => {
|
||||
expect(isStaff(6, 7)).toBe(false);
|
||||
expect(isStaff(1, 7)).toBe(false);
|
||||
});
|
||||
it("is false below the min staff rank", () => {
|
||||
expect(isStaff(6, 7)).toBe(false);
|
||||
expect(isStaff(1, 7)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
/** AtomCMS housekeeping gate: staff are users with rank >= min_staff_rank. */
|
||||
export function isStaff(rank: number, minStaffRank: number): boolean {
|
||||
return rank >= minStaffRank;
|
||||
return rank >= minStaffRank;
|
||||
}
|
||||
@@ -2,14 +2,21 @@ import { describe, expect, it } from "vitest";
|
||||
import { buildStaffActivityWhere } from "@/lib/admin/log-filters";
|
||||
|
||||
describe("buildStaffActivityWhere", () => {
|
||||
it("filters authorization events by prefix", () => {
|
||||
expect(buildStaffActivityWhere({ authorizationOnly: true })).toEqual({
|
||||
action: { startsWith: "permission." },
|
||||
});
|
||||
});
|
||||
it("combines staff and search filters", () => {
|
||||
const result = buildStaffActivityWhere({ q: "rank", staffId: 11, authorizationOnly: true });
|
||||
expect(result).toMatchObject({ userId: 11n, action: { startsWith: "permission." } });
|
||||
expect(result.OR).toHaveLength(3);
|
||||
});
|
||||
it("filters authorization events by prefix", () => {
|
||||
expect(buildStaffActivityWhere({ authorizationOnly: true })).toEqual({
|
||||
action: { startsWith: "permission." },
|
||||
});
|
||||
});
|
||||
it("combines staff and search filters", () => {
|
||||
const result = buildStaffActivityWhere({
|
||||
q: "rank",
|
||||
staffId: 11,
|
||||
authorizationOnly: true,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
userId: 11n,
|
||||
action: { startsWith: "permission." },
|
||||
});
|
||||
expect(result.OR).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,24 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
export interface StaffActivityFilters {
|
||||
q?: string;
|
||||
staffId?: number | null;
|
||||
action?: string | null;
|
||||
authorizationOnly?: boolean;
|
||||
q?: string;
|
||||
staffId?: number | null;
|
||||
action?: string | null;
|
||||
authorizationOnly?: boolean;
|
||||
}
|
||||
|
||||
export function buildStaffActivityWhere(filters: StaffActivityFilters): Prisma.StaffActivitiesWhereInput {
|
||||
const where: Prisma.StaffActivitiesWhereInput = {};
|
||||
if (filters.q?.trim())
|
||||
where.OR = [
|
||||
{ action: { contains: filters.q.trim() } },
|
||||
{ description: { contains: filters.q.trim() } },
|
||||
{ ipAddress: { contains: filters.q.trim() } },
|
||||
];
|
||||
if (filters.staffId) where.userId = BigInt(filters.staffId);
|
||||
if (filters.authorizationOnly) where.action = { startsWith: "permission." };
|
||||
else if (filters.action) where.action = { contains: filters.action };
|
||||
return where;
|
||||
export function buildStaffActivityWhere(
|
||||
filters: StaffActivityFilters,
|
||||
): Prisma.StaffActivitiesWhereInput {
|
||||
const where: Prisma.StaffActivitiesWhereInput = {};
|
||||
if (filters.q?.trim())
|
||||
where.OR = [
|
||||
{ action: { contains: filters.q.trim() } },
|
||||
{ description: { contains: filters.q.trim() } },
|
||||
{ ipAddress: { contains: filters.q.trim() } },
|
||||
];
|
||||
if (filters.staffId) where.userId = BigInt(filters.staffId);
|
||||
if (filters.authorizationOnly) where.action = { startsWith: "permission." };
|
||||
else if (filters.action) where.action = { contains: filters.action };
|
||||
return where;
|
||||
}
|
||||
@@ -2,23 +2,23 @@ import { describe, expect, it } from "vitest";
|
||||
import { adminMutationNotice } from "@/lib/admin/notice";
|
||||
|
||||
describe("adminMutationNotice", () => {
|
||||
it("maps a successful redirect to a safe notice", () => {
|
||||
expect(adminMutationNotice({ saved: "1" })).toEqual({
|
||||
tone: "ok",
|
||||
label: "Saved",
|
||||
message: "Changes were saved successfully.",
|
||||
});
|
||||
});
|
||||
it("maps a successful redirect to a safe notice", () => {
|
||||
expect(adminMutationNotice({ saved: "1" })).toEqual({
|
||||
tone: "ok",
|
||||
label: "Saved",
|
||||
message: "Changes were saved successfully.",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps an error code without reflecting arbitrary query text", () => {
|
||||
expect(adminMutationNotice({ error: "<script>" })).toEqual({
|
||||
tone: "danger",
|
||||
label: "Error",
|
||||
message: "The operation could not be completed.",
|
||||
});
|
||||
});
|
||||
it("maps an error code without reflecting arbitrary query text", () => {
|
||||
expect(adminMutationNotice({ error: "<script>" })).toEqual({
|
||||
tone: "danger",
|
||||
label: "Error",
|
||||
message: "The operation could not be completed.",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when there is no mutation result", () => {
|
||||
expect(adminMutationNotice({})).toBeNull();
|
||||
});
|
||||
it("returns null when there is no mutation result", () => {
|
||||
expect(adminMutationNotice({})).toBeNull();
|
||||
});
|
||||
});
|
||||
+22
-19
@@ -1,23 +1,26 @@
|
||||
export interface AdminMutationNotice {
|
||||
tone: "ok" | "danger";
|
||||
label: "Saved" | "Error";
|
||||
message: string;
|
||||
tone: "ok" | "danger";
|
||||
label: "Saved" | "Error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function adminMutationNotice(params: { saved?: string; error?: string }): AdminMutationNotice | null {
|
||||
if (params.error) {
|
||||
return {
|
||||
tone: "danger",
|
||||
label: "Error",
|
||||
message: "The operation could not be completed.",
|
||||
};
|
||||
}
|
||||
if (params.saved === "1") {
|
||||
return {
|
||||
tone: "ok",
|
||||
label: "Saved",
|
||||
message: "Changes were saved successfully.",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
export function adminMutationNotice(params: {
|
||||
saved?: string;
|
||||
error?: string;
|
||||
}): AdminMutationNotice | null {
|
||||
if (params.error) {
|
||||
return {
|
||||
tone: "danger",
|
||||
label: "Error",
|
||||
message: "The operation could not be completed.",
|
||||
};
|
||||
}
|
||||
if (params.saved === "1") {
|
||||
return {
|
||||
tone: "ok",
|
||||
label: "Saved",
|
||||
message: "Changes were saved successfully.",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,24 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAuthorizationState } from "@/lib/admin/rank-authority";
|
||||
|
||||
function db(user: { id: number; username: string; rank: number } | null, highest: number | null) {
|
||||
return {
|
||||
user: { findUnique: async () => user },
|
||||
highestRank: async () => highest,
|
||||
};
|
||||
function db(
|
||||
user: { id: number; username: string; rank: number } | null,
|
||||
highest: number | null,
|
||||
) {
|
||||
return {
|
||||
user: { findUnique: async () => user },
|
||||
highestRank: async () => highest,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveAuthorizationState", () => {
|
||||
it("uses current database rank and highest rank 2000", async () => {
|
||||
await expect(
|
||||
resolveAuthorizationState(7, db({ id: 7, username: "root", rank: 2000 }, 2000)),
|
||||
).resolves.toEqual({ actor: { id: 7, username: "root", rank: 2000 }, highestRank: 2000 });
|
||||
});
|
||||
it("returns null for a deleted user", async () =>
|
||||
expect(resolveAuthorizationState(7, db(null, 2000))).resolves.toBeNull());
|
||||
it("fails closed when there are no ranks", async () =>
|
||||
expect(resolveAuthorizationState(7, db({ id: 7, username: "root", rank: 1 }, null))).resolves.toEqual({
|
||||
actor: { id: 7, username: "root", rank: 1 },
|
||||
highestRank: null,
|
||||
}));
|
||||
it("uses current database rank and highest rank 2000", async () => {
|
||||
await expect(
|
||||
resolveAuthorizationState(
|
||||
7,
|
||||
db({ id: 7, username: "root", rank: 2000 }, 2000),
|
||||
),
|
||||
).resolves.toEqual({
|
||||
actor: { id: 7, username: "root", rank: 2000 },
|
||||
highestRank: 2000,
|
||||
});
|
||||
});
|
||||
it("returns null for a deleted user", async () =>
|
||||
expect(resolveAuthorizationState(7, db(null, 2000))).resolves.toBeNull());
|
||||
it("fails closed when there are no ranks", async () =>
|
||||
expect(
|
||||
resolveAuthorizationState(
|
||||
7,
|
||||
db({ id: 7, username: "root", rank: 1 }, null),
|
||||
),
|
||||
).resolves.toEqual({
|
||||
actor: { id: 7, username: "root", rank: 1 },
|
||||
highestRank: null,
|
||||
}));
|
||||
});
|
||||
@@ -1,23 +1,26 @@
|
||||
import type { AuthorizationActor } from "@/lib/admin/authorization-policy";
|
||||
|
||||
export interface RankAuthorityDb {
|
||||
user: {
|
||||
findUnique(args: {
|
||||
where: { id: number };
|
||||
select: { id: true; username: true; rank: true };
|
||||
}): Promise<{ id: number; username: string; rank: number } | null>;
|
||||
};
|
||||
highestRank(): Promise<number | null>;
|
||||
user: {
|
||||
findUnique(args: {
|
||||
where: { id: number };
|
||||
select: { id: true; username: true; rank: true };
|
||||
}): Promise<{ id: number; username: string; rank: number } | null>;
|
||||
};
|
||||
highestRank(): Promise<number | null>;
|
||||
}
|
||||
|
||||
export async function resolveAuthorizationState(
|
||||
userId: number,
|
||||
db: RankAuthorityDb,
|
||||
userId: number,
|
||||
db: RankAuthorityDb,
|
||||
): Promise<{ actor: AuthorizationActor; highestRank: number | null } | null> {
|
||||
const [user, highestRank] = await Promise.all([
|
||||
db.user.findUnique({ where: { id: userId }, select: { id: true, username: true, rank: true } }),
|
||||
db.highestRank(),
|
||||
]);
|
||||
if (!user) return null;
|
||||
return { actor: user, highestRank };
|
||||
const [user, highestRank] = await Promise.all([
|
||||
db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, username: true, rank: true },
|
||||
}),
|
||||
db.highestRank(),
|
||||
]);
|
||||
if (!user) return null;
|
||||
return { actor: user, highestRank };
|
||||
}
|
||||
@@ -2,19 +2,19 @@ import { describe, expect, it } from "vitest";
|
||||
import { resolveStaffUser } from "@/lib/admin/staff-user";
|
||||
|
||||
describe("resolveStaffUser", () => {
|
||||
it("rejects a session whose database rank has been revoked", async () => {
|
||||
const findUser = async () => ({ id: 7, rank: 1, username: "Alice" });
|
||||
it("rejects a session whose database rank has been revoked", async () => {
|
||||
const findUser = async () => ({ id: 7, rank: 1, username: "Alice" });
|
||||
|
||||
await expect(resolveStaffUser("7", 7, findUser)).resolves.toBeNull();
|
||||
});
|
||||
await expect(resolveStaffUser("7", 7, findUser)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("returns the current database identity instead of stale JWT values", async () => {
|
||||
const findUser = async () => ({ id: 7, rank: 8, username: "CurrentName" });
|
||||
it("returns the current database identity instead of stale JWT values", async () => {
|
||||
const findUser = async () => ({ id: 7, rank: 8, username: "CurrentName" });
|
||||
|
||||
await expect(resolveStaffUser("7", 7, findUser)).resolves.toEqual({
|
||||
id: 7,
|
||||
rank: 8,
|
||||
username: "CurrentName",
|
||||
});
|
||||
});
|
||||
await expect(resolveStaffUser("7", 7, findUser)).resolves.toEqual({
|
||||
id: 7,
|
||||
rank: 8,
|
||||
username: "CurrentName",
|
||||
});
|
||||
});
|
||||
});
|
||||
+10
-10
@@ -1,21 +1,21 @@
|
||||
import { isStaff } from "@/lib/admin/is-staff";
|
||||
|
||||
export interface StaffUserRecord {
|
||||
id: number;
|
||||
rank: number;
|
||||
username: string;
|
||||
id: number;
|
||||
rank: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export type FindStaffUser = (id: number) => Promise<StaffUserRecord | null>;
|
||||
|
||||
export async function resolveStaffUser(
|
||||
sessionUserId: string,
|
||||
minStaffRank: number,
|
||||
findUser: FindStaffUser,
|
||||
sessionUserId: string,
|
||||
minStaffRank: number,
|
||||
findUser: FindStaffUser,
|
||||
): Promise<StaffUserRecord | null> {
|
||||
const id = Number(sessionUserId);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) return null;
|
||||
const id = Number(sessionUserId);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) return null;
|
||||
|
||||
const user = await findUser(id);
|
||||
return user && isStaff(user.rank, minStaffRank) ? user : null;
|
||||
const user = await findUser(id);
|
||||
return user && isStaff(user.rank, minStaffRank) ? user : null;
|
||||
}
|
||||
@@ -2,10 +2,10 @@ import { describe, expect, it } from "vitest";
|
||||
import { personalTokenScope } from "@/lib/auth/personal-token-scope";
|
||||
|
||||
describe("personalTokenScope", () => {
|
||||
it("scopes token operations to the user model and owner id", () => {
|
||||
expect(personalTokenScope(42)).toEqual({
|
||||
tokenableId: 42n,
|
||||
tokenableType: "App\\Models\\User",
|
||||
});
|
||||
});
|
||||
it("scopes token operations to the user model and owner id", () => {
|
||||
expect(personalTokenScope(42)).toEqual({
|
||||
tokenableId: 42n,
|
||||
tokenableType: "App\\Models\\User",
|
||||
});
|
||||
});
|
||||
});
|
||||
+52
-45
@@ -1,7 +1,7 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { personalTokenScope, USER_TOKENABLE_TYPE } from "@/lib/auth/personal-token-scope";
|
||||
import { personalTokenScope } from "@/lib/auth/personal-token-scope";
|
||||
import { databaseUserId } from "@/lib/auth/session-user";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
/**
|
||||
* Bearer-token auth for the public REST API, backed by personal_access_tokens
|
||||
@@ -10,55 +10,62 @@ import { databaseUserId } from "@/lib/auth/session-user";
|
||||
* Sanctum "{id}|{plaintext}" form) as `Authorization: Bearer …`.
|
||||
*/
|
||||
function hashToken(raw: string): string {
|
||||
return createHash("sha256").update(raw).digest("hex");
|
||||
return createHash("sha256").update(raw).digest("hex");
|
||||
}
|
||||
|
||||
/** Resolve the user id behind a Bearer token, or null. */
|
||||
export async function bearerUserId(req: Request): Promise<number | null> {
|
||||
const header = req.headers.get("authorization") ?? "";
|
||||
const m = header.match(/^Bearer\s+(.+)$/i);
|
||||
if (!m) return null;
|
||||
let raw = m[1].trim();
|
||||
const pipe = raw.indexOf("|");
|
||||
if (pipe >= 0) raw = raw.slice(pipe + 1); // Sanctum "{id}|{token}"
|
||||
if (!raw) return null;
|
||||
const header = req.headers.get("authorization") ?? "";
|
||||
const m = header.match(/^Bearer\s+(.+)$/i);
|
||||
if (!m) return null;
|
||||
let raw = m[1].trim();
|
||||
const pipe = raw.indexOf("|");
|
||||
if (pipe >= 0) raw = raw.slice(pipe + 1); // Sanctum "{id}|{token}"
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
const row = await prisma.personalAccessTokens.findFirst({
|
||||
where: {
|
||||
token: hashToken(raw),
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
|
||||
},
|
||||
select: { id: true, tokenableId: true },
|
||||
});
|
||||
if (!row) return null;
|
||||
// Best-effort last-used stamp (don't fail the request if it errors).
|
||||
prisma.personalAccessTokens
|
||||
.update({ where: { id: row.id }, data: { lastUsedAt: new Date() }, select: { id: true } })
|
||||
.catch(() => {});
|
||||
return databaseUserId(row.tokenableId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const row = await prisma.personalAccessTokens.findFirst({
|
||||
where: {
|
||||
token: hashToken(raw),
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
|
||||
},
|
||||
select: { id: true, tokenableId: true },
|
||||
});
|
||||
if (!row) return null;
|
||||
// Best-effort last-used stamp (don't fail the request if it errors).
|
||||
prisma.personalAccessTokens
|
||||
.update({
|
||||
where: { id: row.id },
|
||||
data: { lastUsedAt: new Date() },
|
||||
select: { id: true },
|
||||
})
|
||||
.catch(() => {});
|
||||
return databaseUserId(row.tokenableId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Mint a new token for a user. Returns the plaintext (shown once). */
|
||||
export async function issueToken(userId: number, name = "api"): Promise<string | null> {
|
||||
const plaintext = randomBytes(32).toString("hex");
|
||||
try {
|
||||
await prisma.personalAccessTokens.create({
|
||||
data: {
|
||||
...personalTokenScope(userId),
|
||||
name: name.slice(0, 100),
|
||||
token: hashToken(plaintext),
|
||||
abilities: '["*"]',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return plaintext;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
export async function issueToken(
|
||||
userId: number,
|
||||
name = "api",
|
||||
): Promise<string | null> {
|
||||
const plaintext = randomBytes(32).toString("hex");
|
||||
try {
|
||||
await prisma.personalAccessTokens.create({
|
||||
data: {
|
||||
...personalTokenScope(userId),
|
||||
name: name.slice(0, 100),
|
||||
token: hashToken(plaintext),
|
||||
abilities: '["*"]',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return plaintext;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+75
-51
@@ -1,9 +1,9 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { logAuthorizationEvent } from "@/lib/admin/authorization-events";
|
||||
import { validateCsrfToken } from "@/lib/foundation/security";
|
||||
import { canAccess, getApiAdminContext } from "@/lib/permissions";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { validateCsrfToken } from "@/lib/foundation/security";
|
||||
|
||||
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||
const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||
@@ -11,60 +11,84 @@ const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||
type AdminContext = NonNullable<Awaited<ReturnType<typeof getApiAdminContext>>>;
|
||||
type RouteContext = { params?: Promise<Record<string, string | string[]>> };
|
||||
type AdminHandler = (
|
||||
request: NextRequest,
|
||||
context: AdminContext,
|
||||
routeContext: RouteContext,
|
||||
request: NextRequest,
|
||||
context: AdminContext,
|
||||
routeContext: RouteContext,
|
||||
) => Promise<Response> | Response;
|
||||
|
||||
export function withAdmin(
|
||||
options: { permission?: string; requireCsrf?: boolean; maxBodyBytes?: number },
|
||||
handler: AdminHandler,
|
||||
options: {
|
||||
permission?: string;
|
||||
requireCsrf?: boolean;
|
||||
maxBodyBytes?: number;
|
||||
},
|
||||
handler: AdminHandler,
|
||||
) {
|
||||
return async (request: NextRequest, routeContext: RouteContext = {}) => {
|
||||
if (options.requireCsrf === true && MUTATING_METHODS.has(request.method)) {
|
||||
const csrfToken = request.headers.get("x-csrf-token") ?? request.headers.get("csrf-token") ?? "";
|
||||
const valid = await validateCsrfToken(csrfToken);
|
||||
if (!valid) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid or missing CSRF token" }, { status: 403 });
|
||||
}
|
||||
}
|
||||
return async (request: NextRequest, routeContext: RouteContext = {}) => {
|
||||
if (options.requireCsrf === true && MUTATING_METHODS.has(request.method)) {
|
||||
const csrfToken =
|
||||
request.headers.get("x-csrf-token") ??
|
||||
request.headers.get("csrf-token") ??
|
||||
"";
|
||||
const valid = await validateCsrfToken(csrfToken);
|
||||
if (!valid) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Invalid or missing CSRF token" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (MUTATING_METHODS.has(request.method)) {
|
||||
const contentLength = request.headers.get("content-length");
|
||||
const maxBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;
|
||||
if (contentLength && Number(contentLength) > maxBytes) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: `Request body exceeds ${maxBytes} bytes` },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (MUTATING_METHODS.has(request.method)) {
|
||||
const contentLength = request.headers.get("content-length");
|
||||
const maxBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;
|
||||
if (contentLength && Number(contentLength) > maxBytes) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: `Request body exceeds ${maxBytes} bytes` },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const context = await getApiAdminContext();
|
||||
if (!context) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
if (
|
||||
options.permission &&
|
||||
!canAccess(context.permissions, options.permission, context.session.user.rank)
|
||||
) {
|
||||
await logAuthorizationEvent({
|
||||
kind: "permission.denied",
|
||||
userId: context.session.user.id,
|
||||
username: context.session.user.username,
|
||||
rank: context.session.user.rank,
|
||||
permission: options.permission,
|
||||
source: request.nextUrl.pathname,
|
||||
reason: "API permission check denied",
|
||||
});
|
||||
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
try {
|
||||
return await handler(request, context, routeContext);
|
||||
} catch (error) {
|
||||
logServerError("admin.api_failed", error, {
|
||||
path: request.nextUrl.pathname,
|
||||
userId: context.session.user.id,
|
||||
});
|
||||
return NextResponse.json({ ok: false, error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
};
|
||||
const context = await getApiAdminContext();
|
||||
if (!context)
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Unauthorized" },
|
||||
{ status: 401 },
|
||||
);
|
||||
if (
|
||||
options.permission &&
|
||||
!canAccess(
|
||||
context.permissions,
|
||||
options.permission,
|
||||
context.session.user.rank,
|
||||
)
|
||||
) {
|
||||
await logAuthorizationEvent({
|
||||
kind: "permission.denied",
|
||||
userId: context.session.user.id,
|
||||
username: context.session.user.username,
|
||||
rank: context.session.user.rank,
|
||||
permission: options.permission,
|
||||
source: request.nextUrl.pathname,
|
||||
reason: "API permission check denied",
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Forbidden" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await handler(request, context, routeContext);
|
||||
} catch (error) {
|
||||
logServerError("admin.api_failed", error, {
|
||||
path: request.nextUrl.pathname,
|
||||
userId: context.session.user.id,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
+34
-26
@@ -3,46 +3,54 @@ import { ZodError, type z } from "zod";
|
||||
|
||||
/** Throwable API error with HTTP status code */
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status: number = 400) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
status: number;
|
||||
constructor(message: string, status: number = 400) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** Standard success response: { ok: true, ...data } */
|
||||
export function apiOk(data?: Record<string, unknown>) {
|
||||
return NextResponse.json({ ok: true, ...data });
|
||||
return NextResponse.json({ ok: true, ...data });
|
||||
}
|
||||
|
||||
/** Standard error response: { error: message } with given status */
|
||||
export function apiError(message: string, status: number = 400) {
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
|
||||
/** Validation error from Zod: { error: fieldErrors } with 400 */
|
||||
export function apiValidationError(zodError: z.ZodError) {
|
||||
return NextResponse.json({ error: zodError.flatten().fieldErrors }, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{ error: zodError.flatten().fieldErrors },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Centralized error handler for API routes */
|
||||
export function handleApiError(error: unknown): Response {
|
||||
if (error instanceof ApiError) {
|
||||
return apiError(error.message, error.status);
|
||||
}
|
||||
if (error instanceof ZodError) {
|
||||
return apiValidationError(error);
|
||||
}
|
||||
// Prisma P2025 "Record not found"
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.constructor.name === "PrismaClientKnownRequestError" ||
|
||||
error.name === "PrismaClientKnownRequestError") &&
|
||||
(error as Error & { code?: string }).code === "P2025"
|
||||
) {
|
||||
return apiError("Not found", 404);
|
||||
}
|
||||
console.error("[API error]", error instanceof Error ? { message: error.message, name: error.name } : error);
|
||||
return apiError("Internal server error", 500);
|
||||
if (error instanceof ApiError) {
|
||||
return apiError(error.message, error.status);
|
||||
}
|
||||
if (error instanceof ZodError) {
|
||||
return apiValidationError(error);
|
||||
}
|
||||
// Prisma P2025 "Record not found"
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.constructor.name === "PrismaClientKnownRequestError" ||
|
||||
error.name === "PrismaClientKnownRequestError") &&
|
||||
(error as Error & { code?: string }).code === "P2025"
|
||||
) {
|
||||
return apiError("Not found", 404);
|
||||
}
|
||||
console.error(
|
||||
"[API error]",
|
||||
error instanceof Error
|
||||
? { message: error.message, name: error.name }
|
||||
: error,
|
||||
);
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
+39
-32
@@ -2,47 +2,54 @@ import { describe, expect, it } from "vitest";
|
||||
import { apiUnavailable, pagination, positiveBigInt } from "@/lib/api";
|
||||
|
||||
describe("apiUnavailable", () => {
|
||||
it("returns a no-store 503 error without exposing internal details", async () => {
|
||||
const response = apiUnavailable("Account data is temporarily unavailable");
|
||||
it("returns a no-store 503 error without exposing internal details", async () => {
|
||||
const response = apiUnavailable("Account data is temporarily unavailable");
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error: "Account data is temporarily unavailable",
|
||||
});
|
||||
});
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error: "Account data is temporarily unavailable",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("positiveBigInt", () => {
|
||||
it.each(["1", "9007199254740993"])("parses positive integer id %s", (raw) => {
|
||||
expect(positiveBigInt(raw)).toBe(BigInt(raw));
|
||||
});
|
||||
it.each(["1", "9007199254740993"])("parses positive integer id %s", (raw) => {
|
||||
expect(positiveBigInt(raw)).toBe(BigInt(raw));
|
||||
});
|
||||
|
||||
it.each([null, "", "0", "-1", "1.5", "abc"])("rejects invalid id %s", (raw) => {
|
||||
expect(positiveBigInt(raw)).toBeNull();
|
||||
});
|
||||
it.each([
|
||||
null,
|
||||
"",
|
||||
"0",
|
||||
"-1",
|
||||
"1.5",
|
||||
"abc",
|
||||
])("rejects invalid id %s", (raw) => {
|
||||
expect(positiveBigInt(raw)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pagination", () => {
|
||||
it("rejects fractional and malformed values before they reach Prisma", () => {
|
||||
const params = new URLSearchParams({ page: "2.5", perPage: "10.1" });
|
||||
it("rejects fractional and malformed values before they reach Prisma", () => {
|
||||
const params = new URLSearchParams({ page: "2.5", perPage: "10.1" });
|
||||
|
||||
expect(pagination(params, 20, 100)).toEqual({
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
skip: 0,
|
||||
take: 20,
|
||||
});
|
||||
});
|
||||
expect(pagination(params, 20, 100)).toEqual({
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
skip: 0,
|
||||
take: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps valid page sizes to the configured maximum", () => {
|
||||
const params = new URLSearchParams({ page: "3", perPage: "999" });
|
||||
it("clamps valid page sizes to the configured maximum", () => {
|
||||
const params = new URLSearchParams({ page: "3", perPage: "999" });
|
||||
|
||||
expect(pagination(params, 20, 100)).toEqual({
|
||||
page: 3,
|
||||
perPage: 100,
|
||||
skip: 200,
|
||||
take: 100,
|
||||
});
|
||||
});
|
||||
expect(pagination(params, 20, 100)).toEqual({
|
||||
page: 3,
|
||||
perPage: 100,
|
||||
skip: 200,
|
||||
take: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
+37
-26
@@ -8,47 +8,58 @@ import { NextResponse } from "next/server";
|
||||
const CORS_ORIGIN = process.env.APP_URL ?? "http://localhost:3000";
|
||||
|
||||
export function apiJson(data: unknown, init?: ResponseInit): NextResponse {
|
||||
const body = JSON.stringify(data, (_k, v) => (typeof v === "bigint" ? v.toString() : v));
|
||||
return new NextResponse(body, {
|
||||
status: init?.status ?? 200,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"access-control-allow-origin": CORS_ORIGIN,
|
||||
"cache-control": "no-store",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
const body = JSON.stringify(data, (_k, v) =>
|
||||
typeof v === "bigint" ? v.toString() : v,
|
||||
);
|
||||
return new NextResponse(body, {
|
||||
status: init?.status ?? 200,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"access-control-allow-origin": CORS_ORIGIN,
|
||||
"cache-control": "no-store",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Standard error envelope. */
|
||||
export function apiError(message: string, status = 400): NextResponse {
|
||||
return apiJson({ error: message }, { status });
|
||||
return apiJson({ error: message }, { status });
|
||||
}
|
||||
|
||||
/** Safe response for temporary infrastructure failures. */
|
||||
export function apiUnavailable(message = "Service temporarily unavailable"): NextResponse {
|
||||
return apiError(message, 503);
|
||||
export function apiUnavailable(
|
||||
message = "Service temporarily unavailable",
|
||||
): NextResponse {
|
||||
return apiError(message, 503);
|
||||
}
|
||||
|
||||
/** Parse an unsigned, non-zero database identifier without Number precision loss. */
|
||||
export function positiveBigInt(raw: string | null): bigint | null {
|
||||
if (!raw || !/^\d+$/.test(raw)) return null;
|
||||
const value = BigInt(raw);
|
||||
return value > 0n ? value : null;
|
||||
if (!raw || !/^\d+$/.test(raw)) return null;
|
||||
const value = BigInt(raw);
|
||||
return value > 0n ? value : null;
|
||||
}
|
||||
|
||||
/** Clamp a ?page / ?perPage pair from search params. */
|
||||
export function pagination(searchParams: URLSearchParams, defaultPer = 20, maxPer = 100) {
|
||||
const requestedPage = positiveInteger(searchParams.get("page"), 1);
|
||||
const requestedPerPage = positiveInteger(searchParams.get("perPage"), defaultPer);
|
||||
const perPage = Math.min(maxPer, requestedPerPage);
|
||||
const maxSafePage = Math.floor(Number.MAX_SAFE_INTEGER / perPage) + 1;
|
||||
const page = Math.min(requestedPage, maxSafePage);
|
||||
return { page, perPage, skip: (page - 1) * perPage, take: perPage };
|
||||
export function pagination(
|
||||
searchParams: URLSearchParams,
|
||||
defaultPer = 20,
|
||||
maxPer = 100,
|
||||
) {
|
||||
const requestedPage = positiveInteger(searchParams.get("page"), 1);
|
||||
const requestedPerPage = positiveInteger(
|
||||
searchParams.get("perPage"),
|
||||
defaultPer,
|
||||
);
|
||||
const perPage = Math.min(maxPer, requestedPerPage);
|
||||
const maxSafePage = Math.floor(Number.MAX_SAFE_INTEGER / perPage) + 1;
|
||||
const page = Math.min(requestedPage, maxSafePage);
|
||||
return { page, perPage, skip: (page - 1) * perPage, take: perPage };
|
||||
}
|
||||
|
||||
function positiveInteger(raw: string | null, fallback: number): number {
|
||||
if (!raw || !/^\d+$/.test(raw)) return fallback;
|
||||
const value = Number(raw);
|
||||
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
|
||||
if (!raw || !/^\d+$/.test(raw)) return fallback;
|
||||
const value = Number(raw);
|
||||
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
+226
-185
@@ -2,220 +2,261 @@ import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import Discord from "next-auth/providers/discord";
|
||||
import Google from "next-auth/providers/google";
|
||||
import { env } from "@/env";
|
||||
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
|
||||
import { checkLogin } from "@/lib/auth/password";
|
||||
import { verifyTotp } from "@/lib/auth/totp";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { env } from "@/env";
|
||||
|
||||
async function verify2faCode(userId: number, code: string): Promise<boolean> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { twoFactorSecret: true, twoFactorRecoveryCodes: true },
|
||||
});
|
||||
if (!user?.twoFactorSecret) return false;
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { twoFactorSecret: true, twoFactorRecoveryCodes: true },
|
||||
});
|
||||
if (!user?.twoFactorSecret) return false;
|
||||
|
||||
// Try TOTP first
|
||||
try {
|
||||
const appKey = env.APP_KEY;
|
||||
if (!appKey) throw new Error("APP_KEY not configured");
|
||||
const secret = new LaravelEncrypter(appKey).decrypt(user.twoFactorSecret);
|
||||
if (verifyTotp(code, secret)) return true;
|
||||
} catch {
|
||||
/* fall through to recovery */
|
||||
}
|
||||
// Try TOTP first
|
||||
try {
|
||||
const appKey = env.APP_KEY;
|
||||
if (!appKey) throw new Error("APP_KEY not configured");
|
||||
const secret = new LaravelEncrypter(appKey).decrypt(user.twoFactorSecret);
|
||||
if (verifyTotp(code, secret)) return true;
|
||||
} catch {
|
||||
/* fall through to recovery */
|
||||
}
|
||||
|
||||
// Try recovery codes
|
||||
if (user.twoFactorRecoveryCodes) {
|
||||
let codes: string[];
|
||||
try {
|
||||
codes = JSON.parse(user.twoFactorRecoveryCodes) as string[];
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const idx = codes.indexOf(code);
|
||||
if (idx !== -1) {
|
||||
codes.splice(idx, 1);
|
||||
const remaining = codes.length > 0 ? JSON.stringify(codes) : null;
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { twoFactorRecoveryCodes: remaining },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Try recovery codes
|
||||
if (user.twoFactorRecoveryCodes) {
|
||||
let codes: string[];
|
||||
try {
|
||||
codes = JSON.parse(user.twoFactorRecoveryCodes) as string[];
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const idx = codes.indexOf(code);
|
||||
if (idx !== -1) {
|
||||
codes.splice(idx, 1);
|
||||
const remaining = codes.length > 0 ? JSON.stringify(codes) : null;
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { twoFactorRecoveryCodes: remaining },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return false;
|
||||
}
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
trustHost: true,
|
||||
secret: process.env.AUTH_SECRET,
|
||||
session: { strategy: "jwt", maxAge: 24 * 60 * 60 },
|
||||
pages: { signIn: "/login" },
|
||||
providers: [
|
||||
Credentials({
|
||||
credentials: {
|
||||
username: { label: "Username", type: "text" },
|
||||
password: { label: "Password", type: "password" },
|
||||
code: { label: "2FA code", type: "text" },
|
||||
},
|
||||
authorize: async (credentials) => {
|
||||
const username = String(credentials?.username ?? "").trim();
|
||||
const password = String(credentials?.password ?? "");
|
||||
if (!username || !password) return null;
|
||||
trustHost: true,
|
||||
secret: process.env.AUTH_SECRET,
|
||||
session: { strategy: "jwt", maxAge: 24 * 60 * 60 },
|
||||
pages: { signIn: "/login" },
|
||||
providers: [
|
||||
Credentials({
|
||||
credentials: {
|
||||
username: { label: "Username", type: "text" },
|
||||
password: { label: "Password", type: "password" },
|
||||
code: { label: "2FA code", type: "text" },
|
||||
},
|
||||
authorize: async (credentials) => {
|
||||
const username = String(credentials?.username ?? "").trim();
|
||||
const password = String(credentials?.password ?? "");
|
||||
if (!username || !password) return null;
|
||||
|
||||
// Throttle login attempts per IP (10 per 5 min) against credential stuffing.
|
||||
if (!(await rateLimit(`login:${await clientIp()}`, 10, 5 * 60_000)).ok) return null;
|
||||
// Throttle login attempts per IP (10 per 5 min) against credential stuffing.
|
||||
if (!(await rateLimit(`login:${await clientIp()}`, 10, 5 * 60_000)).ok)
|
||||
return null;
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { username } });
|
||||
if (!user) {
|
||||
// Prevent timing-based enumeration: always run a dummy hash check.
|
||||
await checkLogin(password, "$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd", {
|
||||
convertPasswords: false,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const user = await prisma.user.findUnique({ where: { username } });
|
||||
if (!user) {
|
||||
// Prevent timing-based enumeration: always run a dummy hash check.
|
||||
await checkLogin(
|
||||
password,
|
||||
"$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd",
|
||||
{
|
||||
convertPasswords: false,
|
||||
},
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Byte-compatible AtomCMS check (argon2id/bcrypt + md5->argon2id upgrade).
|
||||
const res = await checkLogin(password, user.password, {
|
||||
convertPasswords: env.CONVERT_PASSWORDS,
|
||||
});
|
||||
if (!res.valid) return null;
|
||||
// Byte-compatible AtomCMS check (argon2id/bcrypt + md5->argon2id upgrade).
|
||||
const res = await checkLogin(password, user.password, {
|
||||
convertPasswords: env.CONVERT_PASSWORDS,
|
||||
});
|
||||
if (!res.valid) return null;
|
||||
|
||||
if (res.upgradedHash) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: res.upgradedHash },
|
||||
});
|
||||
}
|
||||
if (res.upgradedHash) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: res.upgradedHash },
|
||||
});
|
||||
}
|
||||
|
||||
// Two-factor: if enabled, a valid TOTP or recovery code is required.
|
||||
if (user.twoFactorConfirmedAt && user.twoFactorSecret) {
|
||||
const code = String(credentials?.code ?? "").trim();
|
||||
if (!code || !env.APP_KEY) return null;
|
||||
// Two-factor: if enabled, a valid TOTP or recovery code is required.
|
||||
if (user.twoFactorConfirmedAt && user.twoFactorSecret) {
|
||||
const code = String(credentials?.code ?? "").trim();
|
||||
if (!code || !env.APP_KEY) return null;
|
||||
|
||||
// Per-user 2FA rate limit (5 attempts per 30s) — prevents TOTP brute-force
|
||||
// even when the attacker rotates IPs or knows the password.
|
||||
if (!(await rateLimit(`2fa:${user.id}`, 5, 30_000)).ok) return null;
|
||||
// Per-user 2FA rate limit (5 attempts per 30s) — prevents TOTP brute-force
|
||||
// even when the attacker rotates IPs or knows the password.
|
||||
if (!(await rateLimit(`2fa:${user.id}`, 5, 30_000)).ok) return null;
|
||||
|
||||
if (!(await verify2faCode(user.id, code))) return null;
|
||||
}
|
||||
if (!(await verify2faCode(user.id, code))) return null;
|
||||
}
|
||||
|
||||
// Record the successful login for the user's "session logs" page.
|
||||
// Best-effort — never let logging block or fail the sign-in.
|
||||
try {
|
||||
const { headers } = await import("next/headers");
|
||||
const ua = (await headers()).get("user-agent")?.slice(0, 512) ?? null;
|
||||
await prisma.websiteLoginLogs.create({
|
||||
data: { userId: user.id, ip: await clientIp(), userAgent: ua, createdAt: new Date() },
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// Record the successful login for the user's "session logs" page.
|
||||
// Best-effort — never let logging block or fail the sign-in.
|
||||
try {
|
||||
const { headers } = await import("next/headers");
|
||||
const ua = (await headers()).get("user-agent")?.slice(0, 512) ?? null;
|
||||
await prisma.websiteLoginLogs.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
ip: await clientIp(),
|
||||
userAgent: ua,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
return { id: String(user.id), name: user.username, rank: user.rank };
|
||||
},
|
||||
}),
|
||||
// OAuth providers — enabled only when both id + secret are configured.
|
||||
...(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET
|
||||
? [Discord({ clientId: env.DISCORD_CLIENT_ID, clientSecret: env.DISCORD_CLIENT_SECRET })]
|
||||
: []),
|
||||
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
|
||||
? [Google({ clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET })]
|
||||
: []),
|
||||
],
|
||||
callbacks: {
|
||||
async signIn({ user, account }) {
|
||||
if (account?.provider === "credentials") return true;
|
||||
return { id: String(user.id), name: user.username, rank: user.rank };
|
||||
},
|
||||
}),
|
||||
// OAuth providers — enabled only when both id + secret are configured.
|
||||
...(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET
|
||||
? [
|
||||
Discord({
|
||||
clientId: env.DISCORD_CLIENT_ID,
|
||||
clientSecret: env.DISCORD_CLIENT_SECRET,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
|
||||
? [
|
||||
Google({
|
||||
clientId: env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
callbacks: {
|
||||
async signIn({ user, account }) {
|
||||
if (account?.provider === "credentials") return true;
|
||||
|
||||
const requireLink = await siteSettings.getBool("oauth_require_link", false);
|
||||
const requireLink = await siteSettings.getBool(
|
||||
"oauth_require_link",
|
||||
false,
|
||||
);
|
||||
|
||||
// Always allow explicitly linked accounts.
|
||||
if (account?.provider === "discord" && account.providerAccountId) {
|
||||
try {
|
||||
const linked = await prisma.socialAccounts.findUnique({
|
||||
where: { provider_providerId: { provider: "discord", providerId: account.providerAccountId } },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (linked) return true;
|
||||
} catch {
|
||||
return "/login?error=Unavailable";
|
||||
}
|
||||
}
|
||||
// Always allow explicitly linked accounts.
|
||||
if (account?.provider === "discord" && account.providerAccountId) {
|
||||
try {
|
||||
const linked = await prisma.socialAccounts.findUnique({
|
||||
where: {
|
||||
provider_providerId: {
|
||||
provider: "discord",
|
||||
providerId: account.providerAccountId,
|
||||
},
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
if (linked) return true;
|
||||
} catch {
|
||||
return "/login?error=Unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
// Email-based binding: only allowed when oauth_require_link is disabled
|
||||
// AND the matched account does NOT have 2FA enabled (account takeover guard).
|
||||
if (!requireLink && user.email) {
|
||||
try {
|
||||
const dbUser = await prisma.user.findFirst({
|
||||
where: { mail: user.email, twoFactorConfirmedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (dbUser) return true;
|
||||
} catch {
|
||||
return "/login?error=Unavailable";
|
||||
}
|
||||
}
|
||||
// Email-based binding: only allowed when oauth_require_link is disabled
|
||||
// AND the matched account does NOT have 2FA enabled (account takeover guard).
|
||||
if (!requireLink && user.email) {
|
||||
try {
|
||||
const dbUser = await prisma.user.findFirst({
|
||||
where: { mail: user.email, twoFactorConfirmedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (dbUser) return true;
|
||||
} catch {
|
||||
return "/login?error=Unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
return "/login?error=NoAccount";
|
||||
},
|
||||
async jwt({ token, user, account }) {
|
||||
if (user && account?.provider === "credentials") {
|
||||
token.rank = (user as { rank?: number }).rank;
|
||||
return token;
|
||||
}
|
||||
return "/login?error=NoAccount";
|
||||
},
|
||||
async jwt({ token, user, account }) {
|
||||
if (user && account?.provider === "credentials") {
|
||||
token.rank = (user as { rank?: number }).rank;
|
||||
return token;
|
||||
}
|
||||
|
||||
const requireLink = await siteSettings.getBool("oauth_require_link", false);
|
||||
const requireLink = await siteSettings.getBool(
|
||||
"oauth_require_link",
|
||||
false,
|
||||
);
|
||||
|
||||
// Try Discord ID via SocialAccounts (always allowed, even when requireLink is true).
|
||||
if (!token.sub && account?.provider === "discord" && account.providerAccountId) {
|
||||
try {
|
||||
const linked = await prisma.socialAccounts.findUnique({
|
||||
where: { provider_providerId: { provider: "discord", providerId: account.providerAccountId } },
|
||||
});
|
||||
if (linked) {
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: Number(linked.userId) },
|
||||
select: { id: true, rank: true, username: true },
|
||||
});
|
||||
if (dbUser) {
|
||||
token.sub = String(dbUser.id);
|
||||
token.rank = dbUser.rank;
|
||||
token.name = dbUser.username;
|
||||
return token;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// leave token as-is on lookup failure
|
||||
}
|
||||
}
|
||||
// Try Discord ID via SocialAccounts (always allowed, even when requireLink is true).
|
||||
if (
|
||||
!token.sub &&
|
||||
account?.provider === "discord" &&
|
||||
account.providerAccountId
|
||||
) {
|
||||
try {
|
||||
const linked = await prisma.socialAccounts.findUnique({
|
||||
where: {
|
||||
provider_providerId: {
|
||||
provider: "discord",
|
||||
providerId: account.providerAccountId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (linked) {
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: Number(linked.userId) },
|
||||
select: { id: true, rank: true, username: true },
|
||||
});
|
||||
if (dbUser) {
|
||||
token.sub = String(dbUser.id);
|
||||
token.rank = dbUser.rank;
|
||||
token.name = dbUser.username;
|
||||
return token;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// leave token as-is on lookup failure
|
||||
}
|
||||
}
|
||||
|
||||
// Email-based binding: only when requireLink is off AND account has no 2FA.
|
||||
if (!requireLink && user?.email && !token.sub) {
|
||||
try {
|
||||
const dbUser = await prisma.user.findFirst({
|
||||
where: { mail: user.email, twoFactorConfirmedAt: null },
|
||||
select: { id: true, rank: true, username: true },
|
||||
});
|
||||
if (dbUser) {
|
||||
token.sub = String(dbUser.id);
|
||||
token.rank = dbUser.rank;
|
||||
token.name = dbUser.username;
|
||||
}
|
||||
} catch {
|
||||
// leave token as-is on lookup failure
|
||||
}
|
||||
}
|
||||
// Email-based binding: only when requireLink is off AND account has no 2FA.
|
||||
if (!requireLink && user?.email && !token.sub) {
|
||||
try {
|
||||
const dbUser = await prisma.user.findFirst({
|
||||
where: { mail: user.email, twoFactorConfirmedAt: null },
|
||||
select: { id: true, rank: true, username: true },
|
||||
});
|
||||
if (dbUser) {
|
||||
token.sub = String(dbUser.id);
|
||||
token.rank = dbUser.rank;
|
||||
token.name = dbUser.username;
|
||||
}
|
||||
} catch {
|
||||
// leave token as-is on lookup failure
|
||||
}
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
session({ session, token }) {
|
||||
if (token.sub && session.user) session.user.id = token.sub;
|
||||
if (typeof token.rank === "number" && session.user) session.user.rank = token.rank;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
return token;
|
||||
},
|
||||
session({ session, token }) {
|
||||
if (token.sub && session.user) session.user.id = token.sub;
|
||||
if (typeof token.rank === "number" && session.user)
|
||||
session.user.rank = token.rank;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
});
|
||||
+18
-3
@@ -1,4 +1,19 @@
|
||||
export { checkLogin, hashPassword, isMd5Of, md5Hex, verifyPassword, type LoginCheck } from "./password";
|
||||
export { generateSsoTicket, issueSsoTicket, type SsoUserUpdater } from "./sso-ticket";
|
||||
export { LaravelEncrypter, phpSerializeString, phpUnserializeString } from "./laravel-encrypter";
|
||||
export {
|
||||
LaravelEncrypter,
|
||||
phpSerializeString,
|
||||
phpUnserializeString,
|
||||
} from "./laravel-encrypter";
|
||||
export {
|
||||
checkLogin,
|
||||
hashPassword,
|
||||
isMd5Of,
|
||||
type LoginCheck,
|
||||
md5Hex,
|
||||
verifyPassword,
|
||||
} from "./password";
|
||||
export {
|
||||
generateSsoTicket,
|
||||
issueSsoTicket,
|
||||
type SsoUserUpdater,
|
||||
} from "./sso-ticket";
|
||||
export { generateTotp, totpKeyUri, verifyTotp } from "./totp";
|
||||
@@ -1,52 +1,58 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LaravelEncrypter, phpSerializeString, phpUnserializeString } from "./laravel-encrypter";
|
||||
import {
|
||||
LaravelEncrypter,
|
||||
phpSerializeString,
|
||||
phpUnserializeString,
|
||||
} from "./laravel-encrypter";
|
||||
|
||||
// Dynamically generated 32-byte key so no secret is hardcoded in source.
|
||||
const APP_KEY = `base64:${randomBytes(32).toString("base64")}`;
|
||||
|
||||
describe("LaravelEncrypter", () => {
|
||||
it("rejects a key that is not 32 bytes", () => {
|
||||
expect(() => new LaravelEncrypter("base64:c2hvcnQ=")).toThrow(/32 bytes/);
|
||||
});
|
||||
it("rejects a key that is not 32 bytes", () => {
|
||||
expect(() => new LaravelEncrypter("base64:c2hvcnQ=")).toThrow(/32 bytes/);
|
||||
});
|
||||
|
||||
it("round-trips encrypt/decrypt (serialize=true, like Laravel encrypt())", () => {
|
||||
const enc = new LaravelEncrypter(APP_KEY);
|
||||
const plaintext = randomBytes(16).toString("hex");
|
||||
const payload = enc.encrypt(plaintext);
|
||||
expect(payload).not.toContain(plaintext);
|
||||
expect(enc.decrypt(payload)).toBe(plaintext);
|
||||
});
|
||||
it("round-trips encrypt/decrypt (serialize=true, like Laravel encrypt())", () => {
|
||||
const enc = new LaravelEncrypter(APP_KEY);
|
||||
const plaintext = randomBytes(16).toString("hex");
|
||||
const payload = enc.encrypt(plaintext);
|
||||
expect(payload).not.toContain(plaintext);
|
||||
expect(enc.decrypt(payload)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("round-trips encryptString/decryptString (serialize=false)", () => {
|
||||
const enc = new LaravelEncrypter(APP_KEY);
|
||||
const payload = enc.encryptString("hello world");
|
||||
expect(enc.decryptString(payload)).toBe("hello world");
|
||||
});
|
||||
it("round-trips encryptString/decryptString (serialize=false)", () => {
|
||||
const enc = new LaravelEncrypter(APP_KEY);
|
||||
const payload = enc.encryptString("hello world");
|
||||
expect(enc.decryptString(payload)).toBe("hello world");
|
||||
});
|
||||
|
||||
it("fails closed when the auth tag is tampered", () => {
|
||||
const enc = new LaravelEncrypter(APP_KEY);
|
||||
const payload = enc.encrypt("x");
|
||||
const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
|
||||
json.tag = Buffer.alloc(16).toString("base64"); // zeroed auth tag
|
||||
const tampered = Buffer.from(JSON.stringify(json), "utf8").toString("base64");
|
||||
expect(() => enc.decrypt(tampered)).toThrow();
|
||||
});
|
||||
it("fails closed when the auth tag is tampered", () => {
|
||||
const enc = new LaravelEncrypter(APP_KEY);
|
||||
const payload = enc.encrypt("x");
|
||||
const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
|
||||
json.tag = Buffer.alloc(16).toString("base64"); // zeroed auth tag
|
||||
const tampered = Buffer.from(JSON.stringify(json), "utf8").toString(
|
||||
"base64",
|
||||
);
|
||||
expect(() => enc.decrypt(tampered)).toThrow();
|
||||
});
|
||||
|
||||
it("decrypts a payload produced with a fresh instance of the same key", () => {
|
||||
const payload = new LaravelEncrypter(APP_KEY).encrypt("shared");
|
||||
expect(new LaravelEncrypter(APP_KEY).decrypt(payload)).toBe("shared");
|
||||
});
|
||||
it("decrypts a payload produced with a fresh instance of the same key", () => {
|
||||
const payload = new LaravelEncrypter(APP_KEY).encrypt("shared");
|
||||
expect(new LaravelEncrypter(APP_KEY).decrypt(payload)).toBe("shared");
|
||||
});
|
||||
});
|
||||
|
||||
describe("php string (de)serialization", () => {
|
||||
it("serializes by byte length", () => {
|
||||
expect(phpSerializeString("hello")).toBe('s:5:"hello";');
|
||||
expect(phpSerializeString("café")).toBe('s:5:"café";'); // é is 2 bytes
|
||||
});
|
||||
it("serializes by byte length", () => {
|
||||
expect(phpSerializeString("hello")).toBe('s:5:"hello";');
|
||||
expect(phpSerializeString("café")).toBe('s:5:"café";'); // é is 2 bytes
|
||||
});
|
||||
|
||||
it("round-trips including multibyte", () => {
|
||||
expect(phpUnserializeString(phpSerializeString("café"))).toBe("café");
|
||||
expect(phpUnserializeString('s:5:"hello";')).toBe("hello");
|
||||
});
|
||||
it("round-trips including multibyte", () => {
|
||||
expect(phpUnserializeString(phpSerializeString("café"))).toBe("café");
|
||||
expect(phpUnserializeString('s:5:"hello";')).toBe("hello");
|
||||
});
|
||||
});
|
||||
@@ -10,68 +10,74 @@ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||
* } )
|
||||
*/
|
||||
export class LaravelEncrypter {
|
||||
private readonly key: Buffer;
|
||||
private readonly key: Buffer;
|
||||
|
||||
/** APP_KEY is "base64:...." (or a raw 32-byte string). */
|
||||
constructor(appKey: string) {
|
||||
const raw = appKey.startsWith("base64:")
|
||||
? Buffer.from(appKey.slice("base64:".length), "base64")
|
||||
: Buffer.from(appKey, "utf8");
|
||||
if (raw.length !== 32) {
|
||||
throw new Error(`APP_KEY must decode to 32 bytes for AES-256-GCM (got ${raw.length})`);
|
||||
}
|
||||
this.key = raw;
|
||||
}
|
||||
/** APP_KEY is "base64:...." (or a raw 32-byte string). */
|
||||
constructor(appKey: string) {
|
||||
const raw = appKey.startsWith("base64:")
|
||||
? Buffer.from(appKey.slice("base64:".length), "base64")
|
||||
: Buffer.from(appKey, "utf8");
|
||||
if (raw.length !== 32) {
|
||||
throw new Error(
|
||||
`APP_KEY must decode to 32 bytes for AES-256-GCM (got ${raw.length})`,
|
||||
);
|
||||
}
|
||||
this.key = raw;
|
||||
}
|
||||
|
||||
encrypt(value: string, serialize = true): string {
|
||||
const iv = randomBytes(12);
|
||||
const data = serialize ? phpSerializeString(value) : value;
|
||||
const cipher = createCipheriv("aes-256-gcm", this.key, iv);
|
||||
const valueB64 = cipher.update(data, "utf8", "base64") + cipher.final("base64");
|
||||
const tag = cipher.getAuthTag();
|
||||
const ivB64 = iv.toString("base64");
|
||||
const tagB64 = tag.toString("base64");
|
||||
const payload = JSON.stringify({ iv: ivB64, value: valueB64, tag: tagB64 });
|
||||
return Buffer.from(payload, "utf8").toString("base64");
|
||||
}
|
||||
encrypt(value: string, serialize = true): string {
|
||||
const iv = randomBytes(12);
|
||||
const data = serialize ? phpSerializeString(value) : value;
|
||||
const cipher = createCipheriv("aes-256-gcm", this.key, iv);
|
||||
const valueB64 =
|
||||
cipher.update(data, "utf8", "base64") + cipher.final("base64");
|
||||
const tag = cipher.getAuthTag();
|
||||
const ivB64 = iv.toString("base64");
|
||||
const tagB64 = tag.toString("base64");
|
||||
const payload = JSON.stringify({ iv: ivB64, value: valueB64, tag: tagB64 });
|
||||
return Buffer.from(payload, "utf8").toString("base64");
|
||||
}
|
||||
|
||||
decrypt(payload: string, serialize = true): string {
|
||||
const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8")) as {
|
||||
iv: string;
|
||||
value: string;
|
||||
tag: string;
|
||||
};
|
||||
const iv = Buffer.from(json.iv, "base64");
|
||||
const tag = Buffer.from(json.tag, "base64");
|
||||
const decipher = createDecipheriv("aes-256-gcm", this.key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const plain = decipher.update(json.value, "base64", "utf8") + decipher.final("utf8");
|
||||
return serialize ? phpUnserializeString(plain) : plain;
|
||||
}
|
||||
decrypt(payload: string, serialize = true): string {
|
||||
const json = JSON.parse(
|
||||
Buffer.from(payload, "base64").toString("utf8"),
|
||||
) as {
|
||||
iv: string;
|
||||
value: string;
|
||||
tag: string;
|
||||
};
|
||||
const iv = Buffer.from(json.iv, "base64");
|
||||
const tag = Buffer.from(json.tag, "base64");
|
||||
const decipher = createDecipheriv("aes-256-gcm", this.key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const plain =
|
||||
decipher.update(json.value, "base64", "utf8") + decipher.final("utf8");
|
||||
return serialize ? phpUnserializeString(plain) : plain;
|
||||
}
|
||||
|
||||
encryptString(value: string): string {
|
||||
return this.encrypt(value, false);
|
||||
}
|
||||
encryptString(value: string): string {
|
||||
return this.encrypt(value, false);
|
||||
}
|
||||
|
||||
decryptString(payload: string): string {
|
||||
return this.decrypt(payload, false);
|
||||
}
|
||||
decryptString(payload: string): string {
|
||||
return this.decrypt(payload, false);
|
||||
}
|
||||
}
|
||||
|
||||
/** PHP serialize() for a string: s:<byteLength>:"<value>"; */
|
||||
export function phpSerializeString(value: string): string {
|
||||
return `s:${Buffer.byteLength(value, "utf8")}:"${value}";`;
|
||||
return `s:${Buffer.byteLength(value, "utf8")}:"${value}";`;
|
||||
}
|
||||
|
||||
/** PHP unserialize() for a serialized string payload. */
|
||||
export function phpUnserializeString(serialized: string): string {
|
||||
const m = /^s:(\d+):"/.exec(serialized);
|
||||
if (!m) throw new Error("Not a serialized PHP string");
|
||||
const byteLen = Number(m[1]);
|
||||
const start = m[0].length;
|
||||
// Slice by BYTE length (PHP counts bytes), then back to a JS string.
|
||||
const bytes = Buffer.from(serialized, "utf8").subarray(
|
||||
Buffer.byteLength(serialized.slice(0, start), "utf8"),
|
||||
);
|
||||
return bytes.subarray(0, byteLen).toString("utf8");
|
||||
const m = /^s:(\d+):"/.exec(serialized);
|
||||
if (!m) throw new Error("Not a serialized PHP string");
|
||||
const byteLen = Number(m[1]);
|
||||
const start = m[0].length;
|
||||
// Slice by BYTE length (PHP counts bytes), then back to a JS string.
|
||||
const bytes = Buffer.from(serialized, "utf8").subarray(
|
||||
Buffer.byteLength(serialized.slice(0, start), "utf8"),
|
||||
);
|
||||
return bytes.subarray(0, byteLen).toString("utf8");
|
||||
}
|
||||
@@ -1,89 +1,99 @@
|
||||
import { hash as bcryptHash } from "bcryptjs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { checkLogin, hashPassword, isMd5Of, md5Hex, verifyPassword } from "./password";
|
||||
import {
|
||||
checkLogin,
|
||||
hashPassword,
|
||||
isMd5Of,
|
||||
md5Hex,
|
||||
verifyPassword,
|
||||
} from "./password";
|
||||
|
||||
describe("md5Hex", () => {
|
||||
it("matches PHP md5() on canonical vectors", async () => {
|
||||
expect(await md5Hex("")).toBe("d41d8cd98f00b204e9800998ecf8427e");
|
||||
expect(await md5Hex("abc")).toBe("900150983cd24fb0d6963f7d28e17f72");
|
||||
});
|
||||
it("matches PHP md5() on canonical vectors", async () => {
|
||||
expect(await md5Hex("")).toBe("d41d8cd98f00b204e9800998ecf8427e");
|
||||
expect(await md5Hex("abc")).toBe("900150983cd24fb0d6963f7d28e17f72");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashPassword (default driver: bcrypt)", () => {
|
||||
it("emits a PHP-style $2y$ bcrypt hash that fits varchar(64) and round-trips", async () => {
|
||||
const prev = process.env.PASSWORD_HASH;
|
||||
delete process.env.PASSWORD_HASH; // exercise the default
|
||||
try {
|
||||
const h = await hashPassword("s3cret!");
|
||||
expect(h).toMatch(/^\$2y\$/);
|
||||
expect(h.length).toBeLessThanOrEqual(60); // fits varchar(64)
|
||||
expect(await verifyPassword("s3cret!", h)).toBe(true);
|
||||
expect(await verifyPassword("wrong", h)).toBe(false);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.PASSWORD_HASH;
|
||||
else process.env.PASSWORD_HASH = prev;
|
||||
}
|
||||
});
|
||||
it("emits a PHP-style $2y$ bcrypt hash that fits varchar(64) and round-trips", async () => {
|
||||
const prev = process.env.PASSWORD_HASH;
|
||||
delete process.env.PASSWORD_HASH; // exercise the default
|
||||
try {
|
||||
const h = await hashPassword("s3cret!");
|
||||
expect(h).toMatch(/^\$2y\$/);
|
||||
expect(h.length).toBeLessThanOrEqual(60); // fits varchar(64)
|
||||
expect(await verifyPassword("s3cret!", h)).toBe(true);
|
||||
expect(await verifyPassword("wrong", h)).toBe(false);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.PASSWORD_HASH;
|
||||
else process.env.PASSWORD_HASH = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashPassword (PASSWORD_HASH=argon2id)", () => {
|
||||
it("hashes with the AtomCMS params (m=65536,t=4,p=1) and round-trips", async () => {
|
||||
const prev = process.env.PASSWORD_HASH;
|
||||
process.env.PASSWORD_HASH = "argon2id";
|
||||
try {
|
||||
const h = await hashPassword("s3cret!");
|
||||
expect(h).toMatch(/^\$argon2id\$v=19\$m=65536,t=4,p=1\$/);
|
||||
expect(await verifyPassword("s3cret!", h)).toBe(true);
|
||||
expect(await verifyPassword("wrong", h)).toBe(false);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.PASSWORD_HASH;
|
||||
else process.env.PASSWORD_HASH = prev;
|
||||
}
|
||||
});
|
||||
it("hashes with the AtomCMS params (m=65536,t=4,p=1) and round-trips", async () => {
|
||||
const prev = process.env.PASSWORD_HASH;
|
||||
process.env.PASSWORD_HASH = "argon2id";
|
||||
try {
|
||||
const h = await hashPassword("s3cret!");
|
||||
expect(h).toMatch(/^\$argon2id\$v=19\$m=65536,t=4,p=1\$/);
|
||||
expect(await verifyPassword("s3cret!", h)).toBe(true);
|
||||
expect(await verifyPassword("wrong", h)).toBe(false);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.PASSWORD_HASH;
|
||||
else process.env.PASSWORD_HASH = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("bcrypt", () => {
|
||||
it("verifies a bcrypt hash and accepts the PHP $2y$ prefix", async () => {
|
||||
const h = await bcryptHash("hunter2", 10); // bcryptjs emits $2a$
|
||||
expect(await verifyPassword("hunter2", h)).toBe(true);
|
||||
// PHP stores $2y$ — bcryptjs must accept it as equivalent.
|
||||
const phpStyle = h.replace(/^\$2[ab]\$/, "$2y$");
|
||||
expect(await verifyPassword("hunter2", phpStyle)).toBe(true);
|
||||
expect(await verifyPassword("nope", h)).toBe(false);
|
||||
});
|
||||
it("verifies a bcrypt hash and accepts the PHP $2y$ prefix", async () => {
|
||||
const h = await bcryptHash("hunter2", 10); // bcryptjs emits $2a$
|
||||
expect(await verifyPassword("hunter2", h)).toBe(true);
|
||||
// PHP stores $2y$ — bcryptjs must accept it as equivalent.
|
||||
const phpStyle = h.replace(/^\$2[ab]\$/, "$2y$");
|
||||
expect(await verifyPassword("hunter2", phpStyle)).toBe(true);
|
||||
expect(await verifyPassword("nope", h)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMd5Of", () => {
|
||||
it("detects a legacy md5 password", async () => {
|
||||
expect(await isMd5Of("habbo", await md5Hex("habbo"))).toBe(true);
|
||||
expect(await isMd5Of("habbo", await md5Hex("other"))).toBe(false);
|
||||
expect(await isMd5Of("habbo", "not-a-hash")).toBe(false);
|
||||
});
|
||||
it("detects a legacy md5 password", async () => {
|
||||
expect(await isMd5Of("habbo", await md5Hex("habbo"))).toBe(true);
|
||||
expect(await isMd5Of("habbo", await md5Hex("other"))).toBe(false);
|
||||
expect(await isMd5Of("habbo", "not-a-hash")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkLogin", () => {
|
||||
it("upgrades a legacy md5 hash to the configured hash when conversion is enabled", async () => {
|
||||
const stored = await md5Hex("oldpass");
|
||||
const res = await checkLogin("oldpass", stored, { convertPasswords: true });
|
||||
expect(res.valid).toBe(true);
|
||||
// Default driver is bcrypt — the upgraded hash must fit varchar(64).
|
||||
expect(res.upgradedHash).toMatch(/^\$2y\$/);
|
||||
expect((res.upgradedHash as string).length).toBeLessThanOrEqual(60);
|
||||
// The upgraded hash verifies the same password.
|
||||
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(true);
|
||||
});
|
||||
it("upgrades a legacy md5 hash to the configured hash when conversion is enabled", async () => {
|
||||
const stored = await md5Hex("oldpass");
|
||||
const res = await checkLogin("oldpass", stored, { convertPasswords: true });
|
||||
expect(res.valid).toBe(true);
|
||||
// Default driver is bcrypt — the upgraded hash must fit varchar(64).
|
||||
expect(res.upgradedHash).toMatch(/^\$2y\$/);
|
||||
expect((res.upgradedHash as string).length).toBeLessThanOrEqual(60);
|
||||
// The upgraded hash verifies the same password.
|
||||
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT upgrade md5 when conversion is disabled", async () => {
|
||||
const stored = await md5Hex("oldpass");
|
||||
const res = await checkLogin("oldpass", stored, { convertPasswords: false });
|
||||
expect(res.valid).toBe(false);
|
||||
expect(res.upgradedHash).toBeUndefined();
|
||||
});
|
||||
it("does NOT upgrade md5 when conversion is disabled", async () => {
|
||||
const stored = await md5Hex("oldpass");
|
||||
const res = await checkLogin("oldpass", stored, {
|
||||
convertPasswords: false,
|
||||
});
|
||||
expect(res.valid).toBe(false);
|
||||
expect(res.upgradedHash).toBeUndefined();
|
||||
});
|
||||
|
||||
it("validates an existing modern hash with no upgrade", async () => {
|
||||
const stored = await hashPassword("modern");
|
||||
const res = await checkLogin("modern", stored, { convertPasswords: true });
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.upgradedHash).toBeUndefined();
|
||||
});
|
||||
it("validates an existing modern hash with no upgrade", async () => {
|
||||
const stored = await hashPassword("modern");
|
||||
const res = await checkLogin("modern", stored, { convertPasswords: true });
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.upgradedHash).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+57
-46
@@ -6,10 +6,10 @@ import { argon2id, argon2Verify, md5 } from "hash-wasm";
|
||||
// threads=1; bcrypt rounds=12 as the legacy fallback. The game emulator
|
||||
// validates the SAME users.password hash, so these must match.
|
||||
const ARGON2_PARAMS = {
|
||||
parallelism: 1,
|
||||
iterations: 4,
|
||||
memorySize: 65536, // KiB
|
||||
hashLength: 32,
|
||||
parallelism: 1,
|
||||
iterations: 4,
|
||||
memorySize: 65536, // KiB
|
||||
hashLength: 32,
|
||||
} as const;
|
||||
const BCRYPT_ROUNDS = 12;
|
||||
|
||||
@@ -18,7 +18,9 @@ const BCRYPT_ROUNDS = 12;
|
||||
// - "argon2id": ~97-char PHC hash. Opt in with PASSWORD_HASH=argon2id.
|
||||
// verifyPassword() always accepts BOTH, so logins keep working either way.
|
||||
function hashDriver(): "bcrypt" | "argon2id" {
|
||||
return process.env.PASSWORD_HASH?.toLowerCase() === "argon2id" ? "argon2id" : "bcrypt";
|
||||
return process.env.PASSWORD_HASH?.toLowerCase() === "argon2id"
|
||||
? "argon2id"
|
||||
: "bcrypt";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,7 +32,7 @@ function hashDriver(): "bcrypt" | "argon2id" {
|
||||
* and does NOT affect credential security.
|
||||
*/
|
||||
export async function md5Hex(input: string): Promise<string> {
|
||||
return await md5(input);
|
||||
return await md5(input);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,23 +41,29 @@ export async function md5Hex(input: string): Promise<string> {
|
||||
* for argon2id (requires a wider column). Both are verifiable by verifyPassword.
|
||||
*/
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
if (hashDriver() === "argon2id") {
|
||||
return argon2id({
|
||||
password,
|
||||
salt: randomBytes(16),
|
||||
outputType: "encoded",
|
||||
...ARGON2_PARAMS,
|
||||
});
|
||||
}
|
||||
// bcryptjs emits $2a$/$2b$; normalise to the PHP-canonical $2y$ the emulator
|
||||
// and existing AtomCMS rows use.
|
||||
const h = await bcryptHash(password, BCRYPT_ROUNDS);
|
||||
return h.replace(/^\$2[ab]\$/, "$2y$");
|
||||
if (hashDriver() === "argon2id") {
|
||||
return argon2id({
|
||||
password,
|
||||
salt: randomBytes(16),
|
||||
outputType: "encoded",
|
||||
...ARGON2_PARAMS,
|
||||
});
|
||||
}
|
||||
// bcryptjs emits $2a$/$2b$; normalise to the PHP-canonical $2y$ the emulator
|
||||
// and existing AtomCMS rows use.
|
||||
const h = await bcryptHash(password, BCRYPT_ROUNDS);
|
||||
return h.replace(/^\$2[ab]\$/, "$2y$");
|
||||
}
|
||||
|
||||
/** True when `stored` is exactly the md5 of `password` (legacy AtomCMS accounts). */
|
||||
export async function isMd5Of(password: string, stored: string): Promise<boolean> {
|
||||
return /^[a-f0-9]{32}$/i.test(stored) && (await md5Hex(password)) === stored.toLowerCase();
|
||||
export async function isMd5Of(
|
||||
password: string,
|
||||
stored: string,
|
||||
): Promise<boolean> {
|
||||
return (
|
||||
/^[a-f0-9]{32}$/i.test(stored) &&
|
||||
(await md5Hex(password)) === stored.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,28 +71,31 @@ export async function isMd5Of(password: string, stored: string): Promise<boolean
|
||||
* Laravel's Hash::check does. Returns false for unknown/legacy formats (md5 is
|
||||
* handled by the conversion path in checkLogin, not here).
|
||||
*/
|
||||
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||
if (stored.startsWith("$argon2")) {
|
||||
try {
|
||||
return await argon2Verify({ password, hash: stored });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (/^\$2[aby]\$/.test(stored)) {
|
||||
try {
|
||||
return await bcryptCompare(password, stored);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
export async function verifyPassword(
|
||||
password: string,
|
||||
stored: string,
|
||||
): Promise<boolean> {
|
||||
if (stored.startsWith("$argon2")) {
|
||||
try {
|
||||
return await argon2Verify({ password, hash: stored });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (/^\$2[aby]\$/.test(stored)) {
|
||||
try {
|
||||
return await bcryptCompare(password, stored);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface LoginCheck {
|
||||
valid: boolean;
|
||||
/** Set when a legacy md5 hash was upgraded — persist it to users.password. */
|
||||
upgradedHash?: string;
|
||||
valid: boolean;
|
||||
/** Set when a legacy md5 hash was upgraded — persist it to users.password. */
|
||||
upgradedHash?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,12 +104,12 @@ export interface LoginCheck {
|
||||
* Mirrors RedirectIfTwoFactorAuthenticatable::convertUserPassword + validate.
|
||||
*/
|
||||
export async function checkLogin(
|
||||
password: string,
|
||||
stored: string,
|
||||
opts: { convertPasswords: boolean },
|
||||
password: string,
|
||||
stored: string,
|
||||
opts: { convertPasswords: boolean },
|
||||
): Promise<LoginCheck> {
|
||||
if (opts.convertPasswords && (await isMd5Of(password, stored))) {
|
||||
return { valid: true, upgradedHash: await hashPassword(password) };
|
||||
}
|
||||
return { valid: await verifyPassword(password, stored) };
|
||||
if (opts.convertPasswords && (await isMd5Of(password, stored))) {
|
||||
return { valid: true, upgradedHash: await hashPassword(password) };
|
||||
}
|
||||
return { valid: await verifyPassword(password, stored) };
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
export const USER_TOKENABLE_TYPE = "App\\Models\\User";
|
||||
|
||||
export function personalTokenScope(userId: number) {
|
||||
return {
|
||||
tokenableId: BigInt(userId),
|
||||
tokenableType: USER_TOKENABLE_TYPE,
|
||||
} as const;
|
||||
return {
|
||||
tokenableId: BigInt(userId),
|
||||
tokenableType: USER_TOKENABLE_TYPE,
|
||||
} as const;
|
||||
}
|
||||
@@ -2,24 +2,34 @@ import { describe, expect, it } from "vitest";
|
||||
import { databaseUserId, sessionUserId } from "@/lib/auth/session-user";
|
||||
|
||||
describe("sessionUserId", () => {
|
||||
it("returns a positive safe integer from a valid session id", () => {
|
||||
expect(sessionUserId("42")).toBe(42);
|
||||
});
|
||||
it("returns a positive safe integer from a valid session id", () => {
|
||||
expect(sessionUserId("42")).toBe(42);
|
||||
});
|
||||
|
||||
it.each([undefined, null, "", "0", "-1", "1.5", "abc", Number.MAX_SAFE_INTEGER + 1])(
|
||||
"rejects invalid session id %s",
|
||||
(value) => {
|
||||
expect(sessionUserId(value)).toBeNull();
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
undefined,
|
||||
null,
|
||||
"",
|
||||
"0",
|
||||
"-1",
|
||||
"1.5",
|
||||
"abc",
|
||||
Number.MAX_SAFE_INTEGER + 1,
|
||||
])("rejects invalid session id %s", (value) => {
|
||||
expect(sessionUserId(value)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("databaseUserId", () => {
|
||||
it("converts a safe positive database id", () => {
|
||||
expect(databaseUserId(42n)).toBe(42);
|
||||
});
|
||||
it("converts a safe positive database id", () => {
|
||||
expect(databaseUserId(42n)).toBe(42);
|
||||
});
|
||||
|
||||
it.each([0n, -1n, BigInt(Number.MAX_SAFE_INTEGER) + 1n])("rejects unsafe database id %s", (value) => {
|
||||
expect(databaseUserId(value)).toBeNull();
|
||||
});
|
||||
it.each([
|
||||
0n,
|
||||
-1n,
|
||||
BigInt(Number.MAX_SAFE_INTEGER) + 1n,
|
||||
])("rejects unsafe database id %s", (value) => {
|
||||
expect(databaseUserId(value)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
export function sessionUserId(value: unknown): number | null {
|
||||
const id = typeof value === "string" && /^\d+$/.test(value) ? Number(value) : NaN;
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
const id =
|
||||
typeof value === "string" && /^\d+$/.test(value) ? Number(value) : NaN;
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
export function databaseUserId(value: bigint): number | null {
|
||||
const id = Number(value);
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
const id = Number(value);
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
@@ -1,34 +1,37 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { generateSsoTicket, issueSsoTicket } from "./sso-ticket";
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
describe("generateSsoTicket", () => {
|
||||
it("uses '{hotelName-without-spaces}-{uuidv4}'", () => {
|
||||
const t = generateSsoTicket("Atom Hotel");
|
||||
expect(t.startsWith("AtomHotel-")).toBe(true);
|
||||
expect(UUID_RE.test(t.slice("AtomHotel-".length))).toBe(true);
|
||||
});
|
||||
it("uses '{hotelName-without-spaces}-{uuidv4}'", () => {
|
||||
const t = generateSsoTicket("Atom Hotel");
|
||||
expect(t.startsWith("AtomHotel-")).toBe(true);
|
||||
expect(UUID_RE.test(t.slice("AtomHotel-".length))).toBe(true);
|
||||
});
|
||||
|
||||
it("strips every space in the hotel name", () => {
|
||||
expect(generateSsoTicket("My Cool Hotel").startsWith("MyCoolHotel-")).toBe(true);
|
||||
});
|
||||
it("strips every space in the hotel name", () => {
|
||||
expect(generateSsoTicket("My Cool Hotel").startsWith("MyCoolHotel-")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("produces a fresh ticket each call", () => {
|
||||
expect(generateSsoTicket("Atom")).not.toBe(generateSsoTicket("Atom"));
|
||||
});
|
||||
it("produces a fresh ticket each call", () => {
|
||||
expect(generateSsoTicket("Atom")).not.toBe(generateSsoTicket("Atom"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("issueSsoTicket", () => {
|
||||
it("writes auth_ticket AND ip_current and returns the ticket", async () => {
|
||||
const update = vi.fn().mockResolvedValue(undefined);
|
||||
const db = { user: { update } };
|
||||
const ticket = await issueSsoTicket(db, 42, "Atom Hotel", "1.2.3.4");
|
||||
it("writes auth_ticket AND ip_current and returns the ticket", async () => {
|
||||
const update = vi.fn().mockResolvedValue(undefined);
|
||||
const db = { user: { update } };
|
||||
const ticket = await issueSsoTicket(db, 42, "Atom Hotel", "1.2.3.4");
|
||||
|
||||
expect(ticket.startsWith("AtomHotel-")).toBe(true);
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
where: { id: 42 },
|
||||
data: { authTicket: ticket, ipCurrent: "1.2.3.4" },
|
||||
});
|
||||
});
|
||||
expect(ticket.startsWith("AtomHotel-")).toBe(true);
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
where: { id: 42 },
|
||||
data: { authTicket: ticket, ipCurrent: "1.2.3.4" },
|
||||
});
|
||||
});
|
||||
});
|
||||
+18
-18
@@ -8,18 +8,18 @@ import { randomUUID } from "node:crypto";
|
||||
* The emulator validates this exact value when the Nitro/Flash client connects.
|
||||
*/
|
||||
export function generateSsoTicket(hotelName: string): string {
|
||||
const normalized = hotelName.replace(/ /g, "");
|
||||
return `${normalized}-${randomUUID()}`;
|
||||
const normalized = hotelName.replace(/ /g, "");
|
||||
return `${normalized}-${randomUUID()}`;
|
||||
}
|
||||
|
||||
/** Minimal shape of the Prisma client this needs (keeps it unit-testable). */
|
||||
export interface SsoUserUpdater {
|
||||
user: {
|
||||
update(args: {
|
||||
where: { id: number };
|
||||
data: { authTicket: string; ipCurrent: string };
|
||||
}): Promise<unknown>;
|
||||
};
|
||||
user: {
|
||||
update(args: {
|
||||
where: { id: number };
|
||||
data: { authTicket: string; ipCurrent: string };
|
||||
}): Promise<unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,15 +27,15 @@ export interface SsoUserUpdater {
|
||||
* ip_current on the user, then returns the ticket for the client launcher.
|
||||
*/
|
||||
export async function issueSsoTicket(
|
||||
db: SsoUserUpdater,
|
||||
userId: number,
|
||||
hotelName: string,
|
||||
ip: string,
|
||||
db: SsoUserUpdater,
|
||||
userId: number,
|
||||
hotelName: string,
|
||||
ip: string,
|
||||
): Promise<string> {
|
||||
const ticket = generateSsoTicket(hotelName);
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: { authTicket: ticket, ipCurrent: ip },
|
||||
});
|
||||
return ticket;
|
||||
const ticket = generateSsoTicket(hotelName);
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: { authTicket: ticket, ipCurrent: ip },
|
||||
});
|
||||
return ticket;
|
||||
}
|
||||
+24
-19
@@ -1,26 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateTotp, generateTotpSecret, totpKeyUri, verifyTotp } from "./totp";
|
||||
import {
|
||||
generateTotp,
|
||||
generateTotpSecret,
|
||||
totpKeyUri,
|
||||
verifyTotp,
|
||||
} from "./totp";
|
||||
|
||||
describe("totp", () => {
|
||||
const SECRET = generateTotpSecret();
|
||||
it("verifies the current generated code", () => {
|
||||
const code = generateTotp(SECRET);
|
||||
expect(code).toMatch(/^\d{6}$/);
|
||||
expect(verifyTotp(code, SECRET)).toBe(true);
|
||||
});
|
||||
const SECRET = generateTotpSecret();
|
||||
it("verifies the current generated code", () => {
|
||||
const code = generateTotp(SECRET);
|
||||
expect(code).toMatch(/^\d{6}$/);
|
||||
expect(verifyTotp(code, SECRET)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a wrong code", () => {
|
||||
expect(verifyTotp("000000", SECRET)).toBe(false);
|
||||
});
|
||||
it("rejects a wrong code", () => {
|
||||
expect(verifyTotp("000000", SECRET)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects malformed input without throwing", () => {
|
||||
expect(verifyTotp("not-a-code", SECRET)).toBe(false);
|
||||
});
|
||||
it("rejects malformed input without throwing", () => {
|
||||
expect(verifyTotp("not-a-code", SECRET)).toBe(false);
|
||||
});
|
||||
|
||||
it("builds an otpauth provisioning URI", () => {
|
||||
const uri = totpKeyUri(SECRET, "alice", "AtomHotel");
|
||||
expect(uri.startsWith("otpauth://totp/")).toBe(true);
|
||||
expect(uri).toContain("secret=" + SECRET);
|
||||
expect(uri).toContain("issuer=AtomHotel");
|
||||
});
|
||||
it("builds an otpauth provisioning URI", () => {
|
||||
const uri = totpKeyUri(SECRET, "alice", "AtomHotel");
|
||||
expect(uri.startsWith("otpauth://totp/")).toBe(true);
|
||||
expect(uri).toContain(`secret=${SECRET}`);
|
||||
expect(uri).toContain("issuer=AtomHotel");
|
||||
});
|
||||
});
|
||||
+13
-9
@@ -6,24 +6,28 @@ authenticator.options = { window: 1 };
|
||||
|
||||
/** Verify a 6-digit TOTP code against a base32 secret. */
|
||||
export function verifyTotp(token: string, secret: string): boolean {
|
||||
try {
|
||||
return authenticator.check(token, secret);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return authenticator.check(token, secret);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Current TOTP code for a secret (used in tests / tooling). */
|
||||
export function generateTotp(secret: string): string {
|
||||
return authenticator.generate(secret);
|
||||
return authenticator.generate(secret);
|
||||
}
|
||||
|
||||
/** Generate a fresh base32 secret for enrolling a new authenticator. */
|
||||
export function generateTotpSecret(): string {
|
||||
return authenticator.generateSecret();
|
||||
return authenticator.generateSecret();
|
||||
}
|
||||
|
||||
/** otpauth:// URI for provisioning a QR code. */
|
||||
export function totpKeyUri(secret: string, accountName: string, issuer: string): string {
|
||||
return authenticator.keyuri(accountName, issuer, secret);
|
||||
export function totpKeyUri(
|
||||
secret: string,
|
||||
accountName: string,
|
||||
issuer: string,
|
||||
): string {
|
||||
return authenticator.keyuri(accountName, issuer, secret);
|
||||
}
|
||||
+14
-10
@@ -1,17 +1,21 @@
|
||||
type CacheEntry<T> = { data: T; expiresAt: number };
|
||||
const store = new Map<string, CacheEntry<unknown>>();
|
||||
|
||||
export function cached<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T> {
|
||||
const existing = store.get(key);
|
||||
if (existing && existing.expiresAt > Date.now()) {
|
||||
return Promise.resolve(existing.data as T);
|
||||
}
|
||||
return fn().then((data) => {
|
||||
store.set(key, { data, expiresAt: Date.now() + ttlMs });
|
||||
return data;
|
||||
});
|
||||
export function cached<T>(
|
||||
key: string,
|
||||
ttlMs: number,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const existing = store.get(key);
|
||||
if (existing && existing.expiresAt > Date.now()) {
|
||||
return Promise.resolve(existing.data as T);
|
||||
}
|
||||
return fn().then((data) => {
|
||||
store.set(key, { data, expiresAt: Date.now() + ttlMs });
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
export function bustCache(key: string): void {
|
||||
store.delete(key);
|
||||
store.delete(key);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -5,59 +5,59 @@
|
||||
// ── Release Eras ───────────────────────────────────────────────────
|
||||
|
||||
export interface ReleaseEra {
|
||||
key: string;
|
||||
label: string;
|
||||
labelEn: string;
|
||||
revisionMin: number;
|
||||
revisionMax: number;
|
||||
key: string;
|
||||
label: string;
|
||||
labelEn: string;
|
||||
revisionMin: number;
|
||||
revisionMax: number;
|
||||
}
|
||||
|
||||
export const RELEASE_ERAS: ReleaseEra[] = [
|
||||
{
|
||||
key: "classic",
|
||||
label: "Classici 2000-2003",
|
||||
labelEn: "Classic 2000-2003",
|
||||
revisionMin: 0,
|
||||
revisionMax: 19999,
|
||||
},
|
||||
{
|
||||
key: "mid",
|
||||
label: "Era HC 2004-2006",
|
||||
labelEn: "HC Era 2004-2006",
|
||||
revisionMin: 20000,
|
||||
revisionMax: 39999,
|
||||
},
|
||||
{
|
||||
key: "modern",
|
||||
label: "Moderni 2007-2009",
|
||||
labelEn: "Modern 2007-2009",
|
||||
revisionMin: 40000,
|
||||
revisionMax: 59999,
|
||||
},
|
||||
{
|
||||
key: "recent",
|
||||
label: "Recenti 2010-2012",
|
||||
labelEn: "Recent 2010-2012",
|
||||
revisionMin: 60000,
|
||||
revisionMax: 79999,
|
||||
},
|
||||
{
|
||||
key: "latest",
|
||||
label: "Ultimi 2013+",
|
||||
labelEn: "Latest 2013+",
|
||||
revisionMin: 80000,
|
||||
revisionMax: Infinity,
|
||||
},
|
||||
{
|
||||
key: "classic",
|
||||
label: "Classici 2000-2003",
|
||||
labelEn: "Classic 2000-2003",
|
||||
revisionMin: 0,
|
||||
revisionMax: 19999,
|
||||
},
|
||||
{
|
||||
key: "mid",
|
||||
label: "Era HC 2004-2006",
|
||||
labelEn: "HC Era 2004-2006",
|
||||
revisionMin: 20000,
|
||||
revisionMax: 39999,
|
||||
},
|
||||
{
|
||||
key: "modern",
|
||||
label: "Moderni 2007-2009",
|
||||
labelEn: "Modern 2007-2009",
|
||||
revisionMin: 40000,
|
||||
revisionMax: 59999,
|
||||
},
|
||||
{
|
||||
key: "recent",
|
||||
label: "Recenti 2010-2012",
|
||||
labelEn: "Recent 2010-2012",
|
||||
revisionMin: 60000,
|
||||
revisionMax: 79999,
|
||||
},
|
||||
{
|
||||
key: "latest",
|
||||
label: "Ultimi 2013+",
|
||||
labelEn: "Latest 2013+",
|
||||
revisionMin: 80000,
|
||||
revisionMax: Infinity,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Match a revision number to a release era.
|
||||
*/
|
||||
export function matchEra(revision: number): ReleaseEra {
|
||||
for (const era of RELEASE_ERAS) {
|
||||
if (revision >= era.revisionMin && revision <= era.revisionMax) {
|
||||
return era;
|
||||
}
|
||||
}
|
||||
return RELEASE_ERAS[RELEASE_ERAS.length - 1];
|
||||
for (const era of RELEASE_ERAS) {
|
||||
if (revision >= era.revisionMin && revision <= era.revisionMax) {
|
||||
return era;
|
||||
}
|
||||
}
|
||||
return RELEASE_ERAS[RELEASE_ERAS.length - 1];
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import { SUPREME_CATEGORIES, type SupremeCategory } from "./categories";
|
||||
* and lowercases the result.
|
||||
*/
|
||||
export function normalizeName(itemName: string): string {
|
||||
return itemName.replace(/\*\d+$/, "").toLowerCase();
|
||||
return itemName.replace(/\*\d+$/, "").toLowerCase();
|
||||
}
|
||||
|
||||
// ── Category Detection ──────────────────────────────────────────
|
||||
@@ -39,45 +39,48 @@ export const AUTO_PREFIX_MIN_ITEMS = 5;
|
||||
* First match wins within each priority level.
|
||||
* Item names are normalized (strip *N suffix, lowercase) before matching.
|
||||
*/
|
||||
export function matchCategory(itemName: string, interactionType?: string): SupremeCategory {
|
||||
const lower = normalizeName(itemName);
|
||||
export function matchCategory(
|
||||
itemName: string,
|
||||
interactionType?: string,
|
||||
): SupremeCategory {
|
||||
const lower = normalizeName(itemName);
|
||||
|
||||
// Pass 1: Interaction type (most specific)
|
||||
for (const cat of SUPREME_CATEGORIES) {
|
||||
if (interactionType && cat.interactionTypes?.includes(interactionType)) {
|
||||
return cat;
|
||||
}
|
||||
}
|
||||
// Pass 1: Interaction type (most specific)
|
||||
for (const cat of SUPREME_CATEGORIES) {
|
||||
if (interactionType && cat.interactionTypes?.includes(interactionType)) {
|
||||
return cat;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Keywords anywhere in name (user priority)
|
||||
for (const cat of SUPREME_CATEGORIES) {
|
||||
if (cat.key === UNCATEGORIZED_KEY) continue;
|
||||
for (const kw of cat.keywords) {
|
||||
if (lower.includes(kw)) return cat;
|
||||
}
|
||||
}
|
||||
// Pass 2: Keywords anywhere in name (user priority)
|
||||
for (const cat of SUPREME_CATEGORIES) {
|
||||
if (cat.key === UNCATEGORIZED_KEY) continue;
|
||||
for (const kw of cat.keywords) {
|
||||
if (lower.includes(kw)) return cat;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: Regex prefix patterns (fallback)
|
||||
for (const cat of SUPREME_CATEGORIES) {
|
||||
if (cat.key === UNCATEGORIZED_KEY) continue;
|
||||
for (const pattern of cat.patterns) {
|
||||
if (pattern.test(lower)) return cat;
|
||||
}
|
||||
}
|
||||
// Pass 3: Regex prefix patterns (fallback)
|
||||
for (const cat of SUPREME_CATEGORIES) {
|
||||
if (cat.key === UNCATEGORIZED_KEY) continue;
|
||||
for (const pattern of cat.patterns) {
|
||||
if (pattern.test(lower)) return cat;
|
||||
}
|
||||
}
|
||||
|
||||
return SUPREME_CATEGORIES[SUPREME_CATEGORIES.length - 1];
|
||||
return SUPREME_CATEGORIES[SUPREME_CATEGORIES.length - 1];
|
||||
}
|
||||
|
||||
// ── Auto-Prefix Grouping ────────────────────────────────────────────
|
||||
|
||||
export interface AutoDetectedCategory {
|
||||
key: string;
|
||||
prefix: string;
|
||||
label: string;
|
||||
labelEn: string;
|
||||
icon: number;
|
||||
layout: string;
|
||||
autoDetected: true;
|
||||
key: string;
|
||||
prefix: string;
|
||||
label: string;
|
||||
labelEn: string;
|
||||
icon: number;
|
||||
layout: string;
|
||||
autoDetected: true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,9 +91,9 @@ export interface AutoDetectedCategory {
|
||||
* - Lowercase the result; return null if prefix is empty or 1 character
|
||||
*/
|
||||
export function extractPrefix(itemName: string): string | null {
|
||||
const normalized = normalizeName(itemName);
|
||||
const idx = normalized.indexOf("_");
|
||||
if (idx > 1) return normalized.substring(0, idx);
|
||||
const match = normalized.match(/^([a-z]{2,})/);
|
||||
return match ? match[1] : null;
|
||||
const normalized = normalizeName(itemName);
|
||||
const idx = normalized.indexOf("_");
|
||||
if (idx > 1) return normalized.substring(0, idx);
|
||||
const match = normalized.match(/^([a-z]{2,})/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
+38
-38
@@ -1,47 +1,47 @@
|
||||
/** All known catalog page layouts for Habbo/Arcturus. */
|
||||
export const CATALOG_LAYOUTS = [
|
||||
"default_3x3",
|
||||
"frontpage",
|
||||
"spaces_new",
|
||||
"recycler",
|
||||
"trophies",
|
||||
"pets",
|
||||
"pets2",
|
||||
"pets3",
|
||||
"soundmachine",
|
||||
"guilds",
|
||||
"info_loyalty",
|
||||
"info_duckets",
|
||||
"loyalty_vip_buy",
|
||||
"single_bundle",
|
||||
"club_buy",
|
||||
"marketplace",
|
||||
"badge_display",
|
||||
"room_bundle",
|
||||
"sold_ltd_items",
|
||||
"default_3x3",
|
||||
"frontpage",
|
||||
"spaces_new",
|
||||
"recycler",
|
||||
"trophies",
|
||||
"pets",
|
||||
"pets2",
|
||||
"pets3",
|
||||
"soundmachine",
|
||||
"guilds",
|
||||
"info_loyalty",
|
||||
"info_duckets",
|
||||
"loyalty_vip_buy",
|
||||
"single_bundle",
|
||||
"club_buy",
|
||||
"marketplace",
|
||||
"badge_display",
|
||||
"room_bundle",
|
||||
"sold_ltd_items",
|
||||
] as const;
|
||||
|
||||
export type CatalogLayout = (typeof CATALOG_LAYOUTS)[number];
|
||||
|
||||
/** Short human-readable description for each layout. Shown in the layout selector. */
|
||||
export const LAYOUT_DESCRIPTIONS: Record<CatalogLayout, string> = {
|
||||
default_3x3: "Standard 3-column grid of items",
|
||||
frontpage: "Featured landing page with large teasers",
|
||||
spaces_new: "Wide 4-column grid used for themed spaces",
|
||||
recycler: "Ecotron recycler exchange page",
|
||||
trophies: "Trophy crafting form",
|
||||
pets: "Legacy pet purchase page",
|
||||
pets2: "Pet purchase page (v2)",
|
||||
pets3: "Pet purchase page (v3)",
|
||||
soundmachine: "Soundmachine track picker",
|
||||
guilds: "Guild/group creation page",
|
||||
info_loyalty: "Informational loyalty page (no items)",
|
||||
info_duckets: "Informational duckets page (no items)",
|
||||
loyalty_vip_buy: "HC/VIP loyalty purchase",
|
||||
single_bundle: "Single prominent bundle tile",
|
||||
club_buy: "HC subscription purchase page",
|
||||
marketplace: "Marketplace entry page",
|
||||
badge_display: "Badge showcase page",
|
||||
room_bundle: "Room bundle purchase page",
|
||||
sold_ltd_items: "Sold limited editions archive",
|
||||
default_3x3: "Standard 3-column grid of items",
|
||||
frontpage: "Featured landing page with large teasers",
|
||||
spaces_new: "Wide 4-column grid used for themed spaces",
|
||||
recycler: "Ecotron recycler exchange page",
|
||||
trophies: "Trophy crafting form",
|
||||
pets: "Legacy pet purchase page",
|
||||
pets2: "Pet purchase page (v2)",
|
||||
pets3: "Pet purchase page (v3)",
|
||||
soundmachine: "Soundmachine track picker",
|
||||
guilds: "Guild/group creation page",
|
||||
info_loyalty: "Informational loyalty page (no items)",
|
||||
info_duckets: "Informational duckets page (no items)",
|
||||
loyalty_vip_buy: "HC/VIP loyalty purchase",
|
||||
single_bundle: "Single prominent bundle tile",
|
||||
club_buy: "HC subscription purchase page",
|
||||
marketplace: "Marketplace entry page",
|
||||
badge_display: "Badge showcase page",
|
||||
room_bundle: "Room bundle purchase page",
|
||||
sold_ltd_items: "Sold limited editions archive",
|
||||
};
|
||||
+199
-199
@@ -4,214 +4,214 @@
|
||||
* Add new entries as needed.
|
||||
*/
|
||||
export const CATALOG_IT: Record<string, string> = {
|
||||
// ── Main categories ───────────────────────────────────
|
||||
frontpage: "Pagina Principale",
|
||||
"front page": "Pagina Principale",
|
||||
furniture: "Mobili",
|
||||
furni: "Mobili",
|
||||
rare: "Rari",
|
||||
rares: "Rari",
|
||||
"super rares": "Super Rari",
|
||||
"ultra rares": "Ultra Rari",
|
||||
limited: "Limitati",
|
||||
"limited edition": "Edizione Limitata",
|
||||
ltd: "Limitati",
|
||||
new: "Novità",
|
||||
"new furni": "Nuovi Mobili",
|
||||
newest: "Novità",
|
||||
// ── Main categories ───────────────────────────────────
|
||||
frontpage: "Pagina Principale",
|
||||
"front page": "Pagina Principale",
|
||||
furniture: "Mobili",
|
||||
furni: "Mobili",
|
||||
rare: "Rari",
|
||||
rares: "Rari",
|
||||
"super rares": "Super Rari",
|
||||
"ultra rares": "Ultra Rari",
|
||||
limited: "Limitati",
|
||||
"limited edition": "Edizione Limitata",
|
||||
ltd: "Limitati",
|
||||
new: "Novità",
|
||||
"new furni": "Nuovi Mobili",
|
||||
newest: "Novità",
|
||||
|
||||
// ── Rooms & Spaces ────────────────────────────────────
|
||||
rooms: "Stanze",
|
||||
"room bundles": "Pacchetti Stanza",
|
||||
"room bundle": "Pacchetto Stanza",
|
||||
spaces: "Spazi",
|
||||
walls: "Pareti",
|
||||
wall: "Parete",
|
||||
floors: "Pavimenti",
|
||||
floor: "Pavimento",
|
||||
landscapes: "Paesaggi",
|
||||
landscape: "Paesaggio",
|
||||
wallpaper: "Carta da Parati",
|
||||
wallpapers: "Carte da Parati",
|
||||
// ── Rooms & Spaces ────────────────────────────────────
|
||||
rooms: "Stanze",
|
||||
"room bundles": "Pacchetti Stanza",
|
||||
"room bundle": "Pacchetto Stanza",
|
||||
spaces: "Spazi",
|
||||
walls: "Pareti",
|
||||
wall: "Parete",
|
||||
floors: "Pavimenti",
|
||||
floor: "Pavimento",
|
||||
landscapes: "Paesaggi",
|
||||
landscape: "Paesaggio",
|
||||
wallpaper: "Carta da Parati",
|
||||
wallpapers: "Carte da Parati",
|
||||
|
||||
// ── Pets ──────────────────────────────────────────────
|
||||
pets: "Animali",
|
||||
"pet accessories": "Accessori Animali",
|
||||
"pet food": "Cibo Animali",
|
||||
horses: "Cavalli",
|
||||
dogs: "Cani",
|
||||
cats: "Gatti",
|
||||
crocodiles: "Coccodrilli",
|
||||
terriers: "Terrier",
|
||||
bears: "Orsi",
|
||||
pigs: "Maiali",
|
||||
lions: "Leoni",
|
||||
rhinos: "Rinoceronti",
|
||||
spiders: "Ragni",
|
||||
turtles: "Tartarughe",
|
||||
chickens: "Galline",
|
||||
frogs: "Rane",
|
||||
dragons: "Draghi",
|
||||
monkeys: "Scimmie",
|
||||
gnomes: "Gnomi",
|
||||
monsters: "Mostri",
|
||||
"monster plants": "Piante Mostro",
|
||||
butterflies: "Farfalle",
|
||||
bunnies: "Coniglietti",
|
||||
pigeons: "Piccioni",
|
||||
// ── Pets ──────────────────────────────────────────────
|
||||
pets: "Animali",
|
||||
"pet accessories": "Accessori Animali",
|
||||
"pet food": "Cibo Animali",
|
||||
horses: "Cavalli",
|
||||
dogs: "Cani",
|
||||
cats: "Gatti",
|
||||
crocodiles: "Coccodrilli",
|
||||
terriers: "Terrier",
|
||||
bears: "Orsi",
|
||||
pigs: "Maiali",
|
||||
lions: "Leoni",
|
||||
rhinos: "Rinoceronti",
|
||||
spiders: "Ragni",
|
||||
turtles: "Tartarughe",
|
||||
chickens: "Galline",
|
||||
frogs: "Rane",
|
||||
dragons: "Draghi",
|
||||
monkeys: "Scimmie",
|
||||
gnomes: "Gnomi",
|
||||
monsters: "Mostri",
|
||||
"monster plants": "Piante Mostro",
|
||||
butterflies: "Farfalle",
|
||||
bunnies: "Coniglietti",
|
||||
pigeons: "Piccioni",
|
||||
|
||||
// ── Economy ───────────────────────────────────────────
|
||||
credits: "Crediti",
|
||||
diamonds: "Diamanti",
|
||||
duckets: "Duckets",
|
||||
pixels: "Pixels",
|
||||
marketplace: "Mercatino",
|
||||
recycler: "Riciclatore",
|
||||
ecotron: "Ecotron",
|
||||
// ── Economy ───────────────────────────────────────────
|
||||
credits: "Crediti",
|
||||
diamonds: "Diamanti",
|
||||
duckets: "Duckets",
|
||||
pixels: "Pixels",
|
||||
marketplace: "Mercatino",
|
||||
recycler: "Riciclatore",
|
||||
ecotron: "Ecotron",
|
||||
|
||||
// ── Habbo Club ────────────────────────────────────────
|
||||
"habbo club": "Club Habbo",
|
||||
hc: "Club Habbo",
|
||||
vip: "VIP",
|
||||
"club furni": "Mobili Club",
|
||||
"club furniture": "Mobili Club",
|
||||
"club offers": "Offerte Club",
|
||||
membership: "Abbonamento",
|
||||
// ── Habbo Club ────────────────────────────────────────
|
||||
"habbo club": "Club Habbo",
|
||||
hc: "Club Habbo",
|
||||
vip: "VIP",
|
||||
"club furni": "Mobili Club",
|
||||
"club furniture": "Mobili Club",
|
||||
"club offers": "Offerte Club",
|
||||
membership: "Abbonamento",
|
||||
|
||||
// ── Building ──────────────────────────────────────────
|
||||
building: "Costruzione",
|
||||
"builders club": "Club Costruttori",
|
||||
wired: "Wired",
|
||||
"wired furni": "Mobili Wired",
|
||||
teleports: "Teletrasporti",
|
||||
rollers: "Rulli",
|
||||
gates: "Cancelli",
|
||||
switches: "Interruttori",
|
||||
// ── Building ──────────────────────────────────────────
|
||||
building: "Costruzione",
|
||||
"builders club": "Club Costruttori",
|
||||
wired: "Wired",
|
||||
"wired furni": "Mobili Wired",
|
||||
teleports: "Teletrasporti",
|
||||
rollers: "Rulli",
|
||||
gates: "Cancelli",
|
||||
switches: "Interruttori",
|
||||
|
||||
// ── Decorations / Themes ──────────────────────────────
|
||||
decorations: "Decorazioni",
|
||||
decoration: "Decorazione",
|
||||
plants: "Piante",
|
||||
lighting: "Illuminazione",
|
||||
lights: "Luci",
|
||||
candles: "Candele",
|
||||
kitchen: "Cucina",
|
||||
bathroom: "Bagno",
|
||||
bedroom: "Camera da Letto",
|
||||
"living room": "Soggiorno",
|
||||
garden: "Giardino",
|
||||
outdoor: "Esterno",
|
||||
office: "Ufficio",
|
||||
study: "Studio",
|
||||
music: "Musica",
|
||||
sound: "Suoni",
|
||||
"sound machine": "Jukebox",
|
||||
// ── Decorations / Themes ──────────────────────────────
|
||||
decorations: "Decorazioni",
|
||||
decoration: "Decorazione",
|
||||
plants: "Piante",
|
||||
lighting: "Illuminazione",
|
||||
lights: "Luci",
|
||||
candles: "Candele",
|
||||
kitchen: "Cucina",
|
||||
bathroom: "Bagno",
|
||||
bedroom: "Camera da Letto",
|
||||
"living room": "Soggiorno",
|
||||
garden: "Giardino",
|
||||
outdoor: "Esterno",
|
||||
office: "Ufficio",
|
||||
study: "Studio",
|
||||
music: "Musica",
|
||||
sound: "Suoni",
|
||||
"sound machine": "Jukebox",
|
||||
|
||||
// ── Collections / Series ──────────────────────────────
|
||||
gothic: "Gotico",
|
||||
executive: "Executive",
|
||||
mode: "Moda",
|
||||
iced: "Ghiacciato",
|
||||
pura: "Pura",
|
||||
lodge: "Baita",
|
||||
plastic: "Plastica",
|
||||
area: "Area",
|
||||
asian: "Asiatico",
|
||||
candy: "Caramelle",
|
||||
chrome: "Cromo",
|
||||
country: "Country",
|
||||
diner: "Tavola Calda",
|
||||
fairy: "Fatato",
|
||||
greek: "Greco",
|
||||
haunted: "Stregato",
|
||||
hollywood: "Hollywood",
|
||||
japanese: "Giapponese",
|
||||
jungle: "Giungla",
|
||||
love: "Amore",
|
||||
marble: "Marmo",
|
||||
medieval: "Medievale",
|
||||
neon: "Neon",
|
||||
prairie: "Prateria",
|
||||
romantic: "Romantico",
|
||||
"sci-fi": "Fantascienza",
|
||||
sport: "Sport",
|
||||
sports: "Sport",
|
||||
street: "Strada",
|
||||
tropical: "Tropicale",
|
||||
victorian: "Vittoriano",
|
||||
vintage: "Vintage",
|
||||
winter: "Inverno",
|
||||
summer: "Estate",
|
||||
spring: "Primavera",
|
||||
autumn: "Autunno",
|
||||
fall: "Autunno",
|
||||
christmas: "Natale",
|
||||
xmas: "Natale",
|
||||
easter: "Pasqua",
|
||||
halloween: "Halloween",
|
||||
valentine: "San Valentino",
|
||||
valentines: "San Valentino",
|
||||
"st patricks": "San Patrizio",
|
||||
// ── Collections / Series ──────────────────────────────
|
||||
gothic: "Gotico",
|
||||
executive: "Executive",
|
||||
mode: "Moda",
|
||||
iced: "Ghiacciato",
|
||||
pura: "Pura",
|
||||
lodge: "Baita",
|
||||
plastic: "Plastica",
|
||||
area: "Area",
|
||||
asian: "Asiatico",
|
||||
candy: "Caramelle",
|
||||
chrome: "Cromo",
|
||||
country: "Country",
|
||||
diner: "Tavola Calda",
|
||||
fairy: "Fatato",
|
||||
greek: "Greco",
|
||||
haunted: "Stregato",
|
||||
hollywood: "Hollywood",
|
||||
japanese: "Giapponese",
|
||||
jungle: "Giungla",
|
||||
love: "Amore",
|
||||
marble: "Marmo",
|
||||
medieval: "Medievale",
|
||||
neon: "Neon",
|
||||
prairie: "Prateria",
|
||||
romantic: "Romantico",
|
||||
"sci-fi": "Fantascienza",
|
||||
sport: "Sport",
|
||||
sports: "Sport",
|
||||
street: "Strada",
|
||||
tropical: "Tropicale",
|
||||
victorian: "Vittoriano",
|
||||
vintage: "Vintage",
|
||||
winter: "Inverno",
|
||||
summer: "Estate",
|
||||
spring: "Primavera",
|
||||
autumn: "Autunno",
|
||||
fall: "Autunno",
|
||||
christmas: "Natale",
|
||||
xmas: "Natale",
|
||||
easter: "Pasqua",
|
||||
halloween: "Halloween",
|
||||
valentine: "San Valentino",
|
||||
valentines: "San Valentino",
|
||||
"st patricks": "San Patrizio",
|
||||
|
||||
// ── Games & Activities ────────────────────────────────
|
||||
games: "Giochi",
|
||||
game: "Gioco",
|
||||
"battle banzai": "Battaglia Banzai",
|
||||
freeze: "Freeze",
|
||||
football: "Calcio",
|
||||
snowstorm: "Tempesta di Neve",
|
||||
"roller skating": "Pattinaggio",
|
||||
trophies: "Trofei",
|
||||
trophy: "Trofeo",
|
||||
prizes: "Premi",
|
||||
rewards: "Ricompense",
|
||||
// ── Games & Activities ────────────────────────────────
|
||||
games: "Giochi",
|
||||
game: "Gioco",
|
||||
"battle banzai": "Battaglia Banzai",
|
||||
freeze: "Freeze",
|
||||
football: "Calcio",
|
||||
snowstorm: "Tempesta di Neve",
|
||||
"roller skating": "Pattinaggio",
|
||||
trophies: "Trofei",
|
||||
trophy: "Trofeo",
|
||||
prizes: "Premi",
|
||||
rewards: "Ricompense",
|
||||
|
||||
// ── Badges & Clothing ────────────────────────────────
|
||||
badges: "Distintivi",
|
||||
badge: "Distintivo",
|
||||
clothing: "Abbigliamento",
|
||||
clothes: "Vestiti",
|
||||
effects: "Effetti",
|
||||
dances: "Balli",
|
||||
// ── Badges & Clothing ────────────────────────────────
|
||||
badges: "Distintivi",
|
||||
badge: "Distintivo",
|
||||
clothing: "Abbigliamento",
|
||||
clothes: "Vestiti",
|
||||
effects: "Effetti",
|
||||
dances: "Balli",
|
||||
|
||||
// ── Bots & Features ──────────────────────────────────
|
||||
bots: "Bot",
|
||||
bot: "Bot",
|
||||
groups: "Gruppi",
|
||||
guilds: "Gilde",
|
||||
guild: "Gilda",
|
||||
"group furni": "Mobili Gruppo",
|
||||
// ── Bots & Features ──────────────────────────────────
|
||||
bots: "Bot",
|
||||
bot: "Bot",
|
||||
groups: "Gruppi",
|
||||
guilds: "Gilde",
|
||||
guild: "Gilda",
|
||||
"group furni": "Mobili Gruppo",
|
||||
|
||||
// ── Misc ──────────────────────────────────────────────
|
||||
offers: "Offerte",
|
||||
specials: "Speciali",
|
||||
special: "Speciale",
|
||||
deals: "Occasioni",
|
||||
sale: "Saldi",
|
||||
bundles: "Pacchetti",
|
||||
bundle: "Pacchetto",
|
||||
promotions: "Promozioni",
|
||||
promo: "Promozione",
|
||||
info: "Informazioni",
|
||||
help: "Aiuto",
|
||||
loyalty: "Fedeltà",
|
||||
"loyalty rewards": "Premi Fedeltà",
|
||||
gift: "Regalo",
|
||||
gifts: "Regali",
|
||||
"gift shop": "Negozio Regali",
|
||||
"credit furni": "Mobili Crediti",
|
||||
exchange: "Cambio",
|
||||
"sold out": "Esaurito",
|
||||
"coming soon": "In Arrivo",
|
||||
all: "Tutto",
|
||||
search: "Cerca",
|
||||
featured: "In Evidenza",
|
||||
popular: "Popolari",
|
||||
"top picks": "Le Migliori",
|
||||
trending: "Tendenze",
|
||||
collections: "Collezioni",
|
||||
collection: "Collezione",
|
||||
"imported furni": "Mobili Importati",
|
||||
// ── Misc ──────────────────────────────────────────────
|
||||
offers: "Offerte",
|
||||
specials: "Speciali",
|
||||
special: "Speciale",
|
||||
deals: "Occasioni",
|
||||
sale: "Saldi",
|
||||
bundles: "Pacchetti",
|
||||
bundle: "Pacchetto",
|
||||
promotions: "Promozioni",
|
||||
promo: "Promozione",
|
||||
info: "Informazioni",
|
||||
help: "Aiuto",
|
||||
loyalty: "Fedeltà",
|
||||
"loyalty rewards": "Premi Fedeltà",
|
||||
gift: "Regalo",
|
||||
gifts: "Regali",
|
||||
"gift shop": "Negozio Regali",
|
||||
"credit furni": "Mobili Crediti",
|
||||
exchange: "Cambio",
|
||||
"sold out": "Esaurito",
|
||||
"coming soon": "In Arrivo",
|
||||
all: "Tutto",
|
||||
search: "Cerca",
|
||||
featured: "In Evidenza",
|
||||
popular: "Popolari",
|
||||
"top picks": "Le Migliori",
|
||||
trending: "Tendenze",
|
||||
collections: "Collezioni",
|
||||
collection: "Collezione",
|
||||
"imported furni": "Mobili Importati",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -219,6 +219,6 @@ export const CATALOG_IT: Record<string, string> = {
|
||||
* Returns the translated name if found, otherwise null.
|
||||
*/
|
||||
export function translateCaption(caption: string): string | null {
|
||||
const key = caption.toLowerCase().trim();
|
||||
return CATALOG_IT[key] ?? null;
|
||||
const key = caption.toLowerCase().trim();
|
||||
return CATALOG_IT[key] ?? null;
|
||||
}
|
||||
@@ -10,35 +10,38 @@
|
||||
*/
|
||||
|
||||
export interface SoundtrackOption {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
author: string;
|
||||
length: number;
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
author: string;
|
||||
length: number;
|
||||
}
|
||||
|
||||
let cachedOptions: SoundtrackOption[] | null = null;
|
||||
let inflight: Promise<SoundtrackOption[]> | null = null;
|
||||
|
||||
export async function loadSongPickerOptions(): Promise<SoundtrackOption[]> {
|
||||
if (cachedOptions) return cachedOptions;
|
||||
if (inflight) return inflight;
|
||||
inflight = (async () => {
|
||||
const res = await fetch("/api/admin/sounds", { cache: "no-store" });
|
||||
if (!res.ok) throw new Error("Failed to load soundtracks");
|
||||
const json = (await res.json()) as { ok: boolean; items?: SoundtrackOption[] };
|
||||
cachedOptions = json.items ?? [];
|
||||
inflight = null;
|
||||
return cachedOptions;
|
||||
})();
|
||||
return inflight;
|
||||
if (cachedOptions) return cachedOptions;
|
||||
if (inflight) return inflight;
|
||||
inflight = (async () => {
|
||||
const res = await fetch("/api/admin/sounds", { cache: "no-store" });
|
||||
if (!res.ok) throw new Error("Failed to load soundtracks");
|
||||
const json = (await res.json()) as {
|
||||
ok: boolean;
|
||||
items?: SoundtrackOption[];
|
||||
};
|
||||
cachedOptions = json.items ?? [];
|
||||
inflight = null;
|
||||
return cachedOptions;
|
||||
})();
|
||||
return inflight;
|
||||
}
|
||||
|
||||
export function getCachedSongPickerOptions(): SoundtrackOption[] | null {
|
||||
return cachedOptions;
|
||||
return cachedOptions;
|
||||
}
|
||||
|
||||
/** Clear the cache so new uploads show up on next open. */
|
||||
export function invalidateSongPickerCache(): void {
|
||||
cachedOptions = null;
|
||||
cachedOptions = null;
|
||||
}
|
||||
@@ -6,92 +6,94 @@
|
||||
export type ClientTranslationFormat = "json5" | "json";
|
||||
|
||||
export interface ClientTranslationFile {
|
||||
id: string;
|
||||
/** Path relative to the project root. */
|
||||
relPath: string;
|
||||
format: ClientTranslationFormat;
|
||||
language: "it" | "en" | "shared";
|
||||
readOnly: boolean;
|
||||
/**
|
||||
* True when the on-disk file commonly contains comments that the json5
|
||||
* serializer cannot preserve. Used to surface a warning in the UI.
|
||||
*/
|
||||
hasComments: boolean;
|
||||
/** Free-form note shown in the UI (e.g. why a file is read-only). */
|
||||
note?: string;
|
||||
id: string;
|
||||
/** Path relative to the project root. */
|
||||
relPath: string;
|
||||
format: ClientTranslationFormat;
|
||||
language: "it" | "en" | "shared";
|
||||
readOnly: boolean;
|
||||
/**
|
||||
* True when the on-disk file commonly contains comments that the json5
|
||||
* serializer cannot preserve. Used to surface a warning in the UI.
|
||||
*/
|
||||
hasComments: boolean;
|
||||
/** Free-form note shown in the UI (e.g. why a file is read-only). */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export const CLIENT_TRANSLATION_FILES: ClientTranslationFile[] = [
|
||||
{
|
||||
id: "ui-texts-it",
|
||||
relPath: "public/nitro-assets/config/UITexts.json5",
|
||||
format: "json5",
|
||||
language: "it",
|
||||
readOnly: false,
|
||||
hasComments: true,
|
||||
note: "Override del client (sovrascrive ExternalTexts).",
|
||||
},
|
||||
{
|
||||
id: "ui-texts-en",
|
||||
relPath: "public/nitro-assets/config/UITexts_en.json5",
|
||||
format: "json5",
|
||||
language: "en",
|
||||
readOnly: true,
|
||||
hasComments: true,
|
||||
note: "Riferimento EN — non viene caricato dal client.",
|
||||
},
|
||||
{
|
||||
id: "external-texts",
|
||||
relPath: "public/nitro-assets/config/ExternalTexts.json5",
|
||||
format: "json5",
|
||||
language: "shared",
|
||||
readOnly: false,
|
||||
hasComments: false,
|
||||
},
|
||||
{
|
||||
id: "external-texts-badges",
|
||||
relPath: "public/nitro-assets/config/ExternalTexts_Badges.json5",
|
||||
format: "json5",
|
||||
language: "shared",
|
||||
readOnly: false,
|
||||
hasComments: false,
|
||||
note: "File grande (~30k voci): usa la ricerca.",
|
||||
},
|
||||
{
|
||||
id: "catalog-texts",
|
||||
relPath: "public/nitro-assets/config/CatalogTexts.json5",
|
||||
format: "json5",
|
||||
language: "shared",
|
||||
readOnly: false,
|
||||
hasComments: false,
|
||||
},
|
||||
{
|
||||
id: "gamedata-external-texts",
|
||||
relPath: "public/nitro-assets/gamedata/ExternalTexts.json",
|
||||
format: "json",
|
||||
language: "shared",
|
||||
readOnly: true,
|
||||
hasComments: false,
|
||||
note: 'Copia "live" servita al client. Generata dal build, modifiche manuali sovrascritte.',
|
||||
},
|
||||
{
|
||||
id: "badge-texts-en",
|
||||
relPath: "public/nitro3/localization/badge-texts-en.json",
|
||||
format: "json",
|
||||
language: "en",
|
||||
readOnly: false,
|
||||
hasComments: false,
|
||||
},
|
||||
{
|
||||
id: "badge-texts-it",
|
||||
relPath: "public/nitro3/localization/badge-texts-it.json",
|
||||
format: "json",
|
||||
language: "it",
|
||||
readOnly: false,
|
||||
hasComments: false,
|
||||
},
|
||||
{
|
||||
id: "ui-texts-it",
|
||||
relPath: "public/nitro-assets/config/UITexts.json5",
|
||||
format: "json5",
|
||||
language: "it",
|
||||
readOnly: false,
|
||||
hasComments: true,
|
||||
note: "Override del client (sovrascrive ExternalTexts).",
|
||||
},
|
||||
{
|
||||
id: "ui-texts-en",
|
||||
relPath: "public/nitro-assets/config/UITexts_en.json5",
|
||||
format: "json5",
|
||||
language: "en",
|
||||
readOnly: true,
|
||||
hasComments: true,
|
||||
note: "Riferimento EN — non viene caricato dal client.",
|
||||
},
|
||||
{
|
||||
id: "external-texts",
|
||||
relPath: "public/nitro-assets/config/ExternalTexts.json5",
|
||||
format: "json5",
|
||||
language: "shared",
|
||||
readOnly: false,
|
||||
hasComments: false,
|
||||
},
|
||||
{
|
||||
id: "external-texts-badges",
|
||||
relPath: "public/nitro-assets/config/ExternalTexts_Badges.json5",
|
||||
format: "json5",
|
||||
language: "shared",
|
||||
readOnly: false,
|
||||
hasComments: false,
|
||||
note: "File grande (~30k voci): usa la ricerca.",
|
||||
},
|
||||
{
|
||||
id: "catalog-texts",
|
||||
relPath: "public/nitro-assets/config/CatalogTexts.json5",
|
||||
format: "json5",
|
||||
language: "shared",
|
||||
readOnly: false,
|
||||
hasComments: false,
|
||||
},
|
||||
{
|
||||
id: "gamedata-external-texts",
|
||||
relPath: "public/nitro-assets/gamedata/ExternalTexts.json",
|
||||
format: "json",
|
||||
language: "shared",
|
||||
readOnly: true,
|
||||
hasComments: false,
|
||||
note: 'Copia "live" servita al client. Generata dal build, modifiche manuali sovrascritte.',
|
||||
},
|
||||
{
|
||||
id: "badge-texts-en",
|
||||
relPath: "public/nitro3/localization/badge-texts-en.json",
|
||||
format: "json",
|
||||
language: "en",
|
||||
readOnly: false,
|
||||
hasComments: false,
|
||||
},
|
||||
{
|
||||
id: "badge-texts-it",
|
||||
relPath: "public/nitro3/localization/badge-texts-it.json",
|
||||
format: "json",
|
||||
language: "it",
|
||||
readOnly: false,
|
||||
hasComments: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function getClientTranslationFile(id: string): ClientTranslationFile | undefined {
|
||||
return CLIENT_TRANSLATION_FILES.find((f) => f.id === id);
|
||||
export function getClientTranslationFile(
|
||||
id: string,
|
||||
): ClientTranslationFile | undefined {
|
||||
return CLIENT_TRANSLATION_FILES.find((f) => f.id === id);
|
||||
}
|
||||
@@ -3,10 +3,13 @@ import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("production deploy workflow", () => {
|
||||
const workflow = readFileSync(resolve(process.cwd(), ".gitea/workflows/deploy.yaml"), "utf8");
|
||||
const workflow = readFileSync(
|
||||
resolve(process.cwd(), ".gitea/workflows/deploy.yaml"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
it("preserves the Next.js incremental build cache", () => {
|
||||
expect(workflow).not.toMatch(/rm\s+-rf[^\n]*\.next/);
|
||||
expect(workflow).toContain("pnpm install --frozen-lockfile");
|
||||
});
|
||||
it("preserves the Next.js incremental build cache", () => {
|
||||
expect(workflow).not.toMatch(/rm\s+-rf[^\n]*\.next/);
|
||||
expect(workflow).toContain("pnpm install --frozen-lockfile");
|
||||
});
|
||||
});
|
||||
+16
-10
@@ -2,17 +2,23 @@ import { describe, expect, it } from "vitest";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
|
||||
describe("formPositiveBigInt", () => {
|
||||
it("parses a positive identifier from FormData", () => {
|
||||
const formData = new FormData();
|
||||
formData.set("id", "42");
|
||||
it("parses a positive identifier from FormData", () => {
|
||||
const formData = new FormData();
|
||||
formData.set("id", "42");
|
||||
|
||||
expect(formPositiveBigInt(formData, "id")).toBe(42n);
|
||||
});
|
||||
expect(formPositiveBigInt(formData, "id")).toBe(42n);
|
||||
});
|
||||
|
||||
it.each(["", "0", "-1", "1.5", "invalid"])("rejects invalid identifier %s", (value) => {
|
||||
const formData = new FormData();
|
||||
formData.set("id", value);
|
||||
it.each([
|
||||
"",
|
||||
"0",
|
||||
"-1",
|
||||
"1.5",
|
||||
"invalid",
|
||||
])("rejects invalid identifier %s", (value) => {
|
||||
const formData = new FormData();
|
||||
formData.set("id", value);
|
||||
|
||||
expect(formPositiveBigInt(formData, "id")).toBeNull();
|
||||
});
|
||||
expect(formPositiveBigInt(formData, "id")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { positiveBigInt } from "@/lib/api";
|
||||
|
||||
export function formPositiveBigInt(formData: FormData, field: string): bigint | null {
|
||||
const value = formData.get(field);
|
||||
return typeof value === "string" ? positiveBigInt(value.trim()) : null;
|
||||
export function formPositiveBigInt(
|
||||
formData: FormData,
|
||||
field: string,
|
||||
): bigint | null {
|
||||
const value = formData.get(field);
|
||||
return typeof value === "string" ? positiveBigInt(value.trim()) : null;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
/** Format a Date as 'YYYY-MM-DD HH:MM:SS' — used across admin and radio pages. */
|
||||
export function formatDateTime(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 19).replace("T", " ") : "";
|
||||
return d ? d.toISOString().slice(0, 19).replace("T", " ") : "";
|
||||
}
|
||||
|
||||
/** Format a Date as 'YYYY-MM-DD' — used across radio list pages. */
|
||||
export function formatDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 10) : "";
|
||||
return d ? d.toISOString().slice(0, 10) : "";
|
||||
}
|
||||
+30
-28
@@ -2,41 +2,43 @@ import { describe, expect, it } from "vitest";
|
||||
import { avatarImageUrl, excerpt, slugify } from "./format";
|
||||
|
||||
describe("slugify", () => {
|
||||
it("lowercases and hyphenates", () => {
|
||||
expect(slugify("Hello World!")).toBe("hello-world");
|
||||
});
|
||||
it("strips accents via NFKD", () => {
|
||||
expect(slugify("Café del Mar")).toBe("cafe-del-mar");
|
||||
});
|
||||
it("falls back to 'article' for empty input", () => {
|
||||
expect(slugify(" *** ")).toBe("article");
|
||||
});
|
||||
it("lowercases and hyphenates", () => {
|
||||
expect(slugify("Hello World!")).toBe("hello-world");
|
||||
});
|
||||
it("strips accents via NFKD", () => {
|
||||
expect(slugify("Café del Mar")).toBe("cafe-del-mar");
|
||||
});
|
||||
it("falls back to 'article' for empty input", () => {
|
||||
expect(slugify(" *** ")).toBe("article");
|
||||
});
|
||||
});
|
||||
|
||||
describe("avatarImageUrl", () => {
|
||||
const base = "https://www.habbo.com/habbo-imaging/avatarimage";
|
||||
const base = "https://www.habbo.com/habbo-imaging/avatarimage";
|
||||
|
||||
it("appends the figure and options", () => {
|
||||
const url = avatarImageUrl(base, "hr-100", { size: "l", headOnly: true });
|
||||
expect(url).toContain("figure=hr-100");
|
||||
expect(url).toContain("size=l");
|
||||
expect(url).toContain("headonly=1");
|
||||
expect(url.startsWith(`${base}?`)).toBe(true);
|
||||
});
|
||||
it("appends the figure and options", () => {
|
||||
const url = avatarImageUrl(base, "hr-100", { size: "l", headOnly: true });
|
||||
expect(url).toContain("figure=hr-100");
|
||||
expect(url).toContain("size=l");
|
||||
expect(url).toContain("headonly=1");
|
||||
expect(url.startsWith(`${base}?`)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses & when the base already has a query string", () => {
|
||||
expect(avatarImageUrl(`${base}?x=1`, "hr-100").includes("?x=1&figure=hr-100")).toBe(true);
|
||||
});
|
||||
it("uses & when the base already has a query string", () => {
|
||||
expect(
|
||||
avatarImageUrl(`${base}?x=1`, "hr-100").includes("?x=1&figure=hr-100"),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("excerpt", () => {
|
||||
it("returns short text unchanged", () => {
|
||||
expect(excerpt("hello", 160)).toBe("hello");
|
||||
});
|
||||
it("returns short text unchanged", () => {
|
||||
expect(excerpt("hello", 160)).toBe("hello");
|
||||
});
|
||||
|
||||
it("truncates on a word boundary with an ellipsis", () => {
|
||||
const out = excerpt("the quick brown fox jumps", 12);
|
||||
expect(out).toBe("the quick…");
|
||||
expect(out.length).toBeLessThanOrEqual(13);
|
||||
});
|
||||
it("truncates on a word boundary with an ellipsis", () => {
|
||||
const out = excerpt("the quick brown fox jumps", 12);
|
||||
expect(out).toBe("the quick…");
|
||||
expect(out.length).toBeLessThanOrEqual(13);
|
||||
});
|
||||
});
|
||||
+23
-22
@@ -1,34 +1,35 @@
|
||||
/** Build a Habbo avatar-imager URL for a figure string. */
|
||||
export function avatarImageUrl(
|
||||
base: string,
|
||||
look: string,
|
||||
opts: { size?: "s" | "m" | "l"; headOnly?: boolean; direction?: number } = {},
|
||||
base: string,
|
||||
look: string,
|
||||
opts: { size?: "s" | "m" | "l"; headOnly?: boolean; direction?: number } = {},
|
||||
): string {
|
||||
const params = new URLSearchParams({ figure: look });
|
||||
if (opts.size) params.set("size", opts.size);
|
||||
if (opts.headOnly) params.set("headonly", "1");
|
||||
if (opts.direction !== undefined) params.set("direction", String(opts.direction));
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
return `${base}${sep}${params.toString()}`;
|
||||
const params = new URLSearchParams({ figure: look });
|
||||
if (opts.size) params.set("size", opts.size);
|
||||
if (opts.headOnly) params.set("headonly", "1");
|
||||
if (opts.direction !== undefined)
|
||||
params.set("direction", String(opts.direction));
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
return `${base}${sep}${params.toString()}`;
|
||||
}
|
||||
|
||||
/** URL-safe slug from a title (lowercase, ascii, hyphenated). */
|
||||
export function slugify(input: string): string {
|
||||
// NFKD splits accented letters into base + combining mark; the combining
|
||||
// marks (and any other non-alphanumerics) are then collapsed to hyphens.
|
||||
const slug = input
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 255);
|
||||
return slug || "article";
|
||||
// NFKD splits accented letters into base + combining mark; the combining
|
||||
// marks (and any other non-alphanumerics) are then collapsed to hyphens.
|
||||
const slug = input
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 255);
|
||||
return slug || "article";
|
||||
}
|
||||
|
||||
/** Trim text to `max` chars on a word boundary, adding an ellipsis. */
|
||||
export function excerpt(text: string, max = 160): string {
|
||||
if (text.length <= max) return text;
|
||||
const cut = text.slice(0, max);
|
||||
const lastSpace = cut.lastIndexOf(" ");
|
||||
return `${(lastSpace > 0 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
|
||||
if (text.length <= max) return text;
|
||||
const cut = text.slice(0, max);
|
||||
const lastSpace = cut.lastIndexOf(" ");
|
||||
return `${(lastSpace > 0 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
|
||||
}
|
||||
+212
-159
@@ -1,204 +1,257 @@
|
||||
import type { z } from "zod";
|
||||
import { logAuthorizationEvent } from "@/lib/admin/authorization-events";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { canAccess, getApiAdminContext } from "@/lib/permissions";
|
||||
import { logAuthorizationEvent } from "@/lib/admin/authorization-events";
|
||||
import { getRequestId, runWithStore, createStore, setContextUserId } from "./request-context";
|
||||
import {
|
||||
NotFoundError,
|
||||
UnauthorizedError,
|
||||
ForbiddenError,
|
||||
ValidationError,
|
||||
RateLimitError,
|
||||
DatabaseError,
|
||||
} from "./errors";
|
||||
import type {
|
||||
ActionResult,
|
||||
ActionSuccess,
|
||||
ActionFailure,
|
||||
AppSession,
|
||||
AdminActionContext,
|
||||
IpAddress,
|
||||
RequestId,
|
||||
} from "./types";
|
||||
import { extractClientIpAsync } from "./security";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
DatabaseError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
RateLimitError,
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
} from "./errors";
|
||||
import {
|
||||
createStore,
|
||||
getRequestId,
|
||||
runWithStore,
|
||||
setContextUserId,
|
||||
} from "./request-context";
|
||||
import { extractClientIpAsync } from "./security";
|
||||
import type {
|
||||
ActionFailure,
|
||||
ActionResult,
|
||||
ActionSuccess,
|
||||
AdminActionContext,
|
||||
AppSession,
|
||||
IpAddress,
|
||||
RequestId,
|
||||
} from "./types";
|
||||
|
||||
function ok<T = Record<string, unknown>>(data?: T): ActionSuccess<T> {
|
||||
return { ok: true, data: (data ?? {}) as T } as unknown as ActionSuccess<T>;
|
||||
return { ok: true, data: (data ?? {}) as T } as unknown as ActionSuccess<T>;
|
||||
}
|
||||
|
||||
function fail(error: string, fieldErrors?: Record<string, string[]>): ActionFailure {
|
||||
return { ok: false, error, fieldErrors };
|
||||
function fail(
|
||||
error: string,
|
||||
fieldErrors?: Record<string, string[]>,
|
||||
): ActionFailure {
|
||||
return { ok: false, error, fieldErrors };
|
||||
}
|
||||
|
||||
export { ok as actionOk, fail as actionError };
|
||||
export { fail as actionError, ok as actionOk };
|
||||
|
||||
interface AdminOpts<TSchema extends z.ZodType | undefined> {
|
||||
permission?: string;
|
||||
schema?: TSchema;
|
||||
rateLimitKey?: string;
|
||||
rateLimitMax?: number;
|
||||
rateLimitWindowMs?: number;
|
||||
permission?: string;
|
||||
schema?: TSchema;
|
||||
rateLimitKey?: string;
|
||||
rateLimitMax?: number;
|
||||
rateLimitWindowMs?: number;
|
||||
}
|
||||
|
||||
type ActionHandler<TSchema extends z.ZodType | undefined> = (
|
||||
ctx: AdminActionContext & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : Record<string, never>),
|
||||
ctx: AdminActionContext &
|
||||
(TSchema extends z.ZodType
|
||||
? { data: z.infer<TSchema> }
|
||||
: Record<string, never>),
|
||||
) => Promise<ActionResult>;
|
||||
|
||||
export function adminAction<TSchema extends z.ZodType | undefined = undefined>(
|
||||
opts: AdminOpts<TSchema>,
|
||||
handler: ActionHandler<TSchema>,
|
||||
opts: AdminOpts<TSchema>,
|
||||
handler: ActionHandler<TSchema>,
|
||||
) {
|
||||
return async (input: TSchema extends z.ZodType ? z.input<TSchema> : void): Promise<ActionResult> => {
|
||||
const ip = await extractClientIpAsync();
|
||||
const store = createStore(ip);
|
||||
return async (
|
||||
input: TSchema extends z.ZodType ? z.input<TSchema> : undefined,
|
||||
): Promise<ActionResult> => {
|
||||
const ip = await extractClientIpAsync();
|
||||
const store = createStore(ip);
|
||||
|
||||
return runWithStore(store, async () => {
|
||||
try {
|
||||
const apiCtx = await getApiAdminContext();
|
||||
if (!apiCtx) return fail("Unauthorized");
|
||||
return runWithStore(store, async () => {
|
||||
try {
|
||||
const apiCtx = await getApiAdminContext();
|
||||
if (!apiCtx) return fail("Unauthorized");
|
||||
|
||||
setContextUserId(Number(apiCtx.session.user.id) as never);
|
||||
setContextUserId(Number(apiCtx.session.user.id) as never);
|
||||
|
||||
if (opts.permission) {
|
||||
if (!canAccess(apiCtx.permissions, opts.permission, apiCtx.session.user.rank)) {
|
||||
await logAuthorizationEvent({
|
||||
kind: "permission.denied",
|
||||
userId: Number(apiCtx.session.user.id),
|
||||
username: apiCtx.session.user.name ?? undefined,
|
||||
rank: apiCtx.session.user.rank,
|
||||
permission: opts.permission,
|
||||
source: "adminAction",
|
||||
reason: "Permission check denied",
|
||||
});
|
||||
return fail("Unauthorized");
|
||||
}
|
||||
}
|
||||
if (opts.permission) {
|
||||
if (
|
||||
!canAccess(
|
||||
apiCtx.permissions,
|
||||
opts.permission,
|
||||
apiCtx.session.user.rank,
|
||||
)
|
||||
) {
|
||||
await logAuthorizationEvent({
|
||||
kind: "permission.denied",
|
||||
userId: Number(apiCtx.session.user.id),
|
||||
username: apiCtx.session.user.name ?? undefined,
|
||||
rank: apiCtx.session.user.rank,
|
||||
permission: opts.permission,
|
||||
source: "adminAction",
|
||||
reason: "Permission check denied",
|
||||
});
|
||||
return fail("Unauthorized");
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.rateLimitKey && opts.rateLimitMax && opts.rateLimitWindowMs) {
|
||||
const rlKey = `${opts.rateLimitKey}:${ip}`;
|
||||
const result = await rateLimit(rlKey, opts.rateLimitMax, opts.rateLimitWindowMs);
|
||||
if (!result.ok) return fail(`Rate limited. Retry in ${result.retryAfter}s.`);
|
||||
}
|
||||
if (opts.rateLimitKey && opts.rateLimitMax && opts.rateLimitWindowMs) {
|
||||
const rlKey = `${opts.rateLimitKey}:${ip}`;
|
||||
const result = await rateLimit(
|
||||
rlKey,
|
||||
opts.rateLimitMax,
|
||||
opts.rateLimitWindowMs,
|
||||
);
|
||||
if (!result.ok)
|
||||
return fail(`Rate limited. Retry in ${result.retryAfter}s.`);
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
if (opts.schema) {
|
||||
const parsed = opts.schema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return fail("Validation failed", parsed.error.flatten().fieldErrors as Record<string, string[]>);
|
||||
}
|
||||
data = parsed.data;
|
||||
}
|
||||
let data: unknown;
|
||||
if (opts.schema) {
|
||||
const parsed = opts.schema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return fail(
|
||||
"Validation failed",
|
||||
parsed.error.flatten().fieldErrors as Record<string, string[]>,
|
||||
);
|
||||
}
|
||||
data = parsed.data;
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
session: apiCtx.session,
|
||||
permissions: apiCtx.permissions,
|
||||
requestId: getRequestId(),
|
||||
ip,
|
||||
...(opts.schema ? { data: data as z.infer<NonNullable<TSchema>> } : {}),
|
||||
} as AdminActionContext &
|
||||
(TSchema extends z.ZodType ? { data: z.infer<TSchema> } : Record<string, never>);
|
||||
const ctx = {
|
||||
session: apiCtx.session,
|
||||
permissions: apiCtx.permissions,
|
||||
requestId: getRequestId(),
|
||||
ip,
|
||||
...(opts.schema
|
||||
? { data: data as z.infer<NonNullable<TSchema>> }
|
||||
: {}),
|
||||
} as AdminActionContext &
|
||||
(TSchema extends z.ZodType
|
||||
? { data: z.infer<TSchema> }
|
||||
: Record<string, never>);
|
||||
|
||||
return await handler(ctx);
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
return await handler(ctx);
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
interface AuthOpts<TSchema extends z.ZodType | undefined> {
|
||||
schema?: TSchema;
|
||||
rateLimitKey?: string;
|
||||
rateLimitMax?: number;
|
||||
rateLimitWindowMs?: number;
|
||||
schema?: TSchema;
|
||||
rateLimitKey?: string;
|
||||
rateLimitMax?: number;
|
||||
rateLimitWindowMs?: number;
|
||||
}
|
||||
|
||||
export function authAction<TSchema extends z.ZodType | undefined = undefined>(
|
||||
opts: AuthOpts<TSchema>,
|
||||
handler: (
|
||||
ctx: { session: AppSession; requestId: RequestId; ip: IpAddress } & (TSchema extends z.ZodType
|
||||
? { data: z.infer<TSchema> }
|
||||
: Record<string, never>),
|
||||
) => Promise<ActionResult>,
|
||||
opts: AuthOpts<TSchema>,
|
||||
handler: (
|
||||
ctx: {
|
||||
session: AppSession;
|
||||
requestId: RequestId;
|
||||
ip: IpAddress;
|
||||
} & (TSchema extends z.ZodType
|
||||
? { data: z.infer<TSchema> }
|
||||
: Record<string, never>),
|
||||
) => Promise<ActionResult>,
|
||||
) {
|
||||
return async (input: TSchema extends z.ZodType ? z.input<TSchema> : void): Promise<ActionResult> => {
|
||||
const ip = await extractClientIpAsync();
|
||||
const store = createStore(ip);
|
||||
return async (
|
||||
input: TSchema extends z.ZodType ? z.input<TSchema> : undefined,
|
||||
): Promise<ActionResult> => {
|
||||
const ip = await extractClientIpAsync();
|
||||
const store = createStore(ip);
|
||||
|
||||
return runWithStore(store, async () => {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user) return fail("Unauthorized");
|
||||
return runWithStore(store, async () => {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user) return fail("Unauthorized");
|
||||
|
||||
setContextUserId(Number(session.user.id) as never);
|
||||
setContextUserId(Number(session.user.id) as never);
|
||||
|
||||
if (opts.rateLimitKey && opts.rateLimitMax && opts.rateLimitWindowMs) {
|
||||
const rlKey = `${opts.rateLimitKey}:${ip}`;
|
||||
const result = await rateLimit(rlKey, opts.rateLimitMax, opts.rateLimitWindowMs);
|
||||
if (!result.ok) return fail(`Rate limited. Retry in ${result.retryAfter}s.`);
|
||||
}
|
||||
if (opts.rateLimitKey && opts.rateLimitMax && opts.rateLimitWindowMs) {
|
||||
const rlKey = `${opts.rateLimitKey}:${ip}`;
|
||||
const result = await rateLimit(
|
||||
rlKey,
|
||||
opts.rateLimitMax,
|
||||
opts.rateLimitWindowMs,
|
||||
);
|
||||
if (!result.ok)
|
||||
return fail(`Rate limited. Retry in ${result.retryAfter}s.`);
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
if (opts.schema) {
|
||||
const parsed = opts.schema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return fail("Validation failed", parsed.error.flatten().fieldErrors as Record<string, string[]>);
|
||||
}
|
||||
data = parsed.data;
|
||||
}
|
||||
let data: unknown;
|
||||
if (opts.schema) {
|
||||
const parsed = opts.schema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return fail(
|
||||
"Validation failed",
|
||||
parsed.error.flatten().fieldErrors as Record<string, string[]>,
|
||||
);
|
||||
}
|
||||
data = parsed.data;
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
session: session as unknown as AppSession,
|
||||
requestId: getRequestId(),
|
||||
ip,
|
||||
} as { session: AppSession; requestId: RequestId; ip: IpAddress } & (TSchema extends z.ZodType
|
||||
? { data: z.infer<TSchema> }
|
||||
: Record<string, never>);
|
||||
const ctx = {
|
||||
session: session as unknown as AppSession,
|
||||
requestId: getRequestId(),
|
||||
ip,
|
||||
} as {
|
||||
session: AppSession;
|
||||
requestId: RequestId;
|
||||
ip: IpAddress;
|
||||
} & (TSchema extends z.ZodType
|
||||
? { data: z.infer<TSchema> }
|
||||
: Record<string, never>);
|
||||
|
||||
if (opts.schema) {
|
||||
(ctx as Record<string, unknown>).data = data as z.infer<NonNullable<TSchema>>;
|
||||
}
|
||||
if (opts.schema) {
|
||||
(ctx as Record<string, unknown>).data = data as z.infer<
|
||||
NonNullable<TSchema>
|
||||
>;
|
||||
}
|
||||
|
||||
return await handler(ctx);
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
return await handler(ctx);
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function handleActionError(error: unknown): ActionFailure {
|
||||
if (error instanceof ValidationError) {
|
||||
return fail(error.message, error.fieldErrors);
|
||||
}
|
||||
if (error instanceof UnauthorizedError || error instanceof ForbiddenError) {
|
||||
return fail(error.message);
|
||||
}
|
||||
if (error instanceof NotFoundError) {
|
||||
return fail(error.message);
|
||||
}
|
||||
if (error instanceof RateLimitError) {
|
||||
return fail(error.message);
|
||||
}
|
||||
if (error instanceof DatabaseError) {
|
||||
return fail("A database error occurred");
|
||||
}
|
||||
if (error instanceof Error && error.name === "ZodError") {
|
||||
return fail("Validation failed");
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.constructor.name === "PrismaClientKnownRequestError" ||
|
||||
error.name === "PrismaClientKnownRequestError") &&
|
||||
(error as Error & { code?: string }).code === "P2025"
|
||||
) {
|
||||
return fail("Not found");
|
||||
}
|
||||
if (error instanceof ValidationError) {
|
||||
return fail(error.message, error.fieldErrors);
|
||||
}
|
||||
if (error instanceof UnauthorizedError || error instanceof ForbiddenError) {
|
||||
return fail(error.message);
|
||||
}
|
||||
if (error instanceof NotFoundError) {
|
||||
return fail(error.message);
|
||||
}
|
||||
if (error instanceof RateLimitError) {
|
||||
return fail(error.message);
|
||||
}
|
||||
if (error instanceof DatabaseError) {
|
||||
return fail("A database error occurred");
|
||||
}
|
||||
if (error instanceof Error && error.name === "ZodError") {
|
||||
return fail("Validation failed");
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.constructor.name === "PrismaClientKnownRequestError" ||
|
||||
error.name === "PrismaClientKnownRequestError") &&
|
||||
(error as Error & { code?: string }).code === "P2025"
|
||||
) {
|
||||
return fail("Not found");
|
||||
}
|
||||
|
||||
console.error(
|
||||
"[Action error]",
|
||||
error instanceof Error ? { message: error.message, name: error.name } : error,
|
||||
);
|
||||
return fail("Internal server error");
|
||||
console.error(
|
||||
"[Action error]",
|
||||
error instanceof Error
|
||||
? { message: error.message, name: error.name }
|
||||
: error,
|
||||
);
|
||||
return fail("Internal server error");
|
||||
}
|
||||
+130
-114
@@ -1,6 +1,6 @@
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { env } from "@/env";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { DatabaseError } from "./errors";
|
||||
import { getRequestId } from "./request-context";
|
||||
@@ -8,133 +8,149 @@ import { getRequestId } from "./request-context";
|
||||
const globalForDb = globalThis as unknown as { _db?: DbService };
|
||||
|
||||
interface HealthStatus {
|
||||
ok: boolean;
|
||||
latencyMs: number;
|
||||
poolSize: number;
|
||||
activeQueries: number;
|
||||
error?: string;
|
||||
ok: boolean;
|
||||
latencyMs: number;
|
||||
poolSize: number;
|
||||
activeQueries: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class DbService {
|
||||
private readonly client: PrismaClient;
|
||||
private queryCount = 0;
|
||||
private lastHealthCheck = 0;
|
||||
private healthCache: HealthStatus | null = null;
|
||||
private readonly healthTtlMs = 10_000;
|
||||
private readonly client: PrismaClient;
|
||||
private lastHealthCheck = 0;
|
||||
private healthCache: HealthStatus | null = null;
|
||||
private readonly healthTtlMs = 10_000;
|
||||
|
||||
constructor() {
|
||||
const url = new URL(env.DATABASE_URL);
|
||||
const adapter = new PrismaMariaDb({
|
||||
host: url.hostname,
|
||||
port: Number(url.port) || 3306,
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
database: url.pathname.replace(/^\//, ""),
|
||||
connectionLimit: env.DATABASE_POOL_SIZE,
|
||||
connectTimeout: env.DATABASE_CONNECT_TIMEOUT_MS,
|
||||
acquireTimeout: env.DATABASE_CONNECT_TIMEOUT_MS,
|
||||
idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS,
|
||||
});
|
||||
constructor() {
|
||||
const url = new URL(env.DATABASE_URL);
|
||||
const adapter = new PrismaMariaDb({
|
||||
host: url.hostname,
|
||||
port: Number(url.port) || 3306,
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
database: url.pathname.replace(/^\//, ""),
|
||||
connectionLimit: env.DATABASE_POOL_SIZE,
|
||||
connectTimeout: env.DATABASE_CONNECT_TIMEOUT_MS,
|
||||
acquireTimeout: env.DATABASE_CONNECT_TIMEOUT_MS,
|
||||
idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
this.client = new PrismaClient({
|
||||
adapter,
|
||||
log:
|
||||
env.NODE_ENV === "development"
|
||||
? [
|
||||
{ emit: "event", level: "query" },
|
||||
{ emit: "event", level: "error" },
|
||||
]
|
||||
: [{ emit: "event", level: "error" }],
|
||||
});
|
||||
this.client = new PrismaClient({
|
||||
adapter,
|
||||
log:
|
||||
env.NODE_ENV === "development"
|
||||
? [
|
||||
{ emit: "event", level: "query" },
|
||||
{ emit: "event", level: "error" },
|
||||
]
|
||||
: [{ emit: "event", level: "error" }],
|
||||
});
|
||||
|
||||
if (env.NODE_ENV === "development") {
|
||||
this.client.$on("query" as never, (e: unknown) => {
|
||||
const ev = e as { query: string; duration: number };
|
||||
logger.debug("DB query", {
|
||||
query: ev.query.slice(0, 200),
|
||||
durationMs: ev.duration,
|
||||
requestId: getRequestId(),
|
||||
});
|
||||
});
|
||||
}
|
||||
if (env.NODE_ENV === "development") {
|
||||
this.client.$on("query" as never, (e: unknown) => {
|
||||
const ev = e as { query: string; duration: number };
|
||||
logger.debug("DB query", {
|
||||
query: ev.query.slice(0, 200),
|
||||
durationMs: ev.duration,
|
||||
requestId: getRequestId(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.client.$on("error" as never, (e: unknown) => {
|
||||
const ev = e as { message: string };
|
||||
logger.error("DB error", { message: ev.message, requestId: getRequestId() });
|
||||
});
|
||||
}
|
||||
this.client.$on("error" as never, (e: unknown) => {
|
||||
const ev = e as { message: string };
|
||||
logger.error("DB error", {
|
||||
message: ev.message,
|
||||
requestId: getRequestId(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
get prisma(): PrismaClient {
|
||||
return this.client;
|
||||
}
|
||||
get prisma(): PrismaClient {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async health(): Promise<HealthStatus> {
|
||||
const now = Date.now();
|
||||
if (this.healthCache && now - this.lastHealthCheck < this.healthTtlMs) {
|
||||
return this.healthCache;
|
||||
}
|
||||
async health(): Promise<HealthStatus> {
|
||||
const now = Date.now();
|
||||
if (this.healthCache && now - this.lastHealthCheck < this.healthTtlMs) {
|
||||
return this.healthCache;
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
await this.client.$queryRaw`SELECT 1`;
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
this.healthCache = { ok: true, latencyMs, poolSize: env.DATABASE_POOL_SIZE, activeQueries: 0 };
|
||||
this.lastHealthCheck = now;
|
||||
return this.healthCache;
|
||||
} catch (cause) {
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
const message = cause instanceof Error ? cause.message : "Unknown database error";
|
||||
this.healthCache = {
|
||||
ok: false,
|
||||
latencyMs,
|
||||
poolSize: env.DATABASE_POOL_SIZE,
|
||||
activeQueries: 0,
|
||||
error: message,
|
||||
};
|
||||
this.lastHealthCheck = now;
|
||||
return this.healthCache;
|
||||
}
|
||||
}
|
||||
const start = performance.now();
|
||||
try {
|
||||
await this.client.$queryRaw`SELECT 1`;
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
this.healthCache = {
|
||||
ok: true,
|
||||
latencyMs,
|
||||
poolSize: env.DATABASE_POOL_SIZE,
|
||||
activeQueries: 0,
|
||||
};
|
||||
this.lastHealthCheck = now;
|
||||
return this.healthCache;
|
||||
} catch (cause) {
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
const message =
|
||||
cause instanceof Error ? cause.message : "Unknown database error";
|
||||
this.healthCache = {
|
||||
ok: false,
|
||||
latencyMs,
|
||||
poolSize: env.DATABASE_POOL_SIZE,
|
||||
activeQueries: 0,
|
||||
error: message,
|
||||
};
|
||||
this.lastHealthCheck = now;
|
||||
return this.healthCache;
|
||||
}
|
||||
}
|
||||
|
||||
async execute<T>(fn: (client: PrismaClient) => Promise<T>): Promise<T> {
|
||||
this.queryCount++;
|
||||
try {
|
||||
return await fn(this.client);
|
||||
} catch (cause) {
|
||||
throw new DatabaseError("Query failed", cause);
|
||||
}
|
||||
}
|
||||
async execute<T>(fn: (client: PrismaClient) => Promise<T>): Promise<T> {
|
||||
this.queryCount++;
|
||||
try {
|
||||
return await fn(this.client);
|
||||
} catch (cause) {
|
||||
throw new DatabaseError("Query failed", cause);
|
||||
}
|
||||
}
|
||||
|
||||
async transaction<T>(
|
||||
fn: (tx: Omit<PrismaClient, "$connect" | "$disconnect" | "$on" | "$use" | "$extends">) => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await this.client.$transaction(fn);
|
||||
} catch (cause) {
|
||||
throw new DatabaseError("Transaction failed", cause);
|
||||
}
|
||||
}
|
||||
async transaction<T>(
|
||||
fn: (
|
||||
tx: Omit<
|
||||
PrismaClient,
|
||||
"$connect" | "$disconnect" | "$on" | "$use" | "$extends"
|
||||
>,
|
||||
) => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await this.client.$transaction(fn);
|
||||
} catch (cause) {
|
||||
throw new DatabaseError("Transaction failed", cause);
|
||||
}
|
||||
}
|
||||
|
||||
async rawQuery<T>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T> {
|
||||
try {
|
||||
return await this.client.$queryRaw<T>(strings, ...values);
|
||||
} catch (cause) {
|
||||
throw new DatabaseError("Raw query failed", cause);
|
||||
}
|
||||
}
|
||||
async rawQuery<T>(
|
||||
strings: TemplateStringsArray,
|
||||
...values: unknown[]
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await this.client.$queryRaw<T>(strings, ...values);
|
||||
} catch (cause) {
|
||||
throw new DatabaseError("Raw query failed", cause);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a raw SQL string with parameterized ? placeholders.
|
||||
* Named "Unsafe" because the caller is responsible for using ? placeholders
|
||||
* and never interpolating user input directly into the query string.
|
||||
*/
|
||||
async executeRawUnsafe(query: string, ...values: unknown[]): Promise<number> {
|
||||
try {
|
||||
return await this.client.$executeRawUnsafe(query, ...values);
|
||||
} catch (cause) {
|
||||
throw new DatabaseError("Execute raw failed", cause);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Execute a raw SQL string with parameterized ? placeholders.
|
||||
* Named "Unsafe" because the caller is responsible for using ? placeholders
|
||||
* and never interpolating user input directly into the query string.
|
||||
*/
|
||||
async executeRawUnsafe(query: string, ...values: unknown[]): Promise<number> {
|
||||
try {
|
||||
return await this.client.$executeRawUnsafe(query, ...values);
|
||||
} catch (cause) {
|
||||
throw new DatabaseError("Execute raw failed", cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const db = globalForDb._db ?? (globalForDb._db = new DbService());
|
||||
|
||||
@@ -1,67 +1,67 @@
|
||||
export class DomainError extends Error {
|
||||
public readonly status: number;
|
||||
public readonly status: number;
|
||||
|
||||
constructor(message: string, status: number = 500) {
|
||||
super(message);
|
||||
this.name = "DomainError";
|
||||
this.status = status;
|
||||
}
|
||||
constructor(message: string, status: number = 500) {
|
||||
super(message);
|
||||
this.name = "DomainError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends DomainError {
|
||||
constructor(entity: string, id?: number | string) {
|
||||
super(id ? `${entity} #${id} not found` : `${entity} not found`, 404);
|
||||
this.name = "NotFoundError";
|
||||
}
|
||||
constructor(entity: string, id?: number | string) {
|
||||
super(id ? `${entity} #${id} not found` : `${entity} not found`, 404);
|
||||
this.name = "NotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends DomainError {
|
||||
constructor(message: string = "Unauthorized") {
|
||||
super(message, 401);
|
||||
this.name = "UnauthorizedError";
|
||||
}
|
||||
constructor(message: string = "Unauthorized") {
|
||||
super(message, 401);
|
||||
this.name = "UnauthorizedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends DomainError {
|
||||
constructor(message: string = "Forbidden") {
|
||||
super(message, 403);
|
||||
this.name = "ForbiddenError";
|
||||
}
|
||||
constructor(message: string = "Forbidden") {
|
||||
super(message, 403);
|
||||
this.name = "ForbiddenError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends DomainError {
|
||||
public readonly fieldErrors: Record<string, string[]>;
|
||||
public readonly fieldErrors: Record<string, string[]>;
|
||||
|
||||
constructor(fieldErrors: Record<string, string[]>) {
|
||||
super("Validation failed", 422);
|
||||
this.name = "ValidationError";
|
||||
this.fieldErrors = fieldErrors;
|
||||
}
|
||||
constructor(fieldErrors: Record<string, string[]>) {
|
||||
super("Validation failed", 422);
|
||||
this.name = "ValidationError";
|
||||
this.fieldErrors = fieldErrors;
|
||||
}
|
||||
}
|
||||
|
||||
export class RateLimitError extends DomainError {
|
||||
public readonly retryAfter: number;
|
||||
public readonly retryAfter: number;
|
||||
|
||||
constructor(retryAfter: number) {
|
||||
super(`Rate limited. Try again in ${retryAfter}s.`, 429);
|
||||
this.name = "RateLimitError";
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
constructor(retryAfter: number) {
|
||||
super(`Rate limited. Try again in ${retryAfter}s.`, 429);
|
||||
this.name = "RateLimitError";
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends DomainError {
|
||||
constructor(message: string) {
|
||||
super(message, 409);
|
||||
this.name = "ConflictError";
|
||||
}
|
||||
constructor(message: string) {
|
||||
super(message, 409);
|
||||
this.name = "ConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
export class DatabaseError extends DomainError {
|
||||
public readonly cause: unknown;
|
||||
public readonly cause: unknown;
|
||||
|
||||
constructor(message: string, cause?: unknown) {
|
||||
super(message, 500);
|
||||
this.name = "DatabaseError";
|
||||
this.cause = cause;
|
||||
}
|
||||
constructor(message: string, cause?: unknown) {
|
||||
super(message, 500);
|
||||
this.name = "DatabaseError";
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
+69
-63
@@ -1,73 +1,79 @@
|
||||
export { adminAction, authAction, actionOk, actionError, handleActionError } from "./action";
|
||||
export {
|
||||
actionError,
|
||||
actionOk,
|
||||
adminAction,
|
||||
authAction,
|
||||
handleActionError,
|
||||
} from "./action";
|
||||
|
||||
export { DbService, db } from "./database";
|
||||
|
||||
export {
|
||||
safeRedirect,
|
||||
redirectSafe,
|
||||
setCsrfCookie,
|
||||
validateCsrfToken,
|
||||
canonicalize,
|
||||
sanitizeFilename,
|
||||
canonicalizeFormValue,
|
||||
canonicalizeFormData,
|
||||
extractClientIpAsync,
|
||||
} from "./security";
|
||||
|
||||
export {
|
||||
NotFoundError,
|
||||
UnauthorizedError,
|
||||
ForbiddenError,
|
||||
ValidationError,
|
||||
RateLimitError,
|
||||
ConflictError,
|
||||
DatabaseError,
|
||||
DomainError,
|
||||
ConflictError,
|
||||
DatabaseError,
|
||||
DomainError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
RateLimitError,
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
} from "./errors";
|
||||
export {
|
||||
addSecurityHeaders,
|
||||
chain,
|
||||
protectAdminRoutes,
|
||||
withRequestContext,
|
||||
} from "./middleware";
|
||||
|
||||
export {
|
||||
getRequestStore,
|
||||
getRequestId,
|
||||
getClientIp,
|
||||
setContextUserId,
|
||||
elapsed,
|
||||
createStore,
|
||||
runWithStore,
|
||||
createStore,
|
||||
elapsed,
|
||||
getClientIp,
|
||||
getRequestId,
|
||||
getRequestStore,
|
||||
runWithStore,
|
||||
setContextUserId,
|
||||
} from "./request-context";
|
||||
|
||||
export { chain, withRequestContext, protectAdminRoutes, addSecurityHeaders } from "./middleware";
|
||||
|
||||
export {
|
||||
username,
|
||||
password,
|
||||
email,
|
||||
hexColor,
|
||||
slug,
|
||||
url,
|
||||
look,
|
||||
positiveInt,
|
||||
nonNegativeInt,
|
||||
bigIntString,
|
||||
idParam,
|
||||
pagination,
|
||||
boolString,
|
||||
buildSearchQuery,
|
||||
} from "./validation";
|
||||
|
||||
canonicalize,
|
||||
canonicalizeFormData,
|
||||
canonicalizeFormValue,
|
||||
extractClientIpAsync,
|
||||
redirectSafe,
|
||||
safeRedirect,
|
||||
sanitizeFilename,
|
||||
setCsrfCookie,
|
||||
validateCsrfToken,
|
||||
} from "./security";
|
||||
export type {
|
||||
UserId,
|
||||
RankId,
|
||||
IpAddress,
|
||||
RequestId,
|
||||
SessionUser,
|
||||
AppSession,
|
||||
ActionContext,
|
||||
AdminActionContext,
|
||||
ActionResult,
|
||||
ActionSuccess,
|
||||
ActionFailure,
|
||||
PaginatedQuery,
|
||||
PaginatedResult,
|
||||
PermissionSet,
|
||||
RequestContext,
|
||||
ActionContext,
|
||||
ActionFailure,
|
||||
ActionResult,
|
||||
ActionSuccess,
|
||||
AdminActionContext,
|
||||
AppSession,
|
||||
IpAddress,
|
||||
PaginatedQuery,
|
||||
PaginatedResult,
|
||||
PermissionSet,
|
||||
RankId,
|
||||
RequestContext,
|
||||
RequestId,
|
||||
SessionUser,
|
||||
UserId,
|
||||
} from "./types";
|
||||
export {
|
||||
bigIntString,
|
||||
boolString,
|
||||
buildSearchQuery,
|
||||
email,
|
||||
hexColor,
|
||||
idParam,
|
||||
look,
|
||||
nonNegativeInt,
|
||||
pagination,
|
||||
password,
|
||||
positiveInt,
|
||||
slug,
|
||||
url,
|
||||
username,
|
||||
} from "./validation";
|
||||
@@ -1,94 +1,102 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { extractClientIpAsync, safeRedirect } from "./security";
|
||||
import { runWithStore, createStore, getRequestId } from "./request-context";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { createStore, getRequestId, runWithStore } from "./request-context";
|
||||
import { extractClientIpAsync, safeRedirect } from "./security";
|
||||
|
||||
type MiddlewareHandler = (req: NextRequest) => Promise<NextResponse | null>;
|
||||
|
||||
export function chain(...handlers: MiddlewareHandler[]): MiddlewareHandler {
|
||||
return async (req: NextRequest) => {
|
||||
for (const handler of handlers) {
|
||||
const result = await handler(req);
|
||||
if (result) return result;
|
||||
}
|
||||
return NextResponse.next();
|
||||
};
|
||||
return async (req: NextRequest) => {
|
||||
for (const handler of handlers) {
|
||||
const result = await handler(req);
|
||||
if (result) return result;
|
||||
}
|
||||
return NextResponse.next();
|
||||
};
|
||||
}
|
||||
|
||||
export function withRequestContext(handler: MiddlewareHandler): MiddlewareHandler {
|
||||
return async (req: NextRequest) => {
|
||||
const ip = await extractClientIpAsync();
|
||||
const store = createStore(ip);
|
||||
return runWithStore(store, async () => {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await handler(req);
|
||||
const duration = Date.now() - start;
|
||||
logger.info("Request completed", {
|
||||
method: req.method,
|
||||
path: req.nextUrl.pathname,
|
||||
status: result?.status ?? 200,
|
||||
durationMs: duration,
|
||||
requestId: getRequestId(),
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - start;
|
||||
logger.error("Request failed", {
|
||||
method: req.method,
|
||||
path: req.nextUrl.pathname,
|
||||
durationMs: duration,
|
||||
requestId: getRequestId(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
const safeUrl = new URL(safeRedirect(req.nextUrl.pathname, "/"), req.url);
|
||||
return NextResponse.redirect(safeUrl);
|
||||
}
|
||||
});
|
||||
};
|
||||
export function withRequestContext(
|
||||
handler: MiddlewareHandler,
|
||||
): MiddlewareHandler {
|
||||
return async (req: NextRequest) => {
|
||||
const ip = await extractClientIpAsync();
|
||||
const store = createStore(ip);
|
||||
return runWithStore(store, async () => {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await handler(req);
|
||||
const duration = Date.now() - start;
|
||||
logger.info("Request completed", {
|
||||
method: req.method,
|
||||
path: req.nextUrl.pathname,
|
||||
status: result?.status ?? 200,
|
||||
durationMs: duration,
|
||||
requestId: getRequestId(),
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - start;
|
||||
logger.error("Request failed", {
|
||||
method: req.method,
|
||||
path: req.nextUrl.pathname,
|
||||
durationMs: duration,
|
||||
requestId: getRequestId(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
const safeUrl = new URL(
|
||||
safeRedirect(req.nextUrl.pathname, "/"),
|
||||
req.url,
|
||||
);
|
||||
return NextResponse.redirect(safeUrl);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function protectAdminRoutes(req: NextRequest): NextResponse | null {
|
||||
const { pathname } = req.nextUrl;
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
if (!pathname.startsWith("/admin")) return null;
|
||||
if (!pathname.startsWith("/admin")) return null;
|
||||
|
||||
const authToken =
|
||||
req.cookies.get("next-auth.session-token")?.value ??
|
||||
req.cookies.get("__Secure-next-auth.session-token")?.value;
|
||||
const authToken =
|
||||
req.cookies.get("next-auth.session-token")?.value ??
|
||||
req.cookies.get("__Secure-next-auth.session-token")?.value;
|
||||
|
||||
if (!authToken) {
|
||||
const loginUrl = new URL("/login", req.url);
|
||||
loginUrl.searchParams.set("callbackUrl", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
if (!authToken) {
|
||||
const loginUrl = new URL("/login", req.url);
|
||||
loginUrl.searchParams.set("callbackUrl", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function addSecurityHeaders(req: NextRequest): NextResponse | null {
|
||||
if (req.method === "OPTIONS") return null;
|
||||
if (req.method === "OPTIONS") return null;
|
||||
|
||||
const response = NextResponse.next();
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-eval' 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: https: http:",
|
||||
"font-src 'self' https:",
|
||||
"connect-src 'self' https: wss:",
|
||||
"frame-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
].join("; ");
|
||||
const response = NextResponse.next();
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-eval' 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: https: http:",
|
||||
"font-src 'self' https:",
|
||||
"connect-src 'self' https: wss:",
|
||||
"frame-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
].join("; ");
|
||||
|
||||
response.headers.set("Content-Security-Policy", csp);
|
||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
response.headers.set("X-Frame-Options", "DENY");
|
||||
response.headers.set("X-XSS-Protection", "0");
|
||||
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
||||
response.headers.set("Content-Security-Policy", csp);
|
||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
response.headers.set("X-Frame-Options", "DENY");
|
||||
response.headers.set("X-XSS-Protection", "0");
|
||||
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
response.headers.set(
|
||||
"Permissions-Policy",
|
||||
"camera=(), microphone=(), geolocation=()",
|
||||
);
|
||||
|
||||
return response;
|
||||
return response;
|
||||
}
|
||||
@@ -2,10 +2,10 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import type { IpAddress, RequestId, UserId } from "./types";
|
||||
|
||||
export interface RequestStore {
|
||||
requestId: RequestId;
|
||||
ip: IpAddress;
|
||||
userId: UserId | null;
|
||||
startedAt: number;
|
||||
requestId: RequestId;
|
||||
ip: IpAddress;
|
||||
userId: UserId | null;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
const als = new AsyncLocalStorage<RequestStore>();
|
||||
@@ -13,41 +13,41 @@ const als = new AsyncLocalStorage<RequestStore>();
|
||||
let counter = 0;
|
||||
|
||||
function generateRequestId(): RequestId {
|
||||
counter = (counter + 1) & 0xffff;
|
||||
return `${Date.now().toString(36)}-${counter.toString(36)}-${crypto.randomUUID().slice(0, 8)}` as RequestId;
|
||||
counter = (counter + 1) & 0xffff;
|
||||
return `${Date.now().toString(36)}-${counter.toString(36)}-${crypto.randomUUID().slice(0, 8)}` as RequestId;
|
||||
}
|
||||
|
||||
export function createStore(ip: IpAddress): RequestStore {
|
||||
return {
|
||||
requestId: generateRequestId(),
|
||||
ip,
|
||||
userId: null,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
return {
|
||||
requestId: generateRequestId(),
|
||||
ip,
|
||||
userId: null,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function runWithStore<T>(store: RequestStore, fn: () => T): T {
|
||||
return als.run(store, fn);
|
||||
return als.run(store, fn);
|
||||
}
|
||||
|
||||
export function getRequestStore(): RequestStore | null {
|
||||
return als.getStore() ?? null;
|
||||
return als.getStore() ?? null;
|
||||
}
|
||||
|
||||
export function getRequestId(): RequestId {
|
||||
return als.getStore()?.requestId ?? generateRequestId();
|
||||
return als.getStore()?.requestId ?? generateRequestId();
|
||||
}
|
||||
|
||||
export function getClientIp(): IpAddress {
|
||||
return als.getStore()?.ip ?? ("0.0.0.0" as IpAddress);
|
||||
return als.getStore()?.ip ?? ("0.0.0.0" as IpAddress);
|
||||
}
|
||||
|
||||
export function setContextUserId(userId: UserId): void {
|
||||
const store = als.getStore();
|
||||
if (store) store.userId = userId;
|
||||
const store = als.getStore();
|
||||
if (store) store.userId = userId;
|
||||
}
|
||||
|
||||
export function elapsed(): number {
|
||||
const store = als.getStore();
|
||||
return store ? Date.now() - store.startedAt : 0;
|
||||
const store = als.getStore();
|
||||
return store ? Date.now() - store.startedAt : 0;
|
||||
}
|
||||
+110
-93
@@ -1,7 +1,6 @@
|
||||
import { headers } from "next/headers";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import crypto from "node:crypto";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { env } from "@/env";
|
||||
import type { IpAddress } from "./types";
|
||||
|
||||
@@ -10,134 +9,152 @@ const CSRF_COOKIE = "__Host-csrf-token";
|
||||
const CSRF_COOKIE_MAX_AGE = 86400; // 24h
|
||||
|
||||
const ALLOWED_HOSTS: ReadonlySet<string> = new Set(
|
||||
[env.APP_URL ? new URL(env.APP_URL).host : "", "localhost", "127.0.0.1"].filter(Boolean),
|
||||
[
|
||||
env.APP_URL ? new URL(env.APP_URL).host : "",
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
].filter(Boolean),
|
||||
);
|
||||
|
||||
const SAFE_REDIRECT_PATHS = new Set([
|
||||
"/login",
|
||||
"/register",
|
||||
"/forgot",
|
||||
"/reset",
|
||||
"/verify",
|
||||
"/banned",
|
||||
"/maintenance",
|
||||
"/",
|
||||
"/me",
|
||||
"/settings",
|
||||
"/login",
|
||||
"/register",
|
||||
"/forgot",
|
||||
"/reset",
|
||||
"/verify",
|
||||
"/banned",
|
||||
"/maintenance",
|
||||
"/",
|
||||
"/me",
|
||||
"/settings",
|
||||
]);
|
||||
|
||||
function isSafePath(path: string): boolean {
|
||||
if (!path.startsWith("/")) return false;
|
||||
if (SAFE_REDIRECT_PATHS.has(path)) return true;
|
||||
if (path.startsWith("/admin/") || path.startsWith("/api/")) return true;
|
||||
return false;
|
||||
if (!path.startsWith("/")) return false;
|
||||
if (SAFE_REDIRECT_PATHS.has(path)) return true;
|
||||
if (path.startsWith("/admin/") || path.startsWith("/api/")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function safeRedirect(destination: string, fallback: string = "/"): string {
|
||||
try {
|
||||
const url = new URL(destination, env.APP_URL || "http://localhost:3000");
|
||||
if (ALLOWED_HOSTS.has(url.host)) return destination;
|
||||
if (url.host === "localhost" || url.host === "127.0.0.1") return destination;
|
||||
} catch {
|
||||
if (isSafePath(destination)) return destination;
|
||||
}
|
||||
return fallback;
|
||||
export function safeRedirect(
|
||||
destination: string,
|
||||
fallback: string = "/",
|
||||
): string {
|
||||
try {
|
||||
const url = new URL(destination, env.APP_URL || "http://localhost:3000");
|
||||
if (ALLOWED_HOSTS.has(url.host)) return destination;
|
||||
if (url.host === "localhost" || url.host === "127.0.0.1")
|
||||
return destination;
|
||||
} catch {
|
||||
if (isSafePath(destination)) return destination;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function redirectSafe(destination: string, fallback: string = "/"): never {
|
||||
redirect(safeRedirect(destination, fallback));
|
||||
export function redirectSafe(
|
||||
destination: string,
|
||||
fallback: string = "/",
|
||||
): never {
|
||||
redirect(safeRedirect(destination, fallback));
|
||||
}
|
||||
|
||||
function csrfCookieOpts(): {
|
||||
name: string;
|
||||
value: string;
|
||||
httpOnly: boolean;
|
||||
secure: boolean;
|
||||
sameSite: "lax";
|
||||
path: string;
|
||||
maxAge: number;
|
||||
name: string;
|
||||
value: string;
|
||||
httpOnly: boolean;
|
||||
secure: boolean;
|
||||
sameSite: "lax";
|
||||
path: string;
|
||||
maxAge: number;
|
||||
} {
|
||||
return {
|
||||
name: CSRF_COOKIE,
|
||||
value: crypto.randomBytes(CSRF_BYTES).toString("hex"),
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
maxAge: CSRF_COOKIE_MAX_AGE,
|
||||
};
|
||||
return {
|
||||
name: CSRF_COOKIE,
|
||||
value: crypto.randomBytes(CSRF_BYTES).toString("hex"),
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
maxAge: CSRF_COOKIE_MAX_AGE,
|
||||
};
|
||||
}
|
||||
|
||||
export async function setCsrfCookie(): Promise<string> {
|
||||
const c = await cookies();
|
||||
const existing = c.get(CSRF_COOKIE);
|
||||
if (existing?.value && existing.value.length === CSRF_BYTES * 2) return existing.value;
|
||||
const opts = csrfCookieOpts();
|
||||
c.set(opts.name, opts.value, opts);
|
||||
return opts.value;
|
||||
const c = await cookies();
|
||||
const existing = c.get(CSRF_COOKIE);
|
||||
if (existing?.value && existing.value.length === CSRF_BYTES * 2)
|
||||
return existing.value;
|
||||
const opts = csrfCookieOpts();
|
||||
c.set(opts.name, opts.value, opts);
|
||||
return opts.value;
|
||||
}
|
||||
|
||||
export async function validateCsrfToken(token: string): Promise<boolean> {
|
||||
if (!token || token.length !== CSRF_BYTES * 2) return false;
|
||||
try {
|
||||
const c = await cookies();
|
||||
const stored = c.get(CSRF_COOKIE)?.value;
|
||||
if (!stored || stored.length !== CSRF_BYTES * 2) return false;
|
||||
return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(stored));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!token || token.length !== CSRF_BYTES * 2) return false;
|
||||
try {
|
||||
const c = await cookies();
|
||||
const stored = c.get(CSRF_COOKIE)?.value;
|
||||
if (!stored || stored.length !== CSRF_BYTES * 2) return false;
|
||||
return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(stored));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function canonicalize(input: string): string {
|
||||
return input.normalize("NFC").trim();
|
||||
return input.normalize("NFC").trim();
|
||||
}
|
||||
|
||||
const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/;
|
||||
|
||||
function removeControlChars(s: string): string {
|
||||
let result = "";
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const code = s.charCodeAt(i);
|
||||
if (code >= 32) result += s.charAt(i);
|
||||
}
|
||||
return result;
|
||||
let result = "";
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const code = s.charCodeAt(i);
|
||||
if (code >= 32) result += s.charAt(i);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function sanitizeFilename(name: string): string {
|
||||
return removeControlChars(
|
||||
name
|
||||
.normalize("NFC")
|
||||
.replace(INVALID_FILENAME_CHARS, "")
|
||||
.replace(/\.\.(?:\/|$)/g, ""),
|
||||
)
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
return removeControlChars(
|
||||
name
|
||||
.normalize("NFC")
|
||||
.replace(INVALID_FILENAME_CHARS, "")
|
||||
.replace(/\.\.(?:\/|$)/g, ""),
|
||||
)
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
}
|
||||
|
||||
export function canonicalizeFormValue(value: FormDataEntryValue | null, maxLen?: number): string {
|
||||
const s = canonicalize(String(value ?? ""));
|
||||
return maxLen ? s.slice(0, maxLen) : s;
|
||||
export function canonicalizeFormValue(
|
||||
value: FormDataEntryValue | null,
|
||||
maxLen?: number,
|
||||
): string {
|
||||
const s = canonicalize(String(value ?? ""));
|
||||
return maxLen ? s.slice(0, maxLen) : s;
|
||||
}
|
||||
|
||||
export function canonicalizeFormData(
|
||||
formData: FormData,
|
||||
fields: Record<string, number | undefined>,
|
||||
formData: FormData,
|
||||
fields: Record<string, number | undefined>,
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(fields).map(([key, maxLen]) => [key, canonicalizeFormValue(formData.get(key), maxLen)]),
|
||||
);
|
||||
return Object.fromEntries(
|
||||
Object.entries(fields).map(([key, maxLen]) => [
|
||||
key,
|
||||
canonicalizeFormValue(formData.get(key), maxLen),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export async function extractClientIpAsync(): Promise<IpAddress> {
|
||||
try {
|
||||
const h = await headers();
|
||||
return (h.get("x-real-client-ip") ??
|
||||
h.get("cf-connecting-ip") ??
|
||||
h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
h.get("x-real-ip") ??
|
||||
"0.0.0.0") as IpAddress;
|
||||
} catch {
|
||||
return "0.0.0.0" as IpAddress;
|
||||
}
|
||||
try {
|
||||
const h = await headers();
|
||||
return (h.get("x-real-client-ip") ??
|
||||
h.get("cf-connecting-ip") ??
|
||||
h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
h.get("x-real-ip") ??
|
||||
"0.0.0.0") as IpAddress;
|
||||
} catch {
|
||||
return "0.0.0.0" as IpAddress;
|
||||
}
|
||||
}
|
||||
+37
-35
@@ -7,65 +7,67 @@ export type IpAddress = Branded<string, "IpAddress">;
|
||||
export type RequestId = Branded<string, "RequestId">;
|
||||
|
||||
export interface SessionUser {
|
||||
id: UserId;
|
||||
username: string;
|
||||
rank: RankId;
|
||||
look: string;
|
||||
mail: string;
|
||||
id: UserId;
|
||||
username: string;
|
||||
rank: RankId;
|
||||
look: string;
|
||||
mail: string;
|
||||
}
|
||||
|
||||
export interface AppSession {
|
||||
user: SessionUser;
|
||||
expires: string;
|
||||
user: SessionUser;
|
||||
expires: string;
|
||||
}
|
||||
|
||||
export interface ActionContext {
|
||||
session: AppSession;
|
||||
requestId: RequestId;
|
||||
ip: IpAddress;
|
||||
session: AppSession;
|
||||
requestId: RequestId;
|
||||
ip: IpAddress;
|
||||
}
|
||||
|
||||
export interface AdminActionContext extends ActionContext {
|
||||
permissions: PermissionSet;
|
||||
permissions: PermissionSet;
|
||||
}
|
||||
|
||||
export interface ActionSuccess<T = Record<string, unknown>> {
|
||||
ok: true;
|
||||
data?: T;
|
||||
ok: true;
|
||||
data?: T;
|
||||
}
|
||||
export interface ActionFailure {
|
||||
ok: false;
|
||||
error: string;
|
||||
fieldErrors?: Record<string, string[]>;
|
||||
ok: false;
|
||||
error: string;
|
||||
fieldErrors?: Record<string, string[]>;
|
||||
}
|
||||
export type ActionResult<T = Record<string, unknown>> = ActionSuccess<T> | ActionFailure;
|
||||
export type ActionResult<T = Record<string, unknown>> =
|
||||
| ActionSuccess<T>
|
||||
| ActionFailure;
|
||||
|
||||
export interface PaginatedQuery {
|
||||
page: number;
|
||||
perPage: number;
|
||||
sort?: string;
|
||||
order?: "asc" | "desc";
|
||||
search?: string;
|
||||
page: number;
|
||||
perPage: number;
|
||||
sort?: string;
|
||||
order?: "asc" | "desc";
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
rows: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
lastPage: number;
|
||||
rows: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
lastPage: number;
|
||||
}
|
||||
|
||||
export interface PermissionSet {
|
||||
has(permission: string): boolean;
|
||||
hasAny(...permissions: string[]): boolean;
|
||||
hasAll(...permissions: string[]): boolean;
|
||||
isSuperAdmin: boolean;
|
||||
has(permission: string): boolean;
|
||||
hasAny(...permissions: string[]): boolean;
|
||||
hasAll(...permissions: string[]): boolean;
|
||||
isSuperAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface RequestContext {
|
||||
requestId: RequestId;
|
||||
ip: IpAddress;
|
||||
userId: UserId | null;
|
||||
startedAt: number;
|
||||
requestId: RequestId;
|
||||
ip: IpAddress;
|
||||
userId: UserId | null;
|
||||
startedAt: number;
|
||||
}
|
||||
@@ -3,73 +3,97 @@ import { z } from "zod";
|
||||
const USERNAME_RE = /^[a-zA-Z0-9\-_.]+$/;
|
||||
|
||||
export const username = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Username is required")
|
||||
.max(32, "Username must be at most 32 characters")
|
||||
.regex(USERNAME_RE, "Username may only contain letters, numbers, hyphens, underscores, and dots")
|
||||
.transform((v) => v.normalize("NFC"));
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Username is required")
|
||||
.max(32, "Username must be at most 32 characters")
|
||||
.regex(
|
||||
USERNAME_RE,
|
||||
"Username may only contain letters, numbers, hyphens, underscores, and dots",
|
||||
)
|
||||
.transform((v) => v.normalize("NFC"));
|
||||
|
||||
export const password = z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.max(128, "Password must be at most 128 characters");
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.max(128, "Password must be at most 128 characters");
|
||||
|
||||
export const email = z
|
||||
.string()
|
||||
.trim()
|
||||
.email("Invalid email address")
|
||||
.max(255, "Email must be at most 255 characters")
|
||||
.transform((v) => v.normalize("NFC").toLowerCase());
|
||||
.string()
|
||||
.trim()
|
||||
.email("Invalid email address")
|
||||
.max(255, "Email must be at most 255 characters")
|
||||
.transform((v) => v.normalize("NFC").toLowerCase());
|
||||
|
||||
const HEX_COLOR_RE = /^#[0-9a-f]{6}$/i;
|
||||
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
export const hexColor = z
|
||||
.string()
|
||||
.length(7, "Must be exactly 7 characters (e.g. #ff0000)")
|
||||
.regex(HEX_COLOR_RE, "Must be a valid hex color (e.g. #ff0000)");
|
||||
.string()
|
||||
.length(7, "Must be exactly 7 characters (e.g. #ff0000)")
|
||||
.regex(HEX_COLOR_RE, "Must be a valid hex color (e.g. #ff0000)");
|
||||
|
||||
export const slug = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Slug is required")
|
||||
.max(64, "Slug must be at most 64 characters")
|
||||
.regex(SLUG_RE, "Slug must be lowercase alphanumeric with hyphens only between characters")
|
||||
.refine((v) => !v.startsWith("-") && !v.endsWith("-"), "Slug must not start or end with a hyphen")
|
||||
.transform((v) => v.normalize("NFC"));
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Slug is required")
|
||||
.max(64, "Slug must be at most 64 characters")
|
||||
.regex(
|
||||
SLUG_RE,
|
||||
"Slug must be lowercase alphanumeric with hyphens only between characters",
|
||||
)
|
||||
.refine(
|
||||
(v) => !v.startsWith("-") && !v.endsWith("-"),
|
||||
"Slug must not start or end with a hyphen",
|
||||
)
|
||||
.transform((v) => v.normalize("NFC"));
|
||||
|
||||
export const url = z.string().url("Invalid URL").max(2048, "URL must be at most 2048 characters");
|
||||
export const url = z
|
||||
.string()
|
||||
.url("Invalid URL")
|
||||
.max(2048, "URL must be at most 2048 characters");
|
||||
|
||||
export const look = z
|
||||
.string()
|
||||
.max(512, "Look string must be at most 512 characters")
|
||||
.regex(/^[a-zA-Z0-9.\-_\s]+$/, "Invalid look format")
|
||||
.optional();
|
||||
.string()
|
||||
.max(512, "Look string must be at most 512 characters")
|
||||
.regex(/^[a-zA-Z0-9.\-_\s]+$/, "Invalid look format")
|
||||
.optional();
|
||||
|
||||
export const positiveInt = z.number().int("Must be a whole number").positive("Must be positive");
|
||||
export const positiveInt = z
|
||||
.number()
|
||||
.int("Must be a whole number")
|
||||
.positive("Must be positive");
|
||||
|
||||
export const nonNegativeInt = z.number().int("Must be a whole number").nonnegative("Must not be negative");
|
||||
export const nonNegativeInt = z
|
||||
.number()
|
||||
.int("Must be a whole number")
|
||||
.nonnegative("Must not be negative");
|
||||
|
||||
export const bigIntString = z.string().regex(/^\d+$/, "Must be a numeric string").transform(BigInt);
|
||||
export const bigIntString = z
|
||||
.string()
|
||||
.regex(/^\d+$/, "Must be a numeric string")
|
||||
.transform(BigInt);
|
||||
|
||||
export const idParam = z.string().regex(/^\d+$/, "ID must be numeric").transform(Number);
|
||||
export const idParam = z
|
||||
.string()
|
||||
.regex(/^\d+$/, "ID must be numeric")
|
||||
.transform(Number);
|
||||
|
||||
export const pagination = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
perPage: z.coerce.number().int().min(1).max(250).default(50),
|
||||
sort: z.string().optional(),
|
||||
order: z.enum(["asc", "desc"]).optional(),
|
||||
search: z.string().max(256).optional(),
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
perPage: z.coerce.number().int().min(1).max(250).default(50),
|
||||
sort: z.string().optional(),
|
||||
order: z.enum(["asc", "desc"]).optional(),
|
||||
search: z.string().max(256).optional(),
|
||||
});
|
||||
|
||||
export const boolString = z
|
||||
.string()
|
||||
.transform((v) => v === "true" || v === "1")
|
||||
.or(z.boolean());
|
||||
.string()
|
||||
.transform((v) => v === "true" || v === "1")
|
||||
.or(z.boolean());
|
||||
|
||||
export function buildSearchQuery(fields: string[], search: string | undefined) {
|
||||
if (!search || !search.trim()) return undefined;
|
||||
const sanitized = search.normalize("NFC").trim().slice(0, 256);
|
||||
return fields.map((f) => ({ [f]: { contains: sanitized } }));
|
||||
if (!search?.trim()) return undefined;
|
||||
const sanitized = search.normalize("NFC").trim().slice(0, 256);
|
||||
return fields.map((f) => ({ [f]: { contains: sanitized } }));
|
||||
}
|
||||
+16
-13
@@ -1,42 +1,45 @@
|
||||
const CLASSNAME_CHAR_RE = /^[a-z0-9_*\-.]+$/;
|
||||
|
||||
export function getBaseClassname(classname: string): string {
|
||||
const star = classname.indexOf("*");
|
||||
return star >= 0 ? classname.substring(0, star) : classname;
|
||||
const star = classname.indexOf("*");
|
||||
return star >= 0 ? classname.substring(0, star) : classname;
|
||||
}
|
||||
|
||||
export function getSafeClassnameStem(classname: string): string {
|
||||
return classname.replace(/\*/g, "_");
|
||||
return classname.replace(/\*/g, "_");
|
||||
}
|
||||
|
||||
export function getIconFileName(classname: string): string {
|
||||
return `${getSafeClassnameStem(classname)}_icon.png`;
|
||||
return `${getSafeClassnameStem(classname)}_icon.png`;
|
||||
}
|
||||
|
||||
export function getSwfFileName(classname: string): string {
|
||||
return `${getBaseClassname(classname)}.swf`;
|
||||
return `${getBaseClassname(classname)}.swf`;
|
||||
}
|
||||
|
||||
export function getNitroFileName(classname: string): string {
|
||||
return `${getBaseClassname(classname)}.nitro`;
|
||||
return `${getBaseClassname(classname)}.nitro`;
|
||||
}
|
||||
|
||||
export function normalizeClassname(raw: string): string {
|
||||
return raw.trim().toLowerCase();
|
||||
return raw.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function isValidClassname(classname: string): boolean {
|
||||
if (!classname) return false;
|
||||
return CLASSNAME_CHAR_RE.test(classname);
|
||||
if (!classname) return false;
|
||||
return CLASSNAME_CHAR_RE.test(classname);
|
||||
}
|
||||
|
||||
export const LOCAL_ICON_URL_PREFIX = "/swf/dcr/hof_furni/icons/";
|
||||
|
||||
export function getLocalIconUrl(classname: string): string {
|
||||
return `${LOCAL_ICON_URL_PREFIX}${encodeURIComponent(getIconFileName(classname))}`;
|
||||
return `${LOCAL_ICON_URL_PREFIX}${encodeURIComponent(getIconFileName(classname))}`;
|
||||
}
|
||||
|
||||
export function getHabboCdnIconUrl(classname: string, revision: number): string {
|
||||
const base = getBaseClassname(classname);
|
||||
return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(base)}_icon.png`;
|
||||
export function getHabboCdnIconUrl(
|
||||
classname: string,
|
||||
revision: number,
|
||||
): string {
|
||||
const base = getBaseClassname(classname);
|
||||
return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(base)}_icon.png`;
|
||||
}
|
||||
+22
-12
@@ -19,19 +19,29 @@ import type { AvatarOptions } from "@/types/admin";
|
||||
* getAvatarUrl(look, { size: 'l', headOnly: true })
|
||||
* getAvatarUrl(look, { size: 'm', gesture: 'sml', direction: 2 })
|
||||
*/
|
||||
export function getAvatarUrl(figure: string, options: AvatarOptions = {}): string {
|
||||
const { size = "m", direction = 2, headDirection = 3, headOnly = false, gesture, action } = options;
|
||||
export function getAvatarUrl(
|
||||
figure: string,
|
||||
options: AvatarOptions = {},
|
||||
): string {
|
||||
const {
|
||||
size = "m",
|
||||
direction = 2,
|
||||
headDirection = 3,
|
||||
headOnly = false,
|
||||
gesture,
|
||||
action,
|
||||
} = options;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
figure,
|
||||
direction: String(direction),
|
||||
head_direction: String(headDirection),
|
||||
size,
|
||||
});
|
||||
const params = new URLSearchParams({
|
||||
figure,
|
||||
direction: String(direction),
|
||||
head_direction: String(headDirection),
|
||||
size,
|
||||
});
|
||||
|
||||
if (headOnly) params.set("headonly", "1");
|
||||
if (gesture) params.set("gesture", gesture);
|
||||
if (action) params.set("action", action);
|
||||
if (headOnly) params.set("headonly", "1");
|
||||
if (gesture) params.set("gesture", gesture);
|
||||
if (action) params.set("action", action);
|
||||
|
||||
return `${IMAGER_URL}?${params.toString()}`;
|
||||
return `${IMAGER_URL}?${params.toString()}`;
|
||||
}
|
||||
@@ -2,39 +2,39 @@ import { existsSync, readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROUTES = [
|
||||
"badges",
|
||||
"badges/edit",
|
||||
"clone",
|
||||
"clone/batch",
|
||||
"clone/icon",
|
||||
"clothing",
|
||||
"clothing/batch",
|
||||
"clothing/sets",
|
||||
"clothing/sets/batch",
|
||||
"effects",
|
||||
"effects/batch",
|
||||
"furni",
|
||||
"furni/batch",
|
||||
"furni/batch-regen",
|
||||
"furni/nitro-editor",
|
||||
"furni/resync",
|
||||
"pets",
|
||||
"pets/batch",
|
||||
"pets/icon",
|
||||
"repair",
|
||||
"repair/audit",
|
||||
"badges",
|
||||
"badges/edit",
|
||||
"clone",
|
||||
"clone/batch",
|
||||
"clone/icon",
|
||||
"clothing",
|
||||
"clothing/batch",
|
||||
"clothing/sets",
|
||||
"clothing/sets/batch",
|
||||
"effects",
|
||||
"effects/batch",
|
||||
"furni",
|
||||
"furni/batch",
|
||||
"furni/batch-regen",
|
||||
"furni/nitro-editor",
|
||||
"furni/resync",
|
||||
"pets",
|
||||
"pets/batch",
|
||||
"pets/icon",
|
||||
"repair",
|
||||
"repair/audit",
|
||||
];
|
||||
|
||||
describe("admin import backend contract", () => {
|
||||
it.each(ROUTES)("provides and guards /api/admin/import/%s", (route) => {
|
||||
const path = `src/app/api/admin/import/${route}/route.ts`;
|
||||
expect(existsSync(path), path).toBe(true);
|
||||
const source = readFileSync(path, "utf8");
|
||||
expect(source, path).toContain("PERMS.ASSETS_IMPORT");
|
||||
});
|
||||
it.each(ROUTES)("provides and guards /api/admin/import/%s", (route) => {
|
||||
const path = `src/app/api/admin/import/${route}/route.ts`;
|
||||
expect(existsSync(path), path).toBe(true);
|
||||
const source = readFileSync(path, "utf8");
|
||||
expect(source, path).toContain("PERMS.ASSETS_IMPORT");
|
||||
});
|
||||
|
||||
it("does not leave import actions as successful no-op stubs", () => {
|
||||
const source = readFileSync("src/actions/import-furni.ts", "utf8");
|
||||
expect(source).not.toContain("deleted: 0, remaining: 0");
|
||||
});
|
||||
it("does not leave import actions as successful no-op stubs", () => {
|
||||
const source = readFileSync("src/actions/import-furni.ts", "utf8");
|
||||
expect(source).not.toContain("deleted: 0, remaining: 0");
|
||||
});
|
||||
});
|
||||
+45
-45
@@ -10,23 +10,23 @@
|
||||
// the caller can decide whether to fall back to a full re-serialization.
|
||||
|
||||
export interface PatchResult {
|
||||
/** The new file content, with surgical edits applied. */
|
||||
content: string;
|
||||
/** Keys that we could not patch surgically (missing or unusual format). */
|
||||
unpatchedKeys: string[];
|
||||
/** The new file content, with surgical edits applied. */
|
||||
content: string;
|
||||
/** Keys that we could not patch surgically (missing or unusual format). */
|
||||
unpatchedKeys: string[];
|
||||
}
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function escapeForQuote(value: string, quote: '"' | "'"): string {
|
||||
// Escape backslashes first, then the chosen quote. Newlines/tabs are
|
||||
// preserved as escape sequences so we don't break the JSON5 parser.
|
||||
let out = value.replace(/\\/g, "\\\\");
|
||||
out = out.replace(new RegExp(quote, "g"), `\\${quote}`);
|
||||
out = out.replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
|
||||
return out;
|
||||
// Escape backslashes first, then the chosen quote. Newlines/tabs are
|
||||
// preserved as escape sequences so we don't break the JSON5 parser.
|
||||
let out = value.replace(/\\/g, "\\\\");
|
||||
out = out.replace(new RegExp(quote, "g"), `\\${quote}`);
|
||||
out = out.replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,44 +35,44 @@ function escapeForQuote(value: string, quote: '"' | "'"): string {
|
||||
* different value in `updated`) are touched.
|
||||
*/
|
||||
export function patchJson5(
|
||||
raw: string,
|
||||
original: Record<string, string>,
|
||||
updated: Record<string, string>,
|
||||
raw: string,
|
||||
original: Record<string, string>,
|
||||
updated: Record<string, string>,
|
||||
): PatchResult {
|
||||
let result = raw;
|
||||
const unpatchedKeys: string[] = [];
|
||||
let result = raw;
|
||||
const unpatchedKeys: string[] = [];
|
||||
|
||||
for (const [key, newVal] of Object.entries(updated)) {
|
||||
if (!(key in original)) {
|
||||
// Key did not exist on disk — append/merge logic is the caller's job.
|
||||
unpatchedKeys.push(key);
|
||||
continue;
|
||||
}
|
||||
if (original[key] === newVal) continue;
|
||||
for (const [key, newVal] of Object.entries(updated)) {
|
||||
if (!(key in original)) {
|
||||
// Key did not exist on disk — append/merge logic is the caller's job.
|
||||
unpatchedKeys.push(key);
|
||||
continue;
|
||||
}
|
||||
if (original[key] === newVal) continue;
|
||||
|
||||
const keyPattern = `(['"]?)${escapeRegExp(key)}\\1\\s*:\\s*(["'])((?:\\\\.|(?!\\2).)*)\\2`;
|
||||
const re = new RegExp(keyPattern);
|
||||
const idx = result.search(re);
|
||||
if (idx < 0) {
|
||||
unpatchedKeys.push(key);
|
||||
continue;
|
||||
}
|
||||
const match = re.exec(result);
|
||||
if (!match) {
|
||||
unpatchedKeys.push(key);
|
||||
continue;
|
||||
}
|
||||
const keyPattern = `(['"]?)${escapeRegExp(key)}\\1\\s*:\\s*(["'])((?:\\\\.|(?!\\2).)*)\\2`;
|
||||
const re = new RegExp(keyPattern);
|
||||
const idx = result.search(re);
|
||||
if (idx < 0) {
|
||||
unpatchedKeys.push(key);
|
||||
continue;
|
||||
}
|
||||
const match = re.exec(result);
|
||||
if (!match) {
|
||||
unpatchedKeys.push(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
const valueQuote = match[2] as '"' | "'";
|
||||
const oldValueLen = match[3].length;
|
||||
const fullLen = match[0].length;
|
||||
// prefix keeps everything up to and including the opening value quote
|
||||
const prefix = match[0].slice(0, fullLen - oldValueLen - 1);
|
||||
const escaped = escapeForQuote(newVal, valueQuote);
|
||||
const replacement = prefix + escaped + valueQuote;
|
||||
const valueQuote = match[2] as '"' | "'";
|
||||
const oldValueLen = match[3].length;
|
||||
const fullLen = match[0].length;
|
||||
// prefix keeps everything up to and including the opening value quote
|
||||
const prefix = match[0].slice(0, fullLen - oldValueLen - 1);
|
||||
const escaped = escapeForQuote(newVal, valueQuote);
|
||||
const replacement = prefix + escaped + valueQuote;
|
||||
|
||||
result = result.slice(0, idx) + replacement + result.slice(idx + fullLen);
|
||||
}
|
||||
result = result.slice(0, idx) + replacement + result.slice(idx + fullLen);
|
||||
}
|
||||
|
||||
return { content: result, unpatchedKeys };
|
||||
return { content: result, unpatchedKeys };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
@@ -7,30 +7,35 @@ import { findMissingLocalImports } from "./local-imports";
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
await Promise.all(
|
||||
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("findMissingLocalImports", () => {
|
||||
it("reports unresolved alias imports and ignores modules that exist", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "epicnext-imports-"));
|
||||
roots.push(root);
|
||||
await mkdir(join(root, "src", "features"), { recursive: true });
|
||||
await mkdir(join(root, "src", "components", "ui"), { recursive: true });
|
||||
await writeFile(join(root, "src", "components", "ui", "card.tsx"), "export const Card = {};\n");
|
||||
await writeFile(
|
||||
join(root, "src", "features", "page.tsx"),
|
||||
[
|
||||
'import { Card } from "@/components/ui/card";',
|
||||
'import { Button } from "@/components/ui/button";',
|
||||
"export default Card;",
|
||||
].join("\n"),
|
||||
);
|
||||
it("reports unresolved alias imports and ignores modules that exist", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "epicnext-imports-"));
|
||||
roots.push(root);
|
||||
await mkdir(join(root, "src", "features"), { recursive: true });
|
||||
await mkdir(join(root, "src", "components", "ui"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "src", "components", "ui", "card.tsx"),
|
||||
"export const Card = {};\n",
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "src", "features", "page.tsx"),
|
||||
[
|
||||
'import { Card } from "@/components/ui/card";',
|
||||
'import { Button } from "@/components/ui/button";',
|
||||
"export default Card;",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
await expect(findMissingLocalImports(root)).resolves.toEqual([
|
||||
{
|
||||
importer: "src/features/page.tsx",
|
||||
specifier: "@/components/ui/button",
|
||||
},
|
||||
]);
|
||||
});
|
||||
await expect(findMissingLocalImports(root)).resolves.toEqual([
|
||||
{
|
||||
importer: "src/features/page.tsx",
|
||||
specifier: "@/components/ui/button",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
+75
-60
@@ -2,81 +2,96 @@ import { access, readdir, readFile } from "node:fs/promises";
|
||||
import { dirname, extname, join, relative, resolve, sep } from "node:path";
|
||||
|
||||
export interface MissingLocalImport {
|
||||
importer: string;
|
||||
specifier: string;
|
||||
importer: string;
|
||||
specifier: string;
|
||||
}
|
||||
|
||||
const sourceExtensions = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
||||
const sourceExtensions = new Set([
|
||||
".ts",
|
||||
".tsx",
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
]);
|
||||
const resolutionSuffixes = [
|
||||
".ts",
|
||||
".tsx",
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".css",
|
||||
"/index.ts",
|
||||
"/index.tsx",
|
||||
"/index.js",
|
||||
"/index.jsx",
|
||||
".ts",
|
||||
".tsx",
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".css",
|
||||
"/index.ts",
|
||||
"/index.tsx",
|
||||
"/index.js",
|
||||
"/index.jsx",
|
||||
];
|
||||
const importPattern =
|
||||
/(?:import|export)\s+(?:[^"']*?\s+from\s+)?["']([^"']+)["']|import\(\s*["']([^"']+)["']\s*\)|require\(\s*["']([^"']+)["']\s*\)/g;
|
||||
/(?:import|export)\s+(?:[^"']*?\s+from\s+)?["']([^"']+)["']|import\(\s*["']([^"']+)["']\s*\)|require\(\s*["']([^"']+)["']\s*\)/g;
|
||||
|
||||
async function collectSourceFiles(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const files = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const path = join(directory, entry.name);
|
||||
if (entry.isDirectory()) return collectSourceFiles(path);
|
||||
if (/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(entry.name)) return [];
|
||||
return sourceExtensions.has(extname(entry.name)) ? [path] : [];
|
||||
}),
|
||||
);
|
||||
return files.flat();
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const files = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const path = join(directory, entry.name);
|
||||
if (entry.isDirectory()) return collectSourceFiles(path);
|
||||
if (/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(entry.name)) return [];
|
||||
return sourceExtensions.has(extname(entry.name)) ? [path] : [];
|
||||
}),
|
||||
);
|
||||
return files.flat();
|
||||
}
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvesToLocalFile(base: string): Promise<boolean> {
|
||||
if (extname(base) && (await exists(base))) return true;
|
||||
for (const suffix of resolutionSuffixes) {
|
||||
if (await exists(`${base}${suffix}`)) return true;
|
||||
}
|
||||
return false;
|
||||
if (extname(base) && (await exists(base))) return true;
|
||||
for (const suffix of resolutionSuffixes) {
|
||||
if (await exists(`${base}${suffix}`)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function findMissingLocalImports(root: string): Promise<MissingLocalImport[]> {
|
||||
const src = resolve(root, "src");
|
||||
const files = await collectSourceFiles(src);
|
||||
const missing: MissingLocalImport[] = [];
|
||||
export async function findMissingLocalImports(
|
||||
root: string,
|
||||
): Promise<MissingLocalImport[]> {
|
||||
const src = resolve(root, "src");
|
||||
const files = await collectSourceFiles(src);
|
||||
const missing: MissingLocalImport[] = [];
|
||||
|
||||
for (const importer of files) {
|
||||
const source = await readFile(importer, "utf8");
|
||||
for (const match of source.matchAll(importPattern)) {
|
||||
const specifier = match[1] ?? match[2] ?? match[3];
|
||||
if (!specifier?.startsWith("@/") && !specifier?.startsWith("./") && !specifier?.startsWith("../"))
|
||||
continue;
|
||||
const base = specifier.startsWith("@/")
|
||||
? resolve(src, specifier.slice(2))
|
||||
: resolve(dirname(importer), specifier);
|
||||
if (!(await resolvesToLocalFile(base))) {
|
||||
missing.push({
|
||||
importer: relative(root, importer).split(sep).join("/"),
|
||||
specifier,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const importer of files) {
|
||||
const source = await readFile(importer, "utf8");
|
||||
for (const match of source.matchAll(importPattern)) {
|
||||
const specifier = match[1] ?? match[2] ?? match[3];
|
||||
if (
|
||||
!specifier?.startsWith("@/") &&
|
||||
!specifier?.startsWith("./") &&
|
||||
!specifier?.startsWith("../")
|
||||
)
|
||||
continue;
|
||||
const base = specifier.startsWith("@/")
|
||||
? resolve(src, specifier.slice(2))
|
||||
: resolve(dirname(importer), specifier);
|
||||
if (!(await resolvesToLocalFile(base))) {
|
||||
missing.push({
|
||||
importer: relative(root, importer).split(sep).join("/"),
|
||||
specifier,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return missing.sort(
|
||||
(a, b) => a.importer.localeCompare(b.importer) || a.specifier.localeCompare(b.specifier),
|
||||
);
|
||||
return missing.sort(
|
||||
(a, b) =>
|
||||
a.importer.localeCompare(b.importer) ||
|
||||
a.specifier.localeCompare(b.specifier),
|
||||
);
|
||||
}
|
||||
+10
-10
@@ -2,15 +2,15 @@ import { describe, expect, it } from "vitest";
|
||||
import { generateRequestId } from "./logger";
|
||||
|
||||
describe("generateRequestId", () => {
|
||||
it("produces a non-empty string", () => {
|
||||
const id = generateRequestId();
|
||||
expect(id).toBeTruthy();
|
||||
expect(typeof id).toBe("string");
|
||||
});
|
||||
it("produces a non-empty string", () => {
|
||||
const id = generateRequestId();
|
||||
expect(id).toBeTruthy();
|
||||
expect(typeof id).toBe("string");
|
||||
});
|
||||
|
||||
it("produces unique values on successive calls", () => {
|
||||
const a = generateRequestId();
|
||||
const b = generateRequestId();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
it("produces unique values on successive calls", () => {
|
||||
const a = generateRequestId();
|
||||
const b = generateRequestId();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
+62
-41
@@ -1,72 +1,93 @@
|
||||
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
interface LogEntry {
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
requestId?: string;
|
||||
module?: string;
|
||||
[key: string]: unknown;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
requestId?: string;
|
||||
module?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
const currentLevel: LogLevel =
|
||||
(process.env.LOG_LEVEL as LogLevel) ?? (process.env.NODE_ENV === "production" ? "info" : "debug");
|
||||
(process.env.LOG_LEVEL as LogLevel) ??
|
||||
(process.env.NODE_ENV === "production" ? "info" : "debug");
|
||||
|
||||
let requestIdCounter = 0;
|
||||
|
||||
export function generateRequestId(): string {
|
||||
requestIdCounter += 1;
|
||||
return `${Date.now().toString(36)}-${requestIdCounter.toString(36)}`;
|
||||
requestIdCounter += 1;
|
||||
return `${Date.now().toString(36)}-${requestIdCounter.toString(36)}`;
|
||||
}
|
||||
|
||||
function shouldLog(level: LogLevel): boolean {
|
||||
// eslint-disable-next-line security/detect-object-injection -- LOG_LEVELS keyed by LogLevel union
|
||||
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
|
||||
// eslint-disable-next-line security/detect-object-injection -- LOG_LEVELS keyed by LogLevel union
|
||||
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
|
||||
}
|
||||
|
||||
function formatLog(entry: LogEntry): string {
|
||||
return JSON.stringify(entry);
|
||||
return JSON.stringify(entry);
|
||||
}
|
||||
|
||||
function writeLog(entry: LogEntry): void {
|
||||
if (!shouldLog(entry.level)) return;
|
||||
if (!shouldLog(entry.level)) return;
|
||||
|
||||
const formatted = formatLog(entry);
|
||||
const formatted = formatLog(entry);
|
||||
|
||||
switch (entry.level) {
|
||||
case "error":
|
||||
console.error(formatted);
|
||||
break;
|
||||
case "warn":
|
||||
console.warn(formatted);
|
||||
break;
|
||||
default:
|
||||
console.log(formatted);
|
||||
break;
|
||||
}
|
||||
switch (entry.level) {
|
||||
case "error":
|
||||
console.error(formatted);
|
||||
break;
|
||||
case "warn":
|
||||
console.warn(formatted);
|
||||
break;
|
||||
default:
|
||||
console.log(formatted);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug(message: string, meta: Record<string, unknown> = {}): void {
|
||||
writeLog({ level: "debug", message, timestamp: new Date().toISOString(), ...meta });
|
||||
},
|
||||
debug(message: string, meta: Record<string, unknown> = {}): void {
|
||||
writeLog({
|
||||
level: "debug",
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
...meta,
|
||||
});
|
||||
},
|
||||
|
||||
info(message: string, meta: Record<string, unknown> = {}): void {
|
||||
writeLog({ level: "info", message, timestamp: new Date().toISOString(), ...meta });
|
||||
},
|
||||
info(message: string, meta: Record<string, unknown> = {}): void {
|
||||
writeLog({
|
||||
level: "info",
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
...meta,
|
||||
});
|
||||
},
|
||||
|
||||
warn(message: string, meta: Record<string, unknown> = {}): void {
|
||||
writeLog({ level: "warn", message, timestamp: new Date().toISOString(), ...meta });
|
||||
},
|
||||
warn(message: string, meta: Record<string, unknown> = {}): void {
|
||||
writeLog({
|
||||
level: "warn",
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
...meta,
|
||||
});
|
||||
},
|
||||
|
||||
error(message: string, meta: Record<string, unknown> = {}): void {
|
||||
writeLog({ level: "error", message, timestamp: new Date().toISOString(), ...meta });
|
||||
},
|
||||
error(message: string, meta: Record<string, unknown> = {}): void {
|
||||
writeLog({
|
||||
level: "error",
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
...meta,
|
||||
});
|
||||
},
|
||||
};
|
||||
+96
-68
@@ -11,105 +11,133 @@
|
||||
const STORAGE_KEY = "catalog_move_suggestions";
|
||||
|
||||
interface MoveRecord {
|
||||
targetPageId: number;
|
||||
count: number;
|
||||
targetPageId: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface SuggestionStore {
|
||||
/** interaction type → target pages with count */
|
||||
byType: Record<string, MoveRecord[]>;
|
||||
/** item name prefix (first segment before _) → target pages with count */
|
||||
byPrefix: Record<string, MoveRecord[]>;
|
||||
/** last N recent targets */
|
||||
recent: number[];
|
||||
/** interaction type → target pages with count */
|
||||
byType: Record<string, MoveRecord[]>;
|
||||
/** item name prefix (first segment before _) → target pages with count */
|
||||
byPrefix: Record<string, MoveRecord[]>;
|
||||
/** last N recent targets */
|
||||
recent: number[];
|
||||
}
|
||||
|
||||
function load(): SuggestionStore {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch {}
|
||||
return { byType: {}, byPrefix: {}, recent: [] };
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch {}
|
||||
return { byType: {}, byPrefix: {}, recent: [] };
|
||||
}
|
||||
|
||||
function save(store: SuggestionStore) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||
} catch {}
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function extractPrefix(itemName: string): string {
|
||||
if (!itemName) return "";
|
||||
const idx = itemName.indexOf("_");
|
||||
return idx > 0 ? itemName.substring(0, idx).toLowerCase() : itemName.toLowerCase();
|
||||
if (!itemName) return "";
|
||||
const idx = itemName.indexOf("_");
|
||||
return idx > 0
|
||||
? itemName.substring(0, idx).toLowerCase()
|
||||
: itemName.toLowerCase();
|
||||
}
|
||||
|
||||
function incrementRecord(records: MoveRecord[], targetPageId: number): MoveRecord[] {
|
||||
const existing = records.find((r) => r.targetPageId === targetPageId);
|
||||
if (existing) {
|
||||
return records
|
||||
.map((r) => (r.targetPageId === targetPageId ? { ...r, count: r.count + 1 } : r))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
return [...records, { targetPageId, count: 1 }].sort((a, b) => b.count - a.count).slice(0, 10);
|
||||
function incrementRecord(
|
||||
records: MoveRecord[],
|
||||
targetPageId: number,
|
||||
): MoveRecord[] {
|
||||
const existing = records.find((r) => r.targetPageId === targetPageId);
|
||||
if (existing) {
|
||||
return records
|
||||
.map((r) =>
|
||||
r.targetPageId === targetPageId ? { ...r, count: r.count + 1 } : r,
|
||||
)
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
return [...records, { targetPageId, count: 1 }]
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a move for learning. Call this after successfully moving an item.
|
||||
*/
|
||||
export function recordMove(opts: { interactionType?: string; itemName?: string; targetPageId: number }) {
|
||||
const store = load();
|
||||
const { interactionType, itemName, targetPageId } = opts;
|
||||
export function recordMove(opts: {
|
||||
interactionType?: string;
|
||||
itemName?: string;
|
||||
targetPageId: number;
|
||||
}) {
|
||||
const store = load();
|
||||
const { interactionType, itemName, targetPageId } = opts;
|
||||
|
||||
if (interactionType) {
|
||||
const key = interactionType.toLowerCase();
|
||||
store.byType[key] = incrementRecord(store.byType[key] || [], targetPageId);
|
||||
}
|
||||
if (interactionType) {
|
||||
const key = interactionType.toLowerCase();
|
||||
store.byType[key] = incrementRecord(store.byType[key] || [], targetPageId);
|
||||
}
|
||||
|
||||
const prefix = extractPrefix(itemName ?? "");
|
||||
if (prefix && prefix.length > 1) {
|
||||
store.byPrefix[prefix] = incrementRecord(store.byPrefix[prefix] || [], targetPageId);
|
||||
}
|
||||
const prefix = extractPrefix(itemName ?? "");
|
||||
if (prefix && prefix.length > 1) {
|
||||
store.byPrefix[prefix] = incrementRecord(
|
||||
store.byPrefix[prefix] || [],
|
||||
targetPageId,
|
||||
);
|
||||
}
|
||||
|
||||
// Recent (deduplicate, keep last 10)
|
||||
store.recent = [targetPageId, ...store.recent.filter((id) => id !== targetPageId)].slice(0, 10);
|
||||
// Recent (deduplicate, keep last 10)
|
||||
store.recent = [
|
||||
targetPageId,
|
||||
...store.recent.filter((id) => id !== targetPageId),
|
||||
].slice(0, 10);
|
||||
|
||||
save(store);
|
||||
save(store);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggested page IDs for moving an item, ranked by relevance.
|
||||
* Returns unique page IDs ordered by best match.
|
||||
*/
|
||||
export function getSuggestions(opts: { interactionType?: string; itemName?: string }): number[] {
|
||||
const store = load();
|
||||
const { interactionType, itemName } = opts;
|
||||
const scores = new Map<number, number>();
|
||||
export function getSuggestions(opts: {
|
||||
interactionType?: string;
|
||||
itemName?: string;
|
||||
}): number[] {
|
||||
const store = load();
|
||||
const { interactionType, itemName } = opts;
|
||||
const scores = new Map<number, number>();
|
||||
|
||||
// By interaction type (highest weight)
|
||||
if (interactionType) {
|
||||
const records = store.byType[interactionType.toLowerCase()] || [];
|
||||
for (const r of records) {
|
||||
scores.set(r.targetPageId, (scores.get(r.targetPageId) ?? 0) + r.count * 3);
|
||||
}
|
||||
}
|
||||
// By interaction type (highest weight)
|
||||
if (interactionType) {
|
||||
const records = store.byType[interactionType.toLowerCase()] || [];
|
||||
for (const r of records) {
|
||||
scores.set(
|
||||
r.targetPageId,
|
||||
(scores.get(r.targetPageId) ?? 0) + r.count * 3,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// By name prefix
|
||||
const prefix = extractPrefix(itemName ?? "");
|
||||
if (prefix && prefix.length > 1) {
|
||||
const records = store.byPrefix[prefix] || [];
|
||||
for (const r of records) {
|
||||
scores.set(r.targetPageId, (scores.get(r.targetPageId) ?? 0) + r.count * 2);
|
||||
}
|
||||
}
|
||||
// By name prefix
|
||||
const prefix = extractPrefix(itemName ?? "");
|
||||
if (prefix && prefix.length > 1) {
|
||||
const records = store.byPrefix[prefix] || [];
|
||||
for (const r of records) {
|
||||
scores.set(
|
||||
r.targetPageId,
|
||||
(scores.get(r.targetPageId) ?? 0) + r.count * 2,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Recent (lowest weight)
|
||||
for (let i = 0; i < store.recent.length; i++) {
|
||||
const id = store.recent[i];
|
||||
scores.set(id, (scores.get(id) ?? 0) + (10 - i));
|
||||
}
|
||||
// Recent (lowest weight)
|
||||
for (let i = 0; i < store.recent.length; i++) {
|
||||
const id = store.recent[i];
|
||||
scores.set(id, (scores.get(id) ?? 0) + (10 - i));
|
||||
}
|
||||
|
||||
return Array.from(scores.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([id]) => id);
|
||||
return Array.from(scores.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([id]) => id);
|
||||
}
|
||||
+62
-62
@@ -3,66 +3,66 @@
|
||||
// (modules.ts → sidebar.tsx) without pulling in prisma/server-only deps.
|
||||
|
||||
export const PERMS = {
|
||||
ADMIN_DASHBOARD: "admin.dashboard",
|
||||
USERS_VIEW: "admin.users.view",
|
||||
USERS_EDIT: "admin.users.edit",
|
||||
USERS_BAN: "admin.users.ban",
|
||||
USERS_RESET_PASSWORD: "admin.users.reset_password",
|
||||
ROOMS_VIEW: "admin.room.view",
|
||||
ROOMS_EDIT: "admin.room.edit",
|
||||
ROOMS_DELETE: "admin.room.delete",
|
||||
BANS_VIEW: "admin.bans.view",
|
||||
NEWS_VIEW: "admin.news.view",
|
||||
NEWS_EDIT: "admin.news.edit",
|
||||
LOGS_VIEW: "admin.logs.view",
|
||||
SETTINGS_VIEW: "admin.settings.view",
|
||||
SETTINGS_EDIT: "admin.settings.edit",
|
||||
PERMISSIONS_MANAGE: "admin.permissions.manage",
|
||||
SHOP_VIEW: "admin.shop.view",
|
||||
SHOP_EDIT: "admin.shop.edit",
|
||||
WORDFILTER_VIEW: "admin.wordfilter.view",
|
||||
WORDFILTER_EDIT: "admin.wordfilter.edit",
|
||||
CATALOG_VIEW: "admin.catalog.view",
|
||||
CATALOG_EDIT: "admin.catalog.edit",
|
||||
RCON_EXECUTE: "admin.rcon.execute",
|
||||
EXPORT: "admin.export",
|
||||
PREFIXES_VIEW: "admin.prefixes.view",
|
||||
PREFIXES_EDIT: "admin.prefixes.edit",
|
||||
TICKETS_VIEW: "admin.tickets.view",
|
||||
TICKETS_EDIT: "admin.tickets.edit",
|
||||
MODERATION_VIEW: "admin.moderation.view",
|
||||
MODERATION_EDIT: "admin.moderation.edit",
|
||||
// ── Events Module ──
|
||||
EVENTS_VIEW: "admin.events.view",
|
||||
EVENTS_EDIT: "admin.events.edit",
|
||||
// ── Analytics Module ──
|
||||
ANALYTICS_VIEW: "admin.analytics.view",
|
||||
ANALYTICS_EXPORT: "admin.analytics.export",
|
||||
// ── DevOps Module ──
|
||||
DEVOPS_VIEW: "admin.devops.view",
|
||||
DEVOPS_EDIT: "admin.devops.edit",
|
||||
// ── Polls Module ──
|
||||
POLLS_VIEW: "admin.polls.view",
|
||||
POLLS_EDIT: "admin.polls.edit",
|
||||
// ── Notifications Module ──
|
||||
NOTIFICATIONS_VIEW: "admin.notifications.view",
|
||||
NOTIFICATIONS_EDIT: "admin.notifications.edit",
|
||||
// ── CMS Pages Module ──
|
||||
PAGES_VIEW: "admin.pages.view",
|
||||
PAGES_EDIT: "admin.pages.edit",
|
||||
// ── Banners Module ──
|
||||
BANNERS_VIEW: "admin.banners.view",
|
||||
BANNERS_EDIT: "admin.banners.edit",
|
||||
// ── Assets Module ──
|
||||
ASSETS_IMPORT: "admin.assets.import",
|
||||
// ── Mod Panel Permissions ──
|
||||
MOD_DASHBOARD: "mod.dashboard",
|
||||
MOD_CFH_VIEW: "mod.cfh.view",
|
||||
MOD_CFH_EDIT: "mod.cfh.edit",
|
||||
MOD_ACTIONS: "mod.actions",
|
||||
MOD_TEAM_VIEW: "mod.team.view",
|
||||
MOD_TICKETS_VIEW: "mod.tickets.view",
|
||||
MOD_TICKETS_EDIT: "mod.tickets.edit",
|
||||
MOD_USERS_VIEW: "mod.users.view",
|
||||
MOD_BANS_VIEW: "mod.bans.view",
|
||||
ADMIN_DASHBOARD: "admin.dashboard",
|
||||
USERS_VIEW: "admin.users.view",
|
||||
USERS_EDIT: "admin.users.edit",
|
||||
USERS_BAN: "admin.users.ban",
|
||||
USERS_RESET_PASSWORD: "admin.users.reset_password",
|
||||
ROOMS_VIEW: "admin.room.view",
|
||||
ROOMS_EDIT: "admin.room.edit",
|
||||
ROOMS_DELETE: "admin.room.delete",
|
||||
BANS_VIEW: "admin.bans.view",
|
||||
NEWS_VIEW: "admin.news.view",
|
||||
NEWS_EDIT: "admin.news.edit",
|
||||
LOGS_VIEW: "admin.logs.view",
|
||||
SETTINGS_VIEW: "admin.settings.view",
|
||||
SETTINGS_EDIT: "admin.settings.edit",
|
||||
PERMISSIONS_MANAGE: "admin.permissions.manage",
|
||||
SHOP_VIEW: "admin.shop.view",
|
||||
SHOP_EDIT: "admin.shop.edit",
|
||||
WORDFILTER_VIEW: "admin.wordfilter.view",
|
||||
WORDFILTER_EDIT: "admin.wordfilter.edit",
|
||||
CATALOG_VIEW: "admin.catalog.view",
|
||||
CATALOG_EDIT: "admin.catalog.edit",
|
||||
RCON_EXECUTE: "admin.rcon.execute",
|
||||
EXPORT: "admin.export",
|
||||
PREFIXES_VIEW: "admin.prefixes.view",
|
||||
PREFIXES_EDIT: "admin.prefixes.edit",
|
||||
TICKETS_VIEW: "admin.tickets.view",
|
||||
TICKETS_EDIT: "admin.tickets.edit",
|
||||
MODERATION_VIEW: "admin.moderation.view",
|
||||
MODERATION_EDIT: "admin.moderation.edit",
|
||||
// ── Events Module ──
|
||||
EVENTS_VIEW: "admin.events.view",
|
||||
EVENTS_EDIT: "admin.events.edit",
|
||||
// ── Analytics Module ──
|
||||
ANALYTICS_VIEW: "admin.analytics.view",
|
||||
ANALYTICS_EXPORT: "admin.analytics.export",
|
||||
// ── DevOps Module ──
|
||||
DEVOPS_VIEW: "admin.devops.view",
|
||||
DEVOPS_EDIT: "admin.devops.edit",
|
||||
// ── Polls Module ──
|
||||
POLLS_VIEW: "admin.polls.view",
|
||||
POLLS_EDIT: "admin.polls.edit",
|
||||
// ── Notifications Module ──
|
||||
NOTIFICATIONS_VIEW: "admin.notifications.view",
|
||||
NOTIFICATIONS_EDIT: "admin.notifications.edit",
|
||||
// ── CMS Pages Module ──
|
||||
PAGES_VIEW: "admin.pages.view",
|
||||
PAGES_EDIT: "admin.pages.edit",
|
||||
// ── Banners Module ──
|
||||
BANNERS_VIEW: "admin.banners.view",
|
||||
BANNERS_EDIT: "admin.banners.edit",
|
||||
// ── Assets Module ──
|
||||
ASSETS_IMPORT: "admin.assets.import",
|
||||
// ── Mod Panel Permissions ──
|
||||
MOD_DASHBOARD: "mod.dashboard",
|
||||
MOD_CFH_VIEW: "mod.cfh.view",
|
||||
MOD_CFH_EDIT: "mod.cfh.edit",
|
||||
MOD_ACTIONS: "mod.actions",
|
||||
MOD_TEAM_VIEW: "mod.team.view",
|
||||
MOD_TICKETS_VIEW: "mod.tickets.view",
|
||||
MOD_TICKETS_EDIT: "mod.tickets.edit",
|
||||
MOD_USERS_VIEW: "mod.users.view",
|
||||
MOD_BANS_VIEW: "mod.bans.view",
|
||||
} as const;
|
||||
+169
-119
@@ -1,12 +1,12 @@
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { cache } from "react";
|
||||
import { auth } from "./auth";
|
||||
import { sessionUserId } from "./auth/session-user";
|
||||
import { prisma } from "./prisma";
|
||||
import { logAuthorizationEvent } from "./admin/authorization-events";
|
||||
import { isDynamicSuperAdmin } from "./admin/authorization-policy";
|
||||
import { resolveAuthorizationState } from "./admin/rank-authority";
|
||||
import { auth } from "./auth";
|
||||
import { sessionUserId } from "./auth/session-user";
|
||||
import { redirectSafe } from "./foundation/security";
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
// Re-export PERMS from the standalone file (safe for client components)
|
||||
export { PERMS } from "./permission-slugs";
|
||||
@@ -20,12 +20,12 @@ export type { PermissionSet } from "@/types/admin";
|
||||
import type { PermissionSet } from "@/types/admin";
|
||||
|
||||
function createEmptySet(): PermissionSet {
|
||||
return {
|
||||
has: () => false,
|
||||
hasAny: () => false,
|
||||
hasAll: () => false,
|
||||
isSuperAdmin: false,
|
||||
};
|
||||
return {
|
||||
has: () => false,
|
||||
hasAny: () => false,
|
||||
hasAll: () => false,
|
||||
isSuperAdmin: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,8 +33,8 @@ function createEmptySet(): PermissionSet {
|
||||
* Cached via unstable_cache with 60s TTL — invalidated via revalidateTag('permissions').
|
||||
*/
|
||||
const getCachedPermissionSlugs = unstable_cache(
|
||||
async (userId: number, rank: number): Promise<string[]> => {
|
||||
const rows = await prisma.$queryRaw<{ slug: string }[]>`
|
||||
async (userId: number, rank: number): Promise<string[]> => {
|
||||
const rows = await prisma.$queryRaw<{ slug: string }[]>`
|
||||
SELECT DISTINCT p.slug
|
||||
FROM acl_model_permissions mp
|
||||
JOIN acl_permissions p ON p.id = mp.permission_id
|
||||
@@ -49,10 +49,10 @@ const getCachedPermissionSlugs = unstable_cache(
|
||||
WHERE ar.slug = ${`rank_${rank}`}
|
||||
)
|
||||
`;
|
||||
return rows.map((r) => r.slug);
|
||||
},
|
||||
["user-permissions"],
|
||||
{ revalidate: 60, tags: ["permissions"] },
|
||||
return rows.map((r) => r.slug);
|
||||
},
|
||||
["user-permissions"],
|
||||
{ revalidate: 60, tags: ["permissions"] },
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -61,55 +61,59 @@ const getCachedPermissionSlugs = unstable_cache(
|
||||
* Wrapped with React cache() to de-duplicate within the same request.
|
||||
*/
|
||||
export const loadUserPermissions = cache(async function loadUserPermissions(
|
||||
userId: number,
|
||||
rank: number,
|
||||
highestRank: number | null,
|
||||
userId: number,
|
||||
rank: number,
|
||||
highestRank: number | null,
|
||||
): Promise<PermissionSet> {
|
||||
try {
|
||||
// Super admin bypasses all permission checks — zero DB queries
|
||||
if (isDynamicSuperAdmin(rank, highestRank)) {
|
||||
return {
|
||||
has: () => true,
|
||||
hasAny: () => true,
|
||||
hasAll: () => true,
|
||||
isSuperAdmin: true,
|
||||
};
|
||||
}
|
||||
try {
|
||||
// Super admin bypasses all permission checks — zero DB queries
|
||||
if (isDynamicSuperAdmin(rank, highestRank)) {
|
||||
return {
|
||||
has: () => true,
|
||||
hasAny: () => true,
|
||||
hasAll: () => true,
|
||||
isSuperAdmin: true,
|
||||
};
|
||||
}
|
||||
|
||||
const slugArray = await getCachedPermissionSlugs(userId, rank);
|
||||
if (slugArray.length === 0) return createEmptySet();
|
||||
const slugArray = await getCachedPermissionSlugs(userId, rank);
|
||||
if (slugArray.length === 0) return createEmptySet();
|
||||
|
||||
const slugs = new Set(slugArray);
|
||||
return {
|
||||
has: (perm: string) => slugs.has(perm),
|
||||
hasAny: (...perms: string[]) => perms.some((p) => slugs.has(p)),
|
||||
hasAll: (...perms: string[]) => perms.every((p) => slugs.has(p)),
|
||||
isSuperAdmin: false,
|
||||
};
|
||||
} catch (error) {
|
||||
await logAuthorizationEvent({
|
||||
kind: "permission.load_error",
|
||||
userId,
|
||||
rank,
|
||||
source: "loadUserPermissions",
|
||||
reason: "ACL query failed",
|
||||
error,
|
||||
});
|
||||
// Fail-closed: return empty set on any error
|
||||
return createEmptySet();
|
||||
}
|
||||
const slugs = new Set(slugArray);
|
||||
return {
|
||||
has: (perm: string) => slugs.has(perm),
|
||||
hasAny: (...perms: string[]) => perms.some((p) => slugs.has(p)),
|
||||
hasAll: (...perms: string[]) => perms.every((p) => slugs.has(p)),
|
||||
isSuperAdmin: false,
|
||||
};
|
||||
} catch (error) {
|
||||
await logAuthorizationEvent({
|
||||
kind: "permission.load_error",
|
||||
userId,
|
||||
rank,
|
||||
source: "loadUserPermissions",
|
||||
reason: "ACL query failed",
|
||||
error,
|
||||
});
|
||||
// Fail-closed: return empty set on any error
|
||||
return createEmptySet();
|
||||
}
|
||||
});
|
||||
|
||||
const getCurrentAuthorizationState = cache(async (userId: number) =>
|
||||
resolveAuthorizationState(userId, {
|
||||
user: prisma.user,
|
||||
highestRank: async () => {
|
||||
const rows = await prisma.$queryRaw<{ highest_rank: number | bigint | null }[]>`
|
||||
resolveAuthorizationState(userId, {
|
||||
user: prisma.user,
|
||||
highestRank: async () => {
|
||||
const rows = await prisma.$queryRaw<
|
||||
{ highest_rank: number | bigint | null }[]
|
||||
>`
|
||||
SELECT MAX(id) AS highest_rank FROM permission_ranks
|
||||
`;
|
||||
return rows[0]?.highest_rank == null ? null : Number(rows[0].highest_rank);
|
||||
},
|
||||
}),
|
||||
return rows[0]?.highest_rank == null
|
||||
? null
|
||||
: Number(rows[0].highest_rank);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Context Helpers ─────────────────────────────────────────────────
|
||||
@@ -119,23 +123,32 @@ const getCurrentAuthorizationState = cache(async (userId: number) =>
|
||||
* Redirects to login if not authenticated.
|
||||
*/
|
||||
export async function getAdminContext() {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
redirectSafe("/login", "/login");
|
||||
}
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
redirectSafe("/login", "/login");
|
||||
}
|
||||
|
||||
const userId = sessionUserId(session.user.id);
|
||||
if (!userId) redirectSafe("/login", "/login");
|
||||
const state = await getCurrentAuthorizationState(userId);
|
||||
if (!state) redirectSafe("/login", "/login");
|
||||
const permissions = await loadUserPermissions(userId, state.actor.rank, state.highestRank);
|
||||
return {
|
||||
session: {
|
||||
...session,
|
||||
user: { ...session.user, id: userId, username: state.actor.username, rank: state.actor.rank },
|
||||
},
|
||||
permissions,
|
||||
};
|
||||
const userId = sessionUserId(session.user.id);
|
||||
if (!userId) redirectSafe("/login", "/login");
|
||||
const state = await getCurrentAuthorizationState(userId);
|
||||
if (!state) redirectSafe("/login", "/login");
|
||||
const permissions = await loadUserPermissions(
|
||||
userId,
|
||||
state.actor.rank,
|
||||
state.highestRank,
|
||||
);
|
||||
return {
|
||||
session: {
|
||||
...session,
|
||||
user: {
|
||||
...session.user,
|
||||
id: userId,
|
||||
username: state.actor.username,
|
||||
rank: state.actor.rank,
|
||||
},
|
||||
},
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,29 +156,42 @@ export async function getAdminContext() {
|
||||
* Returns null if not authenticated (caller handles 401).
|
||||
*/
|
||||
export async function getApiAdminContext() {
|
||||
const session = await auth();
|
||||
if (!session?.user) return null;
|
||||
const session = await auth();
|
||||
if (!session?.user) return null;
|
||||
|
||||
const userId = sessionUserId(session.user.id);
|
||||
if (!userId) return null;
|
||||
const state = await getCurrentAuthorizationState(userId);
|
||||
if (!state) return null;
|
||||
const permissions = await loadUserPermissions(userId, state.actor.rank, state.highestRank);
|
||||
return {
|
||||
session: {
|
||||
...session,
|
||||
user: { ...session.user, id: userId, username: state.actor.username, rank: state.actor.rank },
|
||||
},
|
||||
permissions,
|
||||
};
|
||||
const userId = sessionUserId(session.user.id);
|
||||
if (!userId) return null;
|
||||
const state = await getCurrentAuthorizationState(userId);
|
||||
if (!state) return null;
|
||||
const permissions = await loadUserPermissions(
|
||||
userId,
|
||||
state.actor.rank,
|
||||
state.highestRank,
|
||||
);
|
||||
return {
|
||||
session: {
|
||||
...session,
|
||||
user: {
|
||||
...session.user,
|
||||
id: userId,
|
||||
username: state.actor.username,
|
||||
rank: state.actor.rank,
|
||||
},
|
||||
},
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has a specific permission.
|
||||
* All fallbacks are represented as ACL role permissions by migration 0011.
|
||||
*/
|
||||
export function canAccess(permissions: PermissionSet, slug: string, _rank?: number): boolean {
|
||||
return permissions.has(slug);
|
||||
export function canAccess(
|
||||
permissions: PermissionSet,
|
||||
slug: string,
|
||||
_rank?: number,
|
||||
): boolean {
|
||||
return permissions.has(slug);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,49 +199,73 @@ export function canAccess(permissions: PermissionSet, slug: string, _rank?: numb
|
||||
* Redirects to login if unauthenticated and to / without moderator ACL access.
|
||||
*/
|
||||
export async function getModContext() {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
redirectSafe("/login", "/login");
|
||||
}
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
redirectSafe("/login", "/login");
|
||||
}
|
||||
|
||||
const userId = sessionUserId(session.user.id);
|
||||
if (!userId) redirectSafe("/login", "/login");
|
||||
const state = await getCurrentAuthorizationState(userId);
|
||||
if (!state) redirectSafe("/", "/");
|
||||
const permissions = await loadUserPermissions(userId, state.actor.rank, state.highestRank);
|
||||
if (!canAccess(permissions, PERMS.MOD_DASHBOARD)) redirectSafe("/", "/");
|
||||
return {
|
||||
session: {
|
||||
...session,
|
||||
user: { ...session.user, id: userId, username: state.actor.username, rank: state.actor.rank },
|
||||
},
|
||||
permissions,
|
||||
};
|
||||
const userId = sessionUserId(session.user.id);
|
||||
if (!userId) redirectSafe("/login", "/login");
|
||||
const state = await getCurrentAuthorizationState(userId);
|
||||
if (!state) redirectSafe("/", "/");
|
||||
const permissions = await loadUserPermissions(
|
||||
userId,
|
||||
state.actor.rank,
|
||||
state.highestRank,
|
||||
);
|
||||
if (!canAccess(permissions, PERMS.MOD_DASHBOARD)) redirectSafe("/", "/");
|
||||
return {
|
||||
session: {
|
||||
...session,
|
||||
user: {
|
||||
...session.user,
|
||||
id: userId,
|
||||
username: state.actor.username,
|
||||
rank: state.actor.rank,
|
||||
},
|
||||
},
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Legacy single-check functions (kept for backward compatibility) ──
|
||||
|
||||
/** Check if a user has a CMS permission using their current database rank. */
|
||||
export async function checkPermission(userId: number, _rank: number, permission: string): Promise<boolean> {
|
||||
const state = await getCurrentAuthorizationState(userId);
|
||||
if (!state) return false;
|
||||
const perms = await loadUserPermissions(userId, state.actor.rank, state.highestRank);
|
||||
return perms.has(permission);
|
||||
export async function checkPermission(
|
||||
userId: number,
|
||||
_rank: number,
|
||||
permission: string,
|
||||
): Promise<boolean> {
|
||||
const state = await getCurrentAuthorizationState(userId);
|
||||
if (!state) return false;
|
||||
const perms = await loadUserPermissions(
|
||||
userId,
|
||||
state.actor.rank,
|
||||
state.highestRank,
|
||||
);
|
||||
return perms.has(permission);
|
||||
}
|
||||
|
||||
/** Check multiple permissions (user needs ALL of them) */
|
||||
export async function checkAllPermissions(
|
||||
userId: number,
|
||||
_rank: number,
|
||||
permissions: string[],
|
||||
userId: number,
|
||||
_rank: number,
|
||||
permissions: string[],
|
||||
): Promise<boolean> {
|
||||
const state = await getCurrentAuthorizationState(userId);
|
||||
if (!state) return false;
|
||||
const perms = await loadUserPermissions(userId, state.actor.rank, state.highestRank);
|
||||
return perms.hasAll(...permissions);
|
||||
const state = await getCurrentAuthorizationState(userId);
|
||||
if (!state) return false;
|
||||
const perms = await loadUserPermissions(
|
||||
userId,
|
||||
state.actor.rank,
|
||||
state.highestRank,
|
||||
);
|
||||
return perms.hasAll(...permissions);
|
||||
}
|
||||
|
||||
/** Check if user has admin access */
|
||||
export async function hasAdminAccess(userId: number, rank: number): Promise<boolean> {
|
||||
return checkPermission(userId, rank, PERMS.ADMIN_DASHBOARD);
|
||||
export async function hasAdminAccess(
|
||||
userId: number,
|
||||
rank: number,
|
||||
): Promise<boolean> {
|
||||
return checkPermission(userId, rank, PERMS.ADMIN_DASHBOARD);
|
||||
}
|
||||
+17
-17
@@ -1,26 +1,26 @@
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { env } from "@/env";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
||||
|
||||
function createPrismaClient(): PrismaClient {
|
||||
const url = new URL(env.DATABASE_URL);
|
||||
const adapter = new PrismaMariaDb({
|
||||
host: url.hostname,
|
||||
port: Number(url.port) || 3306,
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
database: url.pathname.replace(/^\//, ""),
|
||||
connectionLimit: env.DATABASE_POOL_SIZE,
|
||||
connectTimeout: env.DATABASE_CONNECT_TIMEOUT_MS,
|
||||
acquireTimeout: env.DATABASE_CONNECT_TIMEOUT_MS,
|
||||
idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS,
|
||||
});
|
||||
return new PrismaClient({
|
||||
adapter,
|
||||
log: env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
||||
});
|
||||
const url = new URL(env.DATABASE_URL);
|
||||
const adapter = new PrismaMariaDb({
|
||||
host: url.hostname,
|
||||
port: Number(url.port) || 3306,
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
database: url.pathname.replace(/^\//, ""),
|
||||
connectionLimit: env.DATABASE_POOL_SIZE,
|
||||
connectTimeout: env.DATABASE_CONNECT_TIMEOUT_MS,
|
||||
acquireTimeout: env.DATABASE_CONNECT_TIMEOUT_MS,
|
||||
idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS,
|
||||
});
|
||||
return new PrismaClient({
|
||||
adapter,
|
||||
log: env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
||||
});
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
|
||||
|
||||
@@ -2,13 +2,17 @@ import { describe, expect, it } from "vitest";
|
||||
import { shouldRedirectAdminRequest } from "./proxy-access";
|
||||
|
||||
describe("shouldRedirectAdminRequest", () => {
|
||||
it("redirects anonymous admin requests before rendering", () => {
|
||||
expect(shouldRedirectAdminRequest("/admin", null)).toBe(true);
|
||||
});
|
||||
it("redirects anonymous admin requests before rendering", () => {
|
||||
expect(shouldRedirectAdminRequest("/admin", null)).toBe(true);
|
||||
});
|
||||
|
||||
it("defers every authenticated rank to database authorization", () => {
|
||||
expect(shouldRedirectAdminRequest("/admin/permissions", { rank: 1 })).toBe(false);
|
||||
expect(shouldRedirectAdminRequest("/admin/permissions", { rank: 2000 })).toBe(false);
|
||||
expect(shouldRedirectAdminRequest("/news", null)).toBe(false);
|
||||
});
|
||||
it("defers every authenticated rank to database authorization", () => {
|
||||
expect(shouldRedirectAdminRequest("/admin/permissions", { rank: 1 })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
shouldRedirectAdminRequest("/admin/permissions", { rank: 2000 }),
|
||||
).toBe(false);
|
||||
expect(shouldRedirectAdminRequest("/news", null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
export interface ProxyToken {
|
||||
rank?: unknown;
|
||||
rank?: unknown;
|
||||
}
|
||||
|
||||
export function shouldRedirectAdminRequest(pathname: string, token: ProxyToken | null): boolean {
|
||||
if (pathname !== "/admin" && !pathname.startsWith("/admin/")) return false;
|
||||
return token === null;
|
||||
export function shouldRedirectAdminRequest(
|
||||
pathname: string,
|
||||
token: ProxyToken | null,
|
||||
): boolean {
|
||||
if (pathname !== "/admin" && !pathname.startsWith("/admin/")) return false;
|
||||
return token === null;
|
||||
}
|
||||
@@ -2,12 +2,14 @@ import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("proxy authentication boundary", () => {
|
||||
it("uses a database-free Auth.js decoder", () => {
|
||||
const proxy = readFileSync("src/proxy.ts", "utf8");
|
||||
const proxyAuth = readFileSync("src/lib/proxy-auth.ts", "utf8");
|
||||
it("uses a database-free Auth.js decoder", () => {
|
||||
const proxy = readFileSync("src/proxy.ts", "utf8");
|
||||
const proxyAuth = readFileSync("src/lib/proxy-auth.ts", "utf8");
|
||||
|
||||
expect(proxy).toContain('from "@/lib/proxy-auth"');
|
||||
expect(proxy).not.toContain('from "@/lib/auth"');
|
||||
expect(proxyAuth).not.toMatch(/from\s+["'][^"']*(prisma|site-settings|credentials)[^"']*["']/i);
|
||||
});
|
||||
expect(proxy).toContain('from "@/lib/proxy-auth"');
|
||||
expect(proxy).not.toContain('from "@/lib/auth"');
|
||||
expect(proxyAuth).not.toMatch(
|
||||
/from\s+["'][^"']*(prisma|site-settings|credentials)[^"']*["']/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,8 @@ import NextAuth from "next-auth";
|
||||
// Proxy authentication must only decode the Auth.js session. Importing the
|
||||
// full CMS auth configuration here would also run Prisma/settings callbacks.
|
||||
export const { auth: proxyAuth } = NextAuth({
|
||||
trustHost: true,
|
||||
secret: process.env.AUTH_SECRET,
|
||||
session: { strategy: "jwt" },
|
||||
providers: [],
|
||||
trustHost: true,
|
||||
secret: process.env.AUTH_SECRET,
|
||||
session: { strategy: "jwt" },
|
||||
providers: [],
|
||||
});
|
||||
+32
-32
@@ -1,47 +1,47 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/redis", () => ({
|
||||
redis: null,
|
||||
redis: null,
|
||||
}));
|
||||
|
||||
import { rateLimit } from "./rate-limit";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("rateLimit (in-memory fallback)", () => {
|
||||
it("allows the first request", async () => {
|
||||
const res = await rateLimit("test:1", 3, 60_000);
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.retryAfter).toBe(0);
|
||||
});
|
||||
it("allows the first request", async () => {
|
||||
const res = await rateLimit("test:1", 3, 60_000);
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.retryAfter).toBe(0);
|
||||
});
|
||||
|
||||
it("allows up to the limit within a window", async () => {
|
||||
const key = `test:2:${Date.now()}`;
|
||||
expect((await rateLimit(key, 2, 60_000)).ok).toBe(true);
|
||||
expect((await rateLimit(key, 2, 60_000)).ok).toBe(true);
|
||||
const res = await rateLimit(key, 2, 60_000);
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.retryAfter).toBeGreaterThan(0);
|
||||
});
|
||||
it("allows up to the limit within a window", async () => {
|
||||
const key = `test:2:${Date.now()}`;
|
||||
expect((await rateLimit(key, 2, 60_000)).ok).toBe(true);
|
||||
expect((await rateLimit(key, 2, 60_000)).ok).toBe(true);
|
||||
const res = await rateLimit(key, 2, 60_000);
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.retryAfter).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("resets after the window expires", async () => {
|
||||
const key = `test:3:${Date.now()}`;
|
||||
await rateLimit(key, 1, 50);
|
||||
const res1 = await rateLimit(key, 1, 50);
|
||||
expect(res1.ok).toBe(false);
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
const res2 = await rateLimit(key, 1, 50);
|
||||
expect(res2.ok).toBe(true);
|
||||
});
|
||||
it("resets after the window expires", async () => {
|
||||
const key = `test:3:${Date.now()}`;
|
||||
await rateLimit(key, 1, 50);
|
||||
const res1 = await rateLimit(key, 1, 50);
|
||||
expect(res1.ok).toBe(false);
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
const res2 = await rateLimit(key, 1, 50);
|
||||
expect(res2.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("uses separate keys independently", async () => {
|
||||
const a = await rateLimit("key-a", 1, 60_000);
|
||||
const b = await rateLimit("key-b", 1, 60_000);
|
||||
expect(a.ok).toBe(true);
|
||||
expect(b.ok).toBe(true);
|
||||
const a2 = await rateLimit("key-a", 1, 60_000);
|
||||
expect(a2.ok).toBe(false);
|
||||
});
|
||||
it("uses separate keys independently", async () => {
|
||||
const a = await rateLimit("key-a", 1, 60_000);
|
||||
const b = await rateLimit("key-b", 1, 60_000);
|
||||
expect(a.ok).toBe(true);
|
||||
expect(b.ok).toBe(true);
|
||||
const a2 = await rateLimit("key-a", 1, 60_000);
|
||||
expect(a2.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
+66
-54
@@ -5,8 +5,8 @@ type Bucket = { count: number; resetAt: number };
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
export interface RateLimitResult {
|
||||
ok: boolean;
|
||||
retryAfter: number;
|
||||
ok: boolean;
|
||||
retryAfter: number;
|
||||
}
|
||||
|
||||
const CLEANUP_INTERVAL_MS = 300_000;
|
||||
@@ -15,69 +15,81 @@ const MAX_BUCKETS = 10_000;
|
||||
let lastCleanup = Date.now();
|
||||
|
||||
function cleanup(): void {
|
||||
const now = Date.now();
|
||||
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
|
||||
lastCleanup = now;
|
||||
const now = Date.now();
|
||||
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
|
||||
lastCleanup = now;
|
||||
|
||||
for (const [k, b] of buckets) {
|
||||
if (now >= b.resetAt) buckets.delete(k);
|
||||
}
|
||||
for (const [k, b] of buckets) {
|
||||
if (now >= b.resetAt) buckets.delete(k);
|
||||
}
|
||||
|
||||
if (buckets.size > MAX_BUCKETS) {
|
||||
const sorted = [...buckets.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt);
|
||||
const keysToRemove = sorted.slice(0, Math.floor(sorted.length * 0.2)).map((entry) => entry[0]);
|
||||
for (const key of keysToRemove) buckets.delete(key);
|
||||
}
|
||||
if (buckets.size > MAX_BUCKETS) {
|
||||
const sorted = [...buckets.entries()].sort(
|
||||
(a, b) => a[1].resetAt - b[1].resetAt,
|
||||
);
|
||||
const keysToRemove = sorted
|
||||
.slice(0, Math.floor(sorted.length * 0.2))
|
||||
.map((entry) => entry[0]);
|
||||
for (const key of keysToRemove) buckets.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rateLimit(key: string, limit: number, windowMs: number): Promise<RateLimitResult> {
|
||||
const now = Date.now();
|
||||
export async function rateLimit(
|
||||
key: string,
|
||||
limit: number,
|
||||
windowMs: number,
|
||||
): Promise<RateLimitResult> {
|
||||
const now = Date.now();
|
||||
|
||||
if (redis) {
|
||||
try {
|
||||
const windowKey = `ratelimit:${key}`;
|
||||
const current = await redis.incr(windowKey);
|
||||
if (current === 1) await redis.pexpire(windowKey, windowMs);
|
||||
const ttl = current === 1 ? windowMs : Math.max(0, await redis.pttl(windowKey));
|
||||
if (current > limit) {
|
||||
return { ok: false, retryAfter: Math.ceil(ttl / 1000) };
|
||||
}
|
||||
return { ok: true, retryAfter: 0 };
|
||||
} catch {
|
||||
// Redis unavailable — fall through to in-memory
|
||||
}
|
||||
}
|
||||
if (redis) {
|
||||
try {
|
||||
const windowKey = `ratelimit:${key}`;
|
||||
const current = await redis.incr(windowKey);
|
||||
if (current === 1) await redis.pexpire(windowKey, windowMs);
|
||||
const ttl =
|
||||
current === 1 ? windowMs : Math.max(0, await redis.pttl(windowKey));
|
||||
if (current > limit) {
|
||||
return { ok: false, retryAfter: Math.ceil(ttl / 1000) };
|
||||
}
|
||||
return { ok: true, retryAfter: 0 };
|
||||
} catch {
|
||||
// Redis unavailable — fall through to in-memory
|
||||
}
|
||||
}
|
||||
|
||||
cleanup();
|
||||
cleanup();
|
||||
|
||||
const windowKey = `mem:${key}`;
|
||||
const bucket = buckets.get(windowKey);
|
||||
const windowKey = `mem:${key}`;
|
||||
const bucket = buckets.get(windowKey);
|
||||
|
||||
if (!bucket || now >= bucket.resetAt) {
|
||||
buckets.set(windowKey, { count: 1, resetAt: now + windowMs });
|
||||
return { ok: true, retryAfter: 0 };
|
||||
}
|
||||
if (!bucket || now >= bucket.resetAt) {
|
||||
buckets.set(windowKey, { count: 1, resetAt: now + windowMs });
|
||||
return { ok: true, retryAfter: 0 };
|
||||
}
|
||||
|
||||
const newCount = bucket.count + 1;
|
||||
if (newCount > limit) {
|
||||
return { ok: false, retryAfter: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)) };
|
||||
}
|
||||
const newCount = bucket.count + 1;
|
||||
if (newCount > limit) {
|
||||
return {
|
||||
ok: false,
|
||||
retryAfter: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
bucket.count = newCount;
|
||||
return { ok: true, retryAfter: 0 };
|
||||
bucket.count = newCount;
|
||||
return { ok: true, retryAfter: 0 };
|
||||
}
|
||||
|
||||
export async function clientIp(): Promise<string> {
|
||||
try {
|
||||
const h = await headers();
|
||||
return (
|
||||
h.get("x-real-client-ip") ??
|
||||
h.get("cf-connecting-ip") ??
|
||||
h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
h.get("x-real-ip") ??
|
||||
"0.0.0.0"
|
||||
);
|
||||
} catch {
|
||||
return "0.0.0.0";
|
||||
}
|
||||
try {
|
||||
const h = await headers();
|
||||
return (
|
||||
h.get("x-real-client-ip") ??
|
||||
h.get("cf-connecting-ip") ??
|
||||
h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
h.get("x-real-ip") ??
|
||||
"0.0.0.0"
|
||||
);
|
||||
} catch {
|
||||
return "0.0.0.0";
|
||||
}
|
||||
}
|
||||
+29
-27
@@ -3,37 +3,39 @@ import Redis from "ioredis";
|
||||
const globalForRedis = globalThis as unknown as { redis?: Redis | null };
|
||||
|
||||
function createRedis(): Redis | null {
|
||||
const url = process.env.REDIS_URL;
|
||||
if (!url) return null;
|
||||
try {
|
||||
const client = new Redis(url, {
|
||||
maxRetriesPerRequest: 3,
|
||||
retryStrategy(times) {
|
||||
if (times > 3) return null;
|
||||
return Math.min(times * 200, 2000);
|
||||
},
|
||||
lazyConnect: true,
|
||||
});
|
||||
client.on("error", () => {});
|
||||
return client;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const url = process.env.REDIS_URL;
|
||||
if (!url) return null;
|
||||
try {
|
||||
const client = new Redis(url, {
|
||||
maxRetriesPerRequest: 3,
|
||||
retryStrategy(times) {
|
||||
if (times > 3) return null;
|
||||
return Math.min(times * 200, 2000);
|
||||
},
|
||||
lazyConnect: true,
|
||||
});
|
||||
client.on("error", () => {});
|
||||
return client;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const redis: Redis | null =
|
||||
globalForRedis.redis !== undefined ? globalForRedis.redis : (globalForRedis.redis = createRedis());
|
||||
globalForRedis.redis !== undefined
|
||||
? globalForRedis.redis
|
||||
: (globalForRedis.redis = createRedis());
|
||||
|
||||
export async function withRedis<T>(
|
||||
fallback: () => Promise<T>,
|
||||
redisFn: (client: Redis) => Promise<T>,
|
||||
fallback: () => Promise<T>,
|
||||
redisFn: (client: Redis) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (redis) {
|
||||
try {
|
||||
return await redisFn(redis);
|
||||
} catch {
|
||||
return fallback();
|
||||
}
|
||||
}
|
||||
return fallback();
|
||||
if (redis) {
|
||||
try {
|
||||
return await redisFn(redis);
|
||||
} catch {
|
||||
return fallback();
|
||||
}
|
||||
}
|
||||
return fallback();
|
||||
}
|
||||
@@ -2,33 +2,36 @@ import { ZodError } from "zod";
|
||||
import { handleActionError as foundationHandle } from "@/lib/foundation/action";
|
||||
|
||||
export type ActionResult<T = Record<string, unknown>> =
|
||||
{ ok: true; data?: T } | { ok: false; error: string; fieldErrors?: Record<string, string[]> };
|
||||
| { ok: true; data?: T }
|
||||
| { ok: false; error: string; fieldErrors?: Record<string, string[]> };
|
||||
|
||||
export function actionOk<T = Record<string, unknown>>(data?: T): ActionResult<T> {
|
||||
return { ok: true, data: data ?? ({} as T) };
|
||||
export function actionOk<T = Record<string, unknown>>(
|
||||
data?: T,
|
||||
): ActionResult<T> {
|
||||
return { ok: true, data: data ?? ({} as T) };
|
||||
}
|
||||
|
||||
export function actionError(message: string): ActionResult<never> {
|
||||
return { ok: false, error: message };
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
|
||||
export class ActionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ActionError";
|
||||
}
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ActionError";
|
||||
}
|
||||
}
|
||||
|
||||
export function handleActionError(error: unknown): ActionResult<never> {
|
||||
if (error instanceof ZodError) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Validation failed",
|
||||
fieldErrors: error.flatten().fieldErrors as Record<string, string[]>,
|
||||
};
|
||||
}
|
||||
if (error instanceof Error && error.name === "ActionError") {
|
||||
return { ok: false, error: error.message };
|
||||
}
|
||||
return foundationHandle(error) as ActionResult<never>;
|
||||
if (error instanceof ZodError) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Validation failed",
|
||||
fieldErrors: error.flatten().fieldErrors as Record<string, string[]>,
|
||||
};
|
||||
}
|
||||
if (error instanceof Error && error.name === "ActionError") {
|
||||
return { ok: false, error: error.message };
|
||||
}
|
||||
return foundationHandle(error) as ActionResult<never>;
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { adminAction as foundationAdmin, authAction as foundationAuth } from "@/lib/foundation/action";
|
||||
import {
|
||||
adminAction as foundationAdmin,
|
||||
authAction as foundationAuth,
|
||||
} from "@/lib/foundation/action";
|
||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||
|
||||
export type { ActionResult };
|
||||
|
||||
+63
-61
@@ -7,68 +7,70 @@ import sanitizeHtml from "sanitize-html";
|
||||
* handlers, no javascript: URLs); images/links are permitted with safe schemes.
|
||||
*/
|
||||
const OPTIONS: sanitizeHtml.IOptions = {
|
||||
allowedTags: [
|
||||
"a",
|
||||
"b",
|
||||
"i",
|
||||
"em",
|
||||
"strong",
|
||||
"u",
|
||||
"s",
|
||||
"p",
|
||||
"br",
|
||||
"hr",
|
||||
"span",
|
||||
"div",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"blockquote",
|
||||
"code",
|
||||
"pre",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"img",
|
||||
"figure",
|
||||
"figcaption",
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tr",
|
||||
"th",
|
||||
"td",
|
||||
],
|
||||
allowedAttributes: {
|
||||
a: ["href", "title", "target", "rel"],
|
||||
img: ["src", "alt", "title", "width", "height"],
|
||||
"*": ["style", "class"],
|
||||
},
|
||||
allowedSchemes: ["http", "https", "mailto"],
|
||||
allowedSchemesByTag: { img: ["http", "https", "data"] },
|
||||
// Drop any style declarations that aren't simple, safe properties.
|
||||
allowedStyles: {
|
||||
"*": {
|
||||
color: [/.*/],
|
||||
"background-color": [/.*/],
|
||||
"text-align": [/^left$|^right$|^center$|^justify$/],
|
||||
"font-weight": [/.*/],
|
||||
"font-style": [/.*/],
|
||||
"text-decoration": [/.*/],
|
||||
"font-size": [/.*/],
|
||||
margin: [/.*/],
|
||||
padding: [/.*/],
|
||||
},
|
||||
},
|
||||
transformTags: {
|
||||
a: sanitizeHtml.simpleTransform("a", { rel: "noopener noreferrer nofollow" }),
|
||||
},
|
||||
allowedTags: [
|
||||
"a",
|
||||
"b",
|
||||
"i",
|
||||
"em",
|
||||
"strong",
|
||||
"u",
|
||||
"s",
|
||||
"p",
|
||||
"br",
|
||||
"hr",
|
||||
"span",
|
||||
"div",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"blockquote",
|
||||
"code",
|
||||
"pre",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"img",
|
||||
"figure",
|
||||
"figcaption",
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tr",
|
||||
"th",
|
||||
"td",
|
||||
],
|
||||
allowedAttributes: {
|
||||
a: ["href", "title", "target", "rel"],
|
||||
img: ["src", "alt", "title", "width", "height"],
|
||||
"*": ["style", "class"],
|
||||
},
|
||||
allowedSchemes: ["http", "https", "mailto"],
|
||||
allowedSchemesByTag: { img: ["http", "https", "data"] },
|
||||
// Drop any style declarations that aren't simple, safe properties.
|
||||
allowedStyles: {
|
||||
"*": {
|
||||
color: [/.*/],
|
||||
"background-color": [/.*/],
|
||||
"text-align": [/^left$|^right$|^center$|^justify$/],
|
||||
"font-weight": [/.*/],
|
||||
"font-style": [/.*/],
|
||||
"text-decoration": [/.*/],
|
||||
"font-size": [/.*/],
|
||||
margin: [/.*/],
|
||||
padding: [/.*/],
|
||||
},
|
||||
},
|
||||
transformTags: {
|
||||
a: sanitizeHtml.simpleTransform("a", {
|
||||
rel: "noopener noreferrer nofollow",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export function sanitize(html: string | null | undefined): string {
|
||||
if (!html) return "";
|
||||
return sanitizeHtml(html, OPTIONS);
|
||||
if (!html) return "";
|
||||
return sanitizeHtml(html, OPTIONS);
|
||||
}
|
||||
+27
-25
@@ -2,30 +2,32 @@ import { describe, expect, it } from "vitest";
|
||||
import { serverErrorRecord } from "@/lib/server-log";
|
||||
|
||||
describe("serverErrorRecord", () => {
|
||||
it("keeps operational context while redacting sensitive fields", () => {
|
||||
expect(
|
||||
serverErrorRecord("paypal.capture_failed", new Error("gateway timeout"), {
|
||||
orderId: "ORDER-123",
|
||||
userId: 42,
|
||||
authorization: "Bearer secret",
|
||||
apiToken: "secret-token",
|
||||
}),
|
||||
).toEqual({
|
||||
level: "error",
|
||||
event: "paypal.capture_failed",
|
||||
message: "gateway timeout",
|
||||
context: {
|
||||
orderId: "ORDER-123",
|
||||
userId: 42,
|
||||
authorization: "[REDACTED]",
|
||||
apiToken: "[REDACTED]",
|
||||
},
|
||||
});
|
||||
});
|
||||
it("keeps operational context while redacting sensitive fields", () => {
|
||||
expect(
|
||||
serverErrorRecord("paypal.capture_failed", new Error("gateway timeout"), {
|
||||
orderId: "ORDER-123",
|
||||
userId: 42,
|
||||
authorization: "Bearer secret",
|
||||
apiToken: "secret-token",
|
||||
}),
|
||||
).toEqual({
|
||||
level: "error",
|
||||
event: "paypal.capture_failed",
|
||||
message: "gateway timeout",
|
||||
context: {
|
||||
orderId: "ORDER-123",
|
||||
userId: 42,
|
||||
authorization: "[REDACTED]",
|
||||
apiToken: "[REDACTED]",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes non-Error failures without serializing arbitrary objects", () => {
|
||||
expect(serverErrorRecord("admin.update_failed", { password: "secret" })).toMatchObject({
|
||||
message: "Unknown server error",
|
||||
});
|
||||
});
|
||||
it("normalizes non-Error failures without serializing arbitrary objects", () => {
|
||||
expect(
|
||||
serverErrorRecord("admin.update_failed", { password: "secret" }),
|
||||
).toMatchObject({
|
||||
message: "Unknown server error",
|
||||
});
|
||||
});
|
||||
});
|
||||
+36
-26
@@ -1,41 +1,51 @@
|
||||
type LogScalar = string | number | boolean | null;
|
||||
type LogContext = Record<string, unknown>;
|
||||
|
||||
const SENSITIVE_KEY = /(authorization|cookie|password|secret|token|api[-_]?key)/i;
|
||||
const SENSITIVE_KEY =
|
||||
/(authorization|cookie|password|secret|token|api[-_]?key)/i;
|
||||
|
||||
export interface ServerErrorRecord {
|
||||
level: "error";
|
||||
event: string;
|
||||
message: string;
|
||||
context: Record<string, LogScalar>;
|
||||
level: "error";
|
||||
event: string;
|
||||
message: string;
|
||||
context: Record<string, LogScalar>;
|
||||
}
|
||||
|
||||
export function serverErrorRecord(
|
||||
event: string,
|
||||
error: unknown,
|
||||
context: LogContext = {},
|
||||
event: string,
|
||||
error: unknown,
|
||||
context: LogContext = {},
|
||||
): ServerErrorRecord {
|
||||
return {
|
||||
level: "error",
|
||||
event,
|
||||
message: error instanceof Error ? error.message.slice(0, 500) : "Unknown server error",
|
||||
context: Object.fromEntries(
|
||||
Object.entries(context).map(([key, value]) => [
|
||||
key,
|
||||
SENSITIVE_KEY.test(key) ? "[REDACTED]" : logScalar(value),
|
||||
]),
|
||||
),
|
||||
};
|
||||
return {
|
||||
level: "error",
|
||||
event,
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message.slice(0, 500)
|
||||
: "Unknown server error",
|
||||
context: Object.fromEntries(
|
||||
Object.entries(context).map(([key, value]) => [
|
||||
key,
|
||||
SENSITIVE_KEY.test(key) ? "[REDACTED]" : logScalar(value),
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function logServerError(event: string, error: unknown, context: LogContext = {}): void {
|
||||
console.error(JSON.stringify(serverErrorRecord(event, error, context)));
|
||||
export function logServerError(
|
||||
event: string,
|
||||
error: unknown,
|
||||
context: LogContext = {},
|
||||
): void {
|
||||
console.error(JSON.stringify(serverErrorRecord(event, error, context)));
|
||||
}
|
||||
|
||||
function logScalar(value: unknown): LogScalar {
|
||||
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
|
||||
? value
|
||||
: value === null
|
||||
? null
|
||||
: "[NON_SCALAR]";
|
||||
return typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
? value
|
||||
: value === null
|
||||
? null
|
||||
: "[NON_SCALAR]";
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ddosDetected } from "@/lib/services/alert";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ddosDetected } from "@/lib/services/alert";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
type Bucket = { count: number; resetAt: number };
|
||||
@@ -16,81 +16,88 @@ const CLEANUP_INTERVAL = 300_000;
|
||||
let lastCleanup = Date.now();
|
||||
|
||||
function cleanupStaleEntries(): void {
|
||||
const now = Date.now();
|
||||
if (now - lastCleanup < CLEANUP_INTERVAL) return;
|
||||
lastCleanup = now;
|
||||
const now = Date.now();
|
||||
if (now - lastCleanup < CLEANUP_INTERVAL) return;
|
||||
lastCleanup = now;
|
||||
|
||||
for (const [k, v] of buckets) {
|
||||
if (now >= v.resetAt) buckets.delete(k);
|
||||
}
|
||||
for (const [k, v] of buckets) {
|
||||
if (now >= v.resetAt) buckets.delete(k);
|
||||
}
|
||||
|
||||
if (buckets.size > MAX_BUCKETS) {
|
||||
const sorted = [...buckets.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt);
|
||||
const toRemove = Math.floor(sorted.length * 0.2);
|
||||
const keys = sorted.slice(0, toRemove).map((entry) => entry[0]);
|
||||
for (const key of keys) buckets.delete(key);
|
||||
}
|
||||
if (buckets.size > MAX_BUCKETS) {
|
||||
const sorted = [...buckets.entries()].sort(
|
||||
(a, b) => a[1].resetAt - b[1].resetAt,
|
||||
);
|
||||
const toRemove = Math.floor(sorted.length * 0.2);
|
||||
const keys = sorted.slice(0, toRemove).map((entry) => entry[0]);
|
||||
for (const key of keys) buckets.delete(key);
|
||||
}
|
||||
|
||||
if (recentlyBlocked.size > MAX_RECENTLY_BLOCKED) {
|
||||
recentlyBlocked.clear();
|
||||
}
|
||||
if (recentlyBlocked.size > MAX_RECENTLY_BLOCKED) {
|
||||
recentlyBlocked.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function isPrivate(ip: string): boolean {
|
||||
return (
|
||||
!ip ||
|
||||
ip === "0.0.0.0" ||
|
||||
ip === "::1" ||
|
||||
ip.startsWith("127.") ||
|
||||
ip.startsWith("10.") ||
|
||||
ip.startsWith("192.168.")
|
||||
);
|
||||
return (
|
||||
!ip ||
|
||||
ip === "0.0.0.0" ||
|
||||
ip === "::1" ||
|
||||
ip.startsWith("127.") ||
|
||||
ip.startsWith("10.") ||
|
||||
ip.startsWith("192.168.")
|
||||
);
|
||||
}
|
||||
|
||||
export async function isIpBlacklisted(ip: string): Promise<boolean> {
|
||||
if (isPrivate(ip)) return false;
|
||||
const now = Date.now();
|
||||
if (now - blacklistLoadedAt >= BLACKLIST_TTL) {
|
||||
try {
|
||||
const rows = await prisma.websiteIpBlacklist.findMany({ select: { ipAddress: true } });
|
||||
blacklist = new Set(rows.map((r) => r.ipAddress));
|
||||
blacklistLoadedAt = now;
|
||||
} catch {
|
||||
/* keep stale set on DB error */
|
||||
}
|
||||
}
|
||||
return blacklist.has(ip);
|
||||
if (isPrivate(ip)) return false;
|
||||
const now = Date.now();
|
||||
if (now - blacklistLoadedAt >= BLACKLIST_TTL) {
|
||||
try {
|
||||
const rows = await prisma.websiteIpBlacklist.findMany({
|
||||
select: { ipAddress: true },
|
||||
});
|
||||
blacklist = new Set(rows.map((r) => r.ipAddress));
|
||||
blacklistLoadedAt = now;
|
||||
} catch {
|
||||
/* keep stale set on DB error */
|
||||
}
|
||||
}
|
||||
return blacklist.has(ip);
|
||||
}
|
||||
|
||||
export async function recordRequest(ip: string): Promise<void> {
|
||||
if (isPrivate(ip)) return;
|
||||
if (!(await siteSettings.getBool("abuse_guard_enabled", false))) return;
|
||||
if (isPrivate(ip)) return;
|
||||
if (!(await siteSettings.getBool("abuse_guard_enabled", false))) return;
|
||||
|
||||
const limit = Number(await siteSettings.get("abuse_guard_threshold", "200")) || 200;
|
||||
const windowMs = (Number(await siteSettings.get("abuse_guard_window_seconds", "10")) || 10) * 1000;
|
||||
const limit =
|
||||
Number(await siteSettings.get("abuse_guard_threshold", "200")) || 200;
|
||||
const windowMs =
|
||||
(Number(await siteSettings.get("abuse_guard_window_seconds", "10")) || 10) *
|
||||
1000;
|
||||
|
||||
const now = Date.now();
|
||||
const now = Date.now();
|
||||
|
||||
cleanupStaleEntries();
|
||||
cleanupStaleEntries();
|
||||
|
||||
const b = buckets.get(ip);
|
||||
if (!b || now >= b.resetAt) {
|
||||
buckets.set(ip, { count: 1, resetAt: now + windowMs });
|
||||
return;
|
||||
}
|
||||
b.count += 1;
|
||||
const b = buckets.get(ip);
|
||||
if (!b || now >= b.resetAt) {
|
||||
buckets.set(ip, { count: 1, resetAt: now + windowMs });
|
||||
return;
|
||||
}
|
||||
b.count += 1;
|
||||
|
||||
if (b.count >= limit && !recentlyBlocked.has(ip)) {
|
||||
recentlyBlocked.add(ip);
|
||||
setTimeout(() => recentlyBlocked.delete(ip), 60_000);
|
||||
try {
|
||||
await prisma.websiteIpBlacklist.create({
|
||||
data: { ipAddress: ip, createdAt: new Date(), updatedAt: new Date() },
|
||||
});
|
||||
blacklistLoadedAt = 0;
|
||||
await ddosDetected(ip, b.count);
|
||||
} catch {
|
||||
/* ignore — alert/blacklist best-effort */
|
||||
}
|
||||
}
|
||||
if (b.count >= limit && !recentlyBlocked.has(ip)) {
|
||||
recentlyBlocked.add(ip);
|
||||
setTimeout(() => recentlyBlocked.delete(ip), 60_000);
|
||||
try {
|
||||
await prisma.websiteIpBlacklist.create({
|
||||
data: { ipAddress: ip, createdAt: new Date(), updatedAt: new Date() },
|
||||
});
|
||||
blacklistLoadedAt = 0;
|
||||
await ddosDetected(ip, b.count);
|
||||
} catch {
|
||||
/* ignore — alert/blacklist best-effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
+169
-135
@@ -1,7 +1,7 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendMail } from "@/lib/services/email";
|
||||
import { env } from "@/env";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendMail } from "@/lib/services/email";
|
||||
|
||||
// === Alert service (AtomCMS → Next.js) ===========================================
|
||||
//
|
||||
@@ -20,53 +20,62 @@ import { logger } from "@/lib/logger";
|
||||
// no-op when their env var is unset). Add them to env.ts later if you want them
|
||||
// validated at boot.
|
||||
|
||||
export type AlertSeverity = "info" | "notice" | "warning" | "error" | "critical";
|
||||
export type AlertSeverity =
|
||||
| "info"
|
||||
| "notice"
|
||||
| "warning"
|
||||
| "error"
|
||||
| "critical";
|
||||
|
||||
export interface SendAlertInput {
|
||||
/** Machine-readable category, e.g. "emulator", "security", "ddos". */
|
||||
type: string;
|
||||
/** Free-text severity; drives Discord embed colour + email subject prefix. */
|
||||
severity: AlertSeverity | string;
|
||||
/** Human-readable message body. */
|
||||
message: string;
|
||||
/** Optional structured payload stored verbatim in alert_logs.context (JSON). */
|
||||
context?: Record<string, unknown>;
|
||||
/** Machine-readable category, e.g. "emulator", "security", "ddos". */
|
||||
type: string;
|
||||
/** Free-text severity; drives Discord embed colour + email subject prefix. */
|
||||
severity: AlertSeverity | string;
|
||||
/** Human-readable message body. */
|
||||
message: string;
|
||||
/** Optional structured payload stored verbatim in alert_logs.context (JSON). */
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SendAlertResult {
|
||||
logged: boolean;
|
||||
sentViaDiscord: boolean;
|
||||
sentViaEmail: boolean;
|
||||
logged: boolean;
|
||||
sentViaDiscord: boolean;
|
||||
sentViaEmail: boolean;
|
||||
}
|
||||
|
||||
// Discord embed sidebar colours (decimal RGB) keyed by normalised severity.
|
||||
const DISCORD_COLORS: Record<string, number> = {
|
||||
critical: 0xc0392b,
|
||||
error: 0xe74c3c,
|
||||
danger: 0xe74c3c,
|
||||
warning: 0xf39c12,
|
||||
warn: 0xf39c12,
|
||||
success: 0x2ecc71,
|
||||
info: 0x3498db,
|
||||
notice: 0x9b59b6,
|
||||
critical: 0xc0392b,
|
||||
error: 0xe74c3c,
|
||||
danger: 0xe74c3c,
|
||||
warning: 0xf39c12,
|
||||
warn: 0xf39c12,
|
||||
success: 0x2ecc71,
|
||||
info: 0x3498db,
|
||||
notice: 0x9b59b6,
|
||||
};
|
||||
|
||||
function severityColor(severity: string): number {
|
||||
return DISCORD_COLORS[severity.toLowerCase()] ?? 0x95a5a6;
|
||||
return DISCORD_COLORS[severity.toLowerCase()] ?? 0x95a5a6;
|
||||
}
|
||||
|
||||
function discordWebhookUrl(): string | undefined {
|
||||
const url = process.env.DISCORD_WEBHOOK_URL?.trim();
|
||||
return url ? url : undefined;
|
||||
const url = process.env.DISCORD_WEBHOOK_URL?.trim();
|
||||
return url ? url : undefined;
|
||||
}
|
||||
|
||||
function alertEmail(): string | undefined {
|
||||
const addr = process.env.ALERT_EMAIL?.trim();
|
||||
return addr ? addr : undefined;
|
||||
const addr = process.env.ALERT_EMAIL?.trim();
|
||||
return addr ? addr : undefined;
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,48 +83,57 @@ function escapeHtml(s: string): string {
|
||||
* when the webhook is unset, the request fails, or Discord returns non-2xx.
|
||||
*/
|
||||
async function postDiscord(input: SendAlertInput): Promise<boolean> {
|
||||
const url = discordWebhookUrl();
|
||||
if (!url) return false;
|
||||
const url = discordWebhookUrl();
|
||||
if (!url) return false;
|
||||
|
||||
const fields = input.context
|
||||
? Object.entries(input.context)
|
||||
.slice(0, 10)
|
||||
.map(([name, value]) => ({
|
||||
name: String(name).slice(0, 256) || "",
|
||||
value: String(value ?? "").slice(0, 1024) || "",
|
||||
inline: true,
|
||||
}))
|
||||
: undefined;
|
||||
const fields = input.context
|
||||
? Object.entries(input.context)
|
||||
.slice(0, 10)
|
||||
.map(([name, value]) => ({
|
||||
name: String(name).slice(0, 256) || "",
|
||||
value: String(value ?? "").slice(0, 1024) || "",
|
||||
inline: true,
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
const body = {
|
||||
username: `${env.HOTEL_NAME} Alerts`,
|
||||
embeds: [
|
||||
{
|
||||
title: `[${String(input.severity).toUpperCase()}] ${input.type}`.slice(0, 256),
|
||||
description: input.message.slice(0, 4096),
|
||||
color: severityColor(input.severity),
|
||||
timestamp: new Date().toISOString(),
|
||||
...(fields && fields.length ? { fields } : {}),
|
||||
footer: { text: env.HOTEL_NAME },
|
||||
},
|
||||
],
|
||||
};
|
||||
const body = {
|
||||
username: `${env.HOTEL_NAME} Alerts`,
|
||||
embeds: [
|
||||
{
|
||||
title: `[${String(input.severity).toUpperCase()}] ${input.type}`.slice(
|
||||
0,
|
||||
256,
|
||||
),
|
||||
description: input.message.slice(0, 4096),
|
||||
color: severityColor(input.severity),
|
||||
timestamp: new Date().toISOString(),
|
||||
...(fields?.length ? { fields } : {}),
|
||||
footer: { text: env.HOTEL_NAME },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
logger.error("Discord webhook returned non-OK status", { module: "alert", status: res.status });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error("Discord webhook failed", { module: "alert", error: (e as Error).message });
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
logger.error("Discord webhook returned non-OK status", {
|
||||
module: "alert",
|
||||
status: res.status,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error("Discord webhook failed", {
|
||||
module: "alert",
|
||||
error: (e as Error).message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,32 +142,37 @@ async function postDiscord(input: SendAlertInput): Promise<boolean> {
|
||||
* already swallows its own errors, but we guard defensively anyway.
|
||||
*/
|
||||
async function emailStaff(input: SendAlertInput): Promise<boolean> {
|
||||
const to = alertEmail();
|
||||
if (!to) return false;
|
||||
const to = alertEmail();
|
||||
if (!to) return false;
|
||||
|
||||
const subject = `[${env.HOTEL_NAME}] ${String(input.severity).toUpperCase()} · ${input.type}`;
|
||||
const contextRows = input.context
|
||||
? Object.entries(input.context)
|
||||
.map(
|
||||
([k, v]) =>
|
||||
`<tr><td style="padding:4px 10px;font-weight:600">${escapeHtml(String(k))}</td>` +
|
||||
`<td style="padding:4px 10px">${escapeHtml(String(v ?? ""))}</td></tr>`,
|
||||
)
|
||||
.join("")
|
||||
: "";
|
||||
const html =
|
||||
`<h2 style="margin:0 0 8px">${escapeHtml(input.type)} — ${escapeHtml(String(input.severity))}</h2>` +
|
||||
`<p style="margin:0 0 12px">${escapeHtml(input.message)}</p>` +
|
||||
(contextRows ? `<table style="border-collapse:collapse;font-size:13px">${contextRows}</table>` : "") +
|
||||
`<p style="margin-top:16px;color:#888;font-size:12px">` +
|
||||
`Sent by ${escapeHtml(env.HOTEL_NAME)} · <a href="${env.APP_URL}/admin/alerts">view alerts</a></p>`;
|
||||
const subject = `[${env.HOTEL_NAME}] ${String(input.severity).toUpperCase()} · ${input.type}`;
|
||||
const contextRows = input.context
|
||||
? Object.entries(input.context)
|
||||
.map(
|
||||
([k, v]) =>
|
||||
`<tr><td style="padding:4px 10px;font-weight:600">${escapeHtml(String(k))}</td>` +
|
||||
`<td style="padding:4px 10px">${escapeHtml(String(v ?? ""))}</td></tr>`,
|
||||
)
|
||||
.join("")
|
||||
: "";
|
||||
const html =
|
||||
`<h2 style="margin:0 0 8px">${escapeHtml(input.type)} — ${escapeHtml(String(input.severity))}</h2>` +
|
||||
`<p style="margin:0 0 12px">${escapeHtml(input.message)}</p>` +
|
||||
(contextRows
|
||||
? `<table style="border-collapse:collapse;font-size:13px">${contextRows}</table>`
|
||||
: "") +
|
||||
`<p style="margin-top:16px;color:#888;font-size:12px">` +
|
||||
`Sent by ${escapeHtml(env.HOTEL_NAME)} · <a href="${env.APP_URL}/admin/alerts">view alerts</a></p>`;
|
||||
|
||||
try {
|
||||
return await sendMail(to, subject, html);
|
||||
} catch (e) {
|
||||
logger.error("Staff email failed", { module: "alert", error: (e as Error).message });
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return await sendMail(to, subject, html);
|
||||
} catch (e) {
|
||||
logger.error("Staff email failed", {
|
||||
module: "alert",
|
||||
error: (e as Error).message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,33 +181,41 @@ async function emailStaff(input: SendAlertInput): Promise<boolean> {
|
||||
* does not block the others. The returned result reports which channels
|
||||
* succeeded (also reflected in the alert_logs row's sent_via_* flags).
|
||||
*/
|
||||
export async function sendAlert(input: SendAlertInput): Promise<SendAlertResult> {
|
||||
// Fan out Discord + email first so we can record their outcome on the row.
|
||||
const [sentViaDiscord, sentViaEmail] = await Promise.all([postDiscord(input), emailStaff(input)]);
|
||||
export async function sendAlert(
|
||||
input: SendAlertInput,
|
||||
): Promise<SendAlertResult> {
|
||||
// Fan out Discord + email first so we can record their outcome on the row.
|
||||
const [sentViaDiscord, sentViaEmail] = await Promise.all([
|
||||
postDiscord(input),
|
||||
emailStaff(input),
|
||||
]);
|
||||
|
||||
let logged = false;
|
||||
try {
|
||||
await prisma.alertLogs.create({
|
||||
data: {
|
||||
type: input.type.slice(0, 255),
|
||||
severity: String(input.severity).slice(0, 255),
|
||||
message: input.message,
|
||||
context: input.context ? (input.context as object) : undefined,
|
||||
sentViaDiscord,
|
||||
sentViaEmail,
|
||||
isRead: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
logged = true;
|
||||
} catch (e) {
|
||||
// DB unreachable / schema drift: keep the alert best-effort. We already
|
||||
// notified Discord/email above, so the alert isn't lost.
|
||||
logger.error("Failed to persist alert_logs row", { module: "alert", error: (e as Error).message });
|
||||
}
|
||||
let logged = false;
|
||||
try {
|
||||
await prisma.alertLogs.create({
|
||||
data: {
|
||||
type: input.type.slice(0, 255),
|
||||
severity: String(input.severity).slice(0, 255),
|
||||
message: input.message,
|
||||
context: input.context ? (input.context as object) : undefined,
|
||||
sentViaDiscord,
|
||||
sentViaEmail,
|
||||
isRead: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
logged = true;
|
||||
} catch (e) {
|
||||
// DB unreachable / schema drift: keep the alert best-effort. We already
|
||||
// notified Discord/email above, so the alert isn't lost.
|
||||
logger.error("Failed to persist alert_logs row", {
|
||||
module: "alert",
|
||||
error: (e as Error).message,
|
||||
});
|
||||
}
|
||||
|
||||
return { logged, sentViaDiscord, sentViaEmail };
|
||||
return { logged, sentViaDiscord, sentViaEmail };
|
||||
}
|
||||
|
||||
// === Helpers =====================================================================
|
||||
@@ -194,29 +225,32 @@ export async function sendAlert(input: SendAlertInput): Promise<SendAlertResult>
|
||||
* (e.g. raised by a health-check cron when the RCON socket can't connect).
|
||||
*/
|
||||
export function emulatorOffline(detail?: string): Promise<SendAlertResult> {
|
||||
return sendAlert({
|
||||
type: "emulator",
|
||||
severity: "critical",
|
||||
message: detail
|
||||
? `Emulator appears offline: ${detail}`
|
||||
: "Emulator appears offline — RCON connection could not be established.",
|
||||
context: {
|
||||
rconHost: env.RCON_HOST,
|
||||
rconPort: env.RCON_PORT,
|
||||
...(detail ? { detail } : {}),
|
||||
},
|
||||
});
|
||||
return sendAlert({
|
||||
type: "emulator",
|
||||
severity: "critical",
|
||||
message: detail
|
||||
? `Emulator appears offline: ${detail}`
|
||||
: "Emulator appears offline — RCON connection could not be established.",
|
||||
context: {
|
||||
rconHost: env.RCON_HOST,
|
||||
rconPort: env.RCON_PORT,
|
||||
...(detail ? { detail } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Raise a WARNING alert that a possible DDoS / abusive request pattern was
|
||||
* detected from a single IP (count = requests seen in the sampling window).
|
||||
*/
|
||||
export function ddosDetected(ip: string, count: number): Promise<SendAlertResult> {
|
||||
return sendAlert({
|
||||
type: "ddos",
|
||||
severity: count >= 1000 ? "critical" : "warning",
|
||||
message: `Possible DDoS detected from ${ip} — ${count} requests in window.`,
|
||||
context: { ip, count },
|
||||
});
|
||||
export function ddosDetected(
|
||||
ip: string,
|
||||
count: number,
|
||||
): Promise<SendAlertResult> {
|
||||
return sendAlert({
|
||||
type: "ddos",
|
||||
severity: count >= 1000 ? "critical" : "warning",
|
||||
message: `Possible DDoS detected from ${ip} — ${count} requests in window.`,
|
||||
context: { ip, count },
|
||||
});
|
||||
}
|
||||
+87
-81
@@ -1,114 +1,120 @@
|
||||
import { prisma } from "../prisma";
|
||||
|
||||
interface AuditEntry {
|
||||
userId: number;
|
||||
action: string;
|
||||
target: string;
|
||||
targetId?: number;
|
||||
before?: Record<string, unknown>;
|
||||
after?: Record<string, unknown>;
|
||||
userId: number;
|
||||
action: string;
|
||||
target: string;
|
||||
targetId?: number;
|
||||
before?: Record<string, unknown>;
|
||||
after?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const SENSITIVE_KEY_RE =
|
||||
/password|secret|token|otp|recovery|authTicket|two_factor|two_factor_secret|api_key/i;
|
||||
/password|secret|token|otp|recovery|authTicket|two_factor|two_factor_secret|api_key/i;
|
||||
const REDACTED = "[Redacted]";
|
||||
|
||||
function sanitizeAuditPayload(value: unknown, depth = 0): unknown {
|
||||
if (depth > 6 || value == null) return value;
|
||||
if (Array.isArray(value)) return value.map((v) => sanitizeAuditPayload(v, depth + 1));
|
||||
if (typeof value !== "object") return value;
|
||||
if (depth > 6 || value == null) return value;
|
||||
if (Array.isArray(value))
|
||||
return value.map((v) => sanitizeAuditPayload(v, depth + 1));
|
||||
if (typeof value !== "object") return value;
|
||||
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[key] = SENSITIVE_KEY_RE.test(key) ? REDACTED : sanitizeAuditPayload(val, depth + 1);
|
||||
}
|
||||
return out;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[key] = SENSITIVE_KEY_RE.test(key)
|
||||
? REDACTED
|
||||
: sanitizeAuditPayload(val, depth + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function computeDiff(
|
||||
before?: Record<string, unknown>,
|
||||
after?: Record<string, unknown>,
|
||||
before?: Record<string, unknown>,
|
||||
after?: Record<string, unknown>,
|
||||
): Record<string, { from: unknown; to: unknown }> | null {
|
||||
if (!before || !after) return null;
|
||||
if (!before || !after) return null;
|
||||
|
||||
const diff: Record<string, { from: unknown; to: unknown }> = {};
|
||||
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
||||
const diff: Record<string, { from: unknown; to: unknown }> = {};
|
||||
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
|
||||
diff[key] = { from: before[key], to: after[key] };
|
||||
}
|
||||
}
|
||||
for (const key of allKeys) {
|
||||
if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
|
||||
diff[key] = { from: before[key], to: after[key] };
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(diff).length > 0 ? diff : null;
|
||||
return Object.keys(diff).length > 0 ? diff : null;
|
||||
}
|
||||
|
||||
export async function logAudit(entry: AuditEntry): Promise<void> {
|
||||
const sanitizedBefore = entry.before
|
||||
? (sanitizeAuditPayload(entry.before) as Record<string, unknown>)
|
||||
: undefined;
|
||||
const sanitizedAfter = entry.after
|
||||
? (sanitizeAuditPayload(entry.after) as Record<string, unknown>)
|
||||
: undefined;
|
||||
const diff = computeDiff(sanitizedBefore, sanitizedAfter);
|
||||
const sanitizedBefore = entry.before
|
||||
? (sanitizeAuditPayload(entry.before) as Record<string, unknown>)
|
||||
: undefined;
|
||||
const sanitizedAfter = entry.after
|
||||
? (sanitizeAuditPayload(entry.after) as Record<string, unknown>)
|
||||
: undefined;
|
||||
const diff = computeDiff(sanitizedBefore, sanitizedAfter);
|
||||
|
||||
await prisma.adminAuditLog.create({
|
||||
data: {
|
||||
userId: entry.userId,
|
||||
action: entry.action,
|
||||
target: entry.target,
|
||||
targetId: entry.targetId,
|
||||
before: sanitizedBefore ? JSON.stringify(sanitizedBefore) : null,
|
||||
after: sanitizedAfter ? JSON.stringify(sanitizedAfter) : null,
|
||||
diff: diff ? JSON.stringify(diff) : null,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
await prisma.adminAuditLog.create({
|
||||
data: {
|
||||
userId: entry.userId,
|
||||
action: entry.action,
|
||||
target: entry.target,
|
||||
targetId: entry.targetId,
|
||||
before: sanitizedBefore ? JSON.stringify(sanitizedBefore) : null,
|
||||
after: sanitizedAfter ? JSON.stringify(sanitizedAfter) : null,
|
||||
diff: diff ? JSON.stringify(diff) : null,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
interface GetLogsOptions {
|
||||
search?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
search?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
export async function getAuditLogs(options: GetLogsOptions = {}) {
|
||||
const { search, page = 1, perPage = 20 } = options;
|
||||
const skip = (page - 1) * perPage;
|
||||
const { search, page = 1, perPage = 20 } = options;
|
||||
const skip = (page - 1) * perPage;
|
||||
|
||||
const where = search
|
||||
? {
|
||||
OR: [{ action: { contains: search } }, { target: { contains: search } }],
|
||||
}
|
||||
: {};
|
||||
const where = search
|
||||
? {
|
||||
OR: [
|
||||
{ action: { contains: search } },
|
||||
{ target: { contains: search } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.adminAuditLog.findMany({
|
||||
where,
|
||||
orderBy: { id: "desc" },
|
||||
skip,
|
||||
take: perPage,
|
||||
}),
|
||||
prisma.adminAuditLog.count({ where }),
|
||||
]);
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.adminAuditLog.findMany({
|
||||
where,
|
||||
orderBy: { id: "desc" },
|
||||
skip,
|
||||
take: perPage,
|
||||
}),
|
||||
prisma.adminAuditLog.count({ where }),
|
||||
]);
|
||||
|
||||
const userIds = [...new Set(rows.map((r) => r.userId))];
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
const userMap = new Map(users.map((u) => [u.id, u.username]));
|
||||
const userIds = [...new Set(rows.map((r) => r.userId))];
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
const userMap = new Map(users.map((u) => [u.id, u.username]));
|
||||
|
||||
const enrichedRows = rows.map((r) => ({
|
||||
...r,
|
||||
username: userMap.get(r.userId) ?? `User #${r.userId}`,
|
||||
}));
|
||||
const enrichedRows = rows.map((r) => ({
|
||||
...r,
|
||||
username: userMap.get(r.userId) ?? `User #${r.userId}`,
|
||||
}));
|
||||
|
||||
return {
|
||||
rows: enrichedRows,
|
||||
total,
|
||||
page,
|
||||
perPage,
|
||||
lastPage: Math.ceil(total / perPage),
|
||||
};
|
||||
return {
|
||||
rows: enrichedRows,
|
||||
total,
|
||||
page,
|
||||
perPage,
|
||||
lastPage: Math.ceil(total / perPage),
|
||||
};
|
||||
}
|
||||
+55
-48
@@ -13,64 +13,71 @@ import { siteSettings } from "@/lib/services/site-settings";
|
||||
* recaptcha_secret / recaptcha_site_key
|
||||
*/
|
||||
export interface CaptchaConfig {
|
||||
provider: "turnstile" | "recaptcha" | "none";
|
||||
siteKey: string;
|
||||
/** Form field the widget writes the token into. */
|
||||
field: string;
|
||||
provider: "turnstile" | "recaptcha" | "none";
|
||||
siteKey: string;
|
||||
/** Form field the widget writes the token into. */
|
||||
field: string;
|
||||
}
|
||||
|
||||
const TURNSTILE_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
||||
const TURNSTILE_URL =
|
||||
"https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
||||
const RECAPTCHA_URL = "https://www.google.com/recaptcha/api/siteverify";
|
||||
|
||||
/** Public config the register page needs to render the widget (no secrets). */
|
||||
export async function captchaConfig(): Promise<CaptchaConfig> {
|
||||
const provider = ((await siteSettings.get("captcha_provider", "none")) ?? "none").toLowerCase();
|
||||
if (provider === "turnstile") {
|
||||
return {
|
||||
provider: "turnstile",
|
||||
siteKey: (await siteSettings.get("turnstile_site_key", "")) ?? "",
|
||||
field: "cf-turnstile-response",
|
||||
};
|
||||
}
|
||||
if (provider === "recaptcha") {
|
||||
return {
|
||||
provider: "recaptcha",
|
||||
siteKey: (await siteSettings.get("recaptcha_site_key", "")) ?? "",
|
||||
field: "g-recaptcha-response",
|
||||
};
|
||||
}
|
||||
return { provider: "none", siteKey: "", field: "" };
|
||||
const provider = (
|
||||
(await siteSettings.get("captcha_provider", "none")) ?? "none"
|
||||
).toLowerCase();
|
||||
if (provider === "turnstile") {
|
||||
return {
|
||||
provider: "turnstile",
|
||||
siteKey: (await siteSettings.get("turnstile_site_key", "")) ?? "",
|
||||
field: "cf-turnstile-response",
|
||||
};
|
||||
}
|
||||
if (provider === "recaptcha") {
|
||||
return {
|
||||
provider: "recaptcha",
|
||||
siteKey: (await siteSettings.get("recaptcha_site_key", "")) ?? "",
|
||||
field: "g-recaptcha-response",
|
||||
};
|
||||
}
|
||||
return { provider: "none", siteKey: "", field: "" };
|
||||
}
|
||||
|
||||
/** Verify a submitted token. Returns true when allowed (incl. fail-open). */
|
||||
export async function verifyCaptcha(token: string | null, remoteIp?: string): Promise<boolean> {
|
||||
const cfg = await captchaConfig();
|
||||
if (cfg.provider === "none" || !cfg.siteKey) return true;
|
||||
export async function verifyCaptcha(
|
||||
token: string | null,
|
||||
remoteIp?: string,
|
||||
): Promise<boolean> {
|
||||
const cfg = await captchaConfig();
|
||||
if (cfg.provider === "none" || !cfg.siteKey) return true;
|
||||
|
||||
const secretKey = cfg.provider === "turnstile" ? "turnstile_secret" : "recaptcha_secret";
|
||||
const secret = (await siteSettings.get(secretKey, "")) ?? "";
|
||||
if (!secret) return true; // configured but no secret — don't hard-block
|
||||
if (!token) return false;
|
||||
const secretKey =
|
||||
cfg.provider === "turnstile" ? "turnstile_secret" : "recaptcha_secret";
|
||||
const secret = (await siteSettings.get(secretKey, "")) ?? "";
|
||||
if (!secret) return true; // configured but no secret — don't hard-block
|
||||
if (!token) return false;
|
||||
|
||||
const url = cfg.provider === "turnstile" ? TURNSTILE_URL : RECAPTCHA_URL;
|
||||
const body = new URLSearchParams({ secret, response: token });
|
||||
if (remoteIp) body.set("remoteip", remoteIp);
|
||||
const url = cfg.provider === "turnstile" ? TURNSTILE_URL : RECAPTCHA_URL;
|
||||
const body = new URLSearchParams({ secret, response: token });
|
||||
if (remoteIp) body.set("remoteip", remoteIp);
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 5000);
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
clearTimeout(timer);
|
||||
const data = (await res.json()) as { success?: boolean };
|
||||
return data?.success === true;
|
||||
} catch {
|
||||
// Network/timeout — fail-open so a provider outage can't lock out signups.
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 5000);
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
clearTimeout(timer);
|
||||
const data = (await res.json()) as { success?: boolean };
|
||||
return data?.success === true;
|
||||
} catch {
|
||||
// Network/timeout — fail-open so a provider outage can't lock out signups.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -5,170 +5,191 @@ import { getFurnitureDataPath } from "@/lib/services/furni-data";
|
||||
|
||||
// Cache FurnitureData.json in memory with TTL to avoid repeated disk I/O
|
||||
let furniDataCache: {
|
||||
roomitemtypes?: {
|
||||
furnitype?: Array<{ id: number; description?: string; classname?: string; revision?: number }>;
|
||||
};
|
||||
wallitemtypes?: {
|
||||
furnitype?: Array<{ id: number; description?: string; classname?: string; revision?: number }>;
|
||||
};
|
||||
roomitemtypes?: {
|
||||
furnitype?: Array<{
|
||||
id: number;
|
||||
description?: string;
|
||||
classname?: string;
|
||||
revision?: number;
|
||||
}>;
|
||||
};
|
||||
wallitemtypes?: {
|
||||
furnitype?: Array<{
|
||||
id: number;
|
||||
description?: string;
|
||||
classname?: string;
|
||||
revision?: number;
|
||||
}>;
|
||||
};
|
||||
} | null = null;
|
||||
let furniDataCacheTime = 0;
|
||||
const FURNI_CACHE_TTL = 30_000; // 30 seconds
|
||||
|
||||
async function getFurnitureData() {
|
||||
if (furniDataCache && Date.now() - furniDataCacheTime < FURNI_CACHE_TTL) return furniDataCache;
|
||||
const furniDataPath = await getFurnitureDataPath();
|
||||
try {
|
||||
const raw = await fs.readFile(furniDataPath, "utf-8");
|
||||
furniDataCache = JSON.parse(raw);
|
||||
furniDataCacheTime = Date.now();
|
||||
return furniDataCache;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (furniDataCache && Date.now() - furniDataCacheTime < FURNI_CACHE_TTL)
|
||||
return furniDataCache;
|
||||
const furniDataPath = await getFurnitureDataPath();
|
||||
try {
|
||||
const raw = await fs.readFile(furniDataPath, "utf-8");
|
||||
furniDataCache = JSON.parse(raw);
|
||||
furniDataCacheTime = Date.now();
|
||||
return furniDataCache;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Invalidate the FurnitureData cache after writes */
|
||||
export function invalidateFurniDataCache() {
|
||||
furniDataCache = null;
|
||||
furniDataCacheTime = 0;
|
||||
furniDataCache = null;
|
||||
furniDataCacheTime = 0;
|
||||
}
|
||||
|
||||
interface RawItem {
|
||||
id: number;
|
||||
itemIds: string;
|
||||
pageId: number;
|
||||
offerId: number;
|
||||
songId: number;
|
||||
orderNumber: number;
|
||||
catalogName: string;
|
||||
costCredits: number;
|
||||
costPoints: number;
|
||||
pointsType: number;
|
||||
amount: number;
|
||||
limitedSells: number;
|
||||
limitedStack: number;
|
||||
extradata: string;
|
||||
haveOffer: string;
|
||||
clubOnly: string;
|
||||
id: number;
|
||||
itemIds: string;
|
||||
pageId: number;
|
||||
offerId: number;
|
||||
songId: number;
|
||||
orderNumber: number;
|
||||
catalogName: string;
|
||||
costCredits: number;
|
||||
costPoints: number;
|
||||
pointsType: number;
|
||||
amount: number;
|
||||
limitedSells: number;
|
||||
limitedStack: number;
|
||||
extradata: string;
|
||||
haveOffer: string;
|
||||
clubOnly: string;
|
||||
}
|
||||
|
||||
interface BaseItem {
|
||||
id: number;
|
||||
spriteId: number;
|
||||
publicName: string;
|
||||
itemName: string;
|
||||
type: string;
|
||||
width: number;
|
||||
length: number;
|
||||
stackHeight: number;
|
||||
allowStack: number;
|
||||
allowSit: number;
|
||||
allowLay: number;
|
||||
allowWalk: number;
|
||||
allowGift: number;
|
||||
allowTrade: number;
|
||||
allowRecycle: number;
|
||||
allowMarketplaceSell: number;
|
||||
allowInventoryStack: number;
|
||||
interactionType: string;
|
||||
interactionModesCount: number;
|
||||
vendingIds: string;
|
||||
customparams: string;
|
||||
effectIdMale: number;
|
||||
effectIdFemale: number;
|
||||
clothingOnWalk: string;
|
||||
id: number;
|
||||
spriteId: number;
|
||||
publicName: string;
|
||||
itemName: string;
|
||||
type: string;
|
||||
width: number;
|
||||
length: number;
|
||||
stackHeight: number;
|
||||
allowStack: number;
|
||||
allowSit: number;
|
||||
allowLay: number;
|
||||
allowWalk: number;
|
||||
allowGift: number;
|
||||
allowTrade: number;
|
||||
allowRecycle: number;
|
||||
allowMarketplaceSell: number;
|
||||
allowInventoryStack: number;
|
||||
interactionType: string;
|
||||
interactionModesCount: number;
|
||||
vendingIds: string;
|
||||
customparams: string;
|
||||
effectIdMale: number;
|
||||
effectIdFemale: number;
|
||||
clothingOnWalk: string;
|
||||
}
|
||||
|
||||
export interface CatalogItemEnriched {
|
||||
id: number;
|
||||
catalogName: string;
|
||||
itemIds: string;
|
||||
costCredits: number;
|
||||
costPoints: number;
|
||||
pointsType: number;
|
||||
amount: number;
|
||||
limitedSells: number;
|
||||
limitedStack: number;
|
||||
orderNumber: number;
|
||||
offerId: number;
|
||||
songId: number;
|
||||
haveOffer: string;
|
||||
clubOnly: string;
|
||||
extradata: string;
|
||||
baseName: string;
|
||||
baseItemName: string;
|
||||
spriteId: number;
|
||||
baseItem: BaseItem | null;
|
||||
id: number;
|
||||
catalogName: string;
|
||||
itemIds: string;
|
||||
costCredits: number;
|
||||
costPoints: number;
|
||||
pointsType: number;
|
||||
amount: number;
|
||||
limitedSells: number;
|
||||
limitedStack: number;
|
||||
orderNumber: number;
|
||||
offerId: number;
|
||||
songId: number;
|
||||
haveOffer: string;
|
||||
clubOnly: string;
|
||||
extradata: string;
|
||||
baseName: string;
|
||||
baseItemName: string;
|
||||
spriteId: number;
|
||||
baseItem: BaseItem | null;
|
||||
}
|
||||
|
||||
export interface CatalogItemsData {
|
||||
items: CatalogItemEnriched[];
|
||||
baseItems: { id: number; publicName: string; itemName: string; spriteId: number; type: string }[];
|
||||
catalogNameMap: Record<number, string>;
|
||||
furniDataIdList: number[];
|
||||
furniDescriptionMap: Record<number, string>;
|
||||
furniRevisionMap: Record<number, { classname: string; revision: number }>;
|
||||
interactionTypes: string[];
|
||||
allPages: { id: number; caption: string }[];
|
||||
items: CatalogItemEnriched[];
|
||||
baseItems: {
|
||||
id: number;
|
||||
publicName: string;
|
||||
itemName: string;
|
||||
spriteId: number;
|
||||
type: string;
|
||||
}[];
|
||||
catalogNameMap: Record<number, string>;
|
||||
furniDataIdList: number[];
|
||||
furniDescriptionMap: Record<number, string>;
|
||||
furniRevisionMap: Record<number, { classname: string; revision: number }>;
|
||||
interactionTypes: string[];
|
||||
allPages: { id: number; caption: string }[];
|
||||
}
|
||||
|
||||
export async function loadCatalogItemsData(pageId: number): Promise<CatalogItemsData> {
|
||||
// Load items via raw query to work around pageId Int vs VARCHAR mismatch
|
||||
const pageIdStr = String(pageId);
|
||||
const rawItems = await prisma.$queryRaw<Array<Record<string, unknown>>>`
|
||||
export async function loadCatalogItemsData(
|
||||
pageId: number,
|
||||
): Promise<CatalogItemsData> {
|
||||
// Load items via raw query to work around pageId Int vs VARCHAR mismatch
|
||||
const pageIdStr = String(pageId);
|
||||
const rawItems = await prisma.$queryRaw<Array<Record<string, unknown>>>`
|
||||
SELECT * FROM catalog_items WHERE page_id = ${pageIdStr} ORDER BY id ASC
|
||||
`;
|
||||
const items: RawItem[] = rawItems.map((r: Record<string, unknown>) => ({
|
||||
id: Number(r.id),
|
||||
itemIds: String(r.item_ids ?? ""),
|
||||
pageId: Number(r.page_id) || 0,
|
||||
offerId: Number(r.offer_id) || -1,
|
||||
songId: Number(r.song_id) || 0,
|
||||
orderNumber: Number(r.order_number) || 99,
|
||||
catalogName: String(r.catalog_name ?? ""),
|
||||
costCredits: Number(r.cost_credits) || 0,
|
||||
costPoints: Number(r.cost_points) || 0,
|
||||
pointsType: Number(r.points_type) || 0,
|
||||
amount: Number(r.amount) || 1,
|
||||
limitedSells: Number(r.limited_sells) || 0,
|
||||
limitedStack: Number(r.limited_stack) || 0,
|
||||
extradata: String(r.extradata ?? ""),
|
||||
haveOffer: String(r.have_offer ?? "1"),
|
||||
clubOnly: String(r.club_only ?? "0"),
|
||||
}));
|
||||
const items: RawItem[] = rawItems.map((r: Record<string, unknown>) => ({
|
||||
id: Number(r.id),
|
||||
itemIds: String(r.item_ids ?? ""),
|
||||
pageId: Number(r.page_id) || 0,
|
||||
offerId: Number(r.offer_id) || -1,
|
||||
songId: Number(r.song_id) || 0,
|
||||
orderNumber: Number(r.order_number) || 99,
|
||||
catalogName: String(r.catalog_name ?? ""),
|
||||
costCredits: Number(r.cost_credits) || 0,
|
||||
costPoints: Number(r.cost_points) || 0,
|
||||
pointsType: Number(r.points_type) || 0,
|
||||
amount: Number(r.amount) || 1,
|
||||
limitedSells: Number(r.limited_sells) || 0,
|
||||
limitedStack: Number(r.limited_stack) || 0,
|
||||
extradata: String(r.extradata ?? ""),
|
||||
haveOffer: String(r.have_offer ?? "1"),
|
||||
clubOnly: String(r.club_only ?? "0"),
|
||||
}));
|
||||
|
||||
const [allPages, interactionTypesRaw] = await Promise.all([
|
||||
prisma.catalogPages.findMany({
|
||||
orderBy: { caption: "asc" },
|
||||
select: { id: true, caption: true },
|
||||
}),
|
||||
prisma.$queryRaw<Array<{ interaction_type: string }>>`
|
||||
const [allPages, interactionTypesRaw] = await Promise.all([
|
||||
prisma.catalogPages.findMany({
|
||||
orderBy: { caption: "asc" },
|
||||
select: { id: true, caption: true },
|
||||
}),
|
||||
prisma.$queryRaw<Array<{ interaction_type: string }>>`
|
||||
SELECT DISTINCT interaction_type FROM items_base ORDER BY interaction_type ASC
|
||||
`,
|
||||
]);
|
||||
]);
|
||||
|
||||
const interactionTypes = interactionTypesRaw.map((r) => String(r.interaction_type));
|
||||
const interactionTypes = interactionTypesRaw.map((r) =>
|
||||
String(r.interaction_type),
|
||||
);
|
||||
|
||||
// Resolve item names from items_base for enrichment + translate
|
||||
const itemIdStrings = items.map((i) => i.itemIds).filter(Boolean);
|
||||
const baseItemIds = [
|
||||
...new Set(
|
||||
itemIdStrings.flatMap((s) =>
|
||||
s
|
||||
.split(";")
|
||||
.map(Number)
|
||||
.filter((n) => n > 0),
|
||||
),
|
||||
),
|
||||
];
|
||||
// Resolve item names from items_base for enrichment + translate
|
||||
const itemIdStrings = items.map((i) => i.itemIds).filter(Boolean);
|
||||
const baseItemIds = [
|
||||
...new Set(
|
||||
itemIdStrings.flatMap((s) =>
|
||||
s
|
||||
.split(";")
|
||||
.map(Number)
|
||||
.filter((n) => n > 0),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
// Raw query to work around ENUM('0','1') columns returning numeric values.
|
||||
// baseItemIds is built from numeric splits + filter(n > 0); joined via Prisma.join.
|
||||
const baseItems: BaseItem[] =
|
||||
baseItemIds.length > 0
|
||||
? (
|
||||
await prisma.$queryRaw<Array<Record<string, unknown>>>`
|
||||
// Raw query to work around ENUM('0','1') columns returning numeric values.
|
||||
// baseItemIds is built from numeric splits + filter(n > 0); joined via Prisma.join.
|
||||
const baseItems: BaseItem[] =
|
||||
baseItemIds.length > 0
|
||||
? (
|
||||
await prisma.$queryRaw<Array<Record<string, unknown>>>`
|
||||
SELECT id, sprite_id, public_name, item_name, type, width, length,
|
||||
stack_height, allow_stack, allow_sit, allow_lay, allow_walk,
|
||||
allow_gift, allow_trade, allow_recycle, allow_marketplace_sell,
|
||||
@@ -177,120 +198,123 @@ export async function loadCatalogItemsData(pageId: number): Promise<CatalogItems
|
||||
clothing_on_walk
|
||||
FROM items_base WHERE id IN (${Prisma.join(baseItemIds)})
|
||||
`
|
||||
).map((r) => ({
|
||||
id: Number(r.id),
|
||||
spriteId: Number(r.sprite_id),
|
||||
publicName: String(r.public_name ?? ""),
|
||||
itemName: String(r.item_name ?? ""),
|
||||
type: String(r.type ?? "s"),
|
||||
width: Number(r.width) || 1,
|
||||
length: Number(r.length) || 1,
|
||||
stackHeight: Number(r.stack_height) || 0,
|
||||
allowStack: Number(r.allow_stack ?? 0),
|
||||
allowSit: Number(r.allow_sit ?? 0),
|
||||
allowLay: Number(r.allow_lay ?? 0),
|
||||
allowWalk: Number(r.allow_walk ?? 0),
|
||||
allowGift: Number(r.allow_gift ?? 1),
|
||||
allowTrade: Number(r.allow_trade ?? 1),
|
||||
allowRecycle: Number(r.allow_recycle ?? 0),
|
||||
allowMarketplaceSell: Number(r.allow_marketplace_sell ?? 0),
|
||||
allowInventoryStack: Number(r.allow_inventory_stack ?? 1),
|
||||
interactionType: String(r.interaction_type ?? "default"),
|
||||
interactionModesCount: Number(r.interaction_modes_count) || 2,
|
||||
vendingIds: String(r.vending_ids ?? "0"),
|
||||
customparams: String(r.customparams ?? ""),
|
||||
effectIdMale: Number(r.effect_id_male) || 0,
|
||||
effectIdFemale: Number(r.effect_id_female) || 0,
|
||||
clothingOnWalk: String(r.clothing_on_walk ?? ""),
|
||||
}))
|
||||
: [];
|
||||
).map((r) => ({
|
||||
id: Number(r.id),
|
||||
spriteId: Number(r.sprite_id),
|
||||
publicName: String(r.public_name ?? ""),
|
||||
itemName: String(r.item_name ?? ""),
|
||||
type: String(r.type ?? "s"),
|
||||
width: Number(r.width) || 1,
|
||||
length: Number(r.length) || 1,
|
||||
stackHeight: Number(r.stack_height) || 0,
|
||||
allowStack: Number(r.allow_stack ?? 0),
|
||||
allowSit: Number(r.allow_sit ?? 0),
|
||||
allowLay: Number(r.allow_lay ?? 0),
|
||||
allowWalk: Number(r.allow_walk ?? 0),
|
||||
allowGift: Number(r.allow_gift ?? 1),
|
||||
allowTrade: Number(r.allow_trade ?? 1),
|
||||
allowRecycle: Number(r.allow_recycle ?? 0),
|
||||
allowMarketplaceSell: Number(r.allow_marketplace_sell ?? 0),
|
||||
allowInventoryStack: Number(r.allow_inventory_stack ?? 1),
|
||||
interactionType: String(r.interaction_type ?? "default"),
|
||||
interactionModesCount: Number(r.interaction_modes_count) || 2,
|
||||
vendingIds: String(r.vending_ids ?? "0"),
|
||||
customparams: String(r.customparams ?? ""),
|
||||
effectIdMale: Number(r.effect_id_male) || 0,
|
||||
effectIdFemale: Number(r.effect_id_female) || 0,
|
||||
clothingOnWalk: String(r.clothing_on_walk ?? ""),
|
||||
}))
|
||||
: [];
|
||||
|
||||
const baseItemMap = Object.fromEntries(baseItems.map((b) => [b.id, b]));
|
||||
const baseItemMap = Object.fromEntries(baseItems.map((b) => [b.id, b]));
|
||||
|
||||
// Build mapping: baseItemId -> catalogName
|
||||
const catalogNameMap: Record<number, string> = {};
|
||||
for (const item of items) {
|
||||
const firstBaseId = item.itemIds
|
||||
.split(";")
|
||||
.map(Number)
|
||||
.find((n) => n > 0);
|
||||
if (firstBaseId && !catalogNameMap[firstBaseId]) {
|
||||
catalogNameMap[firstBaseId] = item.catalogName;
|
||||
}
|
||||
}
|
||||
// Build mapping: baseItemId -> catalogName
|
||||
const catalogNameMap: Record<number, string> = {};
|
||||
for (const item of items) {
|
||||
const firstBaseId = item.itemIds
|
||||
.split(";")
|
||||
.map(Number)
|
||||
.find((n) => n > 0);
|
||||
if (firstBaseId && !catalogNameMap[firstBaseId]) {
|
||||
catalogNameMap[firstBaseId] = item.catalogName;
|
||||
}
|
||||
}
|
||||
|
||||
const catalogItems: CatalogItemEnriched[] = items.map((item) => {
|
||||
const firstBaseId = item.itemIds
|
||||
.split(";")
|
||||
.map(Number)
|
||||
.find((n) => n > 0);
|
||||
const base = firstBaseId ? baseItemMap[firstBaseId] : undefined;
|
||||
return {
|
||||
id: item.id,
|
||||
catalogName: item.catalogName,
|
||||
itemIds: item.itemIds,
|
||||
costCredits: item.costCredits,
|
||||
costPoints: item.costPoints,
|
||||
pointsType: item.pointsType,
|
||||
amount: item.amount,
|
||||
limitedSells: item.limitedSells,
|
||||
limitedStack: item.limitedStack,
|
||||
orderNumber: item.orderNumber,
|
||||
offerId: item.offerId,
|
||||
songId: item.songId,
|
||||
haveOffer: item.haveOffer,
|
||||
clubOnly: item.clubOnly,
|
||||
extradata: item.extradata,
|
||||
baseName: base?.publicName || base?.itemName || "",
|
||||
baseItemName: base?.itemName || "",
|
||||
spriteId: base?.spriteId ?? 0,
|
||||
baseItem: base ? { ...base } : null,
|
||||
};
|
||||
});
|
||||
const catalogItems: CatalogItemEnriched[] = items.map((item) => {
|
||||
const firstBaseId = item.itemIds
|
||||
.split(";")
|
||||
.map(Number)
|
||||
.find((n) => n > 0);
|
||||
const base = firstBaseId ? baseItemMap[firstBaseId] : undefined;
|
||||
return {
|
||||
id: item.id,
|
||||
catalogName: item.catalogName,
|
||||
itemIds: item.itemIds,
|
||||
costCredits: item.costCredits,
|
||||
costPoints: item.costPoints,
|
||||
pointsType: item.pointsType,
|
||||
amount: item.amount,
|
||||
limitedSells: item.limitedSells,
|
||||
limitedStack: item.limitedStack,
|
||||
orderNumber: item.orderNumber,
|
||||
offerId: item.offerId,
|
||||
songId: item.songId,
|
||||
haveOffer: item.haveOffer,
|
||||
clubOnly: item.clubOnly,
|
||||
extradata: item.extradata,
|
||||
baseName: base?.publicName || base?.itemName || "",
|
||||
baseItemName: base?.itemName || "",
|
||||
spriteId: base?.spriteId ?? 0,
|
||||
baseItem: base ? { ...base } : null,
|
||||
};
|
||||
});
|
||||
|
||||
// Build spriteId -> items_base.id mapping for FurnitureData.json lookup
|
||||
const spriteToBaseId = new Map<number, number>();
|
||||
for (const b of baseItems) {
|
||||
spriteToBaseId.set(b.spriteId, b.id);
|
||||
}
|
||||
// Build spriteId -> items_base.id mapping for FurnitureData.json lookup
|
||||
const spriteToBaseId = new Map<number, number>();
|
||||
for (const b of baseItems) {
|
||||
spriteToBaseId.set(b.spriteId, b.id);
|
||||
}
|
||||
|
||||
// Read FurnitureData.json (cached in memory)
|
||||
const foundBaseIds = new Set<number>();
|
||||
const furniDescriptionMap: Record<number, string> = {};
|
||||
const furniRevisionMap: Record<number, { classname: string; revision: number }> = {};
|
||||
const furniData = await getFurnitureData();
|
||||
if (furniData) {
|
||||
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
||||
if (furniData[section]?.furnitype) {
|
||||
for (const item of furniData[section].furnitype) {
|
||||
const baseId = spriteToBaseId.get(item.id);
|
||||
if (baseId !== undefined) {
|
||||
foundBaseIds.add(baseId);
|
||||
furniDescriptionMap[baseId] = item.description ?? "";
|
||||
furniRevisionMap[baseId] = {
|
||||
classname: item.classname ?? "",
|
||||
revision: item.revision ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Read FurnitureData.json (cached in memory)
|
||||
const foundBaseIds = new Set<number>();
|
||||
const furniDescriptionMap: Record<number, string> = {};
|
||||
const furniRevisionMap: Record<
|
||||
number,
|
||||
{ classname: string; revision: number }
|
||||
> = {};
|
||||
const furniData = await getFurnitureData();
|
||||
if (furniData) {
|
||||
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
||||
if (furniData[section]?.furnitype) {
|
||||
for (const item of furniData[section].furnitype) {
|
||||
const baseId = spriteToBaseId.get(item.id);
|
||||
if (baseId !== undefined) {
|
||||
foundBaseIds.add(baseId);
|
||||
furniDescriptionMap[baseId] = item.description ?? "";
|
||||
furniRevisionMap[baseId] = {
|
||||
classname: item.classname ?? "",
|
||||
revision: item.revision ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items: catalogItems,
|
||||
baseItems: baseItems.map((b) => ({
|
||||
id: b.id,
|
||||
publicName: b.publicName,
|
||||
itemName: b.itemName,
|
||||
spriteId: b.spriteId,
|
||||
type: b.type,
|
||||
})),
|
||||
catalogNameMap,
|
||||
furniDataIdList: [...foundBaseIds],
|
||||
furniDescriptionMap,
|
||||
furniRevisionMap,
|
||||
interactionTypes,
|
||||
allPages,
|
||||
};
|
||||
return {
|
||||
items: catalogItems,
|
||||
baseItems: baseItems.map((b) => ({
|
||||
id: b.id,
|
||||
publicName: b.publicName,
|
||||
itemName: b.itemName,
|
||||
spriteId: b.spriteId,
|
||||
type: b.type,
|
||||
})),
|
||||
catalogNameMap,
|
||||
furniDataIdList: [...foundBaseIds],
|
||||
furniDescriptionMap,
|
||||
furniRevisionMap,
|
||||
interactionTypes,
|
||||
allPages,
|
||||
};
|
||||
}
|
||||
+194
-186
@@ -10,54 +10,54 @@ import type { TreeNode } from "@/types/catalog";
|
||||
* Depth is computed from parentId hierarchy (not stored in DB).
|
||||
*/
|
||||
export async function getTreeFlat(): Promise<TreeNode[]> {
|
||||
const [allPages, itemCounts] = await Promise.all([
|
||||
prisma.catalogPages.findMany({
|
||||
orderBy: { orderNum: "asc" },
|
||||
}),
|
||||
prisma.catalogItems.groupBy({
|
||||
by: ["pageId"],
|
||||
_count: true,
|
||||
}),
|
||||
]);
|
||||
const [allPages, itemCounts] = await Promise.all([
|
||||
prisma.catalogPages.findMany({
|
||||
orderBy: { orderNum: "asc" },
|
||||
}),
|
||||
prisma.catalogItems.groupBy({
|
||||
by: ["pageId"],
|
||||
_count: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const itemCountMap = new Map(itemCounts.map((c) => [c.pageId, c._count]));
|
||||
const itemCountMap = new Map(itemCounts.map((c) => [c.pageId, c._count]));
|
||||
|
||||
// Count children per page
|
||||
const childCountMap = new Map<number, number>();
|
||||
for (const p of allPages) {
|
||||
childCountMap.set(p.parentId, (childCountMap.get(p.parentId) ?? 0) + 1);
|
||||
}
|
||||
// Count children per page
|
||||
const childCountMap = new Map<number, number>();
|
||||
for (const p of allPages) {
|
||||
childCountMap.set(p.parentId, (childCountMap.get(p.parentId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
// Compute depth from parent hierarchy
|
||||
const pageMap = new Map(allPages.map((p) => [p.id, p]));
|
||||
const depthCache = new Map<number, number>();
|
||||
// Compute depth from parent hierarchy
|
||||
const pageMap = new Map(allPages.map((p) => [p.id, p]));
|
||||
const depthCache = new Map<number, number>();
|
||||
|
||||
function computeDepth(pageId: number): number {
|
||||
if (depthCache.has(pageId)) return depthCache.get(pageId)!;
|
||||
const page = pageMap.get(pageId);
|
||||
if (!page || page.parentId <= 0) {
|
||||
depthCache.set(pageId, 0);
|
||||
return 0;
|
||||
}
|
||||
const d = computeDepth(page.parentId) + 1;
|
||||
depthCache.set(pageId, d);
|
||||
return d;
|
||||
}
|
||||
function computeDepth(pageId: number): number {
|
||||
if (depthCache.has(pageId)) return depthCache.get(pageId)!;
|
||||
const page = pageMap.get(pageId);
|
||||
if (!page || page.parentId <= 0) {
|
||||
depthCache.set(pageId, 0);
|
||||
return 0;
|
||||
}
|
||||
const d = computeDepth(page.parentId) + 1;
|
||||
depthCache.set(pageId, d);
|
||||
return d;
|
||||
}
|
||||
|
||||
return allPages.map((p) => ({
|
||||
id: p.id,
|
||||
caption: p.caption,
|
||||
parentId: p.parentId,
|
||||
depth: computeDepth(p.id),
|
||||
orderNum: p.orderNum,
|
||||
enabled: String(p.enabled),
|
||||
visible: String(p.visible),
|
||||
iconImage: p.iconImage,
|
||||
iconColor: p.iconColor,
|
||||
pageLayout: p.pageLayout,
|
||||
childCount: childCountMap.get(p.id) ?? 0,
|
||||
itemCount: itemCountMap.get(p.id) ?? 0,
|
||||
}));
|
||||
return allPages.map((p) => ({
|
||||
id: p.id,
|
||||
caption: p.caption,
|
||||
parentId: p.parentId,
|
||||
depth: computeDepth(p.id),
|
||||
orderNum: p.orderNum,
|
||||
enabled: String(p.enabled),
|
||||
visible: String(p.visible),
|
||||
iconImage: p.iconImage,
|
||||
iconColor: p.iconColor,
|
||||
pageLayout: p.pageLayout,
|
||||
childCount: childCountMap.get(p.id) ?? 0,
|
||||
itemCount: itemCountMap.get(p.id) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,111 +65,117 @@ export async function getTreeFlat(): Promise<TreeNode[]> {
|
||||
*/
|
||||
import type { NestedTreeNode } from "@/types/catalog";
|
||||
|
||||
export function buildNestedTree(flat: TreeNode[], rootParentId = -1): NestedTreeNode[] {
|
||||
const childrenMap = new Map<number, NestedTreeNode[]>();
|
||||
const nodeMap = new Map<number, NestedTreeNode>();
|
||||
export function buildNestedTree(
|
||||
flat: TreeNode[],
|
||||
rootParentId = -1,
|
||||
): NestedTreeNode[] {
|
||||
const childrenMap = new Map<number, NestedTreeNode[]>();
|
||||
const nodeMap = new Map<number, NestedTreeNode>();
|
||||
|
||||
for (const node of flat) {
|
||||
const nested: NestedTreeNode = { ...node, children: [] };
|
||||
nodeMap.set(node.id, nested);
|
||||
if (!childrenMap.has(node.parentId)) childrenMap.set(node.parentId, []);
|
||||
childrenMap.get(node.parentId)!.push(nested);
|
||||
}
|
||||
for (const node of flat) {
|
||||
const nested: NestedTreeNode = { ...node, children: [] };
|
||||
nodeMap.set(node.id, nested);
|
||||
if (!childrenMap.has(node.parentId)) childrenMap.set(node.parentId, []);
|
||||
childrenMap.get(node.parentId)?.push(nested);
|
||||
}
|
||||
|
||||
// Attach children
|
||||
for (const node of nodeMap.values()) {
|
||||
node.children = childrenMap.get(node.id) ?? [];
|
||||
}
|
||||
// Attach children
|
||||
for (const node of nodeMap.values()) {
|
||||
node.children = childrenMap.get(node.id) ?? [];
|
||||
}
|
||||
|
||||
return childrenMap.get(rootParentId) ?? [];
|
||||
return childrenMap.get(rootParentId) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new catalog page.
|
||||
*/
|
||||
export async function createPage(data: {
|
||||
parentId: number;
|
||||
caption: string;
|
||||
captionSave?: string;
|
||||
pageLayout?: string;
|
||||
iconImage?: number;
|
||||
iconColor?: number;
|
||||
minRank?: number;
|
||||
orderNum?: number;
|
||||
visible?: boolean;
|
||||
enabled?: boolean;
|
||||
parentId: number;
|
||||
caption: string;
|
||||
captionSave?: string;
|
||||
pageLayout?: string;
|
||||
iconImage?: number;
|
||||
iconColor?: number;
|
||||
minRank?: number;
|
||||
orderNum?: number;
|
||||
visible?: boolean;
|
||||
enabled?: boolean;
|
||||
}): Promise<{ id: number }> {
|
||||
// Auto-generate captionSave if not provided
|
||||
const captionSave =
|
||||
data.captionSave ||
|
||||
data.caption
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]/g, "_")
|
||||
.substring(0, 25);
|
||||
// Auto-generate captionSave if not provided
|
||||
const captionSave =
|
||||
data.captionSave ||
|
||||
data.caption
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]/g, "_")
|
||||
.substring(0, 25);
|
||||
|
||||
// Get next orderNum if not provided
|
||||
let orderNum = data.orderNum;
|
||||
if (orderNum === undefined) {
|
||||
const lastSibling = await prisma.catalogPages.findFirst({
|
||||
where: { parentId: data.parentId },
|
||||
orderBy: { orderNum: "desc" },
|
||||
select: { orderNum: true },
|
||||
});
|
||||
orderNum = (lastSibling?.orderNum ?? 0) + 1;
|
||||
}
|
||||
// Get next orderNum if not provided
|
||||
let orderNum = data.orderNum;
|
||||
if (orderNum === undefined) {
|
||||
const lastSibling = await prisma.catalogPages.findFirst({
|
||||
where: { parentId: data.parentId },
|
||||
orderBy: { orderNum: "desc" },
|
||||
select: { orderNum: true },
|
||||
});
|
||||
orderNum = (lastSibling?.orderNum ?? 0) + 1;
|
||||
}
|
||||
|
||||
const created = await prisma.catalogPages.create({
|
||||
data: {
|
||||
parentId: data.parentId,
|
||||
caption: data.caption,
|
||||
captionSave,
|
||||
pageLayout: data.pageLayout ?? "default_3x3",
|
||||
iconImage: data.iconImage ?? 1,
|
||||
iconColor: data.iconColor ?? 1,
|
||||
minRank: data.minRank ?? 1,
|
||||
orderNum,
|
||||
visible: (data.visible ?? true) ? "1" : "0",
|
||||
enabled: (data.enabled ?? true) ? "1" : "0",
|
||||
pageHeadline: "",
|
||||
pageTeaser: "",
|
||||
pageSpecial: "",
|
||||
pageText1: "",
|
||||
pageText2: "",
|
||||
pageTextDetails: "",
|
||||
pageTextTeaser: "",
|
||||
includes: "",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const created = await prisma.catalogPages.create({
|
||||
data: {
|
||||
parentId: data.parentId,
|
||||
caption: data.caption,
|
||||
captionSave,
|
||||
pageLayout: data.pageLayout ?? "default_3x3",
|
||||
iconImage: data.iconImage ?? 1,
|
||||
iconColor: data.iconColor ?? 1,
|
||||
minRank: data.minRank ?? 1,
|
||||
orderNum,
|
||||
visible: (data.visible ?? true) ? "1" : "0",
|
||||
enabled: (data.enabled ?? true) ? "1" : "0",
|
||||
pageHeadline: "",
|
||||
pageTeaser: "",
|
||||
pageSpecial: "",
|
||||
pageText1: "",
|
||||
pageText2: "",
|
||||
pageTextDetails: "",
|
||||
pageTextTeaser: "",
|
||||
includes: "",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return created;
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a page to a new parent.
|
||||
* Validates against circular hierarchy (A → B → C → A).
|
||||
*/
|
||||
export async function movePage(pageId: number, newParentId: number): Promise<void> {
|
||||
// Walk up from newParentId to root — if we hit pageId, it's circular
|
||||
if (newParentId > 0) {
|
||||
let currentId = newParentId;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
if (currentId === pageId) {
|
||||
throw new Error("Cannot move page: would create a circular hierarchy");
|
||||
}
|
||||
const parent = await prisma.catalogPages.findUnique({
|
||||
where: { id: currentId },
|
||||
select: { parentId: true },
|
||||
});
|
||||
if (!parent || parent.parentId <= 0) break;
|
||||
currentId = parent.parentId;
|
||||
}
|
||||
}
|
||||
export async function movePage(
|
||||
pageId: number,
|
||||
newParentId: number,
|
||||
): Promise<void> {
|
||||
// Walk up from newParentId to root — if we hit pageId, it's circular
|
||||
if (newParentId > 0) {
|
||||
let currentId = newParentId;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
if (currentId === pageId) {
|
||||
throw new Error("Cannot move page: would create a circular hierarchy");
|
||||
}
|
||||
const parent = await prisma.catalogPages.findUnique({
|
||||
where: { id: currentId },
|
||||
select: { parentId: true },
|
||||
});
|
||||
if (!parent || parent.parentId <= 0) break;
|
||||
currentId = parent.parentId;
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.catalogPages.update({
|
||||
where: { id: pageId },
|
||||
data: { parentId: newParentId },
|
||||
});
|
||||
await prisma.catalogPages.update({
|
||||
where: { id: pageId },
|
||||
data: { parentId: newParentId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,80 +184,82 @@ export async function movePage(pageId: number, newParentId: number): Promise<voi
|
||||
* - reparent: moves children to the deleted page's parent
|
||||
*/
|
||||
export async function deletePage(
|
||||
pageId: number,
|
||||
mode: "cascade" | "reparent" = "reparent",
|
||||
pageId: number,
|
||||
mode: "cascade" | "reparent" = "reparent",
|
||||
): Promise<{ deletedPages: number; movedChildren: number }> {
|
||||
const page = await prisma.catalogPages.findUnique({
|
||||
where: { id: pageId },
|
||||
select: { parentId: true },
|
||||
});
|
||||
if (!page) return { deletedPages: 0, movedChildren: 0 };
|
||||
const page = await prisma.catalogPages.findUnique({
|
||||
where: { id: pageId },
|
||||
select: { parentId: true },
|
||||
});
|
||||
if (!page) return { deletedPages: 0, movedChildren: 0 };
|
||||
|
||||
if (mode === "reparent") {
|
||||
// Move children to page's parent
|
||||
const result = await prisma.catalogPages.updateMany({
|
||||
where: { parentId: pageId },
|
||||
data: { parentId: page.parentId },
|
||||
});
|
||||
if (mode === "reparent") {
|
||||
// Move children to page's parent
|
||||
const result = await prisma.catalogPages.updateMany({
|
||||
where: { parentId: pageId },
|
||||
data: { parentId: page.parentId },
|
||||
});
|
||||
|
||||
// Delete items in this page
|
||||
await prisma.catalogItems.deleteMany({ where: { pageId } });
|
||||
// Delete the page
|
||||
await prisma.catalogPages.delete({ where: { id: pageId } });
|
||||
// Delete items in this page
|
||||
await prisma.catalogItems.deleteMany({ where: { pageId } });
|
||||
// Delete the page
|
||||
await prisma.catalogPages.delete({ where: { id: pageId } });
|
||||
|
||||
return { deletedPages: 1, movedChildren: result.count };
|
||||
}
|
||||
return { deletedPages: 1, movedChildren: result.count };
|
||||
}
|
||||
|
||||
// Cascade: delete all descendants
|
||||
const deleted = await cascadeDelete(pageId);
|
||||
return { deletedPages: deleted, movedChildren: 0 };
|
||||
// Cascade: delete all descendants
|
||||
const deleted = await cascadeDelete(pageId);
|
||||
return { deletedPages: deleted, movedChildren: 0 };
|
||||
}
|
||||
|
||||
async function cascadeDelete(pageId: number): Promise<number> {
|
||||
// Collect all descendant IDs iteratively to avoid N+1 recursive queries
|
||||
const toDelete: number[] = [pageId];
|
||||
const queue: number[] = [pageId];
|
||||
// Collect all descendant IDs iteratively to avoid N+1 recursive queries
|
||||
const toDelete: number[] = [pageId];
|
||||
const queue: number[] = [pageId];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const children = await prisma.catalogPages.findMany({
|
||||
where: { parentId: { in: queue } },
|
||||
select: { id: true },
|
||||
});
|
||||
queue.length = 0;
|
||||
for (const child of children) {
|
||||
toDelete.push(child.id);
|
||||
queue.push(child.id);
|
||||
}
|
||||
}
|
||||
while (queue.length > 0) {
|
||||
const children = await prisma.catalogPages.findMany({
|
||||
where: { parentId: { in: queue } },
|
||||
select: { id: true },
|
||||
});
|
||||
queue.length = 0;
|
||||
for (const child of children) {
|
||||
toDelete.push(child.id);
|
||||
queue.push(child.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all items and pages in bulk (children first, then parents)
|
||||
await prisma.catalogItems.deleteMany({ where: { pageId: { in: toDelete } } });
|
||||
// Delete in reverse order (deepest first) to avoid FK issues
|
||||
for (let i = toDelete.length - 1; i >= 0; i--) {
|
||||
await prisma.catalogPages.delete({ where: { id: toDelete[i] } });
|
||||
}
|
||||
// Delete all items and pages in bulk (children first, then parents)
|
||||
await prisma.catalogItems.deleteMany({ where: { pageId: { in: toDelete } } });
|
||||
// Delete in reverse order (deepest first) to avoid FK issues
|
||||
for (let i = toDelete.length - 1; i >= 0; i--) {
|
||||
await prisma.catalogPages.delete({ where: { id: toDelete[i] } });
|
||||
}
|
||||
|
||||
return toDelete.length;
|
||||
return toDelete.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ancestors (breadcrumb) from root to the given page.
|
||||
*/
|
||||
export async function getAncestors(pageId: number): Promise<Array<{ id: number; caption: string }>> {
|
||||
const ancestors: Array<{ id: number; caption: string }> = [];
|
||||
let currentId = pageId;
|
||||
export async function getAncestors(
|
||||
pageId: number,
|
||||
): Promise<Array<{ id: number; caption: string }>> {
|
||||
const ancestors: Array<{ id: number; caption: string }> = [];
|
||||
let currentId = pageId;
|
||||
|
||||
// Safety limit to prevent infinite loops
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const page = await prisma.catalogPages.findUnique({
|
||||
where: { id: currentId },
|
||||
select: { id: true, caption: true, parentId: true },
|
||||
});
|
||||
if (!page) break;
|
||||
ancestors.unshift({ id: page.id, caption: page.caption });
|
||||
if (page.parentId <= 0) break;
|
||||
currentId = page.parentId;
|
||||
}
|
||||
// Safety limit to prevent infinite loops
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const page = await prisma.catalogPages.findUnique({
|
||||
where: { id: currentId },
|
||||
select: { id: true, caption: true, parentId: true },
|
||||
});
|
||||
if (!page) break;
|
||||
ancestors.unshift({ id: page.id, caption: page.caption });
|
||||
if (page.parentId <= 0) break;
|
||||
currentId = page.parentId;
|
||||
}
|
||||
|
||||
return ancestors;
|
||||
return ancestors;
|
||||
}
|
||||
@@ -2,18 +2,18 @@ import { cropRgba, decodePng } from "@/lib/services/imager/png-decode";
|
||||
import { encodePng, parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
||||
|
||||
interface FurniAsset {
|
||||
x?: number;
|
||||
y?: number;
|
||||
source?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
source?: string;
|
||||
}
|
||||
interface FurniFrame {
|
||||
frame: { x: number; y: number; w: number; h: number };
|
||||
rotated?: boolean;
|
||||
frame: { x: number; y: number; w: number; h: number };
|
||||
rotated?: boolean;
|
||||
}
|
||||
interface FurniNitroJson {
|
||||
name?: string;
|
||||
assets?: Record<string, FurniAsset>;
|
||||
spritesheet?: { frames?: Record<string, FurniFrame> };
|
||||
name?: string;
|
||||
assets?: Record<string, FurniAsset>;
|
||||
spritesheet?: { frames?: Record<string, FurniFrame> };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,41 +23,42 @@ interface FurniNitroJson {
|
||||
* `.nitro` instead of serving a separate `{classname}_icon.png`.
|
||||
*/
|
||||
export function extractFurniIconPng(nitro: Buffer): Buffer | null {
|
||||
let json: FurniNitroJson;
|
||||
let png: Buffer;
|
||||
try {
|
||||
const parsed = parseNitroBundle(nitro);
|
||||
json = parsed.json as FurniNitroJson;
|
||||
png = parsed.png;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const name = json.name;
|
||||
if (!name || !json.assets) return null;
|
||||
let json: FurniNitroJson;
|
||||
let png: Buffer;
|
||||
try {
|
||||
const parsed = parseNitroBundle(nitro);
|
||||
json = parsed.json as FurniNitroJson;
|
||||
png = parsed.png;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const name = json.name;
|
||||
if (!name || !json.assets) return null;
|
||||
|
||||
// The icon asset is conventionally `${name}_icon_a`; fall back to any
|
||||
// `${name}_icon*` the bundle exposes.
|
||||
const iconKey = json.assets[`${name}_icon_a`]
|
||||
? `${name}_icon_a`
|
||||
: Object.keys(json.assets).find((k) => k.startsWith(`${name}_icon`));
|
||||
if (!iconKey) return null;
|
||||
// The icon asset is conventionally `${name}_icon_a`; fall back to any
|
||||
// `${name}_icon*` the bundle exposes.
|
||||
const iconKey = json.assets[`${name}_icon_a`]
|
||||
? `${name}_icon_a`
|
||||
: Object.keys(json.assets).find((k) => k.startsWith(`${name}_icon`));
|
||||
if (!iconKey) return null;
|
||||
|
||||
const asset = json.assets[iconKey];
|
||||
// An asset may alias another's pixels via `source`; the frame key is the
|
||||
// bundle name prefixed onto the asset name. Different converters pack the
|
||||
// key with or without a trailing `.png`, so try both.
|
||||
const pixelAsset = asset.source ?? iconKey;
|
||||
const frames = json.spritesheet?.frames ?? {};
|
||||
const frame = frames[`${name}_${pixelAsset}`] ?? frames[`${name}_${pixelAsset}.png`];
|
||||
if (!frame || frame.rotated) return null;
|
||||
const { x, y, w, h } = frame.frame;
|
||||
if (w <= 0 || h <= 0) return null;
|
||||
const asset = json.assets[iconKey];
|
||||
// An asset may alias another's pixels via `source`; the frame key is the
|
||||
// bundle name prefixed onto the asset name. Different converters pack the
|
||||
// key with or without a trailing `.png`, so try both.
|
||||
const pixelAsset = asset.source ?? iconKey;
|
||||
const frames = json.spritesheet?.frames ?? {};
|
||||
const frame =
|
||||
frames[`${name}_${pixelAsset}`] ?? frames[`${name}_${pixelAsset}.png`];
|
||||
if (!frame || frame.rotated) return null;
|
||||
const { x, y, w, h } = frame.frame;
|
||||
if (w <= 0 || h <= 0) return null;
|
||||
|
||||
try {
|
||||
const sheet = decodePng(png);
|
||||
const px = cropRgba(sheet.rgba, sheet.width, x, y, w, h);
|
||||
return encodePng(w, h, px);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const sheet = decodePng(png);
|
||||
const px = cropRgba(sheet.rgba, sheet.width, x, y, w, h);
|
||||
return encodePng(w, h, px);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -3,142 +3,172 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
type AnyFn = (...args: any[]) => any;
|
||||
|
||||
const { downloadFile, appendFurniEntry, parseNitroBundle, execRaw, queryRaw, fsUnlink, fsReadFile } =
|
||||
vi.hoisted(() => ({
|
||||
downloadFile: vi.fn<AnyFn>(async () => ({ ok: true, size: 200 })),
|
||||
appendFurniEntry: vi.fn<AnyFn>(async () => {}),
|
||||
parseNitroBundle: vi.fn<AnyFn>(() => ({ json: {}, png: Buffer.alloc(0) })),
|
||||
execRaw: vi.fn<AnyFn>(async () => 1),
|
||||
queryRaw: vi.fn<AnyFn>(async () => [] as unknown[]),
|
||||
fsUnlink: vi.fn<AnyFn>(async () => {}),
|
||||
fsReadFile: vi.fn<AnyFn>(async () => Buffer.from("NITRO")),
|
||||
}));
|
||||
const {
|
||||
downloadFile,
|
||||
appendFurniEntry,
|
||||
parseNitroBundle,
|
||||
execRaw,
|
||||
queryRaw,
|
||||
fsUnlink,
|
||||
fsReadFile,
|
||||
} = vi.hoisted(() => ({
|
||||
downloadFile: vi.fn<AnyFn>(async () => ({ ok: true, size: 200 })),
|
||||
appendFurniEntry: vi.fn<AnyFn>(async () => {}),
|
||||
parseNitroBundle: vi.fn<AnyFn>(() => ({ json: {}, png: Buffer.alloc(0) })),
|
||||
execRaw: vi.fn<AnyFn>(async () => 1),
|
||||
queryRaw: vi.fn<AnyFn>(async () => [] as unknown[]),
|
||||
fsUnlink: vi.fn<AnyFn>(async () => {}),
|
||||
fsReadFile: vi.fn<AnyFn>(async () => Buffer.from("NITRO")),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/import/core/download", () => ({ downloadFile }));
|
||||
vi.mock("@/lib/services/furni-data", () => ({ appendFurniEntry }));
|
||||
vi.mock("@/lib/services/furni-import", () => ({
|
||||
ensureDirectories: vi.fn<AnyFn>(async () => {}),
|
||||
getOrCreateCategoryPage: vi.fn<AnyFn>(async () => 99),
|
||||
autoPriceFurni: vi.fn<AnyFn>(() => ({ credits: 5, points: 0, pointsType: 0 })),
|
||||
allocateCatalogItemId: vi.fn<AnyFn>(async (fn: (n: number) => Promise<unknown>) => fn(1000)),
|
||||
ensureDirectories: vi.fn<AnyFn>(async () => {}),
|
||||
getOrCreateCategoryPage: vi.fn<AnyFn>(async () => 99),
|
||||
autoPriceFurni: vi.fn<AnyFn>(() => ({
|
||||
credits: 5,
|
||||
points: 0,
|
||||
pointsType: 0,
|
||||
})),
|
||||
allocateCatalogItemId: vi.fn<AnyFn>(
|
||||
async (fn: (n: number) => Promise<unknown>) => fn(1000),
|
||||
),
|
||||
}));
|
||||
vi.mock("@/lib/services/furni-asset-dirs", () => ({
|
||||
getFurniAssetDirs: vi.fn<AnyFn>(async () => ({
|
||||
nitroDir: "/tmp/nitro",
|
||||
iconDir: "/tmp/icons",
|
||||
swfDir: "/tmp/swf",
|
||||
})),
|
||||
getFurniAssetDirs: vi.fn<AnyFn>(async () => ({
|
||||
nitroDir: "/tmp/nitro",
|
||||
iconDir: "/tmp/icons",
|
||||
swfDir: "/tmp/swf",
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/services/swf/nitro-builder", () => ({ parseNitroBundle }));
|
||||
vi.mock("node:fs", async (orig) => {
|
||||
const real = (await orig()) as typeof import("node:fs");
|
||||
return {
|
||||
...real,
|
||||
promises: { ...real.promises, readFile: fsReadFile as AnyFn, unlink: fsUnlink as AnyFn },
|
||||
};
|
||||
const real = (await orig()) as typeof import("node:fs");
|
||||
return {
|
||||
...real,
|
||||
promises: {
|
||||
...real.promises,
|
||||
readFile: fsReadFile as AnyFn,
|
||||
unlink: fsUnlink as AnyFn,
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
$executeRaw: execRaw as AnyFn,
|
||||
$queryRaw: queryRaw as AnyFn,
|
||||
$executeRawUnsafe: execRaw as AnyFn,
|
||||
$queryRawUnsafe: queryRaw as AnyFn,
|
||||
},
|
||||
prisma: {
|
||||
$executeRaw: execRaw as AnyFn,
|
||||
$queryRaw: queryRaw as AnyFn,
|
||||
$executeRawUnsafe: execRaw as AnyFn,
|
||||
$queryRawUnsafe: queryRaw as AnyFn,
|
||||
},
|
||||
}));
|
||||
|
||||
import { cloneSingleFurni, parseFurnidata } from "./clone-import";
|
||||
|
||||
const SOURCE = {
|
||||
id: "s",
|
||||
name: "X",
|
||||
furnidataUrl: "https://x/fd.json",
|
||||
nitroBaseUrl: "https://x/furni",
|
||||
iconBaseUrl: "https://x/icons",
|
||||
id: "s",
|
||||
name: "X",
|
||||
furnidataUrl: "https://x/fd.json",
|
||||
nitroBaseUrl: "https://x/furni",
|
||||
iconBaseUrl: "https://x/icons",
|
||||
};
|
||||
const ENTRY = {
|
||||
id: 5,
|
||||
classname: "bc_sofa",
|
||||
name: "BC Sofa",
|
||||
description: "d",
|
||||
xdim: 2,
|
||||
ydim: 1,
|
||||
canstandon: false,
|
||||
cansiton: true,
|
||||
canlayon: false,
|
||||
customparams: "",
|
||||
id: 5,
|
||||
classname: "bc_sofa",
|
||||
name: "BC Sofa",
|
||||
description: "d",
|
||||
xdim: 2,
|
||||
ydim: 1,
|
||||
canstandon: false,
|
||||
cansiton: true,
|
||||
canlayon: false,
|
||||
customparams: "",
|
||||
};
|
||||
|
||||
describe("clone-import", () => {
|
||||
it("parseFurnidata normalizes room + wall items with itemType", () => {
|
||||
const list = parseFurnidata({
|
||||
roomitemtypes: { furnitype: [ENTRY] },
|
||||
wallitemtypes: { furnitype: [{ ...ENTRY, classname: "wall_x" }] },
|
||||
});
|
||||
expect(list.find((e) => e.classname === "bc_sofa")?.itemType).toBe("s");
|
||||
expect(list.find((e) => e.classname === "wall_x")?.itemType).toBe("i");
|
||||
});
|
||||
it("parseFurnidata normalizes room + wall items with itemType", () => {
|
||||
const list = parseFurnidata({
|
||||
roomitemtypes: { furnitype: [ENTRY] },
|
||||
wallitemtypes: { furnitype: [{ ...ENTRY, classname: "wall_x" }] },
|
||||
});
|
||||
expect(list.find((e) => e.classname === "bc_sofa")?.itemType).toBe("s");
|
||||
expect(list.find((e) => e.classname === "wall_x")?.itemType).toBe("i");
|
||||
});
|
||||
|
||||
it("cloneSingleFurni downloads .nitro+icon, inserts items_base, appends FurnitureData, makes catalog item", async () => {
|
||||
// Reset mocks to clear state from other tests.
|
||||
downloadFile.mockReset();
|
||||
downloadFile.mockResolvedValue({ ok: true, size: 200 });
|
||||
execRaw.mockReset();
|
||||
execRaw.mockResolvedValue(1);
|
||||
queryRaw.mockReset();
|
||||
// First call: dedup check (no existing row); second call: allocateItemsBaseId MAX(id)+1.
|
||||
queryRaw.mockResolvedValueOnce([]).mockResolvedValueOnce([{ next: 42 }]);
|
||||
appendFurniEntry.mockReset();
|
||||
appendFurniEntry.mockResolvedValue(undefined);
|
||||
it("cloneSingleFurni downloads .nitro+icon, inserts items_base, appends FurnitureData, makes catalog item", async () => {
|
||||
// Reset mocks to clear state from other tests.
|
||||
downloadFile.mockReset();
|
||||
downloadFile.mockResolvedValue({ ok: true, size: 200 });
|
||||
execRaw.mockReset();
|
||||
execRaw.mockResolvedValue(1);
|
||||
queryRaw.mockReset();
|
||||
// First call: dedup check (no existing row); second call: allocateItemsBaseId MAX(id)+1.
|
||||
queryRaw.mockResolvedValueOnce([]).mockResolvedValueOnce([{ next: 42 }]);
|
||||
appendFurniEntry.mockReset();
|
||||
appendFurniEntry.mockResolvedValue(undefined);
|
||||
|
||||
const r = await cloneSingleFurni({ source: SOURCE, entry: { ...ENTRY, itemType: "s" } });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(downloadFile).toHaveBeenCalledWith(
|
||||
"https://x/furni/bc_sofa.nitro",
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(downloadFile).toHaveBeenCalledWith(
|
||||
"https://x/icons/bc_sofa_icon.png",
|
||||
expect.any(String),
|
||||
expect.objectContaining({ validate: "png" }),
|
||||
);
|
||||
expect(appendFurniEntry).toHaveBeenCalled();
|
||||
expect(execRaw).toHaveBeenCalled(); // items_base insert + catalog
|
||||
});
|
||||
const r = await cloneSingleFurni({
|
||||
source: SOURCE,
|
||||
entry: { ...ENTRY, itemType: "s" },
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
expect(downloadFile).toHaveBeenCalledWith(
|
||||
"https://x/furni/bc_sofa.nitro",
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(downloadFile).toHaveBeenCalledWith(
|
||||
"https://x/icons/bc_sofa_icon.png",
|
||||
expect.any(String),
|
||||
expect.objectContaining({ validate: "png" }),
|
||||
);
|
||||
expect(appendFurniEntry).toHaveBeenCalled();
|
||||
expect(execRaw).toHaveBeenCalled(); // items_base insert + catalog
|
||||
});
|
||||
|
||||
it("skips when classname already exists (dedup)", async () => {
|
||||
queryRaw.mockReset();
|
||||
queryRaw.mockResolvedValueOnce([{ id: 5 }]); // existing items_base row
|
||||
const r = await cloneSingleFurni({ source: SOURCE, entry: { ...ENTRY, itemType: "s" } });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.skipped).toBe(true);
|
||||
});
|
||||
it("skips when classname already exists (dedup)", async () => {
|
||||
queryRaw.mockReset();
|
||||
queryRaw.mockResolvedValueOnce([{ id: 5 }]); // existing items_base row
|
||||
const r = await cloneSingleFurni({
|
||||
source: SOURCE,
|
||||
entry: { ...ENTRY, itemType: "s" },
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.skipped).toBe(true);
|
||||
});
|
||||
|
||||
it("rolls back: unlinks .nitro and icon, skips FurnitureData when items_base INSERT rejects", async () => {
|
||||
downloadFile.mockReset();
|
||||
downloadFile.mockResolvedValue({ ok: true, size: 200 });
|
||||
fsUnlink.mockReset();
|
||||
fsUnlink.mockResolvedValue(undefined);
|
||||
appendFurniEntry.mockReset();
|
||||
queryRaw.mockReset();
|
||||
execRaw.mockReset();
|
||||
// Dedup query: no existing row.
|
||||
queryRaw.mockResolvedValueOnce([]);
|
||||
// allocateItemsBaseId: MAX(id)+1 query returns next id.
|
||||
queryRaw.mockResolvedValueOnce([{ next: 7 }]);
|
||||
// items_base INSERT rejects.
|
||||
execRaw.mockRejectedValueOnce(new Error("Duplicate entry"));
|
||||
it("rolls back: unlinks .nitro and icon, skips FurnitureData when items_base INSERT rejects", async () => {
|
||||
downloadFile.mockReset();
|
||||
downloadFile.mockResolvedValue({ ok: true, size: 200 });
|
||||
fsUnlink.mockReset();
|
||||
fsUnlink.mockResolvedValue(undefined);
|
||||
appendFurniEntry.mockReset();
|
||||
queryRaw.mockReset();
|
||||
execRaw.mockReset();
|
||||
// Dedup query: no existing row.
|
||||
queryRaw.mockResolvedValueOnce([]);
|
||||
// allocateItemsBaseId: MAX(id)+1 query returns next id.
|
||||
queryRaw.mockResolvedValueOnce([{ next: 7 }]);
|
||||
// items_base INSERT rejects.
|
||||
execRaw.mockRejectedValueOnce(new Error("Duplicate entry"));
|
||||
|
||||
const r = await cloneSingleFurni({ source: SOURCE, entry: { ...ENTRY, itemType: "s" } });
|
||||
const r = await cloneSingleFurni({
|
||||
source: SOURCE,
|
||||
entry: { ...ENTRY, itemType: "s" },
|
||||
});
|
||||
|
||||
// (a) .nitro must be unlinked.
|
||||
expect(fsUnlink).toHaveBeenCalledWith(expect.stringContaining("bc_sofa.nitro"));
|
||||
// (b) icon must also be unlinked.
|
||||
expect(fsUnlink).toHaveBeenCalledWith(expect.stringContaining("bc_sofa_icon.png"));
|
||||
// (c) appendFurniEntry must NOT have been called.
|
||||
expect(appendFurniEntry).not.toHaveBeenCalled();
|
||||
// (d) result must be ok === false.
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/items_base insert failed/);
|
||||
});
|
||||
// (a) .nitro must be unlinked.
|
||||
expect(fsUnlink).toHaveBeenCalledWith(
|
||||
expect.stringContaining("bc_sofa.nitro"),
|
||||
);
|
||||
// (b) icon must also be unlinked.
|
||||
expect(fsUnlink).toHaveBeenCalledWith(
|
||||
expect.stringContaining("bc_sofa_icon.png"),
|
||||
);
|
||||
// (c) appendFurniEntry must NOT have been called.
|
||||
expect(appendFurniEntry).not.toHaveBeenCalled();
|
||||
// (d) result must be ok === false.
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/items_base insert failed/);
|
||||
});
|
||||
});
|
||||
+292
-236
@@ -6,88 +6,98 @@ import type { CloneSource } from "@/lib/services/clone-sources";
|
||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||
import { appendFurniEntry } from "@/lib/services/furni-data";
|
||||
import {
|
||||
allocateCatalogItemId,
|
||||
autoPriceFurni,
|
||||
ensureDirectories,
|
||||
getOrCreateCategoryPage,
|
||||
allocateCatalogItemId,
|
||||
autoPriceFurni,
|
||||
ensureDirectories,
|
||||
getOrCreateCategoryPage,
|
||||
} from "@/lib/services/furni-import";
|
||||
import { downloadFile } from "@/lib/services/import/core/download";
|
||||
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
||||
|
||||
export interface SourceFurni {
|
||||
id: number;
|
||||
classname: string;
|
||||
name: string;
|
||||
description: string;
|
||||
xdim: number;
|
||||
ydim: number;
|
||||
canstandon: boolean;
|
||||
cansiton: boolean;
|
||||
canlayon: boolean;
|
||||
customparams: string;
|
||||
itemType: "s" | "i";
|
||||
[k: string]: unknown;
|
||||
id: number;
|
||||
classname: string;
|
||||
name: string;
|
||||
description: string;
|
||||
xdim: number;
|
||||
ydim: number;
|
||||
canstandon: boolean;
|
||||
cansiton: boolean;
|
||||
canlayon: boolean;
|
||||
customparams: string;
|
||||
itemType: "s" | "i";
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CloneResult {
|
||||
ok: boolean;
|
||||
classname: string;
|
||||
skipped?: boolean;
|
||||
warnings: string[];
|
||||
error?: string;
|
||||
ok: boolean;
|
||||
classname: string;
|
||||
skipped?: boolean;
|
||||
warnings: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface FurniType {
|
||||
furnitype?: Array<Record<string, unknown>>;
|
||||
furnitype?: Array<Record<string, unknown>>;
|
||||
}
|
||||
interface RemoteFurnidata {
|
||||
roomitemtypes?: FurniType;
|
||||
wallitemtypes?: FurniType;
|
||||
furnitype?: Array<Record<string, unknown>>;
|
||||
roomitemtypes?: FurniType;
|
||||
wallitemtypes?: FurniType;
|
||||
furnitype?: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
// Spread ...raw first so the coerced typed fields always win over raw values.
|
||||
function toSourceFurni(raw: Record<string, unknown>, itemType: "s" | "i"): SourceFurni {
|
||||
return {
|
||||
...raw,
|
||||
id: Number(raw.id ?? 0),
|
||||
classname: String(raw.classname ?? ""),
|
||||
name: String(raw.name ?? raw.classname ?? ""),
|
||||
description: String(raw.description ?? ""),
|
||||
xdim: Number(raw.xdim ?? 1),
|
||||
ydim: Number(raw.ydim ?? 1),
|
||||
canstandon: raw.canstandon === true,
|
||||
cansiton: raw.cansiton === true,
|
||||
canlayon: raw.canlayon === true,
|
||||
customparams: String(raw.customparams ?? ""),
|
||||
itemType,
|
||||
};
|
||||
function toSourceFurni(
|
||||
raw: Record<string, unknown>,
|
||||
itemType: "s" | "i",
|
||||
): SourceFurni {
|
||||
return {
|
||||
...raw,
|
||||
id: Number(raw.id ?? 0),
|
||||
classname: String(raw.classname ?? ""),
|
||||
name: String(raw.name ?? raw.classname ?? ""),
|
||||
description: String(raw.description ?? ""),
|
||||
xdim: Number(raw.xdim ?? 1),
|
||||
ydim: Number(raw.ydim ?? 1),
|
||||
canstandon: raw.canstandon === true,
|
||||
cansiton: raw.cansiton === true,
|
||||
canlayon: raw.canlayon === true,
|
||||
customparams: String(raw.customparams ?? ""),
|
||||
itemType,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseFurnidata(data: RemoteFurnidata): SourceFurni[] {
|
||||
const out: SourceFurni[] = [];
|
||||
for (const r of data.roomitemtypes?.furnitype ?? data.furnitype ?? []) out.push(toSourceFurni(r, "s"));
|
||||
for (const r of data.wallitemtypes?.furnitype ?? []) out.push(toSourceFurni(r, "i"));
|
||||
return out.filter((e) => e.classname);
|
||||
const out: SourceFurni[] = [];
|
||||
for (const r of data.roomitemtypes?.furnitype ?? data.furnitype ?? [])
|
||||
out.push(toSourceFurni(r, "s"));
|
||||
for (const r of data.wallitemtypes?.furnitype ?? [])
|
||||
out.push(toSourceFurni(r, "i"));
|
||||
return out.filter((e) => e.classname);
|
||||
}
|
||||
|
||||
const cache = new Map<string, { list: SourceFurni[]; ts: number }>();
|
||||
const TTL = 5 * 60 * 1000;
|
||||
|
||||
export async function fetchSourceFurnidata(url: string, now = Date.now()): Promise<SourceFurni[]> {
|
||||
const hit = cache.get(url);
|
||||
if (hit && now && now - hit.ts < TTL) return hit.list;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
|
||||
if (!res.ok) throw new Error(`furnidata fetch failed: ${res.status} ${url}`);
|
||||
let list: SourceFurni[];
|
||||
try {
|
||||
const json = await res.json();
|
||||
list = parseFurnidata(json);
|
||||
} catch (err) {
|
||||
throw new Error(`furnidata parse failed for ${url}: ${(err as Error).message}`);
|
||||
}
|
||||
cache.set(url, { list, ts: now });
|
||||
return list;
|
||||
export async function fetchSourceFurnidata(
|
||||
url: string,
|
||||
now = Date.now(),
|
||||
): Promise<SourceFurni[]> {
|
||||
const hit = cache.get(url);
|
||||
if (hit && now && now - hit.ts < TTL) return hit.list;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
|
||||
if (!res.ok) throw new Error(`furnidata fetch failed: ${res.status} ${url}`);
|
||||
let list: SourceFurni[];
|
||||
try {
|
||||
const json = await res.json();
|
||||
list = parseFurnidata(json);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`furnidata parse failed for ${url}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
cache.set(url, { list, ts: now });
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,222 +107,268 @@ export async function fetchSourceFurnidata(url: string, now = Date.now()): Promi
|
||||
*/
|
||||
let itemsBaseIdAllocChain: Promise<unknown> = Promise.resolve();
|
||||
|
||||
async function allocateItemsBaseId<T>(insertFn: (nextId: number) => Promise<T>): Promise<T> {
|
||||
const prev = itemsBaseIdAllocChain;
|
||||
let settle!: () => void;
|
||||
itemsBaseIdAllocChain = new Promise<void>((r) => {
|
||||
settle = r;
|
||||
});
|
||||
await prev.catch(() => {});
|
||||
try {
|
||||
const idRow = await prisma.$queryRaw<Array<{ next: number }>>`
|
||||
async function allocateItemsBaseId<T>(
|
||||
insertFn: (nextId: number) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const prev = itemsBaseIdAllocChain;
|
||||
let settle!: () => void;
|
||||
itemsBaseIdAllocChain = new Promise<void>((r) => {
|
||||
settle = r;
|
||||
});
|
||||
await prev.catch(() => {});
|
||||
try {
|
||||
const idRow = await prisma.$queryRaw<Array<{ next: number }>>`
|
||||
SELECT COALESCE(MAX(id), 0) + 1 AS next FROM items_base`;
|
||||
const nextId = Number(idRow[0]?.next ?? 1);
|
||||
return await insertFn(nextId);
|
||||
} finally {
|
||||
settle();
|
||||
}
|
||||
const nextId = Number(idRow[0]?.next ?? 1);
|
||||
return await insertFn(nextId);
|
||||
} finally {
|
||||
settle();
|
||||
}
|
||||
}
|
||||
|
||||
export async function cloneSingleFurni(params: {
|
||||
source: CloneSource;
|
||||
entry: SourceFurni;
|
||||
onProgress?: (status: string) => void;
|
||||
source: CloneSource;
|
||||
entry: SourceFurni;
|
||||
onProgress?: (status: string) => void;
|
||||
}): Promise<CloneResult> {
|
||||
const { source, entry, onProgress } = params;
|
||||
const { classname, itemType } = entry;
|
||||
const warnings: string[] = [];
|
||||
const { source, entry, onProgress } = params;
|
||||
const { classname, itemType } = entry;
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Path-traversal guard: classname comes from remote furnidata and is used to
|
||||
// build file paths — reject anything that doesn't look like a safe furni name.
|
||||
if (!/^[\w\-.*]+$/.test(classname)) {
|
||||
return { ok: false, classname, warnings, error: "invalid classname" };
|
||||
}
|
||||
// Path-traversal guard: classname comes from remote furnidata and is used to
|
||||
// build file paths — reject anything that doesn't look like a safe furni name.
|
||||
if (!/^[\w\-.*]+$/.test(classname)) {
|
||||
return { ok: false, classname, warnings, error: "invalid classname" };
|
||||
}
|
||||
|
||||
// Dedup by classname.
|
||||
const existing = await prisma.$queryRaw<Array<{ id: number }>>`
|
||||
// Dedup by classname.
|
||||
const existing = await prisma.$queryRaw<Array<{ id: number }>>`
|
||||
SELECT id FROM items_base WHERE item_name = ${classname} LIMIT 1`;
|
||||
if (existing.length > 0) {
|
||||
return { ok: false, classname, skipped: true, warnings, error: "Already present" };
|
||||
}
|
||||
if (existing.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
classname,
|
||||
skipped: true,
|
||||
warnings,
|
||||
error: "Already present",
|
||||
};
|
||||
}
|
||||
|
||||
await ensureDirectories();
|
||||
const { iconDir, nitroDir } = await getFurniAssetDirs();
|
||||
await ensureDirectories();
|
||||
const { iconDir, nitroDir } = await getFurniAssetDirs();
|
||||
|
||||
// Download .nitro + icon directly from the source hotel.
|
||||
onProgress?.("downloading");
|
||||
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${classname}.nitro`);
|
||||
const iconPath = path.join(/*turbopackIgnore: true*/ iconDir, `${classname}_icon.png`);
|
||||
const dl = await downloadFile(`${source.nitroBaseUrl}/${classname}.nitro`, nitroPath, {
|
||||
maxRetries: 2,
|
||||
});
|
||||
if (!dl.ok) {
|
||||
console.warn("[clone-import] nitro download failed for", classname);
|
||||
return { ok: false, classname, warnings, error: "nitro download failed" };
|
||||
}
|
||||
// Validate it is a real Nitro bundle.
|
||||
try {
|
||||
parseNitroBundle(await fs.readFile(/*turbopackIgnore: true*/ nitroPath));
|
||||
} catch {
|
||||
await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {});
|
||||
console.warn("[clone-import] invalid .nitro bundle for", classname);
|
||||
return { ok: false, classname, warnings, error: "invalid .nitro bundle" };
|
||||
}
|
||||
const iconDl = await downloadFile(`${source.iconBaseUrl}/${classname}_icon.png`, iconPath, {
|
||||
maxRetries: 1,
|
||||
validate: "png",
|
||||
});
|
||||
if (!iconDl.ok) {
|
||||
// Source serves no standalone icon (e.g. icons embedded in the .nitro) —
|
||||
// extract the catalog icon from the bundle we just downloaded.
|
||||
try {
|
||||
const icon = extractFurniIconPng(await fs.readFile(/*turbopackIgnore: true*/ nitroPath));
|
||||
if (icon) {
|
||||
await fs.writeFile(/*turbopackIgnore: true*/ iconPath, icon);
|
||||
} else {
|
||||
warnings.push("no icon (not in source or bundle)");
|
||||
}
|
||||
} catch {
|
||||
warnings.push("icon extraction failed");
|
||||
}
|
||||
}
|
||||
// Download .nitro + icon directly from the source hotel.
|
||||
onProgress?.("downloading");
|
||||
const nitroPath = path.join(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
`${classname}.nitro`,
|
||||
);
|
||||
const iconPath = path.join(
|
||||
/*turbopackIgnore: true*/ iconDir,
|
||||
`${classname}_icon.png`,
|
||||
);
|
||||
const dl = await downloadFile(
|
||||
`${source.nitroBaseUrl}/${classname}.nitro`,
|
||||
nitroPath,
|
||||
{
|
||||
maxRetries: 2,
|
||||
},
|
||||
);
|
||||
if (!dl.ok) {
|
||||
console.warn("[clone-import] nitro download failed for", classname);
|
||||
return { ok: false, classname, warnings, error: "nitro download failed" };
|
||||
}
|
||||
// Validate it is a real Nitro bundle.
|
||||
try {
|
||||
parseNitroBundle(await fs.readFile(/*turbopackIgnore: true*/ nitroPath));
|
||||
} catch {
|
||||
await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {});
|
||||
console.warn("[clone-import] invalid .nitro bundle for", classname);
|
||||
return { ok: false, classname, warnings, error: "invalid .nitro bundle" };
|
||||
}
|
||||
const iconDl = await downloadFile(
|
||||
`${source.iconBaseUrl}/${classname}_icon.png`,
|
||||
iconPath,
|
||||
{
|
||||
maxRetries: 1,
|
||||
validate: "png",
|
||||
},
|
||||
);
|
||||
if (!iconDl.ok) {
|
||||
// Source serves no standalone icon (e.g. icons embedded in the .nitro) —
|
||||
// extract the catalog icon from the bundle we just downloaded.
|
||||
try {
|
||||
const icon = extractFurniIconPng(
|
||||
await fs.readFile(/*turbopackIgnore: true*/ nitroPath),
|
||||
);
|
||||
if (icon) {
|
||||
await fs.writeFile(/*turbopackIgnore: true*/ iconPath, icon);
|
||||
} else {
|
||||
warnings.push("no icon (not in source or bundle)");
|
||||
}
|
||||
} catch {
|
||||
warnings.push("icon extraction failed");
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.("writing_db");
|
||||
const stackHeight = entry.canlayon || entry.cansiton ? 1.0 : entry.canstandon ? 1.0 : 0.0;
|
||||
// allow_stack: derived from stackHeight (mirrors furni-import.ts `dims.z > 0`).
|
||||
// allow_walk = canstandon, allow_sit = cansiton, allow_lay = canlayon.
|
||||
const allowStack = stackHeight > 0 ? "1" : "0";
|
||||
let newId: number;
|
||||
try {
|
||||
newId = await allocateItemsBaseId(async (nextId) => {
|
||||
await prisma.$executeRaw`
|
||||
onProgress?.("writing_db");
|
||||
const stackHeight =
|
||||
entry.canlayon || entry.cansiton ? 1.0 : entry.canstandon ? 1.0 : 0.0;
|
||||
// allow_stack: derived from stackHeight (mirrors furni-import.ts `dims.z > 0`).
|
||||
// allow_walk = canstandon, allow_sit = cansiton, allow_lay = canlayon.
|
||||
const allowStack = stackHeight > 0 ? "1" : "0";
|
||||
let newId: number;
|
||||
try {
|
||||
newId = await allocateItemsBaseId(async (nextId) => {
|
||||
await prisma.$executeRaw`
|
||||
INSERT INTO items_base
|
||||
(id, sprite_id, public_name, item_name, type, width, length, stack_height,
|
||||
allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams)
|
||||
VALUES
|
||||
(${nextId}, ${nextId}, ${entry.name}, ${classname}, ${itemType}, ${entry.xdim}, ${entry.ydim}, ${stackHeight},
|
||||
${allowStack}, ${entry.cansiton ? "1" : "0"}, ${entry.canlayon ? "1" : "0"}, ${entry.canstandon ? "1" : "0"}, 'default', ${entry.customparams})`;
|
||||
return nextId;
|
||||
});
|
||||
} catch (err) {
|
||||
// Rollback: remove both downloaded files so we don't leave orphaned assets.
|
||||
await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {});
|
||||
await fs.unlink(/*turbopackIgnore: true*/ iconPath).catch(() => {});
|
||||
console.warn("[clone-import] items_base insert failed for", classname, (err as Error).message);
|
||||
return {
|
||||
ok: false,
|
||||
classname,
|
||||
warnings,
|
||||
error: `items_base insert failed: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
return nextId;
|
||||
});
|
||||
} catch (err) {
|
||||
// Rollback: remove both downloaded files so we don't leave orphaned assets.
|
||||
await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {});
|
||||
await fs.unlink(/*turbopackIgnore: true*/ iconPath).catch(() => {});
|
||||
console.warn(
|
||||
"[clone-import] items_base insert failed for",
|
||||
classname,
|
||||
(err as Error).message,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
classname,
|
||||
warnings,
|
||||
error: `items_base insert failed: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
|
||||
// FurnitureData entry — reuse the source's furnitype object, with our id.
|
||||
onProgress?.("writing_furnidata");
|
||||
try {
|
||||
await appendFurniEntry({ ...entry, id: newId } as Record<string, unknown>, itemType);
|
||||
} catch (err) {
|
||||
console.warn("[clone-import] FurnitureData append failed for", classname, (err as Error).message);
|
||||
warnings.push(`FurnitureData append failed: ${(err as Error).message}`);
|
||||
}
|
||||
// FurnitureData entry — reuse the source's furnitype object, with our id.
|
||||
onProgress?.("writing_furnidata");
|
||||
try {
|
||||
await appendFurniEntry(
|
||||
{ ...entry, id: newId } as Record<string, unknown>,
|
||||
itemType,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[clone-import] FurnitureData append failed for",
|
||||
classname,
|
||||
(err as Error).message,
|
||||
);
|
||||
warnings.push(`FurnitureData append failed: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// Catalog entry (category sub-page + auto price), serialized id allocation.
|
||||
try {
|
||||
const pageId = await getOrCreateCategoryPage(classname, itemType);
|
||||
const price = autoPriceFurni(classname);
|
||||
await allocateCatalogItemId(async (nextCatalogId) => {
|
||||
await prisma.$executeRaw`
|
||||
// Catalog entry (category sub-page + auto price), serialized id allocation.
|
||||
try {
|
||||
const pageId = await getOrCreateCategoryPage(classname, itemType);
|
||||
const price = autoPriceFurni(classname);
|
||||
await allocateCatalogItemId(async (nextCatalogId) => {
|
||||
await prisma.$executeRaw`
|
||||
INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)
|
||||
VALUES (${nextCatalogId}, ${String(pageId)}, ${String(newId)}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '')`;
|
||||
return nextCatalogId;
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[clone-import] catalog entry failed for", classname, (err as Error).message);
|
||||
warnings.push(`catalog entry failed: ${(err as Error).message}`);
|
||||
}
|
||||
return nextCatalogId;
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[clone-import] catalog entry failed for",
|
||||
classname,
|
||||
(err as Error).message,
|
||||
);
|
||||
warnings.push(`catalog entry failed: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
return { ok: true, classname, warnings };
|
||||
return { ok: true, classname, warnings };
|
||||
}
|
||||
|
||||
export async function getCloneList(params: {
|
||||
source: CloneSource;
|
||||
search: string;
|
||||
page: number;
|
||||
perPage: number;
|
||||
filter?: "all" | "missing" | "present";
|
||||
source: CloneSource;
|
||||
search: string;
|
||||
page: number;
|
||||
perPage: number;
|
||||
filter?: "all" | "missing" | "present";
|
||||
}): Promise<{
|
||||
items: Array<SourceFurni & { present: boolean }>;
|
||||
meta: { page: number; perPage: number; total: number };
|
||||
items: Array<SourceFurni & { present: boolean }>;
|
||||
meta: { page: number; perPage: number; total: number };
|
||||
}> {
|
||||
const { source, search, page, perPage, filter = "all" } = params;
|
||||
const all = await fetchSourceFurnidata(source.furnidataUrl);
|
||||
const term = search.trim().toLowerCase();
|
||||
const searched = term
|
||||
? all.filter((e) => e.classname.toLowerCase().includes(term) || e.name.toLowerCase().includes(term))
|
||||
: all;
|
||||
const { source, search, page, perPage, filter = "all" } = params;
|
||||
const all = await fetchSourceFurnidata(source.furnidataUrl);
|
||||
const term = search.trim().toLowerCase();
|
||||
const searched = term
|
||||
? all.filter(
|
||||
(e) =>
|
||||
e.classname.toLowerCase().includes(term) ||
|
||||
e.name.toLowerCase().includes(term),
|
||||
)
|
||||
: all;
|
||||
|
||||
// Resolve present status across the whole searched list (not just the page),
|
||||
// so the missing/present filter and the page total stay correct.
|
||||
const allNames = searched.map((e) => e.classname);
|
||||
const present = new Set<string>();
|
||||
if (allNames.length) {
|
||||
const placeholders = allNames.map(() => "?").join(",");
|
||||
const rows = await prisma.$queryRawUnsafe<Array<{ item_name: string }>>(
|
||||
`SELECT item_name FROM items_base WHERE item_name IN (${placeholders})`,
|
||||
...allNames,
|
||||
);
|
||||
for (const r of rows) present.add(r.item_name);
|
||||
}
|
||||
// Resolve present status across the whole searched list (not just the page),
|
||||
// so the missing/present filter and the page total stay correct.
|
||||
const allNames = searched.map((e) => e.classname);
|
||||
const present = new Set<string>();
|
||||
if (allNames.length) {
|
||||
const placeholders = allNames.map(() => "?").join(",");
|
||||
const rows = await prisma.$queryRawUnsafe<Array<{ item_name: string }>>(
|
||||
`SELECT item_name FROM items_base WHERE item_name IN (${placeholders})`,
|
||||
...allNames,
|
||||
);
|
||||
for (const r of rows) present.add(r.item_name);
|
||||
}
|
||||
|
||||
const filtered =
|
||||
filter === "missing"
|
||||
? searched.filter((e) => !present.has(e.classname))
|
||||
: filter === "present"
|
||||
? searched.filter((e) => present.has(e.classname))
|
||||
: searched;
|
||||
const filtered =
|
||||
filter === "missing"
|
||||
? searched.filter((e) => !present.has(e.classname))
|
||||
: filter === "present"
|
||||
? searched.filter((e) => present.has(e.classname))
|
||||
: searched;
|
||||
|
||||
const total = filtered.length;
|
||||
const start = (Math.max(page, 1) - 1) * perPage;
|
||||
const slice = filtered.slice(start, start + perPage);
|
||||
return {
|
||||
items: slice.map((e) => ({ ...e, present: present.has(e.classname) })),
|
||||
meta: { page: Math.max(page, 1), perPage, total },
|
||||
};
|
||||
const total = filtered.length;
|
||||
const start = (Math.max(page, 1) - 1) * perPage;
|
||||
const slice = filtered.slice(start, start + perPage);
|
||||
return {
|
||||
items: slice.map((e) => ({ ...e, present: present.has(e.classname) })),
|
||||
meta: { page: Math.max(page, 1), perPage, total },
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCloneStats(
|
||||
source: CloneSource,
|
||||
source: CloneSource,
|
||||
): Promise<{ total: number; present: number; clonable: number }> {
|
||||
const all = await fetchSourceFurnidata(source.furnidataUrl);
|
||||
const classnames = all.map((e) => e.classname);
|
||||
const have = new Set<string>();
|
||||
if (classnames.length) {
|
||||
const placeholders = classnames.map(() => "?").join(",");
|
||||
const rows = await prisma.$queryRawUnsafe<Array<{ item_name: string }>>(
|
||||
`SELECT item_name FROM items_base WHERE item_name IN (${placeholders})`,
|
||||
...classnames,
|
||||
);
|
||||
for (const r of rows) have.add(r.item_name);
|
||||
}
|
||||
const present = all.filter((e) => have.has(e.classname)).length;
|
||||
return { total: all.length, present, clonable: all.length - present };
|
||||
const all = await fetchSourceFurnidata(source.furnidataUrl);
|
||||
const classnames = all.map((e) => e.classname);
|
||||
const have = new Set<string>();
|
||||
if (classnames.length) {
|
||||
const placeholders = classnames.map(() => "?").join(",");
|
||||
const rows = await prisma.$queryRawUnsafe<Array<{ item_name: string }>>(
|
||||
`SELECT item_name FROM items_base WHERE item_name IN (${placeholders})`,
|
||||
...classnames,
|
||||
);
|
||||
for (const r of rows) have.add(r.item_name);
|
||||
}
|
||||
const present = all.filter((e) => have.has(e.classname)).length;
|
||||
return { total: all.length, present, clonable: all.length - present };
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the classnames of every furni in the source that is NOT yet present
|
||||
* in our items_base — used by the "clone all" action (cloned in chunks by the UI).
|
||||
*/
|
||||
export async function getClonableClassnames(source: CloneSource): Promise<string[]> {
|
||||
const all = await fetchSourceFurnidata(source.furnidataUrl);
|
||||
const classnames = all.map((e) => e.classname);
|
||||
const have = new Set<string>();
|
||||
if (classnames.length) {
|
||||
const placeholders = classnames.map(() => "?").join(",");
|
||||
const rows = await prisma.$queryRawUnsafe<Array<{ item_name: string }>>(
|
||||
`SELECT item_name FROM items_base WHERE item_name IN (${placeholders})`,
|
||||
...classnames,
|
||||
);
|
||||
for (const r of rows) have.add(r.item_name);
|
||||
}
|
||||
return all.filter((e) => !have.has(e.classname)).map((e) => e.classname);
|
||||
export async function getClonableClassnames(
|
||||
source: CloneSource,
|
||||
): Promise<string[]> {
|
||||
const all = await fetchSourceFurnidata(source.furnidataUrl);
|
||||
const classnames = all.map((e) => e.classname);
|
||||
const have = new Set<string>();
|
||||
if (classnames.length) {
|
||||
const placeholders = classnames.map(() => "?").join(",");
|
||||
const rows = await prisma.$queryRawUnsafe<Array<{ item_name: string }>>(
|
||||
`SELECT item_name FROM items_base WHERE item_name IN (${placeholders})`,
|
||||
...classnames,
|
||||
);
|
||||
for (const r of rows) have.add(r.item_name);
|
||||
}
|
||||
return all.filter((e) => !have.has(e.classname)).map((e) => e.classname);
|
||||
}
|
||||
Loaded 100 of 735 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user