Files
DiscordClone/convex/messages.ts
Bryan1029384756 b83360db35
Some checks failed
Build and Release / build-and-release (push) Has been cancelled
bump
2026-04-20 17:09:04 -05:00

672 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, ReactionAccumulator>();
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<string, boolean>)?.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<string, boolean>)?.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,
};
},
});