import { query, internalMutation } from "./_generated/server"; import { v } from "convex/values"; import type { Id } from "./_generated/dataModel"; import { hasPermission } from "./roles"; import { AUDIT_ACTIONS, logAudit } from "./audit"; import { getPublicStorageUrl } from "./storageUrl"; /** * Discord backup importer — server-side non-node half. * * Ghost profiles * -------------- * A ghost is a `userProfiles` row with `isGhost: true` and random * junk in every auth-sensitive field. It can't log in (its DAK hash * can't be reproduced), doesn't get channelKeys / roles / presence, * and is filtered out of member / mention / DM-target lookups. The * admin creates ghosts so imported messages have a real `senderId` * to point at; later, "merge" replaces every imported message's * senderId with a real user's ID and deletes the ghost. * * All public entry points live in `importerActions.ts` because they * run node crypto to verify the admin's Ed25519 signature. */ const MAX_CIPHERTEXT_CHARS = 64 * 1024; const MAX_IMPORT_BATCH = 100; const MERGE_PAGE = 200; /** * Upsert a batch of ghost profiles keyed by Discord snowflake. * * Idempotent: re-running for the same `discordId` returns the * existing row. If the incoming `displayName` / `avatarUrl` changes, * the existing ghost is patched so the UI reflects the most recent * Discord snapshot — but a real user who already had `discordId` * attached (via a prior merge) is never touched. * * Internal-only. Called from `importerActions.prepareGhostsAction` * which does the signature + admin-permission check. */ export const ensureGhostsInternal = internalMutation({ args: { authors: v.array( v.object({ discordId: v.string(), username: v.string(), displayName: v.optional(v.string()), avatarUrl: v.optional(v.string()), }), ), }, returns: v.array( v.object({ discordId: v.string(), userId: v.id("userProfiles"), created: v.boolean(), isGhost: v.boolean(), }), ), handler: async (ctx, args) => { const out: Array<{ discordId: string; userId: Id<"userProfiles">; created: boolean; isGhost: boolean; }> = []; for (const author of args.authors) { const existing = await ctx.db .query("userProfiles") .withIndex("by_discord_id", (q) => q.eq("discordId", author.discordId)) .first(); if (existing) { if (existing.isGhost) { const patch: Record = {}; if (author.displayName && author.displayName !== existing.displayName) { patch.displayName = author.displayName; } if (author.avatarUrl && author.avatarUrl !== existing.ghostAvatarUrl) { patch.ghostAvatarUrl = author.avatarUrl; } if (Object.keys(patch).length > 0) { await ctx.db.patch(existing._id, patch); } } out.push({ discordId: author.discordId, userId: existing._id, created: false, isGhost: !!existing.isGhost, }); continue; } // Ghost usernames are deterministically scoped by Discord // snowflake so they never collide with real usernames, even // if two Discord users happen to share a handle locally. const ghostUsername = `discord_${author.discordId}`; const junk = `ghost-${author.discordId}-${Date.now()}`; const userId = await ctx.db.insert("userProfiles", { username: ghostUsername, clientSalt: junk, encryptedMasterKey: "", hashedAuthKey: junk, publicIdentityKey: "", publicSigningKey: "", encryptedPrivateKeys: "", isAdmin: false, isGhost: true, discordId: author.discordId, displayName: author.displayName ?? author.username, ghostAvatarUrl: author.avatarUrl, }); out.push({ discordId: author.discordId, userId, created: true, isGhost: true, }); } return out; }, }); /** * Insert a batch of pre-encrypted imported messages. Skips rows * whose `discordMessageId` already exists in the target channel so * re-runs after a partial failure don't duplicate history. * * `isBanned` is intentionally NOT checked: ghosts can't be banned * (no login path) and historical messages from a later-banned real * user shouldn't be blocked from import. The admin-only entry * point is the permission gate. * * Returns the per-row Convex IDs keyed by the original Discord * message ID so the client can resolve cross-batch replies via * `resolveReplyTargets` without a second query. */ export const importBatchInternal = internalMutation({ args: { actorId: v.id("userProfiles"), channelId: v.id("channels"), messages: v.array( v.object({ senderId: v.id("userProfiles"), ciphertext: v.string(), nonce: v.string(), signature: v.string(), keyVersion: v.number(), replyTo: v.optional(v.id("messages")), importedCreatedAt: v.number(), discordMessageId: v.string(), }), ), }, returns: v.object({ inserted: v.number(), skipped: v.number(), resolved: v.array( v.object({ discordMessageId: v.string(), messageId: v.id("messages"), }), ), }), handler: async (ctx, args) => { if (!(await hasPermission(ctx, args.actorId, "manage_channels"))) { throw new Error("You don't have permission to import messages."); } if (args.messages.length === 0) { return { inserted: 0, skipped: 0, resolved: [] }; } if (args.messages.length > MAX_IMPORT_BATCH) { throw new Error(`Batch too large (max ${MAX_IMPORT_BATCH}).`); } let inserted = 0; let skipped = 0; const resolved: Array<{ discordMessageId: string; messageId: Id<"messages"> }> = []; for (const msg of args.messages) { if (msg.ciphertext.length > MAX_CIPHERTEXT_CHARS) { throw new Error("Imported message too large"); } const existing = await ctx.db .query("messages") .withIndex("by_channel_discord_message_id", (q) => q .eq("channelId", args.channelId) .eq("discordMessageId", msg.discordMessageId), ) .first(); if (existing) { skipped++; resolved.push({ discordMessageId: msg.discordMessageId, messageId: existing._id, }); continue; } const id = await ctx.db.insert("messages", { channelId: args.channelId, senderId: msg.senderId, ciphertext: msg.ciphertext, nonce: msg.nonce, signature: msg.signature, keyVersion: msg.keyVersion, replyTo: msg.replyTo, importedCreatedAt: msg.importedCreatedAt, discordMessageId: msg.discordMessageId, isImported: true, }); inserted++; resolved.push({ discordMessageId: msg.discordMessageId, messageId: id }); } if (inserted > 0) { await logAudit(ctx, { actorId: args.actorId, action: AUDIT_ACTIONS.MESSAGES_IMPORT_BULK, targetType: "channel", targetId: args.channelId, metadata: { inserted, skipped, total: args.messages.length }, }); } return { inserted, skipped, resolved }; }, }); /** * Merge step 1: rewrite one page of messages from `ghostUserId` to * `targetUserId`. Called repeatedly from the action until no more * pages. Kept at a bounded page size (`MERGE_PAGE`) so a single * mutation never trips Convex's execution limits on users with * tens of thousands of imported messages. * * The action also tracks the count across pages for the audit log. */ export const mergeGhostPageInternal = internalMutation({ args: { actorId: v.id("userProfiles"), ghostUserId: v.id("userProfiles"), targetUserId: v.id("userProfiles"), }, returns: v.object({ rewritten: v.number(), done: v.boolean() }), handler: async (ctx, args) => { if (!(await hasPermission(ctx, args.actorId, "manage_channels"))) { throw new Error("You don't have permission to merge ghosts."); } const ghost = await ctx.db.get(args.ghostUserId); if (!ghost) throw new Error("Ghost not found"); if (!ghost.isGhost) throw new Error("Refusing to merge a non-ghost user"); const target = await ctx.db.get(args.targetUserId); if (!target) throw new Error("Target user not found"); if (target.isGhost) throw new Error("Merge target must be a real user"); const page = await ctx.db .query("messages") .withIndex("by_sender", (q) => q.eq("senderId", args.ghostUserId)) .take(MERGE_PAGE); for (const m of page) { await ctx.db.patch(m._id, { senderId: args.targetUserId }); } return { rewritten: page.length, done: page.length < MERGE_PAGE }; }, }); /** * Final step of the merge flow: once all messages have been * rewritten, inherit the ghost's `discordId` onto the target (so * future imports of the same Discord author auto-link), delete the * ghost, and write a single audit entry. */ export const finalizeMergeInternal = internalMutation({ args: { actorId: v.id("userProfiles"), ghostUserId: v.id("userProfiles"), targetUserId: v.id("userProfiles"), totalRewritten: v.number(), }, returns: v.null(), handler: async (ctx, args) => { if (!(await hasPermission(ctx, args.actorId, "manage_channels"))) { throw new Error("You don't have permission to merge ghosts."); } const ghost = await ctx.db.get(args.ghostUserId); if (!ghost) return null; // already finalized if (!ghost.isGhost) throw new Error("Refusing to finalize a non-ghost user"); const target = await ctx.db.get(args.targetUserId); if (!target) throw new Error("Target user not found"); // Guard against a stray message slipping in between the last // page and this finalize call. If anything's left, refuse — // the caller will run another page. const stray = await ctx.db .query("messages") .withIndex("by_sender", (q) => q.eq("senderId", args.ghostUserId)) .first(); if (stray) { throw new Error("Messages still attributed to ghost; run another page."); } // Inherit discordId if the target doesn't already have one. If // the target already claimed a different Discord identity we // don't overwrite — that's an admin error, not ours to resolve. if (ghost.discordId && !target.discordId) { await ctx.db.patch(args.targetUserId, { discordId: ghost.discordId }); } await ctx.db.delete(args.ghostUserId); await logAudit(ctx, { actorId: args.actorId, action: AUDIT_ACTIONS.GHOST_MERGE, targetType: "user", targetId: args.targetUserId, targetName: target.displayName ?? target.username, metadata: { ghostDisplayName: ghost.displayName, ghostDiscordId: ghost.discordId, rewritten: args.totalRewritten, }, }); return null; }, }); /** * Bulk-delete all imported messages in a channel. Paged so even a * channel with tens of thousands of imports completes without * tripping Convex's per-mutation execution limit. The action layer * loops until `done: true`. * * Used by the Import tab's "Clear imports" button to wipe blank * rows left behind by a failed earlier import (eg. partial * attachment uploads under the old silent-skip behaviour) so the * admin can re-run from scratch. */ const CLEAR_IMPORTS_PAGE = 200; export const clearChannelImportsPageInternal = internalMutation({ args: { actorId: v.id("userProfiles"), channelId: v.id("channels"), }, returns: v.object({ deleted: v.number(), done: v.boolean() }), handler: async (ctx, args) => { if (!(await hasPermission(ctx, args.actorId, "manage_channels"))) { throw new Error("You don't have permission to clear imports."); } // `by_channel_imported_at` is keyed [channelId, importedCreatedAt]. // Live messages don't set `importedCreatedAt`, so this index is // effectively scoped to imported rows for this channel — no // accidental deletion of live content. const page = await ctx.db .query("messages") .withIndex("by_channel_imported_at", (q) => q.eq("channelId", args.channelId), ) .take(CLEAR_IMPORTS_PAGE); let deleted = 0; for (const m of page) { if (!m.isImported) continue; // defence-in-depth // Cascade: drop any reactions tied to this message so we // don't orphan rows in `messageReactions`. 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); await ctx.db.delete(m._id); deleted++; } return { deleted, done: page.length < CLEAR_IMPORTS_PAGE }; }, }); export const finalizeClearImportsInternal = internalMutation({ args: { actorId: v.id("userProfiles"), channelId: v.id("channels"), totalDeleted: v.number(), }, returns: v.null(), handler: async (ctx, args) => { if (!(await hasPermission(ctx, args.actorId, "manage_channels"))) { throw new Error("You don't have permission to clear imports."); } const channel = await ctx.db.get(args.channelId); await logAudit(ctx, { actorId: args.actorId, action: AUDIT_ACTIONS.MESSAGES_IMPORT_BULK, targetType: "channel", targetId: args.channelId, targetName: channel?.name, metadata: { cleared: args.totalDeleted }, }); return null; }, }); /** * Surgical-repair companion to `resolveReplyTargets`. Returns the * full decryptable body (ciphertext + nonce + keyVersion) for each * existing imported message in `discordMessageIds`. The runner * uses this in repair mode to decrypt each row locally and decide * whether its attachment list is missing entries — cheaper than * dropping and re-inserting every row. */ export const getImportedState = query({ args: { channelId: v.id("channels"), discordMessageIds: v.array(v.string()), }, returns: v.array( v.object({ discordMessageId: v.string(), messageId: v.id("messages"), ciphertext: v.string(), nonce: v.string(), keyVersion: v.number(), }), ), handler: async (ctx, args) => { const out: Array<{ discordMessageId: string; messageId: Id<"messages">; ciphertext: string; nonce: string; keyVersion: number; }> = []; for (const dId of args.discordMessageIds) { const row = await ctx.db .query("messages") .withIndex("by_channel_discord_message_id", (q) => q.eq("channelId", args.channelId).eq("discordMessageId", dId), ) .first(); if (!row) continue; out.push({ discordMessageId: dId, messageId: row._id, ciphertext: row.ciphertext, nonce: row.nonce, keyVersion: row.keyVersion, }); } return out; }, }); /** * Delete a specific list of imported messages by their Discord * snowflakes. Paged so any-size list is safe. Used by the runner's * repair mode to surgically drop rows that decrypted to an empty * or under-attached plaintext, before re-inserting them fresh. */ const DELETE_BY_DISCORD_PAGE = 100; export const deleteImportedByDiscordIdsInternal = internalMutation({ args: { actorId: v.id("userProfiles"), channelId: v.id("channels"), discordMessageIds: v.array(v.string()), }, returns: v.object({ deleted: v.number() }), handler: async (ctx, args) => { if (!(await hasPermission(ctx, args.actorId, "manage_channels"))) { throw new Error("You don't have permission to repair imports."); } if (args.discordMessageIds.length > DELETE_BY_DISCORD_PAGE) { throw new Error(`Delete batch too large (max ${DELETE_BY_DISCORD_PAGE}).`); } let deleted = 0; for (const dId of args.discordMessageIds) { const row = await ctx.db .query("messages") .withIndex("by_channel_discord_message_id", (q) => q.eq("channelId", args.channelId).eq("discordMessageId", dId), ) .first(); if (!row) continue; if (!row.isImported) continue; // defence-in-depth const reactions = await ctx.db .query("messageReactions") .withIndex("by_message", (q) => q.eq("messageId", row._id)) .collect(); for (const r of reactions) await ctx.db.delete(r._id); await ctx.db.delete(row._id); deleted++; } return { deleted }; }, }); /** * Reply-remapping helper. Given a list of Discord message IDs the * import runner has yet to place, return the Convex IDs for the * ones that ARE already imported in this channel. Unimported * entries are omitted (client treats missing keys as "no reply * parent yet"). */ export const resolveReplyTargets = query({ args: { channelId: v.id("channels"), discordMessageIds: v.array(v.string()), }, returns: v.array( v.object({ discordMessageId: v.string(), messageId: v.id("messages"), }), ), handler: async (ctx, args) => { const out: Array<{ discordMessageId: string; messageId: Id<"messages"> }> = []; for (const dId of args.discordMessageIds) { const row = await ctx.db .query("messages") .withIndex("by_channel_discord_message_id", (q) => q.eq("channelId", args.channelId).eq("discordMessageId", dId), ) .first(); if (row) out.push({ discordMessageId: dId, messageId: row._id }); } return out; }, }); /** * Returns the highest Discord message ID already imported into * `channelId`, or null if nothing's been imported yet. The runner * uses this to resume after a restart without double-uploading * attachments for rows that are already in Convex. * * Note: Discord snowflakes are monotonic by creation time, so * "highest string when compared lexicographically after left-padding * to 20 chars" ≈ "most recent". We sort by `importedCreatedAt` * (already indexed) instead, which is more robust and avoids * scanning every row. */ export const getImportProgress = query({ args: { channelId: v.id("channels"), actorId: v.id("userProfiles") }, returns: v.object({ importedCount: v.number(), latestImportedAt: v.union(v.number(), v.null()), earliestImportedAt: v.union(v.number(), v.null()), latestDiscordMessageId: v.union(v.string(), v.null()), }), handler: async (ctx, args) => { if (!(await hasPermission(ctx, args.actorId, "manage_channels"))) { throw new Error("Not authorized"); } // Newest imported row first. const latest = await ctx.db .query("messages") .withIndex("by_channel_imported_at", (q) => q.eq("channelId", args.channelId), ) .order("desc") .first(); // Oldest imported row first. const earliest = await ctx.db .query("messages") .withIndex("by_channel_imported_at", (q) => q.eq("channelId", args.channelId), ) .order("asc") .first(); // Count via a bounded take — no dedicated counter. 10k is a // generous ceiling for the progress badge; the real ground // truth is the client's own run state. We return `importedCount` // as an approximate "at least" measure. const sample = await ctx.db .query("messages") .withIndex("by_channel_imported_at", (q) => q.eq("channelId", args.channelId), ) .take(10_000); const importedCount = sample.filter( (m) => m.importedCreatedAt !== undefined, ).length; return { importedCount, latestImportedAt: latest?.importedCreatedAt ?? null, earliestImportedAt: earliest?.importedCreatedAt ?? null, latestDiscordMessageId: latest?.discordMessageId ?? null, }; }, }); /** * List ghost users for the admin merge UI. Each row includes a * message count (bounded-take approximation) so the operator can * prioritise merging noisy ghosts first. */ export const listGhosts = query({ args: { actorId: v.id("userProfiles") }, returns: v.array( v.object({ _id: v.id("userProfiles"), username: v.string(), displayName: v.optional(v.string()), discordId: v.optional(v.string()), ghostAvatarUrl: v.optional(v.string()), messageCount: v.number(), }), ), handler: async (ctx, args) => { if (!(await hasPermission(ctx, args.actorId, "manage_channels"))) { throw new Error("Not authorized"); } const users = await ctx.db.query("userProfiles").collect(); const ghosts = users.filter((u) => u.isGhost); const out = []; for (const g of ghosts) { const sample = await ctx.db .query("messages") .withIndex("by_sender", (q) => q.eq("senderId", g._id)) .take(1000); out.push({ _id: g._id, username: g.username, displayName: g.displayName, discordId: g.discordId, ghostAvatarUrl: g.ghostAvatarUrl, messageCount: sample.length, }); } out.sort((a, b) => b.messageCount - a.messageCount); return out; }, }); /** * Admin-only fetch of the current channel roster's public keys * (used by the import UI to discover which local users could be * mapped to Discord authors). Returns ghosts too so the UI can * show "already-imported-as-ghost" inline. */ export const listMappingCandidates = query({ args: { actorId: v.id("userProfiles") }, returns: v.array( v.object({ _id: v.id("userProfiles"), username: v.string(), displayName: v.optional(v.string()), avatarUrl: v.union(v.string(), v.null()), isGhost: v.boolean(), discordId: v.optional(v.string()), }), ), handler: async (ctx, args) => { if (!(await hasPermission(ctx, args.actorId, "manage_channels"))) { throw new Error("Not authorized"); } const users = await ctx.db.query("userProfiles").collect(); const out = []; for (const u of users) { let avatarUrl: string | null = null; if (u.avatarStorageId) { avatarUrl = await getPublicStorageUrl(ctx, u.avatarStorageId); } else if (u.isGhost && u.ghostAvatarUrl) { avatarUrl = u.ghostAvatarUrl; } out.push({ _id: u._id, username: u.username, displayName: u.displayName, avatarUrl, isGhost: !!u.isGhost, discordId: u.discordId, }); } return out; }, });