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

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;
},
});