This commit is contained in:
4
convex/_generated/api.d.ts
vendored
4
convex/_generated/api.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
677
convex/importer.ts
Normal 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
241
convex/importerActions.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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") },
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user