Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Failing after 1m49s
CI / tests-ui (push) Successful in 2m31s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Second review pass covering security, performance, admin tooling and the public/room flows. All HIGH and MEDIUM findings from the audit are resolved; nothing in this commit changes the visible feature set. Authentication & session security - CSP is now set on the request headers in the proxy, which is what Next.js uses to derive the render nonce, so the nonce is effective. - 2FA: an already-enabled user cannot re-enroll, the setup endpoint is rate-limited per account, and confirmed codes are persisted so the second secret no longer silently never applies. - Password reset revokes the ticket, authTicket and all personal access tokens, and bumps the token version so existing sessions die. The same revocation is now wired into the staff-side password reset. - /reset and /verify return a stable error code instead of raw text; the mail lookups are ordered by id so duplicates cannot vary between runs. - Resending the verification mail gets a per-address cooldown on top of the per-user limit. - Issue API tokens with the narrower radio/ticket ability set instead of "*". Authorization & input handling - Mid-rank staff can no longer keep dynamically granted non-view admin.* permissions: existing grants are revoked by migration and the grant lookup is restricted to "%.view". Rank guards use the dynamic super-admin check. - Alerting a user is permission-checked and audited like the other tools. - Material mutations (giveCredits/giveDuckets/giveDiamonds, the admin user actions route, bulk user actions) are capped and rank-guarded, and bulk ids are bounded. - updateRoom / updateRoomItem write through a field allowlist, and items may only be edited through their own room. - Classnames reaching the filesystem are validated before use so a crafted value cannot escape the asset directories. - The word filter now also covers offline mails, guild forum threads and replies, and user mottos. - Media uploads are validated by magic bytes, /api/media requires the page edit permission, APP_URL must be configured once mail is enabled, and the diagnostics error route checks the fetch site header. Admin tooling - Secret settings render masked and cannot be overwritten with a blank or an arbitrary raw key; radio credentials are new password inputs. - Commandocentrum balance changes are audited. - Admin list pagination reads the caller's per-page instead of the max, and the log exporter caps offset and search length. Performance - Catalog translations are cached per module, with a cheap revision hash; the public online count uses a stale window instead of hammering the DB. - The cache warmup now primes the payload the home route actually reads. - TopHeader batches its queries into one round trip, and LCP avatars load eagerly. - motion/react and sonner are no longer part of the root layout; the nav dropdown and mobile nav panels are lazy client chunks. Anonymous visitors again get the navigation chrome, and public pages get an edge cacheable response. Accessibility - Nested <main> elements in phase pages became <section>; the page entrance and route progress animations are pure CSS that respect reduced motion.
207 lines
6.0 KiB
TypeScript
207 lines
6.0 KiB
TypeScript
import "server-only";
|
||
import { createHash } from "node:crypto";
|
||
import fs from "node:fs/promises";
|
||
import path from "node:path";
|
||
import { cache } from "react";
|
||
import { type AppLocale, isSupportedLocale } from "@/i18n/locales";
|
||
import type { MessageTree } from "@/i18n/merge-messages";
|
||
import { validateCmsMessage } from "./cms-message-validation";
|
||
import { logger } from "./logger";
|
||
import {
|
||
withGamedataLock,
|
||
writeGamedataJsonAtomic,
|
||
} from "./services/import/core/gamedata-json";
|
||
import { flattenObject, unflattenObject } from "./translations-utils";
|
||
|
||
const defaultRoot = path.join(
|
||
/*turbopackIgnore: true*/ process.cwd(),
|
||
"storage",
|
||
"cms-translations",
|
||
);
|
||
export interface CmsTranslationSnapshot {
|
||
messages: Record<string, string>;
|
||
revision: string;
|
||
}
|
||
export class CmsTranslationError extends Error {
|
||
constructor(
|
||
public readonly code:
|
||
| "conflict"
|
||
| "unknownKey"
|
||
| "empty"
|
||
| "invalid"
|
||
| "variables",
|
||
public readonly key = "",
|
||
) {
|
||
super(`${code}: ${key}`);
|
||
}
|
||
}
|
||
async function bundled(locale: AppLocale): Promise<Record<string, string>> {
|
||
return flattenObject((await import(`../messages/${locale}.json`)).default);
|
||
}
|
||
|
||
/**
|
||
* Flattened catalog per locale. The dynamic import is cached by the module
|
||
* system, but flattening 6 100 keys is not: doing that on every request cost
|
||
* 4–12 ms of CPU per page render.
|
||
*/
|
||
const flatBundled = new Map<AppLocale, Promise<Record<string, string>>>();
|
||
function bundledFlat(locale: AppLocale): Promise<Record<string, string>> {
|
||
let pending = flatBundled.get(locale);
|
||
if (!pending) {
|
||
pending = bundled(locale);
|
||
flatBundled.set(locale, pending);
|
||
}
|
||
return pending;
|
||
}
|
||
|
||
/** Digest of the bundled part of a locale's revision, so only overrides hash. */
|
||
const baseDigest = new Map<AppLocale, string>();
|
||
async function revisionFor(
|
||
locale: AppLocale,
|
||
overrides: Record<string, string>,
|
||
): Promise<string> {
|
||
let digest = baseDigest.get(locale);
|
||
if (!digest) {
|
||
digest = createHash("sha256")
|
||
.update(JSON.stringify(await bundledFlat(locale)))
|
||
.digest("hex");
|
||
baseDigest.set(locale, digest);
|
||
}
|
||
return createHash("sha256")
|
||
.update(digest)
|
||
.update(JSON.stringify(overrides))
|
||
.digest("hex");
|
||
}
|
||
async function readOverrides(file: string): Promise<Record<string, string>> {
|
||
try {
|
||
const parsed: unknown = JSON.parse(
|
||
await fs.readFile(/*turbopackIgnore: true*/ file, "utf8"),
|
||
);
|
||
if (
|
||
!parsed ||
|
||
typeof parsed !== "object" ||
|
||
Array.isArray(parsed) ||
|
||
Object.values(parsed).some((v) => typeof v !== "string")
|
||
)
|
||
throw new Error("Invalid CMS translation override file");
|
||
return parsed as Record<string, string>;
|
||
} catch (error) {
|
||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
||
throw error;
|
||
}
|
||
}
|
||
function localeFile(locale: AppLocale, root: string) {
|
||
if (!isSupportedLocale(locale)) throw new Error("Unsupported locale");
|
||
return path.join(root, `${locale}.json`);
|
||
}
|
||
export async function readCmsTranslation(
|
||
locale: AppLocale,
|
||
root = defaultRoot,
|
||
): Promise<CmsTranslationSnapshot> {
|
||
const [base, source, overrides] = await Promise.all([
|
||
bundledFlat(locale),
|
||
bundledFlat("en"),
|
||
readOverrides(localeFile(locale, root)),
|
||
]);
|
||
const messages = { ...base };
|
||
for (const [key, value] of Object.entries(overrides)) {
|
||
// Obsolete or incompatible overrides must not break new releases.
|
||
if (!Object.hasOwn(source, key) || validateCmsMessage(value, source[key]))
|
||
continue;
|
||
messages[key] = value;
|
||
}
|
||
return {
|
||
messages,
|
||
revision: await revisionFor(locale, overrides),
|
||
};
|
||
}
|
||
export async function saveCmsTranslation(
|
||
locale: AppLocale,
|
||
revision: string,
|
||
changes: Record<string, string | null>,
|
||
root = defaultRoot,
|
||
): Promise<CmsTranslationSnapshot> {
|
||
const file = localeFile(locale, root);
|
||
await fs.mkdir(root, { recursive: true });
|
||
return withGamedataLock(file, async () => {
|
||
const current = await readCmsTranslation(locale, root);
|
||
if (current.revision !== revision)
|
||
throw new CmsTranslationError("conflict");
|
||
const [base, source, overrides] = await Promise.all([
|
||
bundledFlat(locale),
|
||
bundledFlat("en"),
|
||
readOverrides(file),
|
||
]);
|
||
for (const [key, value] of Object.entries(changes)) {
|
||
if (!Object.hasOwn(source, key))
|
||
throw new CmsTranslationError("unknownKey", key);
|
||
if (value === null || value === base[key]) {
|
||
delete overrides[key];
|
||
continue;
|
||
}
|
||
const issue = validateCmsMessage(value, source[key]);
|
||
if (issue) throw new CmsTranslationError(issue, key);
|
||
overrides[key] = value;
|
||
}
|
||
await writeGamedataJsonAtomic(file, overrides);
|
||
return readCmsTranslation(locale, root);
|
||
});
|
||
}
|
||
interface MergedMemo {
|
||
locale: AppLocale;
|
||
mtimeMs: number;
|
||
size: number;
|
||
builtAt: number;
|
||
tree: MessageTree;
|
||
}
|
||
|
||
/**
|
||
* Merged tree per locale, reused across requests until the override file
|
||
* changes. Still re-stat'ed (cheap, and at most once per MEMO_TTL_MS) so a
|
||
* runtime translation edit stays visible without a restart.
|
||
*/
|
||
let mergedMemo: MergedMemo | null = null;
|
||
const MEMO_TTL_MS = 2_000;
|
||
|
||
async function overrideFingerprint(
|
||
locale: AppLocale,
|
||
root: string,
|
||
): Promise<{ mtimeMs: number; size: number }> {
|
||
try {
|
||
const stat = await fs.stat(localeFile(locale, root));
|
||
return { mtimeMs: stat.mtimeMs, size: stat.size };
|
||
} catch {
|
||
return { mtimeMs: 0, size: 0 };
|
||
}
|
||
}
|
||
|
||
/** Per-request deduplication plus the cross-request memo above. */
|
||
export const loadCmsMessages = cache(
|
||
async (locale: AppLocale): Promise<MessageTree> => {
|
||
const fingerprint = await overrideFingerprint(locale, defaultRoot);
|
||
if (
|
||
mergedMemo &&
|
||
mergedMemo.locale === locale &&
|
||
mergedMemo.mtimeMs === fingerprint.mtimeMs &&
|
||
mergedMemo.size === fingerprint.size &&
|
||
Date.now() - mergedMemo.builtAt < MEMO_TTL_MS
|
||
) {
|
||
return mergedMemo.tree;
|
||
}
|
||
|
||
try {
|
||
const tree = unflattenObject(
|
||
(await readCmsTranslation(locale)).messages,
|
||
) as MessageTree;
|
||
mergedMemo = { locale, ...fingerprint, builtAt: Date.now(), tree };
|
||
return tree;
|
||
} catch (error) {
|
||
logger.error("CMS translation overrides could not be loaded", {
|
||
locale,
|
||
error,
|
||
});
|
||
return (await import(`../messages/${locale}.json`)).default;
|
||
}
|
||
},
|
||
);
|