feat: harden atoms-nexst against review findings (37 items)
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
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.
This commit is contained in:
1 parent
3933214953
commit
6cc45d7413
150 files changed
+2137
-759
No files matched your search
@@ -38,6 +38,40 @@ export class CmsTranslationError extends Error {
|
||||
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(
|
||||
@@ -65,8 +99,8 @@ export async function readCmsTranslation(
|
||||
root = defaultRoot,
|
||||
): Promise<CmsTranslationSnapshot> {
|
||||
const [base, source, overrides] = await Promise.all([
|
||||
bundled(locale),
|
||||
bundled("en"),
|
||||
bundledFlat(locale),
|
||||
bundledFlat("en"),
|
||||
readOverrides(localeFile(locale, root)),
|
||||
]);
|
||||
const messages = { ...base };
|
||||
@@ -78,9 +112,7 @@ export async function readCmsTranslation(
|
||||
}
|
||||
return {
|
||||
messages,
|
||||
revision: createHash("sha256")
|
||||
.update(JSON.stringify([base, overrides]))
|
||||
.digest("hex"),
|
||||
revision: await revisionFor(locale, overrides),
|
||||
};
|
||||
}
|
||||
export async function saveCmsTranslation(
|
||||
@@ -96,8 +128,8 @@ export async function saveCmsTranslation(
|
||||
if (current.revision !== revision)
|
||||
throw new CmsTranslationError("conflict");
|
||||
const [base, source, overrides] = await Promise.all([
|
||||
bundled(locale),
|
||||
bundled("en"),
|
||||
bundledFlat(locale),
|
||||
bundledFlat("en"),
|
||||
readOverrides(file),
|
||||
]);
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
@@ -115,13 +147,54 @@ export async function saveCmsTranslation(
|
||||
return readCmsTranslation(locale, root);
|
||||
});
|
||||
}
|
||||
/** Per-request deduplication; runtime edits are visible on the next request. */
|
||||
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 {
|
||||
return unflattenObject(
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user