242 lines
7.2 KiB
TypeScript
242 lines
7.2 KiB
TypeScript
"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;
|
|
},
|
|
});
|