bump
Some checks failed
Build and Release / build-and-release (push) Has been cancelled

This commit is contained in:
Bryan1029384756
2026-04-20 17:09:04 -05:00
parent 82f8a12e27
commit b83360db35
48 changed files with 6079 additions and 49 deletions

View File

@@ -20,6 +20,8 @@ import type * as customEmojis from "../customEmojis.js";
import type * as dms from "../dms.js";
import type * as files from "../files.js";
import type * as gifs from "../gifs.js";
import type * as importer from "../importer.js";
import type * as importerActions from "../importerActions.js";
import type * as invites from "../invites.js";
import type * as links from "../links.js";
import type * as members from "../members.js";
@@ -57,6 +59,8 @@ declare const fullApi: ApiFromModules<{
dms: typeof dms;
files: typeof files;
gifs: typeof gifs;
importer: typeof importer;
importerActions: typeof importerActions;
invites: typeof invites;
links: typeof links;
members: typeof members;

View File

@@ -26,6 +26,10 @@ export const AUDIT_ACTIONS = {
SERVER_SETTINGS_UPDATE: "server.settings_update",
BAN_ADD: "ban.add",
BAN_REMOVE: "ban.remove",
MESSAGES_PURGE_ALL: "messages.purge_all",
MESSAGES_IMPORT_BULK: "messages.import_bulk",
GHOST_MERGE: "user.ghost_merge",
KEYS_GRANT: "keys.grant",
} as const;
/**

View File

@@ -217,6 +217,12 @@ export const getPublicKeys = query({
const users = await ctx.db.query("userProfiles").collect();
const results = [];
for (const u of users) {
// Ghost profiles are import-only — they have no real public
// identity key, can't log in, and shouldn't surface in
// mention autocomplete / DM pickers. Rendering of imported
// messages goes through `messages.enrichMessage` which hits
// `userProfiles` directly, so filtering here is safe.
if (u.isGhost) continue;
let avatarUrl: string | null = null;
if (u.avatarStorageId) {
avatarUrl = await getPublicStorageUrl(ctx, u.avatarStorageId);

View File

@@ -1,5 +1,8 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
import { hasPermission } from "./roles";
import { AUDIT_ACTIONS, logAudit } from "./audit";
import { getPublicStorageUrl } from "./storageUrl";
/**
* Rotate the symmetric key for a DM channel. Inserts a brand-new
@@ -142,3 +145,138 @@ export const getKeysForUser = query({
}));
},
});
/**
* Admin-only query: list users who don't have a `channelKeys` row for
* the given (non-DM) channel. Powers the Channel Settings → Access
* panel where an admin grants missing keys to users who joined via a
* broken invite (only received one channel's key instead of all).
*
* The actor themselves is filtered out — you can't be missing your own
* key from your own POV, and the UI never needs to grant to self.
* Ghosts and users without a public key are filtered (can't log in
* anyway / nothing to encrypt against).
*/
export const getUsersMissingChannelKey = query({
args: {
actorId: v.id("userProfiles"),
channelId: v.id("channels"),
},
returns: v.array(
v.object({
userId: v.id("userProfiles"),
username: v.string(),
displayName: v.union(v.string(), v.null()),
avatarUrl: v.union(v.string(), v.null()),
userPublicKey: v.string(),
}),
),
handler: async (ctx, args) => {
if (!(await hasPermission(ctx, args.actorId, "manage_channels"))) {
throw new Error("Forbidden");
}
const channel = await ctx.db.get(args.channelId);
if (!channel) throw new Error("Channel not found");
if (channel.type === "dm") {
throw new Error("grantChannelAccess is not supported for DM channels");
}
const existing = await ctx.db
.query("channelKeys")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.collect();
const have = new Set(existing.map((k) => k.userId as unknown as string));
const users = await ctx.db.query("userProfiles").collect();
const missing = users.filter(
(u) =>
!!u.publicIdentityKey &&
!u.isGhost &&
(u._id as unknown as string) !== (args.actorId as unknown as string) &&
!have.has(u._id as unknown as string),
);
const results = [];
for (const u of missing) {
let avatarUrl: string | null = null;
if (u.avatarStorageId) {
avatarUrl = await getPublicStorageUrl(ctx, u.avatarStorageId);
}
results.push({
userId: u._id,
username: u.username,
displayName: u.displayName ?? null,
avatarUrl,
userPublicKey: u.publicIdentityKey,
});
}
return results;
},
});
/**
* Admin-gated single-user grant. The admin's client has already
* decrypted its own channel-key bundle and re-encrypted the key against
* the target user's RSA public key — this mutation just upserts the
* row, re-checks auth, and writes an audit entry. Kept separate from
* `uploadKeys` on purpose: that path is used unauthenticated during
* register/invite-accept for the caller's own keys, and loosening it
* to accept cross-user writes would let any client grant themselves
* access to any channel.
*/
export const grantChannelAccess = mutation({
args: {
actorId: v.id("userProfiles"),
channelId: v.id("channels"),
userId: v.id("userProfiles"),
encryptedKeyBundle: v.string(),
keyVersion: v.number(),
},
returns: v.object({ success: v.boolean() }),
handler: async (ctx, args) => {
if (!(await hasPermission(ctx, args.actorId, "manage_channels"))) {
throw new Error("Forbidden");
}
const channel = await ctx.db.get(args.channelId);
if (!channel) throw new Error("Channel not found");
if (channel.type === "dm") {
throw new Error("grantChannelAccess is not supported for DM channels");
}
const target = await ctx.db.get(args.userId);
if (!target) throw new Error("Target user not found");
if (target.isGhost || !target.publicIdentityKey) {
throw new Error("Target user can't receive keys");
}
const existing = await ctx.db
.query("channelKeys")
.withIndex("by_channel_and_user", (q) =>
q.eq("channelId", args.channelId).eq("userId", args.userId),
)
.unique();
if (existing) {
await ctx.db.patch(existing._id, {
encryptedKeyBundle: args.encryptedKeyBundle,
keyVersion: args.keyVersion,
});
} else {
await ctx.db.insert("channelKeys", {
channelId: args.channelId,
userId: args.userId,
encryptedKeyBundle: args.encryptedKeyBundle,
keyVersion: args.keyVersion,
});
}
await logAudit(ctx, {
actorId: args.actorId,
action: AUDIT_ACTIONS.KEYS_GRANT,
targetType: "channel",
targetId: args.channelId as unknown as string,
targetName: channel.name,
metadata: { userId: args.userId, username: target.username },
});
return { success: true };
},
});

677
convex/importer.ts Normal file
View File

@@ -0,0 +1,677 @@
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<string, unknown> = {};
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;
},
});

