This commit is contained in:
Bryan1029384756
2026-04-18 15:41:51 -05:00
parent 938df217f4
commit 593eaba82e
47 changed files with 3539 additions and 212 deletions

View File

@@ -8,9 +8,11 @@
* @module
*/
import type * as audit from "../audit.js";
import type * as auth from "../auth.js";
import type * as authActions from "../authActions.js";
import type * as authGuard from "../authGuard.js";
import type * as bans from "../bans.js";
import type * as categories from "../categories.js";
import type * as channelKeys from "../channelKeys.js";
import type * as channels from "../channels.js";
@@ -43,9 +45,11 @@ import type {
} from "convex/server";
declare const fullApi: ApiFromModules<{
audit: typeof audit;
auth: typeof auth;
authActions: typeof authActions;
authGuard: typeof authGuard;
bans: typeof bans;
categories: typeof categories;
channelKeys: typeof channelKeys;
channels: typeof channels;

127
convex/audit.ts Normal file
View File

@@ -0,0 +1,127 @@
import { query } from "./_generated/server";
import { v } from "convex/values";
import type {
GenericMutationCtx,
GenericQueryCtx,
} from "convex/server";
import type { DataModel, Id } from "./_generated/dataModel";
import { hasPermission } from "./roles";
import { getPublicStorageUrl } from "./storageUrl";
/**
* Known audit actions. Client-facing label mapping lives in the
* settings UI; the backend just stores strings so future actions
* don't require a schema change.
*/
export const AUDIT_ACTIONS = {
CHANNEL_CREATE: "channel.create",
CHANNEL_DELETE: "channel.delete",
CHANNEL_RENAME: "channel.rename",
CHANNEL_UPDATE_TOPIC: "channel.update_topic",
ROLE_CREATE: "role.create",
ROLE_DELETE: "role.delete",
ROLE_UPDATE: "role.update",
ROLE_ASSIGN: "role.assign",
ROLE_UNASSIGN: "role.unassign",
SERVER_SETTINGS_UPDATE: "server.settings_update",
BAN_ADD: "ban.add",
BAN_REMOVE: "ban.remove",
} as const;
/**
* Internal helper — call from mutations that mutate server state.
* Silent on failure: an audit-write that throws would roll back the
* real mutation, which is worse than a missing log entry.
*/
export async function logAudit(
ctx: GenericMutationCtx<DataModel>,
args: {
actorId: Id<"userProfiles">;
action: string;
targetType?: string;
targetId?: string;
targetName?: string;
metadata?: unknown;
},
): Promise<void> {
try {
await ctx.db.insert("auditLog", {
actorId: args.actorId,
action: args.action,
targetType: args.targetType,
targetId: args.targetId,
targetName: args.targetName,
metadata: args.metadata,
createdAt: Date.now(),
});
} catch {
// Audit is best-effort. Don't block the caller.
}
}
// Any moderator-adjacent permission is enough to view the log. We
// don't want to leak the log to @everyone but equally don't want to
// hide it behind a narrow permission no role has by default.
async function canViewAuditLog(
ctx: GenericQueryCtx<DataModel>,
userId: Id<"userProfiles">,
): Promise<boolean> {
return (
(await hasPermission(ctx, userId, "ban_members")) ||
(await hasPermission(ctx, userId, "manage_channels")) ||
(await hasPermission(ctx, userId, "manage_roles")) ||
(await hasPermission(ctx, userId, "manage_messages"))
);
}
export const list = query({
args: {
actorId: v.id("userProfiles"),
limit: v.optional(v.number()),
},
returns: v.array(v.any()),
handler: async (ctx, args) => {
if (!(await canViewAuditLog(ctx, args.actorId))) {
throw new Error("Not authorized to view the audit log.");
}
const limit = Math.min(Math.max(args.limit ?? 200, 1), 500);
const rows = await ctx.db
.query("auditLog")
.withIndex("by_created_at")
.order("desc")
.take(limit);
// Walk rows once, de-duping actors via the map itself. Iterating
// a Set widens the element type and breaks `ctx.db.get`'s
// narrowing — using the map as its own seen-set avoids that.
const actors = new Map<
string,
{ username: string; displayName?: string; avatarUrl: string | null }
>();
for (const r of rows) {
if (actors.has(r.actorId)) continue;
const user = await ctx.db.get(r.actorId);
if (!user) continue;
let avatarUrl: string | null = null;
if (user.avatarStorageId) {
avatarUrl = await getPublicStorageUrl(ctx, user.avatarStorageId);
}
actors.set(r.actorId, {
username: user.username,
displayName: user.displayName,
avatarUrl,
});
}
return rows.map((r) => ({
_id: r._id,
action: r.action,
targetType: r.targetType,
targetId: r.targetId,
targetName: r.targetName,
metadata: r.metadata,
createdAt: r.createdAt,
actor: actors.get(r.actorId) ?? { username: "unknown", avatarUrl: null },
}));
},
});

View File

@@ -2,6 +2,7 @@ import { query, mutation, internalQuery, internalMutation } from "./_generated/s
import { v } from "convex/values";
import { getPublicStorageUrl } from "./storageUrl";
import { getRolesForUser } from "./roles";
import { isBanned } from "./bans";
async function sha256Hex(input: string): Promise<string> {
const buffer = await crypto.subtle.digest(
@@ -62,6 +63,9 @@ export const verifyUser = mutation({
const hashedDAK = await sha256Hex(args.dak);
if (hashedDAK === user.hashedAuthKey) {
if (await isBanned(ctx, user._id)) {
return { error: "You've been banned from this server." };
}
return {
success: true,
userId: user._id,
@@ -166,6 +170,7 @@ export const createUserWithProfile = mutation({
create_invite: true,
embed_links: true,
attach_files: true,
ban_members: true,
},
isHoist: true,
});
@@ -205,6 +210,7 @@ export const getPublicKeys = query({
customStatus: v.optional(v.string()),
joinSoundUrl: v.optional(v.union(v.string(), v.null())),
accentColor: v.optional(v.string()),
bannerUrl: v.optional(v.union(v.string(), v.null())),
})
),
handler: async (ctx) => {
@@ -219,6 +225,10 @@ export const getPublicKeys = query({
if (u.joinSoundStorageId) {
joinSoundUrl = await getPublicStorageUrl(ctx, u.joinSoundStorageId);
}
let bannerUrl: string | null = null;
if (u.bannerStorageId) {
bannerUrl = await getPublicStorageUrl(ctx, u.bannerStorageId);
}
results.push({
id: u._id,
username: u.username,
@@ -230,6 +240,7 @@ export const getPublicKeys = query({
customStatus: u.customStatus,
joinSoundUrl,
accentColor: u.accentColor,
bannerUrl,
});
}
return results;
@@ -251,6 +262,8 @@ export const updateProfileInternal = internalMutation({
joinSoundStorageId: v.optional(v.id("_storage")),
removeJoinSound: v.optional(v.boolean()),
accentColor: v.optional(v.string()),
bannerStorageId: v.optional(v.id("_storage")),
removeBanner: v.optional(v.boolean()),
},
returns: v.null(),
handler: async (ctx, args) => {
@@ -262,6 +275,17 @@ export const updateProfileInternal = internalMutation({
if (args.joinSoundStorageId !== undefined) patch.joinSoundStorageId = args.joinSoundStorageId;
if (args.removeJoinSound) patch.joinSoundStorageId = undefined;
if (args.accentColor !== undefined) patch.accentColor = args.accentColor;
if (args.bannerStorageId !== undefined) patch.bannerStorageId = args.bannerStorageId;
if (args.removeBanner) {
// Drop the blob from storage too so orphaned banners don't
// accumulate. Matches how the rest of this file treats one-off
// user uploads.
const existing = await ctx.db.get(args.userId);
if (existing?.bannerStorageId) {
try { await ctx.storage.delete(existing.bannerStorageId); } catch {}
}
patch.bannerStorageId = undefined;
}
await ctx.db.patch(args.userId, patch);
return null;
},

View File

@@ -20,6 +20,8 @@ export const updateProfile = action({
joinSoundStorageId: v.optional(v.id("_storage")),
removeJoinSound: v.optional(v.boolean()),
accentColor: v.optional(v.string()),
bannerStorageId: v.optional(v.id("_storage")),
removeBanner: v.optional(v.boolean()),
authTimestamp: v.number(),
authSignature: v.string(),
},
@@ -36,6 +38,8 @@ export const updateProfile = action({
joinSoundStorageId: args.joinSoundStorageId,
removeJoinSound: args.removeJoinSound,
accentColor: args.accentColor,
bannerStorageId: args.bannerStorageId,
removeBanner: args.removeBanner,
});
return null;
},

160
convex/bans.ts Normal file
View File

@@ -0,0 +1,160 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
import type { GenericQueryCtx } from "convex/server";
import type { DataModel, Id } from "./_generated/dataModel";
import { hasPermission, getRolesForUser } from "./roles";
import { AUDIT_ACTIONS, logAudit } from "./audit";
import { getPublicStorageUrl } from "./storageUrl";
/**
* Returns true if the user is currently banned. Callers should
* surface this as an explicit `{ error: "Banned" }` at auth time or
* throw from sensitive mutations like `messages.send`.
*/
export async function isBanned(
ctx: GenericQueryCtx<DataModel>,
userId: Id<"userProfiles">,
): Promise<boolean> {
const row = await ctx.db
.query("bans")
.withIndex("by_user", (q) => q.eq("userId", userId))
.first();
return !!row;
}
export const isUserBanned = query({
args: { userId: v.id("userProfiles") },
returns: v.boolean(),
handler: async (ctx, args) => isBanned(ctx, args.userId),
});
// List all bans with actor + target enrichment for the Bans tab.
export const list = query({
args: { actorId: v.id("userProfiles") },
returns: v.array(v.any()),
handler: async (ctx, args) => {
if (!(await hasPermission(ctx, args.actorId, "ban_members"))) {
throw new Error("You don't have permission to view bans.");
}
const bans = await ctx.db.query("bans").collect();
const out = [];
for (const b of bans) {
const user = await ctx.db.get(b.userId);
const actor = await ctx.db.get(b.bannedBy);
let avatarUrl: string | null = null;
if (user?.avatarStorageId) {
avatarUrl = await getPublicStorageUrl(ctx, user.avatarStorageId);
}
out.push({
_id: b._id,
userId: b.userId,
bannedBy: b.bannedBy,
reason: b.reason ?? null,
createdAt: b.createdAt,
user: user
? {
username: user.username,
displayName: user.displayName,
avatarUrl,
}
: null,
actor: actor
? { username: actor.username, displayName: actor.displayName }
: null,
});
}
out.sort((a, b) => b.createdAt - a.createdAt);
return out;
},
});
export const ban = mutation({
args: {
actorId: v.id("userProfiles"),
userId: v.id("userProfiles"),
reason: v.optional(v.string()),
},
returns: v.object({ success: v.boolean() }),
handler: async (ctx, args) => {
if (!(await hasPermission(ctx, args.actorId, "ban_members"))) {
throw new Error("You don't have permission to ban members.");
}
if (args.actorId === args.userId) {
throw new Error("You can't ban yourself.");
}
const target = await ctx.db.get(args.userId);
if (!target) throw new Error("User not found.");
// Refuse to ban isAdmin or Owner-role bearers. Single-server
// deployment can't afford to lock itself out of administration.
if (target.isAdmin) {
throw new Error("Server admins can't be banned.");
}
const targetRoles = await getRolesForUser(ctx, args.userId);
if (targetRoles.some((r) => r.name === "Owner")) {
throw new Error("The Owner can't be banned.");
}
const existing = await ctx.db
.query("bans")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.first();
if (existing) {
// Update reason + actor if re-banning.
await ctx.db.patch(existing._id, {
bannedBy: args.actorId,
reason: args.reason,
createdAt: Date.now(),
});
} else {
await ctx.db.insert("bans", {
userId: args.userId,
bannedBy: args.actorId,
reason: args.reason,
createdAt: Date.now(),
});
}
await logAudit(ctx, {
actorId: args.actorId,
action: AUDIT_ACTIONS.BAN_ADD,
targetType: "user",
targetId: args.userId,
targetName: target.displayName ?? target.username,
metadata: args.reason ? { reason: args.reason } : undefined,
});
return { success: true };
},
});
export const unban = mutation({
args: {
actorId: v.id("userProfiles"),
userId: v.id("userProfiles"),
},
returns: v.object({ success: v.boolean() }),
handler: async (ctx, args) => {
if (!(await hasPermission(ctx, args.actorId, "ban_members"))) {
throw new Error("You don't have permission to unban members.");
}
const row = await ctx.db
.query("bans")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.first();
if (!row) return { success: true };
const target = await ctx.db.get(args.userId);
await ctx.db.delete(row._id);
await logAudit(ctx, {
actorId: args.actorId,
action: AUDIT_ACTIONS.BAN_REMOVE,
targetType: "user",
targetId: args.userId,
targetName: target?.displayName ?? target?.username,
});
return { success: true };
},
});

View File

@@ -3,6 +3,7 @@ import { v } from "convex/values";
import { GenericMutationCtx } from "convex/server";
import { DataModel, Id } from "./_generated/dataModel";
import { internal } from "./_generated/api";
import { AUDIT_ACTIONS, logAudit } from "./audit";
type TableWithChannelIndex =
| "channelKeys"
@@ -74,6 +75,7 @@ export const create = mutation({
categoryId: v.optional(v.id("categories")),
topic: v.optional(v.string()),
position: v.optional(v.number()),
actorId: v.optional(v.id("userProfiles")),
},
returns: v.object({ id: v.id("channels") }),
handler: async (ctx, args) => {
@@ -112,6 +114,17 @@ export const create = mutation({
position,
});
if (args.actorId) {
await logAudit(ctx, {
actorId: args.actorId,
action: AUDIT_ACTIONS.CHANNEL_CREATE,
targetType: "channel",
targetId: id,
targetName: args.name,
metadata: { type: args.type || "text" },
});
}
return { id };
},
});
@@ -136,6 +149,7 @@ export const rename = mutation({
args: {
id: v.id("channels"),
name: v.string(),
actorId: v.optional(v.id("userProfiles")),
},
returns: v.object({
_id: v.id("channels"),
@@ -156,7 +170,20 @@ export const rename = mutation({
throw new Error("Channel not found");
}
const oldName = channel.name;
await ctx.db.patch(args.id, { name: args.name });
if (args.actorId && oldName !== args.name) {
await logAudit(ctx, {
actorId: args.actorId,
action: AUDIT_ACTIONS.CHANNEL_RENAME,
targetType: "channel",
targetId: args.id,
targetName: args.name,
metadata: { from: oldName, to: args.name },
});
}
return { ...channel, name: args.name };
},
});
@@ -205,7 +232,10 @@ export const reorderChannels = mutation({
// Delete channel + cascade messages and keys
export const remove = mutation({
args: { id: v.id("channels") },
args: {
id: v.id("channels"),
actorId: v.optional(v.id("userProfiles")),
},
returns: v.object({ success: v.boolean() }),
handler: async (ctx, args) => {
const channel = await ctx.db.get(args.id);
@@ -213,6 +243,9 @@ export const remove = mutation({
throw new Error("Channel not found");
}
const deletedName = channel.name;
const deletedType = channel.type;
// Delete reactions for all messages in this channel
const messages = await ctx.db
.query("messages")
@@ -240,6 +273,17 @@ export const remove = mutation({
await ctx.db.delete(args.id);
if (args.actorId) {
await logAudit(ctx, {
actorId: args.actorId,
action: AUDIT_ACTIONS.CHANNEL_DELETE,
targetType: "channel",
targetId: args.id,
targetName: deletedName,
metadata: { type: deletedType },
});
}
return { success: true };
},
});

View File

@@ -3,6 +3,7 @@ import { paginationOptsValidator } from "convex/server";
import { v } from "convex/values";
import { getPublicStorageUrl } from "./storageUrl";
import { getRolesForUser } from "./roles";
import { isBanned } from "./bans";
const DEFAULT_ROLE_COLOR = "#99aab5";
@@ -252,6 +253,9 @@ export const sendInternal = internalMutation({
if (args.ciphertext.length > MAX_CIPHERTEXT_CHARS) {
throw new Error("Message too large");
}
if (await isBanned(ctx, args.senderId)) {
throw new Error("You've been banned from this server.");
}
const id = await ctx.db.insert("messages", {
channelId: args.channelId,
senderId: args.senderId,

View File

@@ -82,7 +82,11 @@ export const getAllReadStates = query({
},
});
// Get the latest message timestamp per channel (used by Sidebar)
// Get the latest message timestamp per channel (used by Sidebar).
// Also surfaces `messageId` and `senderId` so the notification
// observer can detect genuinely new messages (not just timestamp
// ticks from edits) and suppress notifications for the user's own
// sends without an extra round-trip.
export const getLatestMessageTimestamps = query({
args: {
channelIds: v.array(v.id("channels")),
@@ -91,6 +95,8 @@ export const getLatestMessageTimestamps = query({
v.object({
channelId: v.id("channels"),
latestTimestamp: v.number(),
messageId: v.optional(v.id("messages")),
senderId: v.optional(v.id("userProfiles")),
})
),
handler: async (ctx, args) => {
@@ -105,6 +111,8 @@ export const getLatestMessageTimestamps = query({
results.push({
channelId,
latestTimestamp: Math.floor(latestMsg._creationTime),
messageId: latestMsg._id,
senderId: latestMsg.senderId,
});
}
}

View File

@@ -13,6 +13,7 @@ const PERMISSION_KEYS = [
"move_members",
"mute_members",
"manage_nicknames",
"ban_members",
] as const;
export async function getRolesForUser(
@@ -30,6 +31,31 @@ export async function getRolesForUser(
return roles.filter((r): r is Doc<"roles"> => r !== null);
}
/**
* Server-side permission check. Use before any mutation that's
* supposed to be gated — the client-side `getMyPermissions` hides
* UI, but a crafted client can still call the mutation.
*
* Treats `isAdmin` bootstrap flag and the "Owner" role as granting
* every permission, including ones that don't yet exist on the role
* row. That keeps future permission additions working for the
* original server owner without requiring a migration pass.
*/
export async function hasPermission(
ctx: GenericQueryCtx<DataModel>,
userId: Id<"userProfiles">,
key: (typeof PERMISSION_KEYS)[number],
): Promise<boolean> {
const user = await ctx.db.get(userId);
if (!user) return false;
if (user.isAdmin) return true;
const roles = await getRolesForUser(ctx, userId);
if (roles.some((r) => r.name === "Owner")) return true;
return roles.some(
(r) => (r.permissions as Record<string, boolean> | undefined)?.[key] === true,
);
}
// List all roles
export const list = query({
args: {},
@@ -249,15 +275,22 @@ export const getMyPermissions = query({
move_members: v.boolean(),
mute_members: v.boolean(),
manage_nicknames: v.boolean(),
ban_members: v.boolean(),
}),
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId);
const roles = await getRolesForUser(ctx, args.userId);
// isAdmin or Owner-role bearers get everything — same logic as
// `hasPermission`. Keeps UI and server enforcement consistent.
const isSuper = !!user?.isAdmin || roles.some((r) => r.name === "Owner");
const finalPerms: Record<string, boolean> = {};
for (const key of PERMISSION_KEYS) {
finalPerms[key] = roles.some(
(role) => (role.permissions as Record<string, boolean>)?.[key]
);
finalPerms[key] =
isSuper ||
roles.some(
(role) => (role.permissions as Record<string, boolean>)?.[key],
);
}
return finalPerms as {
@@ -270,6 +303,7 @@ export const getMyPermissions = query({
move_members: boolean;
mute_members: boolean;
manage_nicknames: boolean;
ban_members: boolean;
};
},
});

View File

@@ -18,6 +18,7 @@ export default defineSchema({
customStatus: v.optional(v.string()),
joinSoundStorageId: v.optional(v.id("_storage")),
accentColor: v.optional(v.string()),
bannerStorageId: v.optional(v.id("_storage")),
}).index("by_username", ["username"]),
categories: defineTable({
@@ -191,6 +192,31 @@ export default defineSchema({
.index("by_poll_user_emoji", ["pollId", "userId", "emoji"])
.index("by_user", ["userId"]),
// Banned users — presence of a row = login + send blocked for that
// user. Unique by `userId` (enforced by the only caller path —
// `bans.ban` does an upsert, not raw insert).
bans: defineTable({
userId: v.id("userProfiles"),
bannedBy: v.id("userProfiles"),
reason: v.optional(v.string()),
createdAt: v.number(),
}).index("by_user", ["userId"]),
// Append-only log of admin-ish actions: channel / role edits, bans,
// server settings changes. Target fields are stringly-typed so
// entries can point at any table without a discriminated union.
auditLog: defineTable({
actorId: v.id("userProfiles"),
action: v.string(),
targetType: v.optional(v.string()),
targetId: v.optional(v.string()),
targetName: v.optional(v.string()),
metadata: v.optional(v.any()),
createdAt: v.number(),
})
.index("by_created_at", ["createdAt"])
.index("by_actor", ["actorId"]),
savedMedia: defineTable({
userId: v.id("userProfiles"),
// Convex storage URL — also the dedupe key for a single user.

View File

@@ -1,6 +1,7 @@
import { query, mutation, internalMutation } from "./_generated/server";
import { v } from "convex/values";
import { getRolesForUser } from "./roles";
import { AUDIT_ACTIONS, logAudit } from "./audit";
export const get = query({
args: {},
@@ -58,6 +59,13 @@ export const update = mutation({
});
}
await logAudit(ctx, {
actorId: args.userId,
action: AUDIT_ACTIONS.SERVER_SETTINGS_UPDATE,
targetType: "server",
metadata: { afkChannelId: args.afkChannelId, afkTimeout: args.afkTimeout },
});
return null;
},
});
@@ -85,6 +93,7 @@ export const updateName = mutation({
}
const existing = await ctx.db.query("serverSettings").first();
const oldName = existing?.serverName;
if (existing) {
await ctx.db.patch(existing._id, { serverName: name });
} else {
@@ -94,6 +103,16 @@ export const updateName = mutation({
});
}
if (oldName !== name) {
await logAudit(ctx, {
actorId: args.userId,
action: AUDIT_ACTIONS.SERVER_SETTINGS_UPDATE,
targetType: "server",
targetName: name,
metadata: { field: "serverName", from: oldName, to: name },
});
}
return null;
},
});