feat: Implement core Discord clone functionality including Convex backend, Electron frontend, and UI components for chat, voice, and settings.
This commit is contained in:
226
convex/auth.ts
Normal file
226
convex/auth.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { query, mutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
// Get salt for a username (returns fake salt for non-existent users)
|
||||
export const getSalt = query({
|
||||
args: { username: v.string() },
|
||||
returns: v.object({ salt: v.string() }),
|
||||
handler: async (ctx, args) => {
|
||||
const user = await ctx.db
|
||||
.query("userProfiles")
|
||||
.withIndex("by_username", (q) => q.eq("username", args.username))
|
||||
.unique();
|
||||
|
||||
if (user) {
|
||||
return { salt: user.clientSalt };
|
||||
}
|
||||
|
||||
// Generate deterministic fake salt for non-existent users (privacy)
|
||||
// Simple HMAC-like approach using username
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode("SERVER_SECRET_KEY" + args.username);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||
const hashArray = new Uint8Array(hashBuffer);
|
||||
const fakeSalt = Array.from(hashArray)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
|
||||
return { salt: fakeSalt };
|
||||
},
|
||||
});
|
||||
|
||||
// Verify user credentials (DAK comparison)
|
||||
export const verifyUser = mutation({
|
||||
args: {
|
||||
username: v.string(),
|
||||
dak: v.string(),
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({
|
||||
success: v.boolean(),
|
||||
userId: v.string(),
|
||||
encryptedMK: v.string(),
|
||||
encryptedPrivateKeys: v.string(),
|
||||
publicKey: v.string(),
|
||||
}),
|
||||
v.object({ error: v.string() })
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const user = await ctx.db
|
||||
.query("userProfiles")
|
||||
.withIndex("by_username", (q) => q.eq("username", args.username))
|
||||
.unique();
|
||||
|
||||
if (!user) {
|
||||
return { error: "Invalid credentials" };
|
||||
}
|
||||
|
||||
// Hash the DAK with SHA-256 and compare
|
||||
const encoder = new TextEncoder();
|
||||
const dakBuffer = encoder.encode(args.dak);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", dakBuffer);
|
||||
const hashArray = new Uint8Array(hashBuffer);
|
||||
const hashedDAK = Array.from(hashArray)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
|
||||
if (hashedDAK === user.hashedAuthKey) {
|
||||
return {
|
||||
success: true,
|
||||
userId: user._id,
|
||||
encryptedMK: user.encryptedMasterKey,
|
||||
encryptedPrivateKeys: user.encryptedPrivateKeys,
|
||||
publicKey: user.publicIdentityKey,
|
||||
};
|
||||
}
|
||||
|
||||
return { error: "Invalid credentials" };
|
||||
},
|
||||
});
|
||||
|
||||
// Register new user with crypto keys
|
||||
export const createUserWithProfile = mutation({
|
||||
args: {
|
||||
username: v.string(),
|
||||
salt: v.string(),
|
||||
encryptedMK: v.string(),
|
||||
hak: v.string(),
|
||||
publicKey: v.string(),
|
||||
signingKey: v.string(),
|
||||
encryptedPrivateKeys: v.string(),
|
||||
inviteCode: v.optional(v.string()),
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({ success: v.boolean(), userId: v.string() }),
|
||||
v.object({ error: v.string() })
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
// Check if username is taken
|
||||
const existing = await ctx.db
|
||||
.query("userProfiles")
|
||||
.withIndex("by_username", (q) => q.eq("username", args.username))
|
||||
.unique();
|
||||
|
||||
if (existing) {
|
||||
return { error: "Username taken" };
|
||||
}
|
||||
|
||||
// Count existing users
|
||||
const allUsers = await ctx.db.query("userProfiles").collect();
|
||||
const userCount = allUsers.length;
|
||||
|
||||
// Enforce invite code for non-first users
|
||||
if (userCount > 0) {
|
||||
if (!args.inviteCode) {
|
||||
return { error: "Invite code required" };
|
||||
}
|
||||
|
||||
// Validate invite
|
||||
const invite = await ctx.db
|
||||
.query("invites")
|
||||
.withIndex("by_code", (q) => q.eq("code", args.inviteCode))
|
||||
.unique();
|
||||
|
||||
if (!invite) {
|
||||
return { error: "Invalid invite code" };
|
||||
}
|
||||
|
||||
if (invite.expiresAt && Date.now() > invite.expiresAt) {
|
||||
return { error: "Invite expired" };
|
||||
}
|
||||
|
||||
if (
|
||||
invite.maxUses !== undefined &&
|
||||
invite.maxUses !== null &&
|
||||
invite.uses >= invite.maxUses
|
||||
) {
|
||||
return { error: "Invite max uses reached" };
|
||||
}
|
||||
|
||||
// Increment invite usage
|
||||
await ctx.db.patch(invite._id, { uses: invite.uses + 1 });
|
||||
}
|
||||
|
||||
// Create user profile
|
||||
const userId = await ctx.db.insert("userProfiles", {
|
||||
username: args.username,
|
||||
clientSalt: args.salt,
|
||||
encryptedMasterKey: args.encryptedMK,
|
||||
hashedAuthKey: args.hak,
|
||||
publicIdentityKey: args.publicKey,
|
||||
publicSigningKey: args.signingKey,
|
||||
encryptedPrivateKeys: args.encryptedPrivateKeys,
|
||||
isAdmin: userCount === 0,
|
||||
});
|
||||
|
||||
// First user bootstrap: create Owner + @everyone roles if they don't exist
|
||||
if (userCount === 0) {
|
||||
// Create @everyone role
|
||||
const everyoneRoleId = await ctx.db.insert("roles", {
|
||||
name: "@everyone",
|
||||
color: "#99aab5",
|
||||
position: 0,
|
||||
permissions: {
|
||||
create_invite: true,
|
||||
embed_links: true,
|
||||
attach_files: true,
|
||||
},
|
||||
isHoist: false,
|
||||
});
|
||||
|
||||
// Create Owner role
|
||||
const ownerRoleId = await ctx.db.insert("roles", {
|
||||
name: "Owner",
|
||||
color: "#e91e63",
|
||||
position: 100,
|
||||
permissions: {
|
||||
manage_channels: true,
|
||||
manage_roles: true,
|
||||
create_invite: true,
|
||||
embed_links: true,
|
||||
attach_files: true,
|
||||
},
|
||||
isHoist: true,
|
||||
});
|
||||
|
||||
// Assign both roles to first user
|
||||
await ctx.db.insert("userRoles", { userId, roleId: everyoneRoleId });
|
||||
await ctx.db.insert("userRoles", { userId, roleId: ownerRoleId });
|
||||
} else {
|
||||
// Assign @everyone role to new user
|
||||
const everyoneRole = await ctx.db
|
||||
.query("roles")
|
||||
.filter((q) => q.eq(q.field("name"), "@everyone"))
|
||||
.first();
|
||||
|
||||
if (everyoneRole) {
|
||||
await ctx.db.insert("userRoles", {
|
||||
userId,
|
||||
roleId: everyoneRole._id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, userId };
|
||||
},
|
||||
});
|
||||
|
||||
// Get all users' public keys
|
||||
export const getPublicKeys = query({
|
||||
args: {},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
id: v.string(),
|
||||
username: v.string(),
|
||||
public_identity_key: v.string(),
|
||||
})
|
||||
),
|
||||
handler: async (ctx) => {
|
||||
const users = await ctx.db.query("userProfiles").collect();
|
||||
return users.map((u) => ({
|
||||
id: u._id,
|
||||
username: u.username,
|
||||
public_identity_key: u.publicIdentityKey,
|
||||
}));
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user