241
convex/importerActions.ts Normal file
View File

@@ -0,0 +1,241 @@
"use node";
import { action } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
import { requireAuth } from "./authGuard";
/**
* Signed-import actions.
*
* The same `requireAuth` pattern used for `messageActions.send`:
* the admin signs a canonical string with their Ed25519 key so
* the server can prove the caller controls `actorId` before any
* ghost is created, any message is attributed to someone, or any
* ghost is merged into a real user. Without these signatures an
* attacker who knows the admin's userId could spoof `actorId` and
* piggy-back on the admin's `manage_channels` permission.
*/
export const prepareGhostsAction = action({
args: {
actorId: v.id("userProfiles"),
authors: v.array(
v.object({
discordId: v.string(),
username: v.string(),
displayName: v.optional(v.string()),
avatarUrl: v.optional(v.string()),
}),
),
authTimestamp: v.number(),
authSignature: v.string(),
},
returns: v.array(
v.object({
discordId: v.string(),
userId: v.id("userProfiles"),
created: v.boolean(),
isGhost: v.boolean(),
}),
),
handler: async (ctx, args): Promise<any> => {
// Signature covers the actor + count so a replayed sig can't be
// redirected at a larger author list.
const canonical = `prepareGhosts:${args.actorId}:${args.authors.length}:${args.authTimestamp}`;
await requireAuth(
ctx,
args.actorId,
args.authTimestamp,
args.authSignature,
canonical,
);
return await ctx.runMutation(internal.importer.ensureGhostsInternal, {
authors: args.authors,
});
},
});
export const importBatchAction = action({
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(),
}),
),
authTimestamp: v.number(),
authSignature: 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): Promise<any> => {
const canonical = `importBatch:${args.actorId}:${args.channelId}:${args.messages.length}:${args.authTimestamp}`;
await requireAuth(
ctx,
args.actorId,
args.authTimestamp,
args.authSignature,
canonical,
);
return await ctx.runMutation(internal.importer.importBatchInternal, {
actorId: args.actorId,
channelId: args.channelId,
messages: args.messages,
});
},
});
/**
* Merge a ghost into a real user. Paged so any ghost size is
* supported — the client polls this in a loop until `done: true`,
* accumulating the rewritten count, then calls `finalizeMerge`.
*/
export const mergeGhostPageAction = action({
args: {
actorId: v.id("userProfiles"),
ghostUserId: v.id("userProfiles"),
targetUserId: v.id("userProfiles"),
authTimestamp: v.number(),
authSignature: v.string(),
},
returns: v.object({ rewritten: v.number(), done: v.boolean() }),
handler: async (ctx, args): Promise<any> => {
const canonical = `mergeGhostPage:${args.actorId}:${args.ghostUserId}:${args.targetUserId}:${args.authTimestamp}`;
await requireAuth(
ctx,
args.actorId,
args.authTimestamp,
args.authSignature,
canonical,
);
return await ctx.runMutation(internal.importer.mergeGhostPageInternal, {
actorId: args.actorId,
ghostUserId: args.ghostUserId,
targetUserId: args.targetUserId,
});
},
});
/**
* Repair-mode helper: delete a specific set of imported messages
* by Discord snowflake. The runner calls this right before it
* re-inserts the same rows (with their attachments) under normal
* import flow. Capped at 100 ids per call to match the internal
* mutation's `DELETE_BY_DISCORD_PAGE`.
*/
export const deleteImportedByDiscordIdsAction = action({
args: {
actorId: v.id("userProfiles"),
channelId: v.id("channels"),
discordMessageIds: v.array(v.string()),
authTimestamp: v.number(),
authSignature: v.string(),
},
returns: v.object({ deleted: v.number() }),
handler: async (ctx, args): Promise<{ deleted: number }> => {
const canonical = `deleteImportedByDiscordIds:${args.actorId}:${args.channelId}:${args.discordMessageIds.length}:${args.authTimestamp}`;
await requireAuth(
ctx,
args.actorId,
args.authTimestamp,
args.authSignature,
canonical,
);
return await ctx.runMutation(
internal.importer.deleteImportedByDiscordIdsInternal,
{
actorId: args.actorId,
channelId: args.channelId,
discordMessageIds: args.discordMessageIds,
},
);
},
});
/**
* Wipe every imported message in a channel. Paged so any volume
* succeeds — the action loops over `clearChannelImportsPageInternal`
* until it reports `done`, then writes a single audit entry. Used
* by the Import tab's per-channel "Clear imports" button so a
* partial earlier run can be wiped + re-imported cleanly.
*/
export const clearChannelImportsAction = action({
args: {
actorId: v.id("userProfiles"),
channelId: v.id("channels"),
authTimestamp: v.number(),
authSignature: v.string(),
},
returns: v.object({ deleted: v.number() }),
handler: async (ctx, args): Promise<{ deleted: number }> => {
const canonical = `clearChannelImports:${args.actorId}:${args.channelId}:${args.authTimestamp}`;
await requireAuth(
ctx,
args.actorId,
args.authTimestamp,
args.authSignature,
canonical,
);
let total = 0;
for (;;) {
const page: { deleted: number; done: boolean } = await ctx.runMutation(
internal.importer.clearChannelImportsPageInternal,
{ actorId: args.actorId, channelId: args.channelId },
);
total += page.deleted;
if (page.done) break;
}
await ctx.runMutation(internal.importer.finalizeClearImportsInternal, {
actorId: args.actorId,
channelId: args.channelId,
totalDeleted: total,
});
return { deleted: total };
},
});
export const finalizeMergeAction = action({
args: {
actorId: v.id("userProfiles"),
ghostUserId: v.id("userProfiles"),
targetUserId: v.id("userProfiles"),
totalRewritten: v.number(),
authTimestamp: v.number(),
authSignature: v.string(),
},
returns: v.null(),
handler: async (ctx, args): Promise<null> => {
const canonical = `finalizeMerge:${args.actorId}:${args.ghostUserId}:${args.targetUserId}:${args.authTimestamp}`;
await requireAuth(
ctx,
args.actorId,
args.authTimestamp,
args.authSignature,
canonical,
);
await ctx.runMutation(internal.importer.finalizeMergeInternal, {
actorId: args.actorId,
ghostUserId: args.ghostUserId,
targetUserId: args.targetUserId,
totalRewritten: args.totalRewritten,
});
return null;
},
});

