feat: harden atoms-nexst against review findings (37 items)
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:
@@ -8,7 +8,6 @@ import Link from "@/components/link";
|
||||
import { LiveOnlineCount } from "@/components/live-online-count";
|
||||
import { RoomQuickEntry } from "@/components/room-quick-entry";
|
||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { cached } from "@/lib/cache";
|
||||
import {
|
||||
db,
|
||||
MessengerFriendrequests,
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { cachedOnlineCount } from "@/lib/services/public-counters";
|
||||
|
||||
function Currency({
|
||||
icon,
|
||||
@@ -61,31 +61,62 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
||||
let duckets = 0;
|
||||
let diamonds = 0;
|
||||
let look = "";
|
||||
try {
|
||||
const [[user], currencies] = await Promise.all([
|
||||
db
|
||||
.select({ credits: User.credits, look: User.look })
|
||||
.from(User)
|
||||
.where(eq(User.id, id))
|
||||
.limit(1),
|
||||
db
|
||||
.select({ type: UsersCurrency.type, amount: UsersCurrency.amount })
|
||||
.from(UsersCurrency)
|
||||
.where(eq(UsersCurrency.userId, id)),
|
||||
]);
|
||||
credits = user?.credits ?? 0;
|
||||
look = user?.look ?? "";
|
||||
for (const c of currencies) {
|
||||
let showAdmin = false;
|
||||
let showMod = false;
|
||||
let hotelName = "";
|
||||
let online = 0;
|
||||
let friendRequests: { userFromId: number }[] = [];
|
||||
let friendRequestUsers: {
|
||||
id: number;
|
||||
username: string;
|
||||
look: string;
|
||||
}[] = [];
|
||||
|
||||
// One round of queries instead of five sequential ones: the header is on
|
||||
// every authenticated page, so each extra round-trip is paid on every view.
|
||||
const [wallet, ctx, name, onlineCount, requests] = await Promise.all([
|
||||
(async () => {
|
||||
try {
|
||||
const [user, currencies] = await Promise.all([
|
||||
db
|
||||
.select({ credits: User.credits, look: User.look })
|
||||
.from(User)
|
||||
.where(eq(User.id, id))
|
||||
.limit(1),
|
||||
db
|
||||
.select({ type: UsersCurrency.type, amount: UsersCurrency.amount })
|
||||
.from(UsersCurrency)
|
||||
.where(eq(UsersCurrency.userId, id)),
|
||||
]);
|
||||
return user[0]
|
||||
? { credits: user[0].credits, look: user[0].look, currencies }
|
||||
: null;
|
||||
} catch (error) {
|
||||
logServerError("top-header.wallet_failed", error, { userId: id });
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
getApiAdminContext().catch(() => null),
|
||||
resolveHotelName().catch(() => ""),
|
||||
cachedOnlineCount().catch((error) => {
|
||||
logServerError("top-header.online_count_failed", error);
|
||||
return 0;
|
||||
}),
|
||||
db
|
||||
.select({ userFromId: MessengerFriendrequests.userFromId })
|
||||
.from(MessengerFriendrequests)
|
||||
.where(eq(MessengerFriendrequests.userToId, id))
|
||||
.catch(() => [] as { userFromId: number }[]),
|
||||
]);
|
||||
|
||||
if (wallet) {
|
||||
credits = wallet.credits;
|
||||
look = wallet.look;
|
||||
for (const c of wallet.currencies) {
|
||||
if (c.type === 0) duckets = c.amount;
|
||||
if (c.type === 5) diamonds = c.amount;
|
||||
}
|
||||
} catch (error) {
|
||||
logServerError("top-header.wallet_failed", error, { userId: id });
|
||||
}
|
||||
|
||||
let showAdmin = false;
|
||||
let showMod = false;
|
||||
const ctx = await getApiAdminContext();
|
||||
if (ctx) {
|
||||
showAdmin = canAccess(
|
||||
ctx.permissions,
|
||||
@@ -102,67 +133,38 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
||||
canAccess(ctx.permissions, PERMS.MOD_CFH_VIEW, ctx.session.user.rank) ||
|
||||
canAccess(ctx.permissions, PERMS.MOD_ACTIONS, ctx.session.user.rank);
|
||||
}
|
||||
const textColor =
|
||||
"var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))";
|
||||
|
||||
const hotelName = await resolveHotelName();
|
||||
|
||||
let online: number;
|
||||
try {
|
||||
online = await cached("online_count", 10_000, async () => {
|
||||
const [row] = await db
|
||||
.select({ total: count() })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"));
|
||||
return row?.total ?? 0;
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("top-header.online_count_failed", error);
|
||||
online = 0;
|
||||
hotelName = name;
|
||||
online = onlineCount;
|
||||
friendRequests = requests;
|
||||
if (friendRequests.length > 0) {
|
||||
friendRequestUsers = await db
|
||||
.select({ id: User.id, username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.where(
|
||||
inArray(
|
||||
User.id,
|
||||
friendRequests.map((r) => r.userFromId),
|
||||
),
|
||||
)
|
||||
.catch(() => [] as { id: number; username: string; look: string }[]);
|
||||
}
|
||||
|
||||
// The count and the rows are the same question, so derive it from the rows.
|
||||
const pendingFriendRequests = friendRequests.length;
|
||||
const textColor =
|
||||
"var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))";
|
||||
// Unread offline messages is the only count we still need on its own.
|
||||
let unreadMessages = 0;
|
||||
let pendingFriendRequests = 0;
|
||||
try {
|
||||
const [unreadRows, pendingRows] = await Promise.all([
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(MessengerOffline)
|
||||
.where(eq(MessengerOffline.userId, id)),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(MessengerFriendrequests)
|
||||
.where(eq(MessengerFriendrequests.userToId, id)),
|
||||
]);
|
||||
unreadMessages = unreadRows[0]?.total ?? 0;
|
||||
pendingFriendRequests = pendingRows[0]?.total ?? 0;
|
||||
const [row] = await db
|
||||
.select({ total: count() })
|
||||
.from(MessengerOffline)
|
||||
.where(eq(MessengerOffline.userId, id));
|
||||
unreadMessages = row?.total ?? 0;
|
||||
} catch (error) {
|
||||
logServerError("top-header.messenger_counts_failed", error, { userId: id });
|
||||
}
|
||||
|
||||
const friendRequests = await db
|
||||
.select({ userFromId: MessengerFriendrequests.userFromId })
|
||||
.from(MessengerFriendrequests)
|
||||
.where(eq(MessengerFriendrequests.userToId, id))
|
||||
.catch(() => [] as { userFromId: number }[]);
|
||||
|
||||
const friendRequestUsers = friendRequests.length
|
||||
? await db
|
||||
.select({
|
||||
id: User.id,
|
||||
username: User.username,
|
||||
look: User.look,
|
||||
})
|
||||
.from(User)
|
||||
.where(
|
||||
inArray(
|
||||
User.id,
|
||||
friendRequests.map((r) => r.userFromId),
|
||||
),
|
||||
)
|
||||
.catch(() => [] as { id: number; username: string; look: string }[])
|
||||
: [];
|
||||
|
||||
const totalNotifications = unreadMessages + pendingFriendRequests;
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user