style: format code biome
Local Build and Deploy / deploy (push) Failing after 46s

This commit is contained in:
openhands committed 2026-07-13 21:57:41 +02:00
1 parent 8efd032cc6
commit df38dccbf1
735 files changed
+128321 -120870

No files matched your search

+65 -49
View File
@@ -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 });
}
}
+32 -29
View File
@@ -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
View File
@@ -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
View File
@@ -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,
};
}
+33 -33
View File
@@ -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");
});
});
+79 -65
View File
@@ -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([]);
});
});
+18 -15
View File
@@ -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'");
});
});
+21 -16
View File
@@ -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'");
});
});
+10 -6
View File
@@ -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);
}
});
});
+30 -27
View File
@@ -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,
};
}
+24 -24
View File
@@ -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);
});
});
+20 -15
View File
@@ -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,
});
}
+52 -33
View File
@@ -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" }));
});
+31 -17
View File
@@ -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
View File
@@ -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;
}
+8 -8
View File
@@ -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 -1
View File
@@ -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;
}
+17 -10
View File
@@ -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);
});
});
+18 -16
View File
@@ -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;
}
+17 -17
View File
@@ -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
View File
@@ -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;
}
+31 -17
View File
@@ -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,
}));
});
+18 -15
View File
@@ -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 };
}
+12 -12
View File
@@ -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
View File
@@ -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;
}
+6 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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";
+42 -36
View File
@@ -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");
});
});
+57 -51
View File
@@ -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");
}
+77 -67
View File
@@ -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
View File
@@ -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) };
}
+4 -4
View File
@@ -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;
}
+25 -15
View File
@@ -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();
});
});
+5 -4
View File
@@ -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;
}
+25 -22
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+46 -46
View File
@@ -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];
}
+39 -36
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+21 -18
View File
@@ -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;
}
+85 -83
View File
@@ -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);
}
+8 -5
View File
@@ -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
View File
@@ -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();
});
});
+6 -3
View File
@@ -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;
}
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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());
+40 -40
View File
@@ -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
View File
@@ -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";
+81 -73
View File
@@ -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;
}
+20 -20
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+68 -44
View File
@@ -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
View File
@@ -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
View File
@@ -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()}`;
}
+31 -31
View File
@@ -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
View File
@@ -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 };
}
+28 -23
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+12 -8
View File
@@ -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);
});
});
+7 -4
View File
@@ -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;
}
+9 -7
View File
@@ -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,
);
});
});
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
+22 -19
View File
@@ -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>;
}
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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]";
}
+68 -61
View File
@@ -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
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
+267 -243
View File
@@ -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
View File
@@ -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;
}
+43 -42
View File
@@ -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;
}
}
+141 -111
View File
@@ -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
View File
@@ -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