View File

@@ -71,6 +71,10 @@ export const listAll = query({
const users = await ctx.db.query("userProfiles").collect();
const results = [];
for (const user of users) {
// Ghosts are placeholder profiles for imported messages —
// keep them out of the server-wide member list so they
// don't pollute presence / DM-target / mention pickers.
if (user.isGhost) continue;
let avatarUrl: string | null = null;
if (user.avatarStorageId) {
avatarUrl = await getPublicStorageUrl(ctx, user.avatarStorageId);

View File

@@ -1,18 +1,27 @@
import { query, internalMutation } from "./_generated/server";
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
@@ -137,10 +146,18 @@ async function enrichMessage(ctx: any, msg: any, userId?: any) {
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,
@@ -149,7 +166,7 @@ async function enrichMessage(ctx: any, msg: any, userId?: any) {
nonce: msg.nonce,
signature: msg.signature,
key_version: msg.keyVersion,
created_at: new Date(msg._creationTime).toISOString(),
created_at: new Date(effectiveCreatedAt).toISOString(),
username: sender?.username || "Unknown",
displayName: sender?.displayName || null,
public_signing_key: sender?.publicSigningKey || "",
@@ -164,6 +181,8 @@ async function enrichMessage(ctx: any, msg: any, userId?: any) {
replyToAvatarUrl,
editedAt: msg.editedAt || null,
pinned: msg.pinned || false,
isImported: msg.isImported ?? false,
importedCreatedAt: msg.importedCreatedAt ?? null,
};
}
@@ -537,3 +556,116 @@ export const removeInternal = internalMutation({
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,
};
},
});

View File

@@ -1,5 +1,6 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { getRolesForUser } from "./roles";
const pollOptionValidator = v.object({
id: v.string(),
@@ -195,8 +196,17 @@ export const remove = mutation({
handler: async (ctx, args) => {
const poll = await ctx.db.get(args.pollId);
if (!poll) return null;
if (poll.createdBy !== args.userId) {
throw new Error("Only the poll creator can delete it");
const isCreator = poll.createdBy === args.userId;
if (!isCreator) {
// Mirror `messages.removeInternal` — users with `manage_messages`
// can delete any poll, not just their own.
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 poll");
}
}
const votes = await ctx.db
.query("pollVotes")

View File

@@ -262,6 +262,24 @@ export const unassign = mutation({
},
});
/**
* Owner check — true for the bootstrap admin flag (isAdmin) AND for
* anyone bearing the reserved "Owner" role. Exposed as a query so
* the UI can gate destructive "whole-server" actions (Danger Zone)
* behind owner-only visibility without duplicating the rule.
*/
export const isOwner = query({
args: { userId: v.id("userProfiles") },
returns: v.boolean(),
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId);
if (!user) return false;
if (user.isAdmin) return true;
const roles = await getRolesForUser(ctx, args.userId);
return roles.some((r) => r.name === "Owner");
},
});
// Get current user's aggregated permissions
export const getMyPermissions = query({
args: { userId: v.id("userProfiles") },

View File

@@ -19,7 +19,24 @@ export default defineSchema({
joinSoundStorageId: v.optional(v.id("_storage")),
accentColor: v.optional(v.string()),
bannerStorageId: v.optional(v.id("_storage")),
}).index("by_username", ["username"]),
// Discord backup import support. `discordId` lets us dedupe
// ghost creation across re-runs and later "claim" an identity
// by attaching the snowflake to a real user. `isGhost` marks a
// placeholder profile created to attribute imported messages
// when the original Discord user hasn't been mapped to a real
// local user yet — ghosts can't log in (their auth material is
// random junk) and are filtered out of member / presence /
// mention lookups.
discordId: v.optional(v.string()),
isGhost: v.optional(v.boolean()),
// URL-based avatar fallback for ghosts — we copy Discord's CDN
// URL rather than re-hosting, since the ghost might never be
// merged and uploading 35+ avatars-that-might-expire isn't
// worth the storage churn. `null`/missing on real users, who
// use `avatarStorageId` instead.
ghostAvatarUrl: v.optional(v.string()),
}).index("by_username", ["username"])
.index("by_discord_id", ["discordId"]),
categories: defineTable({
name: v.string(),
@@ -45,9 +62,22 @@ export default defineSchema({
replyTo: v.optional(v.id("messages")),
editedAt: v.optional(v.number()),
pinned: v.optional(v.boolean()),
// Discord backup import fields. `importedCreatedAt` preserves
// the original Discord timestamp so imports land in the right
// place in channel history — the renderer prefers it over
// `_creationTime` when present. `discordMessageId` is the
// snowflake used for dedupe on re-runs + remapping Discord
// reply-to IDs to Convex message IDs. `isImported` is a cheap
// flag for UI badges and future filters.
importedCreatedAt: v.optional(v.number()),
discordMessageId: v.optional(v.string()),
isImported: v.optional(v.boolean()),
}).index("by_channel", ["channelId"])
.index("by_channel_pinned", ["channelId", "pinned"])
.index("by_sender", ["senderId"]),
.index("by_sender", ["senderId"])
.index("by_channel_imported_at", ["channelId", "importedCreatedAt"])
.index("by_discord_message_id", ["discordMessageId"])
.index("by_channel_discord_message_id", ["channelId", "discordMessageId"]),
messageReactions: defineTable({
messageId: v.id("messages"),