Files
EpicNext-Cms/src/actions/messenger.ts
T
openhands 6cc45d7413
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
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.
2026-10-09 16:19:48 +02:00

410 lines
11 KiB
TypeScript

"use server";
import { and, eq, or } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import {
db,
MessengerFriendrequests,
MessengerFriendships,
MessengerOffline,
User,
} from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { isAllowed } from "@/lib/services/moderation";
type FriendOutcome =
| "accepted"
| "declined"
| "removed"
| "not_found"
| "unauthorized"
| "invalid"
| "ratelimit"
| "error";
type SendOutcome =
| "sent"
| "not_friend"
| "empty"
| "invalid"
| "rate_limited"
| "error";
function messagesRedirect(outcome: FriendOutcome): never {
if (outcome === "accepted") redirect("/messages?accepted=1");
if (outcome === "declined") redirect("/messages?declined=1");
redirect(`/messages?error=${outcome}`);
}
function friendsRedirect(outcome: FriendOutcome): never {
if (outcome === "removed") redirect("/friends?removed=1");
redirect(`/friends?error=${outcome}`);
}
function sendRedirect(outcome: SendOutcome): never {
if (outcome === "sent") redirect("/messages?sent=1");
redirect(`/messages?send_error=${outcome}`);
}
/**
* Accept a pending friend request as the SIGNED-IN user.
*
* The ACCEPTER is re-read from the session via auth() and is never trusted from
* the submitted FormData. Only the request id comes from the form, and the
* request is only honoured when its target (user_to_id) is the session user —
* so a crafted form cannot accept a request addressed to someone else.
*
* Arcturus/AtomCMS stores friendships as TWO directional rows in
* messenger_friendships (one user_one_id→user_two_id, one the reverse). We
* create both inside a transaction and delete the originating request so it no
* longer shows as pending in the in-game messenger or here.
*
* Errors redirect back to /messages with a machine-readable ?error= code;
* success redirects with ?accepted=1. redirect() is called OUTSIDE the
* try/catch so its control-flow throw is never swallowed.
*/
export async function acceptFriend(formData: FormData): Promise<void> {
let outcome: FriendOutcome = "error";
try {
const session = await auth();
const meId = Number(session?.user?.id);
if (!Number.isInteger(meId) || meId <= 0) {
redirect("/login");
}
await clientIp();
if (!(await rateLimit(`friend-accept:${meId}`, 10, 60_000)).ok) {
outcome = "ratelimit";
} else {
const requestId = Number(formData.get("requestId"));
if (!Number.isInteger(requestId) || requestId <= 0) {
outcome = "invalid";
} else {
// The request must exist AND be addressed to the session user.
const [request] = await db
.select({
id: MessengerFriendrequests.id,
userFromId: MessengerFriendrequests.userFromId,
userToId: MessengerFriendrequests.userToId,
})
.from(MessengerFriendrequests)
.where(eq(MessengerFriendrequests.id, requestId))
.limit(1);
if (!request) {
outcome = "not_found";
} else if (request.userToId !== meId) {
outcome = "unauthorized";
} else {
const friendId = request.userFromId;
if (
!Number.isInteger(friendId) ||
friendId <= 0 ||
friendId === meId
) {
// Malformed/self request — clear it and treat as not found.
await db
.delete(MessengerFriendrequests)
.where(eq(MessengerFriendrequests.id, requestId));
outcome = "not_found";
} else {
const friendsSince = Math.floor(Date.now() / 1000);
await db.transaction(async (tx) => {
// Don't double-insert if a friendship already exists in either direction.
const [existing] = await tx
.select({ id: MessengerFriendships.id })
.from(MessengerFriendships)
.where(
or(
and(
eq(MessengerFriendships.userOneId, meId),
eq(MessengerFriendships.userTwoId, friendId),
),
and(
eq(MessengerFriendships.userOneId, friendId),
eq(MessengerFriendships.userTwoId, meId),
),
),
)
.limit(1);
if (!existing) {
await tx.insert(MessengerFriendships).values([
{ userOneId: meId, userTwoId: friendId, friendsSince },
{ userOneId: friendId, userTwoId: meId, friendsSince },
]);
}
// Clear this request and any reverse pending request between the pair.
await tx
.delete(MessengerFriendrequests)
.where(
or(
eq(MessengerFriendrequests.id, requestId),
and(
eq(MessengerFriendrequests.userFromId, meId),
eq(MessengerFriendrequests.userToId, friendId),
),
and(
eq(MessengerFriendrequests.userFromId, friendId),
eq(MessengerFriendrequests.userToId, meId),
),
),
);
});
outcome = "accepted";
}
}
}
}
} catch (e) {
// redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it.
if (
e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
) {
throw e;
}
outcome = "error";
}
revalidatePath("/messages");
revalidatePath("/friends");
messagesRedirect(outcome);
}
/**
* Decline a pending friend request as the SIGNED-IN user.
*
* Only the request's target (user_to_id) may decline. Deletes the
* messenger_friendrequests row without creating a friendship.
*/
export async function declineFriendRequest(formData: FormData): Promise<void> {
let outcome: FriendOutcome = "error";
try {
const session = await auth();
const meId = Number(session?.user?.id);
if (!Number.isInteger(meId) || meId <= 0) {
redirect("/login");
}
await clientIp();
if (!(await rateLimit(`friend-decline:${meId}`, 10, 60_000)).ok) {
outcome = "ratelimit";
} else {
const requestId = Number(formData.get("requestId"));
if (!Number.isInteger(requestId) || requestId <= 0) {
outcome = "invalid";
} else {
const [request] = await db
.select({
id: MessengerFriendrequests.id,
userToId: MessengerFriendrequests.userToId,
})
.from(MessengerFriendrequests)
.where(eq(MessengerFriendrequests.id, requestId))
.limit(1);
if (!request) {
outcome = "not_found";
} else if (request.userToId !== meId) {
outcome = "unauthorized";
} else {
await db
.delete(MessengerFriendrequests)
.where(eq(MessengerFriendrequests.id, requestId));
outcome = "declined";
}
}
}
} catch (e) {
if (
e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
) {
throw e;
}
outcome = "error";
}
revalidatePath("/messages");
messagesRedirect(outcome);
}
/**
* Remove an existing friendship between the SIGNED-IN user and another user.
*
* Deletes BOTH directional rows in messenger_friendships (Arcturus stores one
* row each way) and clears any leftover pending requests between the pair.
* Only the friend id comes from the form; the session user is never trusted
* from FormData.
*/
export async function removeFriendship(formData: FormData): Promise<void> {
let outcome: FriendOutcome = "error";
try {
const session = await auth();
const meId = Number(session?.user?.id);
if (!Number.isInteger(meId) || meId <= 0) {
redirect("/login");
}
await clientIp();
if (!(await rateLimit(`friend-remove:${meId}`, 10, 60_000)).ok) {
outcome = "ratelimit";
} else {
const friendId = Number(formData.get("friendId"));
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
outcome = "invalid";
} else {
const deleted = await db.transaction(async (tx) => {
const result = await tx
.delete(MessengerFriendships)
.where(
or(
and(
eq(MessengerFriendships.userOneId, meId),
eq(MessengerFriendships.userTwoId, friendId),
),
and(
eq(MessengerFriendships.userOneId, friendId),
eq(MessengerFriendships.userTwoId, meId),
),
),
);
await tx
.delete(MessengerFriendrequests)
.where(
or(
and(
eq(MessengerFriendrequests.userFromId, meId),
eq(MessengerFriendrequests.userToId, friendId),
),
and(
eq(MessengerFriendrequests.userFromId, friendId),
eq(MessengerFriendrequests.userToId, meId),
),
),
);
return Number(result[0]?.affectedRows ?? 0);
});
outcome = deleted > 0 ? "removed" : "not_found";
}
}
} catch (e) {
if (
e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
) {
throw e;
}
outcome = "error";
}
revalidatePath("/friends");
revalidatePath("/messages");
friendsRedirect(outcome);
}
/**
* Send an offline messenger message to a friend (Arcturus messenger_offline row).
* Only confirmed friendships may receive messages; body is capped at 500 chars.
*/
export async function sendOfflineMessage(formData: FormData): Promise<void> {
let outcome: SendOutcome = "error";
try {
const session = await auth();
const meId = Number(session?.user?.id);
if (!Number.isInteger(meId) || meId <= 0) {
redirect("/login");
}
if (!(await rateLimit(`offline-msg:${meId}`, 10, 60_000)).ok) {
outcome = "rate_limited";
} else {
const friendId = Number(formData.get("friendId"));
const rawMessage = String(formData.get("message") ?? "")
.normalize("NFC")
.trim();
const message = rawMessage.slice(0, 500);
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
outcome = "invalid";
} else if (!message) {
outcome = "empty";
} else {
const [friendship] = await db
.select({ id: MessengerFriendships.id })
.from(MessengerFriendships)
.where(
or(
and(
eq(MessengerFriendships.userOneId, meId),
eq(MessengerFriendships.userTwoId, friendId),
),
and(
eq(MessengerFriendships.userOneId, friendId),
eq(MessengerFriendships.userTwoId, meId),
),
),
)
.limit(1);
if (!friendship) {
outcome = "not_friend";
} else {
const [recipient] = await db
.select({ id: User.id })
.from(User)
.where(eq(User.id, friendId))
.limit(1);
if (!recipient) {
outcome = "invalid";
} else if (!(await isAllowed(message)).ok) {
outcome = "invalid";
} else {
await db.insert(MessengerOffline).values({
userId: friendId,
userFromId: meId,
message,
sendedOn: Math.floor(Date.now() / 1000),
});
outcome = "sent";
}
}
}
}
} catch (e) {
if (
e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
) {
throw e;
}
outcome = "error";
}
revalidatePath("/messages");
sendRedirect(outcome);
}