import { query, mutation, internalMutation } from "./_generated/server"; import { paginationOptsValidator } from "convex/server"; import { v } from "convex/values"; import { getPublicStorageUrl } from "./storageUrl"; import { getRolesForUser } from "./roles"; import { isBanned } from "./bans"; import { AUDIT_ACTIONS, logAudit } from "./audit"; const DEFAULT_ROLE_COLOR = "#99aab5"; async function enrichMessage(ctx: any, msg: any, userId?: any) { const sender = await ctx.db.get(msg.senderId); // Real users use `avatarStorageId` (Convex storage). Ghost users // (placeholder authors for imported Discord messages) don't have // a storage blob — they carry the original Discord CDN URL in // `ghostAvatarUrl` and the client renders it directly. If the // CDN link expires the UI will fall back to initials, which is // acceptable for historical content. let avatarUrl: string | null = null; if (sender?.avatarStorageId) { avatarUrl = await getPublicStorageUrl(ctx, sender.avatarStorageId); } else if (sender?.isGhost && sender?.ghostAvatarUrl) { avatarUrl = sender.ghostAvatarUrl as string; } // Highest-position role with a non-default colour — mirrors how // Discord tints usernames in chat. The Owner role is deliberately // skipped so owners fall through to the next non-default role's // colour (per product decision: we don't want every owner's name // to glow in the bootstrap pink). Default grey (`#99aab5`) is // treated as "no colour" so regular users fall back to // `--text-primary`. let senderRoleColor: string | null = null; try { const senderRoleDocs = await ctx.db .query("userRoles") .withIndex("by_user", (q: any) => q.eq("userId", msg.senderId)) .collect(); let best: { position: number; color: string } | null = null; for (const ur of senderRoleDocs) { const role = await ctx.db.get(ur.roleId); if (!role) continue; if ((role as any).name === "Owner") continue; const color = (role as any).color as string | undefined; const position = (role as any).position ?? 0; if (!color || color.toLowerCase() === DEFAULT_ROLE_COLOR) continue; if (!best || position > best.position) { best = { position, color }; } } senderRoleColor = best?.color ?? null; } catch { senderRoleColor = null; } const reactionDocs = await ctx.db .query("messageReactions") .withIndex("by_message", (q: any) => q.eq("messageId", msg._id)) .collect(); // Accumulate into a Map so we don't use emoji surrogates as object // field names — Convex's return-value validator rejects non-ASCII // field names, which is what caused "Field name 👍 has invalid // character" errors on any channel with a unicode reaction. // // For each emoji we also collect a capped list of reactor profiles // (up to MAX_REACTION_USERS) so the client can render the hover // tooltip + the full reactions modal without a second round-trip. // Reactor profiles are cached per-message so the same user picked // for multiple emojis only costs one db lookup. const MAX_REACTION_USERS = 100; const profileCache = new Map< string, { userId: string; username: string; displayName: string | null } >(); const resolveProfile = async (reactorUserId: any) => { const key = String(reactorUserId); const cached = profileCache.get(key); if (cached) return cached; const profile = await ctx.db.get(reactorUserId); const shaped = { userId: key, username: profile?.username || "Unknown", displayName: profile?.displayName || null, }; profileCache.set(key, shaped); return shaped; }; interface ReactionAccumulator { count: number; me: boolean; users: Array<{ userId: string; username: string; displayName: string | null; }>; } const reactionMap = new Map(); for (const r of reactionDocs) { let entry = reactionMap.get(r.emoji); if (!entry) { entry = { count: 0, me: false, users: [] }; reactionMap.set(r.emoji, entry); } entry.count++; if (userId && r.userId === userId) { entry.me = true; } if (entry.users.length < MAX_REACTION_USERS) { entry.users.push(await resolveProfile(r.userId)); } } const reactions: Array<{ emoji: string; count: number; me: boolean; users: Array<{ userId: string; username: string; displayName: string | null; }>; }> = []; reactionMap.forEach((info, emoji) => { reactions.push({ emoji, count: info.count, me: info.me, users: info.users, }); }); let replyToUsername: string | null = null; let replyToDisplayName: string | null = null; let replyToContent: string | null = null; let replyToNonce: string | null = null; let replyToAvatarUrl: string | null = null; if (msg.replyTo) { const repliedMsg = await ctx.db.get(msg.replyTo); if (repliedMsg) { const repliedSender = await ctx.db.get(repliedMsg.senderId); replyToUsername = repliedSender?.username || "Unknown"; replyToDisplayName = repliedSender?.displayName || null; replyToContent = repliedMsg.ciphertext; replyToNonce = repliedMsg.nonce; if (repliedSender?.avatarStorageId) { replyToAvatarUrl = await getPublicStorageUrl(ctx, repliedSender.avatarStorageId); } else if (repliedSender?.isGhost && repliedSender?.ghostAvatarUrl) { replyToAvatarUrl = repliedSender.ghostAvatarUrl as string; } } } // Imported messages preserve the original Discord timestamp in // `importedCreatedAt` so they slot into the channel history at // the right spot. `created_at` still serialises as ISO for client // compatibility — the client sorts by it directly. const effectiveCreatedAt = msg.importedCreatedAt ?? msg._creationTime; return { id: msg._id, channel_id: msg.channelId, sender_id: msg.senderId, ciphertext: msg.ciphertext, nonce: msg.nonce, signature: msg.signature, key_version: msg.keyVersion, created_at: new Date(effectiveCreatedAt).toISOString(), username: sender?.username || "Unknown", displayName: sender?.displayName || null, public_signing_key: sender?.publicSigningKey || "", avatarUrl, senderRoleColor, reactions: reactions.length > 0 ? reactions : null, replyToId: msg.replyTo || null, replyToUsername, replyToDisplayName, replyToContent, replyToNonce, replyToAvatarUrl, editedAt: msg.editedAt || null, pinned: msg.pinned || false, isImported: msg.isImported ?? false, importedCreatedAt: msg.importedCreatedAt ?? null, }; } export const list = query({ args: { paginationOpts: paginationOptsValidator, channelId: v.id("channels"), userId: v.optional(v.id("userProfiles")), }, returns: v.any(), handler: async (ctx, args) => { const result = await ctx.db .query("messages") .withIndex("by_channel", (q) => q.eq("channelId", args.channelId)) .order("desc") .paginate(args.paginationOpts); const enrichedPage = await Promise.all( result.page.map((msg) => enrichMessage(ctx, msg, args.userId)) ); return { ...result, page: enrichedPage }; }, }); /** * Pull the latest N messages from each of the given channel IDs in a * single round-trip. Used by SearchPanel to scan across every channel * the user can read without spinning up N independent useQuery hooks * (React would error on hook-count drift between renders). * * `perChannelLimit` is clamped to 200 server-side to keep payload * bounded even if the client over-asks. */ export const searchScan = query({ args: { channelIds: v.array(v.id("channels")), perChannelLimit: v.optional(v.number()), userId: v.optional(v.id("userProfiles")), }, returns: v.any(), handler: async (ctx, args) => { const limit = Math.min(Math.max(args.perChannelLimit ?? 100, 1), 200); const out: Array<{ channelId: string; messages: any[]; }> = []; for (const channelId of args.channelIds) { const rows = await ctx.db .query("messages") .withIndex("by_channel", (q) => q.eq("channelId", channelId)) .order("desc") .take(limit); const enriched = await Promise.all( rows.map((msg) => enrichMessage(ctx, msg, args.userId)), ); out.push({ channelId, messages: enriched }); } return out; }, }); // Plain text caps out at 4000 chars in the composer, which encodes to // well under 8KB of AES-GCM output. Attachments ride a separate // metadata-only JSON path, so 64KB leaves ~8× headroom for future // structured content without letting a rogue client write multi-MB // rows and bloat the database. const MAX_CIPHERTEXT_CHARS = 64 * 1024; // Internal write. The public API is `messageActions.send`, which verifies // the caller's Ed25519 signature over (channelId, senderId, timestamp) // before invoking this. Never call this from a public mutation — it // trusts `senderId` completely and doing so would reintroduce the // spoofing vulnerability. export const sendInternal = internalMutation({ args: { channelId: v.id("channels"), senderId: v.id("userProfiles"), ciphertext: v.string(), nonce: v.string(), signature: v.string(), keyVersion: v.number(), replyTo: v.optional(v.id("messages")), }, returns: v.object({ id: v.id("messages") }), handler: async (ctx, args) => { if (args.ciphertext.length > MAX_CIPHERTEXT_CHARS) { throw new Error("Message too large"); } if (await isBanned(ctx, args.senderId)) { throw new Error("You've been banned from this server."); } const id = await ctx.db.insert("messages", { channelId: args.channelId, senderId: args.senderId, ciphertext: args.ciphertext, nonce: args.nonce, signature: args.signature, keyVersion: args.keyVersion, replyTo: args.replyTo, }); return { id }; }, }); // Internal-only: there's no public caller and no way to verify every // senderId in a batch without a signature per message, which defeats // the point of batching. Kept as internalMutation so internal seed // scripts / migrations can still use it. export const sendBatchInternal = internalMutation({ args: { messages: v.array(v.object({ channelId: v.id("channels"), senderId: v.id("userProfiles"), ciphertext: v.string(), nonce: v.string(), signature: v.string(), keyVersion: v.number(), })), }, returns: v.object({ count: v.number() }), handler: async (ctx, args) => { for (const msg of args.messages) { await ctx.db.insert("messages", { ...msg }); } return { count: args.messages.length }; }, }); // Internal write — public entry point is `messageActions.edit`. // Enforces that only the original sender can edit. The action layer // has already verified the caller controls `userId`. export const editInternal = internalMutation({ args: { id: v.id("messages"), userId: v.id("userProfiles"), ciphertext: v.string(), nonce: v.string(), signature: v.string(), }, returns: v.null(), handler: async (ctx, args) => { const msg = await ctx.db.get(args.id); if (!msg) throw new Error("Message not found"); if (msg.senderId !== args.userId) { throw new Error("Only the author can edit this message"); } await ctx.db.patch(args.id, { ciphertext: args.ciphertext, nonce: args.nonce, signature: args.signature, editedAt: Date.now(), }); return null; }, }); // Internal write — public entry point is `messageActions.pin`. Only // `manage_messages` role-holders can pin/unpin; the action layer has // already verified the caller controls `userId`. export const pinInternal = internalMutation({ args: { id: v.id("messages"), userId: v.id("userProfiles"), pinned: v.boolean(), }, returns: v.null(), handler: async (ctx, args) => { const roles = await getRolesForUser(ctx, args.userId); const canManage = roles.some( (role) => (role.permissions as Record)?.manage_messages, ); if (!canManage) { throw new Error("Not authorized to pin messages"); } await ctx.db.patch(args.id, { pinned: args.pinned }); return null; }, }); export const listPinned = query({ args: { channelId: v.id("channels"), userId: v.optional(v.id("userProfiles")), }, returns: v.any(), handler: async (ctx, args) => { const pinned = await ctx.db .query("messages") .withIndex("by_channel_pinned", (q) => q.eq("channelId", args.channelId).eq("pinned", true) ) .collect(); return Promise.all( pinned.map((msg) => enrichMessage(ctx, msg, args.userId)) ); }, }); // Slim paginated query for bulk search index rebuilding. // Skips reactions, avatars, reply resolution — only resolves sender username. export const fetchBulkPage = query({ args: { channelId: v.id("channels"), paginationOpts: paginationOptsValidator, }, returns: v.any(), handler: async (ctx, args) => { const result = await ctx.db .query("messages") .withIndex("by_channel", (q) => q.eq("channelId", args.channelId)) .order("asc") .paginate(args.paginationOpts); const enrichedPage = await Promise.all( result.page.map(async (msg) => { const sender = await ctx.db.get(msg.senderId); return { id: msg._id, channel_id: msg.channelId, sender_id: msg.senderId, username: sender?.username || "Unknown", ciphertext: msg.ciphertext, nonce: msg.nonce, created_at: new Date(msg._creationTime).toISOString(), pinned: msg.pinned || false, replyToId: msg.replyTo || null, }; }) ); return { ...result, page: enrichedPage }; }, }); export const listAround = query({ args: { channelId: v.id("channels"), messageId: v.id("messages"), userId: v.optional(v.id("userProfiles")), }, returns: v.any(), handler: async (ctx, args) => { const target = await ctx.db.get(args.messageId); if (!target || target.channelId !== args.channelId) { return { messages: [], hasOlder: false, hasNewer: false, targetFound: false }; } const targetTime = target._creationTime; const before = await ctx.db .query("messages") .withIndex("by_channel", (q) => q.eq("channelId", args.channelId).lt("_creationTime", targetTime) ) .order("desc") .take(26); const after = await ctx.db .query("messages") .withIndex("by_channel", (q) => q.eq("channelId", args.channelId).gt("_creationTime", targetTime) ) .order("asc") .take(26); const hasOlder = before.length > 25; const hasNewer = after.length > 25; const olderMessages = before.slice(0, 25).reverse(); const newerMessages = after.slice(0, 25); const allRaw = [...olderMessages, target, ...newerMessages]; const messages = await Promise.all( allRaw.map((msg) => enrichMessage(ctx, msg, args.userId)) ); return { messages, hasOlder, hasNewer, targetFound: true }; }, }); export const listBefore = query({ args: { channelId: v.id("channels"), beforeTimestamp: v.number(), userId: v.optional(v.id("userProfiles")), limit: v.optional(v.number()), }, returns: v.any(), handler: async (ctx, args) => { const limit = args.limit ?? 50; const rows = await ctx.db .query("messages") .withIndex("by_channel", (q) => q.eq("channelId", args.channelId).lt("_creationTime", args.beforeTimestamp) ) .order("desc") .take(limit + 1); const hasMore = rows.length > limit; const page = rows.slice(0, limit); const messages = await Promise.all( page.reverse().map((msg) => enrichMessage(ctx, msg, args.userId)) ); return { messages, hasMore }; }, }); export const listAfter = query({ args: { channelId: v.id("channels"), afterTimestamp: v.number(), userId: v.optional(v.id("userProfiles")), limit: v.optional(v.number()), }, returns: v.any(), handler: async (ctx, args) => { const limit = args.limit ?? 50; const rows = await ctx.db .query("messages") .withIndex("by_channel", (q) => q.eq("channelId", args.channelId).gt("_creationTime", args.afterTimestamp) ) .order("asc") .take(limit + 1); const hasMore = rows.length > limit; const page = rows.slice(0, limit); const messages = await Promise.all( page.map((msg) => enrichMessage(ctx, msg, args.userId)) ); return { messages, hasMore }; }, }); // Internal write — public entry point is `messageActions.remove`. The // existing isSender-or-manage_messages check stays here; the action // layer verifies the caller actually controls `userId` before we trust // that arg. export const removeInternal = internalMutation({ args: { id: v.id("messages"), userId: v.id("userProfiles") }, returns: v.null(), handler: async (ctx, args) => { const message = await ctx.db.get(args.id); if (!message) throw new Error("Message not found"); const isSender = message.senderId === args.userId; if (!isSender) { const roles = await getRolesForUser(ctx, args.userId); const canManage = roles.some( (role) => (role.permissions as Record)?.manage_messages ); if (!canManage) { throw new Error("Not authorized to delete this message"); } } const reactions = await ctx.db .query("messageReactions") .withIndex("by_message", (q) => q.eq("messageId", args.id)) .collect(); for (const r of reactions) { await ctx.db.delete(r._id); } await ctx.db.delete(args.id); return null; }, }); /** * Owner-only: wipe every message (and its reactions) across every * channel. Designed for a fresh-start reset — the Danger Zone UI * double-confirms before calling this. * * Returns the remaining message count so the client can loop if a * single batch isn't enough. `batchSize` caps per-call work to stay * inside Convex's default read/write limits; default is 1000 which * comfortably covers a small server in one shot. */ export const purgeAllMessages = mutation({ args: { actorId: v.id("userProfiles"), batchSize: v.optional(v.number()), }, returns: v.object({ deletedMessages: v.number(), deletedReactions: v.number(), deletedPolls: v.number(), deletedPollVotes: v.number(), deletedPollReactions: v.number(), remaining: v.number(), }), handler: async (ctx, args) => { const user = await ctx.db.get(args.actorId); if (!user) throw new Error("User not found."); const roles = await getRolesForUser(ctx, args.actorId); const isOwner = user.isAdmin || roles.some((r) => r.name === "Owner"); if (!isOwner) { throw new Error("Only the Owner can clear all messages."); } const batch = Math.max(1, Math.min(args.batchSize ?? 1000, 2000)); // 1) Messages + their reactions. const messages = await ctx.db.query("messages").take(batch); let deletedMessages = 0; let deletedReactions = 0; for (const m of messages) { const reactions = await ctx.db .query("messageReactions") .withIndex("by_message", (q) => q.eq("messageId", m._id)) .collect(); for (const r of reactions) { await ctx.db.delete(r._id); deletedReactions += 1; } await ctx.db.delete(m._id); deletedMessages += 1; } // 2) Polls + their votes + poll-level emoji reactions. Runs in the // same batch so a single "Clear all messages" pass wipes everything // a user can see in chat, not just plain text messages. const polls = await ctx.db.query("polls").take(batch); let deletedPolls = 0; let deletedPollVotes = 0; let deletedPollReactions = 0; for (const p of polls) { const votes = await ctx.db .query("pollVotes") .withIndex("by_poll", (q) => q.eq("pollId", p._id)) .collect(); for (const v of votes) { await ctx.db.delete(v._id); deletedPollVotes += 1; } const preactions = await ctx.db .query("pollReactions") .withIndex("by_poll", (q) => q.eq("pollId", p._id)) .collect(); for (const r of preactions) { await ctx.db.delete(r._id); deletedPollReactions += 1; } await ctx.db.delete(p._id); deletedPolls += 1; } // Remaining count — either table still has rows means the client // should call again. We peek one past the batch size so `remaining` // is positive whenever there's *anything* left. const leftoverMessages = await ctx.db.query("messages").take(1); const leftoverPolls = await ctx.db.query("polls").take(1); const remaining = leftoverMessages.length + leftoverPolls.length; if (deletedMessages > 0 || deletedPolls > 0) { await logAudit(ctx, { actorId: args.actorId, action: AUDIT_ACTIONS.MESSAGES_PURGE_ALL, targetType: "server", metadata: { deletedMessages, deletedReactions, deletedPolls, deletedPollVotes, deletedPollReactions, remainingAfterBatch: remaining, }, }); } return { deletedMessages, deletedReactions, deletedPolls, deletedPollVotes, deletedPollReactions, remaining, }; }, });