chore: Initial commit of Opencord base state

This commit is contained in:
Jannis Braun
2026-02-18 02:49:21 +01:00
commit 4fd17084a5
124 changed files with 17955 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@opencord/server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "node --import tsx/esm src/index.ts",
"build": "tsc",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fastify/cors": "^9.0.1",
"@fastify/multipart": "^8.3.0",
"@fastify/static": "^7.0.4",
"@fastify/websocket": "^10.0.1",
"@opencord/shared": "workspace:*",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.3.0",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.33.0",
"fastify": "^4.28.1",
"jsonwebtoken": "^9.0.2",
"livekit-server-sdk": "^2.6.1"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.11",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.14.0",
"@types/ws": "^8.18.1",
"drizzle-kit": "^0.24.0",
"tsx": "^4.16.0",
"typescript": "^5.4.0"
}
}
+49
View File
@@ -0,0 +1,49 @@
import { config as dotenvConfig } from 'dotenv';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
dotenvConfig({ path: resolve(__dirname, '../../../.env') });
function env(key: string, defaultValue?: string): string {
const value = process.env[key] ?? defaultValue;
if (value === undefined) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
function envInt(key: string, defaultValue: number): number {
const value = process.env[key];
if (value === undefined) return defaultValue;
const parsed = parseInt(value, 10);
if (isNaN(parsed)) {
throw new Error(`Environment variable ${key} must be a number, got: ${value}`);
}
return parsed;
}
function envBool(key: string, defaultValue: boolean): boolean {
const value = process.env[key];
if (value === undefined) return defaultValue;
return value === 'true' || value === '1';
}
export const config = {
port: envInt('PORT', 3000),
host: env('HOST', '0.0.0.0'),
jwtSecret: env('JWT_SECRET', 'dev-secret-change-me-in-production-please-use-64-chars-hex-string'),
jwtExpiresIn: env('JWT_EXPIRES_IN', '30d'),
livekit: {
url: env('LIVEKIT_URL', 'wss://nova.ddns.net/livekit'),
apiKey: env('LIVEKIT_API_KEY', 'REDACTED_LIVEKIT_KEY'),
apiSecret: env('LIVEKIT_API_SECRET', 'REDACTED_LIVEKIT_SECRET'),
},
uploadDir: env('UPLOAD_DIR', resolve(__dirname, '../../../data/uploads')),
dbPath: env('DB_PATH', resolve(__dirname, '../../../data/opencord.db')),
maxUploadSize: envInt('MAX_UPLOAD_SIZE', 104857600),
registrationOpen: envBool('REGISTRATION_OPEN', true),
} as const;
+123
View File
@@ -0,0 +1,123 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { config } from '../config.js';
import * as schema from './schema.js';
import { mkdirSync } from 'fs';
import { dirname } from 'path';
let sqlite: Database.Database;
function ensureDirectory(filePath: string): void {
const dir = dirname(filePath);
mkdirSync(dir, { recursive: true });
}
function createTables(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
display_name TEXT,
password_hash TEXT NOT NULL,
avatar TEXT,
status TEXT DEFAULT 'offline',
custom_status TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT,
owner_id TEXT NOT NULL REFERENCES users(id),
invite_code TEXT UNIQUE,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS server_members (
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT DEFAULT 'member',
nickname TEXT,
joined_at INTEGER NOT NULL,
PRIMARY KEY (server_id, user_id)
);
CREATE TABLE IF NOT EXISTS channels (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT NOT NULL,
topic TEXT,
position INTEGER DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id),
content TEXT,
edited_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
message_id TEXT REFERENCES messages(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
mimetype TEXT NOT NULL,
size INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS dm_channels (
id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS dm_members (
dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (dm_channel_id, user_id)
);
CREATE TABLE IF NOT EXISTS dm_messages (
id TEXT PRIMARY KEY,
dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id),
content TEXT,
created_at INTEGER NOT NULL
);
`);
}
export function initDatabase() {
ensureDirectory(config.dbPath);
sqlite = new Database(config.dbPath);
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
createTables(sqlite);
console.log(`Database initialized at ${config.dbPath}`);
return drizzle(sqlite, { schema });
}
export type DB = ReturnType<typeof initDatabase>;
let db: DB;
export function getDb(): DB {
if (!db) {
db = initDatabase();
}
return db;
}
export function closeDatabase(): void {
if (sqlite) {
sqlite.close();
}
}
export { schema };
+80
View File
@@ -0,0 +1,80 @@
import { sqliteTable, text, integer, primaryKey } from 'drizzle-orm/sqlite-core';
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
username: text('username').unique().notNull(),
displayName: text('display_name'),
passwordHash: text('password_hash').notNull(),
avatar: text('avatar'),
status: text('status').default('offline'),
customStatus: text('custom_status'),
createdAt: integer('created_at').notNull(),
});
export const servers = sqliteTable('servers', {
id: text('id').primaryKey(),
name: text('name').notNull(),
icon: text('icon'),
ownerId: text('owner_id').notNull().references(() => users.id),
inviteCode: text('invite_code').unique(),
createdAt: integer('created_at').notNull(),
});
export const serverMembers = sqliteTable('server_members', {
serverId: text('server_id').notNull().references(() => servers.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
role: text('role').default('member'),
nickname: text('nickname'),
joinedAt: integer('joined_at').notNull(),
}, (table) => ({
pk: primaryKey({ columns: [table.serverId, table.userId] }),
}));
export const channels = sqliteTable('channels', {
id: text('id').primaryKey(),
serverId: text('server_id').notNull().references(() => servers.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
type: text('type').notNull(),
topic: text('topic'),
position: integer('position').default(0),
createdAt: integer('created_at').notNull(),
});
export const messages = sqliteTable('messages', {
id: text('id').primaryKey(),
channelId: text('channel_id').notNull().references(() => channels.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull().references(() => users.id),
content: text('content'),
editedAt: integer('edited_at'),
createdAt: integer('created_at').notNull(),
});
export const attachments = sqliteTable('attachments', {
id: text('id').primaryKey(),
messageId: text('message_id').references(() => messages.id, { onDelete: 'cascade' }),
filename: text('filename').notNull(),
originalName: text('original_name').notNull(),
mimetype: text('mimetype').notNull(),
size: integer('size').notNull(),
createdAt: integer('created_at').notNull(),
});
export const dmChannels = sqliteTable('dm_channels', {
id: text('id').primaryKey(),
createdAt: integer('created_at').notNull(),
});
export const dmMembers = sqliteTable('dm_members', {
dmChannelId: text('dm_channel_id').notNull().references(() => dmChannels.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
}, (table) => ({
pk: primaryKey({ columns: [table.dmChannelId, table.userId] }),
}));
export const dmMessages = sqliteTable('dm_messages', {
id: text('id').primaryKey(),
dmChannelId: text('dm_channel_id').notNull().references(() => dmChannels.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull().references(() => users.id),
content: text('content'),
createdAt: integer('created_at').notNull(),
});
+69
View File
@@ -0,0 +1,69 @@
import { getDb, schema } from './index.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { hashPassword } from '../utils/auth.js';
import { eq } from 'drizzle-orm';
export async function seedDatabase(): Promise<void> {
const db = getDb();
const existingServers = db.select().from(schema.servers).all();
if (existingServers.length > 0) {
console.log('Database already has data, skipping seed');
return;
}
console.log('Seeding database with default data...');
const adminId = generateSnowflake();
const adminPasswordHash = await hashPassword('admin123');
db.insert(schema.users).values({
id: adminId,
username: 'admin',
displayName: 'Admin',
passwordHash: adminPasswordHash,
status: 'offline',
createdAt: Date.now(),
}).run();
const serverId = generateSnowflake();
db.insert(schema.servers).values({
id: serverId,
name: 'Opencord',
ownerId: adminId,
inviteCode: 'opencord',
createdAt: Date.now(),
}).run();
db.insert(schema.serverMembers).values({
serverId: serverId,
userId: adminId,
role: 'owner',
joinedAt: Date.now(),
}).run();
const generalChannelId = generateSnowflake();
db.insert(schema.channels).values({
id: generalChannelId,
serverId: serverId,
name: 'general',
type: 'text',
topic: 'General discussion',
position: 0,
createdAt: Date.now(),
}).run();
const voiceChannelId = generateSnowflake();
db.insert(schema.channels).values({
id: voiceChannelId,
serverId: serverId,
name: 'General Voice',
type: 'voice',
position: 1,
createdAt: Date.now(),
}).run();
console.log('Database seeded successfully');
console.log(` Default server: Opencord (invite code: opencord)`);
console.log(` Admin user: admin / admin123`);
}
+98
View File
@@ -0,0 +1,98 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import websocket from '@fastify/websocket';
import multipart from '@fastify/multipart';
import fastifyStatic from '@fastify/static';
import { config } from './config.js';
import { initDatabase } from './db/index.js';
import { seedDatabase } from './db/seed.js';
import { authRoutes } from './routes/auth.js';
import { userRoutes } from './routes/users.js';
import { serverRoutes } from './routes/servers.js';
import { channelRoutes } from './routes/channels.js';
import { messageRoutes } from './routes/messages.js';
import { uploadRoutes } from './routes/uploads.js';
import { dmRoutes } from './routes/dm.js';
import { livekitRoutes } from './routes/livekit.js';
import { registerWebSocket } from './ws/handler.js';
import path from 'path';
import fs from 'fs';
async function main(): Promise<void> {
const app = Fastify({
logger: {
level: 'info',
},
});
await app.register(cors, {
origin: true,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
});
await app.register(websocket);
await app.register(multipart, {
limits: {
fileSize: config.maxUploadSize,
},
});
// Serve built frontend in production
const webDistPath = path.resolve(import.meta.dirname ?? '.', '../../web/dist');
if (fs.existsSync(webDistPath)) {
await app.register(fastifyStatic, {
root: webDistPath,
prefix: '/',
wildcard: false,
});
}
initDatabase();
await seedDatabase();
await app.register(authRoutes);
await app.register(userRoutes);
await app.register(serverRoutes);
await app.register(channelRoutes);
await app.register(messageRoutes);
await app.register(uploadRoutes);
await app.register(dmRoutes);
await app.register(livekitRoutes);
await app.register(registerWebSocket);
app.get('/api/health', async () => {
return { status: 'ok', timestamp: Date.now() };
});
// SPA fallback - serve index.html for non-API routes
if (fs.existsSync(webDistPath)) {
app.setNotFoundHandler((request, reply) => {
if (request.url.startsWith('/api/') || request.url.startsWith('/ws')) {
return reply.code(404).send({ error: 'Not found', statusCode: 404 });
}
return reply.sendFile('index.html');
});
}
try {
await app.listen({ port: config.port, host: config.host });
console.log(`Opencord server running at http://${config.host}:${config.port}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}
const shutdown = async () => {
console.log('Shutting down...');
await app.close();
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
main();
+120
View File
@@ -0,0 +1,120 @@
import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { hashPassword, verifyPassword, signJwt } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { config } from '../config.js';
import type { RegisterRequest, LoginRequest, AuthResponse, User } from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
return {
id: row.id,
username: row.username,
displayName: row.displayName,
avatar: row.avatar,
status: (row.status ?? 'offline') as User['status'],
customStatus: row.customStatus,
createdAt: row.createdAt,
};
}
export async function authRoutes(app: FastifyInstance): Promise<void> {
app.post<{ Body: RegisterRequest }>('/api/auth/register', async (request, reply) => {
const { username, password, displayName } = request.body;
if (!username || typeof username !== 'string') {
return reply.code(400).send({ error: 'Username is required', statusCode: 400 });
}
if (!password || typeof password !== 'string') {
return reply.code(400).send({ error: 'Password is required', statusCode: 400 });
}
const trimmedUsername = username.trim();
if (trimmedUsername.length < 3 || trimmedUsername.length > 32) {
return reply.code(400).send({ error: 'Username must be between 3 and 32 characters', statusCode: 400 });
}
if (!/^[a-zA-Z0-9_]+$/.test(trimmedUsername)) {
return reply.code(400).send({ error: 'Username can only contain letters, numbers, and underscores', statusCode: 400 });
}
if (password.length < 6) {
return reply.code(400).send({ error: 'Password must be at least 6 characters', statusCode: 400 });
}
if (!config.registrationOpen) {
return reply.code(403).send({ error: 'Registration is currently closed', statusCode: 403 });
}
const db = getDb();
const existing = db.select().from(schema.users).where(eq(schema.users.username, trimmedUsername)).get();
if (existing) {
return reply.code(409).send({ error: 'Username already taken', statusCode: 409 });
}
const passwordHash = await hashPassword(password);
const userId = generateSnowflake();
const now = Date.now();
db.insert(schema.users).values({
id: userId,
username: trimmedUsername,
displayName: displayName?.trim() || null,
passwordHash,
status: 'online',
createdAt: now,
}).run();
const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
if (!user) {
return reply.code(500).send({ error: 'Failed to create user', statusCode: 500 });
}
const token = signJwt({ userId: user.id, username: user.username });
const response: AuthResponse = {
token,
user: sanitizeUser(user),
};
return reply.code(201).send(response);
});
app.post<{ Body: LoginRequest }>('/api/auth/login', async (request, reply) => {
const { username, password } = request.body;
if (!username || typeof username !== 'string') {
return reply.code(400).send({ error: 'Username is required', statusCode: 400 });
}
if (!password || typeof password !== 'string') {
return reply.code(400).send({ error: 'Password is required', statusCode: 400 });
}
const db = getDb();
const user = db.select().from(schema.users).where(eq(schema.users.username, username.trim())).get();
if (!user) {
return reply.code(401).send({ error: 'Invalid username or password', statusCode: 401 });
}
const validPassword = await verifyPassword(password, user.passwordHash);
if (!validPassword) {
return reply.code(401).send({ error: 'Invalid username or password', statusCode: 401 });
}
db.update(schema.users).set({ status: 'online' }).where(eq(schema.users.id, user.id)).run();
const token = signJwt({ userId: user.id, username: user.username });
const response: AuthResponse = {
token,
user: sanitizeUser({ ...user, status: 'online' }),
};
return reply.code(200).send(response);
});
}
+188
View File
@@ -0,0 +1,188 @@
import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, isAdmin, getChannelServerId } from '../utils/permissions.js';
import type {
CreateChannelRequest,
UpdateChannelRequest,
Channel,
} from '@opencord/shared';
function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
return {
id: row.id,
serverId: row.serverId,
name: row.name,
type: row.type as Channel['type'],
topic: row.topic,
position: row.position ?? 0,
createdAt: row.createdAt,
};
}
export async function channelRoutes(app: FastifyInstance): Promise<void> {
// GET /api/servers/:id/channels - List channels in a server
app.get<{ Params: { id: string } }>('/api/servers/:id/channels', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
}
const channels = db.select()
.from(schema.channels)
.where(eq(schema.channels.serverId, id))
.all();
// Sort by position
channels.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
return reply.code(200).send(channels.map(rowToChannel));
});
// POST /api/servers/:id/channels - Create a channel (admin+)
app.post<{ Params: { id: string }; Body: CreateChannelRequest }>('/api/servers/:id/channels', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { name, type, topic } = request.body;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isAdmin(id, request.userId)) {
return reply.code(403).send({ error: 'Only admins can create channels', statusCode: 403 });
}
if (!name || typeof name !== 'string') {
return reply.code(400).send({ error: 'Channel name is required', statusCode: 400 });
}
const trimmedName = name.trim().toLowerCase().replace(/\s+/g, '-');
if (trimmedName.length < 1 || trimmedName.length > 100) {
return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 });
}
if (!type || !['text', 'voice', 'video'].includes(type)) {
return reply.code(400).send({ error: 'Channel type must be "text", "voice", or "video"', statusCode: 400 });
}
// Get max position for ordering
const existingChannels = db.select()
.from(schema.channels)
.where(eq(schema.channels.serverId, id))
.all();
const maxPosition = existingChannels.reduce((max, ch) => Math.max(max, ch.position ?? 0), -1);
const channelId = generateSnowflake();
const now = Date.now();
db.insert(schema.channels).values({
id: channelId,
serverId: id,
name: trimmedName,
type,
topic: topic?.trim() || null,
position: maxPosition + 1,
createdAt: now,
}).run();
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, channelId)).get();
if (!channel) {
return reply.code(500).send({ error: 'Failed to create channel', statusCode: 500 });
}
return reply.code(201).send(rowToChannel(channel));
});
// PATCH /api/channels/:id - Update a channel (admin+)
app.patch<{ Params: { id: string }; Body: UpdateChannelRequest }>('/api/channels/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { name, topic, position } = request.body;
const db = getDb();
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
if (!channel) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
const serverId = channel.serverId;
if (!isAdmin(serverId, request.userId)) {
return reply.code(403).send({ error: 'Only admins can update channels', statusCode: 403 });
}
const updates: Partial<typeof schema.channels.$inferInsert> = {};
if (name !== undefined) {
const trimmedName = name.trim().toLowerCase().replace(/\s+/g, '-');
if (trimmedName.length < 1 || trimmedName.length > 100) {
return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 });
}
updates.name = trimmedName;
}
if (topic !== undefined) {
updates.topic = topic.trim() || null;
}
if (position !== undefined) {
if (typeof position !== 'number' || position < 0) {
return reply.code(400).send({ error: 'Position must be a non-negative number', statusCode: 400 });
}
updates.position = position;
}
if (Object.keys(updates).length === 0) {
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
}
db.update(schema.channels).set(updates).where(eq(schema.channels.id, id)).run();
const updated = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
if (!updated) {
return reply.code(500).send({ error: 'Failed to update channel', statusCode: 500 });
}
return reply.code(200).send(rowToChannel(updated));
});
// DELETE /api/channels/:id - Delete a channel (admin+)
app.delete<{ Params: { id: string } }>('/api/channels/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
if (!channel) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
const serverId = channel.serverId;
if (!isAdmin(serverId, request.userId)) {
return reply.code(403).send({ error: 'Only admins can delete channels', statusCode: 403 });
}
// Delete messages in channel (attachments cascade), then channel
db.delete(schema.messages).where(eq(schema.messages.channelId, id)).run();
db.delete(schema.channels).where(eq(schema.channels.id, id)).run();
return reply.code(200).send({ success: true });
});
}
+313
View File
@@ -0,0 +1,313 @@
import type { FastifyInstance } from 'fastify';
import { eq, and, desc, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isDmMember } from '../utils/permissions.js';
import { connectionManager } from '../ws/handler.js';
import type {
User,
DmChannel,
DmMessage,
DmMessageWithUser,
CreateDmRequest,
CreateDmMessageRequest,
PaginatedQuery,
} from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
return {
id: row.id,
username: row.username,
displayName: row.displayName,
avatar: row.avatar,
status: (row.status ?? 'offline') as User['status'],
customStatus: row.customStatus,
createdAt: row.createdAt,
};
}
export async function dmRoutes(app: FastifyInstance): Promise<void> {
// GET /api/dm - List user's DM channels
app.get('/api/dm', {
preHandler: authenticate,
}, async (request, reply) => {
const db = getDb();
const memberships = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.userId, request.userId))
.all();
const dmChannels: DmChannel[] = [];
for (const membership of memberships) {
const dmChannel = db.select()
.from(schema.dmChannels)
.where(eq(schema.dmChannels.id, membership.dmChannelId))
.get();
if (!dmChannel) continue;
const dmMemberRows = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, membership.dmChannelId))
.all();
const memberUserIds = dmMemberRows.map(m => m.userId);
const users = memberUserIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all()
: [];
// Get last message
const allMessages = db.select()
.from(schema.dmMessages)
.where(eq(schema.dmMessages.dmChannelId, membership.dmChannelId))
.orderBy(desc(schema.dmMessages.createdAt))
.limit(1)
.all();
const lastMessage = allMessages[0] ?? null;
dmChannels.push({
id: dmChannel.id,
createdAt: dmChannel.createdAt,
members: users.map(sanitizeUser),
lastMessage: lastMessage ? {
id: lastMessage.id,
dmChannelId: lastMessage.dmChannelId,
userId: lastMessage.userId,
content: lastMessage.content,
createdAt: lastMessage.createdAt,
} : null,
});
}
// Sort by last message timestamp (newest first)
dmChannels.sort((a, b) => {
const aTime = a.lastMessage?.createdAt ?? a.createdAt;
const bTime = b.lastMessage?.createdAt ?? b.createdAt;
return bTime - aTime;
});
return reply.code(200).send(dmChannels);
});
// POST /api/dm - Create or get existing DM channel
app.post<{ Body: CreateDmRequest }>('/api/dm', {
preHandler: authenticate,
}, async (request, reply) => {
const { userId } = request.body;
if (!userId || typeof userId !== 'string') {
return reply.code(400).send({ error: 'userId is required', statusCode: 400 });
}
if (userId === request.userId) {
return reply.code(400).send({ error: 'Cannot create DM with yourself', statusCode: 400 });
}
const db = getDb();
// Check if target user exists
const targetUser = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
if (!targetUser) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
// Check if DM channel already exists between these two users
const myDms = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.userId, request.userId))
.all();
for (const myDm of myDms) {
const otherMember = db.select()
.from(schema.dmMembers)
.where(and(
eq(schema.dmMembers.dmChannelId, myDm.dmChannelId),
eq(schema.dmMembers.userId, userId),
))
.get();
if (otherMember) {
// DM channel already exists
const dmChannel = db.select()
.from(schema.dmChannels)
.where(eq(schema.dmChannels.id, myDm.dmChannelId))
.get();
if (!dmChannel) continue;
const dmMemberRows = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, myDm.dmChannelId))
.all();
const memberUserIds = dmMemberRows.map(m => m.userId);
const users = db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all();
const result: DmChannel = {
id: dmChannel.id,
createdAt: dmChannel.createdAt,
members: users.map(sanitizeUser),
lastMessage: null,
};
return reply.code(200).send(result);
}
}
// Create new DM channel
const dmChannelId = generateSnowflake();
const now = Date.now();
db.insert(schema.dmChannels).values({
id: dmChannelId,
createdAt: now,
}).run();
db.insert(schema.dmMembers).values({
dmChannelId,
userId: request.userId,
}).run();
db.insert(schema.dmMembers).values({
dmChannelId,
userId,
}).run();
const currentUserRow = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
const members = [currentUserRow, targetUser]
.filter((u): u is NonNullable<typeof u> => u !== undefined)
.map(sanitizeUser);
const result: DmChannel = {
id: dmChannelId,
createdAt: now,
members,
lastMessage: null,
};
return reply.code(201).send(result);
});
// GET /api/dm/:id/messages - Get DM messages with pagination
app.get<{ Params: { id: string }; Querystring: PaginatedQuery }>('/api/dm/:id/messages', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const before = request.query.before;
const limit = Math.min(Math.max(Number(request.query.limit) || 50, 1), 100);
if (!isDmMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
}
const db = getDb();
let messageRows: (typeof schema.dmMessages.$inferSelect)[];
if (before) {
messageRows = db.select()
.from(schema.dmMessages)
.where(eq(schema.dmMessages.dmChannelId, id))
.orderBy(desc(schema.dmMessages.createdAt))
.all()
.filter(m => m.id < before)
.slice(0, limit);
} else {
messageRows = db.select()
.from(schema.dmMessages)
.where(eq(schema.dmMessages.dmChannelId, id))
.orderBy(desc(schema.dmMessages.createdAt))
.limit(limit)
.all();
}
messageRows.reverse();
if (messageRows.length === 0) {
return reply.code(200).send([]);
}
// Batch fetch users
const userIds = [...new Set(messageRows.map(m => m.userId))];
const users = db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all();
const userMap = new Map(users.map(u => [u.id, u]));
const messages: DmMessageWithUser[] = messageRows
.map(m => {
const user = userMap.get(m.userId);
if (!user) return null;
return {
id: m.id,
dmChannelId: m.dmChannelId,
userId: m.userId,
content: m.content,
createdAt: m.createdAt,
user: sanitizeUser(user),
};
})
.filter((m): m is DmMessageWithUser => m !== null);
return reply.code(200).send(messages);
});
// POST /api/dm/:id/messages - Send a DM message
app.post<{ Params: { id: string }; Body: CreateDmMessageRequest }>('/api/dm/:id/messages', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { content } = request.body;
if (!isDmMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
}
if (!content || typeof content !== 'string' || content.trim().length === 0) {
return reply.code(400).send({ error: 'Message content is required', statusCode: 400 });
}
const db = getDb();
const messageId = generateSnowflake();
const now = Date.now();
db.insert(schema.dmMessages).values({
id: messageId,
dmChannelId: id,
userId: request.userId,
content: content.trim(),
createdAt: now,
}).run();
const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (!user) {
return reply.code(500).send({ error: 'User not found', statusCode: 500 });
}
const message: DmMessageWithUser = {
id: messageId,
dmChannelId: id,
userId: request.userId,
content: content.trim(),
createdAt: now,
user: sanitizeUser(user),
};
// Broadcast via WebSocket to all DM members
const dmMembers = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, id))
.all();
for (const member of dmMembers) {
connectionManager.sendToUser(member.userId, {
type: 'dm_message_created',
message,
});
}
return reply.code(201).send(message);
});
}
+47
View File
@@ -0,0 +1,47 @@
import type { FastifyInstance } from 'fastify';
import { AccessToken } from 'livekit-server-sdk';
import { authenticate } from '../utils/auth.js';
import { config } from '../config.js';
import { getChannelServerId, isMember } from '../utils/permissions.js';
import type { LiveKitTokenRequest, LiveKitTokenResponse } from '@opencord/shared';
export async function livekitRoutes(app: FastifyInstance): Promise<void> {
app.post<{ Body: LiveKitTokenRequest }>('/api/livekit/token', {
preHandler: authenticate,
}, async (request, reply) => {
const { channelId } = request.body;
if (!channelId || typeof channelId !== 'string') {
return reply.code(400).send({ error: 'channelId is required', statusCode: 400 });
}
const serverId = getChannelServerId(channelId);
if (!serverId) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
if (!isMember(serverId, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
}
const identity = `${request.userId}:${request.username}`;
const token = new AccessToken(config.livekit.apiKey, config.livekit.apiSecret, {
identity,
ttl: '1h',
});
token.addGrant({
room: channelId,
roomJoin: true,
canPublish: true,
canSubscribe: true,
canPublishData: true,
});
const jwt = await token.toJwt();
const response: LiveKitTokenResponse = { token: jwt };
return reply.code(200).send(response);
});
}
+294
View File
@@ -0,0 +1,294 @@
import type { FastifyInstance } from 'fastify';
import { eq, lt, desc, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, getChannelServerId, isAdmin } from '../utils/permissions.js';
import { connectionManager } from '../ws/handler.js';
import type {
CreateMessageRequest,
UpdateMessageRequest,
PaginatedQuery,
User,
MessageWithUser,
Attachment,
} from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
return {
id: row.id,
username: row.username,
displayName: row.displayName,
avatar: row.avatar,
status: (row.status ?? 'offline') as User['status'],
customStatus: row.customStatus,
createdAt: row.createdAt,
};
}
function buildMessageWithUser(
message: typeof schema.messages.$inferSelect,
user: typeof schema.users.$inferSelect,
attachmentRows: (typeof schema.attachments.$inferSelect)[],
): MessageWithUser {
return {
id: message.id,
channelId: message.channelId,
userId: message.userId,
content: message.content,
editedAt: message.editedAt,
createdAt: message.createdAt,
user: sanitizeUser(user),
attachments: attachmentRows.map(a => ({
id: a.id,
messageId: a.messageId ?? message.id,
filename: a.filename,
originalName: a.originalName,
mimetype: a.mimetype,
size: a.size,
createdAt: a.createdAt,
})),
};
}
export async function messageRoutes(app: FastifyInstance): Promise<void> {
// GET /api/channels/:id/messages - Get messages with cursor pagination
app.get<{ Params: { id: string }; Querystring: PaginatedQuery }>('/api/channels/:id/messages', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const before = request.query.before;
const limit = Math.min(Math.max(Number(request.query.limit) || 50, 1), 100);
const serverId = getChannelServerId(id);
if (!serverId) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
if (!isMember(serverId, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
}
const db = getDb();
let messageRows: (typeof schema.messages.$inferSelect)[];
if (before) {
messageRows = db.select()
.from(schema.messages)
.where(eq(schema.messages.channelId, id))
.orderBy(desc(schema.messages.createdAt))
.all()
.filter(m => m.id < before)
.slice(0, limit);
} else {
messageRows = db.select()
.from(schema.messages)
.where(eq(schema.messages.channelId, id))
.orderBy(desc(schema.messages.createdAt))
.limit(limit)
.all();
}
// Reverse to get chronological order
messageRows.reverse();
if (messageRows.length === 0) {
return reply.code(200).send([]);
}
// Batch fetch users
const userIds = [...new Set(messageRows.map(m => m.userId))];
const users = db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all();
const userMap = new Map(users.map(u => [u.id, u]));
// Batch fetch attachments
const messageIds = messageRows.map(m => m.id);
const allAttachments = db.select()
.from(schema.attachments)
.where(inArray(schema.attachments.messageId, messageIds))
.all();
const attachmentMap = new Map<string, (typeof schema.attachments.$inferSelect)[]>();
for (const att of allAttachments) {
const mid = att.messageId ?? '';
if (!attachmentMap.has(mid)) {
attachmentMap.set(mid, []);
}
attachmentMap.get(mid)!.push(att);
}
const messages: MessageWithUser[] = messageRows
.map(m => {
const user = userMap.get(m.userId);
if (!user) return null;
return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? []);
})
.filter((m): m is MessageWithUser => m !== null);
return reply.code(200).send(messages);
});
// POST /api/channels/:id/messages - Create a message
app.post<{ Params: { id: string }; Body: CreateMessageRequest }>('/api/channels/:id/messages', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { content, attachments: attachmentIds } = request.body;
const serverId = getChannelServerId(id);
if (!serverId) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
if (!isMember(serverId, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
}
if ((!content || typeof content !== 'string' || content.trim().length === 0) &&
(!attachmentIds || attachmentIds.length === 0)) {
return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 });
}
const db = getDb();
const messageId = generateSnowflake();
const now = Date.now();
db.insert(schema.messages).values({
id: messageId,
channelId: id,
userId: request.userId,
content: content?.trim() || null,
createdAt: now,
}).run();
// Link attachments to message
if (attachmentIds && attachmentIds.length > 0) {
for (const attId of attachmentIds) {
db.update(schema.attachments)
.set({ messageId })
.where(eq(schema.attachments.id, attId))
.run();
}
}
const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (!user) {
return reply.code(500).send({ error: 'User not found', statusCode: 500 });
}
const attachmentRows = db.select()
.from(schema.attachments)
.where(eq(schema.attachments.messageId, messageId))
.all();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get();
if (!message) {
return reply.code(500).send({ error: 'Failed to create message', statusCode: 500 });
}
const messageWithUser = buildMessageWithUser(message, user, attachmentRows);
// Broadcast via WebSocket
connectionManager.sendToServer(serverId, {
type: 'message_created',
message: messageWithUser,
});
return reply.code(201).send(messageWithUser);
});
// PATCH /api/messages/:id - Edit a message (author only)
app.patch<{ Params: { id: string }; Body: UpdateMessageRequest }>('/api/messages/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { content } = request.body;
if (!content || typeof content !== 'string' || content.trim().length === 0) {
return reply.code(400).send({ error: 'Content is required', statusCode: 400 });
}
const db = getDb();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, id)).get();
if (!message) {
return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
}
if (message.userId !== request.userId) {
return reply.code(403).send({ error: 'You can only edit your own messages', statusCode: 403 });
}
const now = Date.now();
db.update(schema.messages)
.set({ content: content.trim(), editedAt: now })
.where(eq(schema.messages.id, id))
.run();
const updatedMessage = db.select().from(schema.messages).where(eq(schema.messages.id, id)).get();
if (!updatedMessage) {
return reply.code(500).send({ error: 'Failed to update message', statusCode: 500 });
}
const user = db.select().from(schema.users).where(eq(schema.users.id, message.userId)).get();
if (!user) {
return reply.code(500).send({ error: 'User not found', statusCode: 500 });
}
const attachmentRows = db.select()
.from(schema.attachments)
.where(eq(schema.attachments.messageId, id))
.all();
const messageWithUser = buildMessageWithUser(updatedMessage, user, attachmentRows);
// Broadcast edit
const serverId = getChannelServerId(message.channelId);
if (serverId) {
connectionManager.sendToServer(serverId, {
type: 'message_updated',
message: messageWithUser,
});
}
return reply.code(200).send(messageWithUser);
});
// DELETE /api/messages/:id - Delete a message (author or admin)
app.delete<{ Params: { id: string } }>('/api/messages/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, id)).get();
if (!message) {
return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
}
const serverId = getChannelServerId(message.channelId);
if (!serverId) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
const isAuthor = message.userId === request.userId;
const isAdminUser = isAdmin(serverId, request.userId);
if (!isAuthor && !isAdminUser) {
return reply.code(403).send({ error: 'You cannot delete this message', statusCode: 403 });
}
// Delete attachments then message
db.delete(schema.attachments).where(eq(schema.attachments.messageId, id)).run();
db.delete(schema.messages).where(eq(schema.messages.id, id)).run();
// Broadcast deletion
connectionManager.sendToServer(serverId, {
type: 'message_deleted',
messageId: id,
channelId: message.channelId,
});
return reply.code(200).send({ success: true });
});
}
+528
View File
@@ -0,0 +1,528 @@
import type { FastifyInstance } from 'fastify';
import { eq, and, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, isOwner, isAdmin } from '../utils/permissions.js';
import crypto from 'crypto';
import type {
CreateServerRequest,
UpdateServerRequest,
JoinServerRequest,
UpdateMemberRequest,
User,
Server,
Channel,
MemberWithUser,
ServerWithChannelsAndMembers,
} from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
return {
id: row.id,
username: row.username,
displayName: row.displayName,
avatar: row.avatar,
status: (row.status ?? 'offline') as User['status'],
customStatus: row.customStatus,
createdAt: row.createdAt,
};
}
function rowToServer(row: typeof schema.servers.$inferSelect): Server {
return {
id: row.id,
name: row.name,
icon: row.icon,
ownerId: row.ownerId,
inviteCode: row.inviteCode,
createdAt: row.createdAt,
};
}
function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
return {
id: row.id,
serverId: row.serverId,
name: row.name,
type: row.type as Channel['type'],
topic: row.topic,
position: row.position ?? 0,
createdAt: row.createdAt,
};
}
function generateInviteCode(): string {
return crypto.randomBytes(4).toString('hex');
}
export async function serverRoutes(app: FastifyInstance): Promise<void> {
// POST /api/servers - Create a new server
app.post<{ Body: CreateServerRequest }>('/api/servers', {
preHandler: authenticate,
}, async (request, reply) => {
const { name, icon } = request.body;
if (!name || typeof name !== 'string') {
return reply.code(400).send({ error: 'Server name is required', statusCode: 400 });
}
const trimmedName = name.trim();
if (trimmedName.length < 1 || trimmedName.length > 100) {
return reply.code(400).send({ error: 'Server name must be between 1 and 100 characters', statusCode: 400 });
}
const db = getDb();
const serverId = generateSnowflake();
const channelId = generateSnowflake();
const now = Date.now();
const inviteCode = generateInviteCode();
// Create the server
db.insert(schema.servers).values({
id: serverId,
name: trimmedName,
icon: icon ?? null,
ownerId: request.userId,
inviteCode,
createdAt: now,
}).run();
// Add owner as member with 'owner' role
db.insert(schema.serverMembers).values({
serverId,
userId: request.userId,
role: 'owner',
joinedAt: now,
}).run();
// Create default #general text channel
db.insert(schema.channels).values({
id: channelId,
serverId,
name: 'general',
type: 'text',
position: 0,
createdAt: now,
}).run();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, serverId)).get();
if (!server) {
return reply.code(500).send({ error: 'Failed to create server', statusCode: 500 });
}
return reply.code(201).send(rowToServer(server));
});
// GET /api/servers - List user's servers
app.get('/api/servers', {
preHandler: authenticate,
}, async (request, reply) => {
const db = getDb();
const memberships = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.userId, request.userId))
.all();
if (memberships.length === 0) {
return reply.code(200).send([]);
}
const serverIds = memberships.map(m => m.serverId);
const servers = db.select()
.from(schema.servers)
.where(inArray(schema.servers.id, serverIds))
.all();
return reply.code(200).send(servers.map(rowToServer));
});
// GET /api/servers/:id - Get server detail with channels and members
app.get<{ Params: { id: string } }>('/api/servers/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
}
const channels = db.select()
.from(schema.channels)
.where(eq(schema.channels.serverId, id))
.all();
const memberRows = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.serverId, id))
.all();
const memberUserIds = memberRows.map(m => m.userId);
const users = memberUserIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all()
: [];
const userMap = new Map(users.map(u => [u.id, u]));
const members: MemberWithUser[] = memberRows
.map(m => {
const user = userMap.get(m.userId);
if (!user) return null;
return {
serverId: m.serverId,
userId: m.userId,
role: (m.role ?? 'member') as MemberWithUser['role'],
nickname: m.nickname,
joinedAt: m.joinedAt,
user: sanitizeUser(user),
};
})
.filter((m): m is MemberWithUser => m !== null);
const result: ServerWithChannelsAndMembers = {
...rowToServer(server),
channels: channels.map(rowToChannel),
members,
};
return reply.code(200).send(result);
});
// PATCH /api/servers/:id - Update server (owner only)
app.patch<{ Params: { id: string }; Body: UpdateServerRequest }>('/api/servers/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { name, icon } = request.body;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isOwner(id, request.userId)) {
return reply.code(403).send({ error: 'Only the server owner can update the server', statusCode: 403 });
}
const updates: Partial<typeof schema.servers.$inferInsert> = {};
if (name !== undefined) {
const trimmedName = name.trim();
if (trimmedName.length < 1 || trimmedName.length > 100) {
return reply.code(400).send({ error: 'Server name must be between 1 and 100 characters', statusCode: 400 });
}
updates.name = trimmedName;
}
if (icon !== undefined) {
updates.icon = icon;
}
if (Object.keys(updates).length === 0) {
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
}
db.update(schema.servers).set(updates).where(eq(schema.servers.id, id)).run();
const updated = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!updated) {
return reply.code(500).send({ error: 'Failed to update server', statusCode: 500 });
}
return reply.code(200).send(rowToServer(updated));
});
// DELETE /api/servers/:id - Delete server (owner only)
app.delete<{ Params: { id: string } }>('/api/servers/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isOwner(id, request.userId)) {
return reply.code(403).send({ error: 'Only the server owner can delete the server', statusCode: 403 });
}
// Delete all channels (messages cascade), members, then server
db.delete(schema.channels).where(eq(schema.channels.serverId, id)).run();
db.delete(schema.serverMembers).where(eq(schema.serverMembers.serverId, id)).run();
db.delete(schema.servers).where(eq(schema.servers.id, id)).run();
return reply.code(200).send({ success: true });
});
// POST /api/servers/:id/invite - Generate invite code (admin+)
app.post<{ Params: { id: string } }>('/api/servers/:id/invite', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isAdmin(id, request.userId)) {
return reply.code(403).send({ error: 'Only admins can generate invite codes', statusCode: 403 });
}
const inviteCode = generateInviteCode();
db.update(schema.servers).set({ inviteCode }).where(eq(schema.servers.id, id)).run();
return reply.code(200).send({ inviteCode });
});
// POST /api/servers/:id/join - Join server by invite code
app.post<{ Params: { id: string }; Body: JoinServerRequest }>('/api/servers/:id/join', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { inviteCode } = request.body;
if (!inviteCode || typeof inviteCode !== 'string') {
return reply.code(400).send({ error: 'Invite code is required', statusCode: 400 });
}
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (server.inviteCode !== inviteCode) {
return reply.code(400).send({ error: 'Invalid invite code', statusCode: 400 });
}
if (isMember(id, request.userId)) {
return reply.code(409).send({ error: 'You are already a member of this server', statusCode: 409 });
}
const now = Date.now();
db.insert(schema.serverMembers).values({
serverId: id,
userId: request.userId,
role: 'member',
joinedAt: now,
}).run();
return reply.code(200).send(rowToServer(server));
});
// POST /api/servers/join - Join server by invite code (no server ID needed)
app.post<{ Body: JoinServerRequest }>('/api/servers/join', {
preHandler: authenticate,
}, async (request, reply) => {
const { inviteCode } = request.body;
if (!inviteCode || typeof inviteCode !== 'string') {
return reply.code(400).send({ error: 'Invite code is required', statusCode: 400 });
}
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.inviteCode, inviteCode)).get();
if (!server) {
return reply.code(404).send({ error: 'Invalid invite code', statusCode: 404 });
}
if (isMember(server.id, request.userId)) {
return reply.code(409).send({ error: 'You are already a member of this server', statusCode: 409 });
}
const now = Date.now();
db.insert(schema.serverMembers).values({
serverId: server.id,
userId: request.userId,
role: 'member',
joinedAt: now,
}).run();
return reply.code(200).send(rowToServer(server));
});
// GET /api/servers/:id/members - List server members
app.get<{ Params: { id: string } }>('/api/servers/:id/members', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
}
const memberRows = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.serverId, id))
.all();
const memberUserIds = memberRows.map(m => m.userId);
const users = memberUserIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all()
: [];
const userMap = new Map(users.map(u => [u.id, u]));
const members: MemberWithUser[] = memberRows
.map(m => {
const user = userMap.get(m.userId);
if (!user) return null;
return {
serverId: m.serverId,
userId: m.userId,
role: (m.role ?? 'member') as MemberWithUser['role'],
nickname: m.nickname,
joinedAt: m.joinedAt,
user: sanitizeUser(user),
};
})
.filter((m): m is MemberWithUser => m !== null);
return reply.code(200).send(members);
});
// PATCH /api/servers/:id/members/:uid - Update member role (owner only)
app.patch<{ Params: { id: string; uid: string }; Body: UpdateMemberRequest }>('/api/servers/:id/members/:uid', {
preHandler: authenticate,
}, async (request, reply) => {
const { id, uid } = request.params;
const { role } = request.body;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isOwner(id, request.userId)) {
return reply.code(403).send({ error: 'Only the server owner can change member roles', statusCode: 403 });
}
if (uid === request.userId) {
return reply.code(400).send({ error: 'You cannot change your own role', statusCode: 400 });
}
if (!role || !['admin', 'member'].includes(role)) {
return reply.code(400).send({ error: 'Role must be "admin" or "member"', statusCode: 400 });
}
const member = db.select()
.from(schema.serverMembers)
.where(and(
eq(schema.serverMembers.serverId, id),
eq(schema.serverMembers.userId, uid),
))
.get();
if (!member) {
return reply.code(404).send({ error: 'Member not found', statusCode: 404 });
}
db.update(schema.serverMembers)
.set({ role })
.where(and(
eq(schema.serverMembers.serverId, id),
eq(schema.serverMembers.userId, uid),
))
.run();
const updatedMember = db.select()
.from(schema.serverMembers)
.where(and(
eq(schema.serverMembers.serverId, id),
eq(schema.serverMembers.userId, uid),
))
.get();
if (!updatedMember) {
return reply.code(500).send({ error: 'Failed to update member', statusCode: 500 });
}
const user = db.select().from(schema.users).where(eq(schema.users.id, uid)).get();
if (!user) {
return reply.code(500).send({ error: 'User not found', statusCode: 500 });
}
const result: MemberWithUser = {
serverId: updatedMember.serverId,
userId: updatedMember.userId,
role: (updatedMember.role ?? 'member') as MemberWithUser['role'],
nickname: updatedMember.nickname,
joinedAt: updatedMember.joinedAt,
user: sanitizeUser(user),
};
return reply.code(200).send(result);
});
// DELETE /api/servers/:id/members/:uid - Kick member (owner) or leave (self)
app.delete<{ Params: { id: string; uid: string } }>('/api/servers/:id/members/:uid', {
preHandler: authenticate,
}, async (request, reply) => {
const { id, uid } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
const isSelf = uid === request.userId;
const isServerOwnerUser = isOwner(id, request.userId);
if (!isSelf && !isServerOwnerUser) {
return reply.code(403).send({ error: 'Only the server owner can kick members', statusCode: 403 });
}
// Owner cannot leave their own server - they must delete it
if (isSelf && isServerOwnerUser) {
return reply.code(400).send({ error: 'Server owner cannot leave. Transfer ownership or delete the server.', statusCode: 400 });
}
const member = db.select()
.from(schema.serverMembers)
.where(and(
eq(schema.serverMembers.serverId, id),
eq(schema.serverMembers.userId, uid),
))
.get();
if (!member) {
return reply.code(404).send({ error: 'Member not found', statusCode: 404 });
}
// Cannot kick the owner
if (member.role === 'owner') {
return reply.code(400).send({ error: 'Cannot remove the server owner', statusCode: 400 });
}
db.delete(schema.serverMembers)
.where(and(
eq(schema.serverMembers.serverId, id),
eq(schema.serverMembers.userId, uid),
))
.run();
return reply.code(200).send({ success: true });
});
}
+106
View File
@@ -0,0 +1,106 @@
import type { FastifyInstance } from 'fastify';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { config } from '../config.js';
import { getDb, schema } from '../db/index.js';
import { eq } from 'drizzle-orm';
import fs from 'fs';
import path from 'path';
import { pipeline } from 'stream/promises';
import type { Attachment } from '@opencord/shared';
export async function uploadRoutes(app: FastifyInstance): Promise<void> {
// Ensure upload directory exists
if (!fs.existsSync(config.uploadDir)) {
fs.mkdirSync(config.uploadDir, { recursive: true });
}
// POST /api/uploads - Upload a file
app.post('/api/uploads', {
preHandler: authenticate,
}, async (request, reply) => {
const data = await request.file();
if (!data) {
return reply.code(400).send({ error: 'No file provided', statusCode: 400 });
}
const originalName = data.filename;
const mimetype = data.mimetype;
// Generate unique filename
const id = generateSnowflake();
const ext = path.extname(originalName);
const filename = `${id}${ext}`;
const filepath = path.join(config.uploadDir, filename);
// Save file to disk
const writeStream = fs.createWriteStream(filepath);
await pipeline(data.file, writeStream);
// Get file size
const stats = fs.statSync(filepath);
const size = stats.size;
// Check size limit
if (size > config.maxUploadSize) {
fs.unlinkSync(filepath);
return reply.code(413).send({ error: 'File too large', statusCode: 413 });
}
const now = Date.now();
const db = getDb();
// Save attachment record
db.insert(schema.attachments).values({
id,
filename,
originalName,
mimetype,
size,
createdAt: now,
}).run();
const attachment: Attachment = {
id,
messageId: '',
filename,
originalName,
mimetype,
size,
createdAt: now,
};
return reply.code(201).send(attachment);
});
// GET /api/uploads/:filename - Serve uploaded file
app.get<{ Params: { filename: string } }>('/api/uploads/:filename', async (request, reply) => {
const { filename } = request.params;
// Prevent directory traversal
const safeName = path.basename(filename);
const filepath = path.join(config.uploadDir, safeName);
if (!fs.existsSync(filepath)) {
return reply.code(404).send({ error: 'File not found', statusCode: 404 });
}
// Get mimetype from DB or guess from extension
const db = getDb();
const attachment = db.select().from(schema.attachments).where(eq(schema.attachments.filename, safeName)).get();
const mimetype = attachment?.mimetype ?? 'application/octet-stream';
const originalName = attachment?.originalName ?? safeName;
// Set caching headers
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
reply.header('Content-Type', mimetype);
// For non-image files, set Content-Disposition to download
if (!mimetype.startsWith('image/') && !mimetype.startsWith('video/') && !mimetype.startsWith('audio/')) {
reply.header('Content-Disposition', `attachment; filename="${encodeURIComponent(originalName)}"`);
}
const stream = fs.createReadStream(filepath);
return reply.send(stream);
});
}
+90
View File
@@ -0,0 +1,90 @@
import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import type { User, UpdateUserRequest } from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
return {
id: row.id,
username: row.username,
displayName: row.displayName,
avatar: row.avatar,
status: (row.status ?? 'offline') as User['status'],
customStatus: row.customStatus,
createdAt: row.createdAt,
};
}
export async function userRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
const db = getDb();
const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (!user) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
return reply.code(200).send(sanitizeUser(user));
});
app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
const { displayName, avatar, customStatus } = request.body;
const db = getDb();
const updateData: Record<string, string | null | undefined> = {};
if (displayName !== undefined) {
if (displayName !== null && typeof displayName === 'string') {
const trimmed = displayName.trim();
if (trimmed.length > 32) {
return reply.code(400).send({ error: 'Display name must be 32 characters or less', statusCode: 400 });
}
updateData.displayName = trimmed || null;
} else {
updateData.displayName = null;
}
}
if (avatar !== undefined) {
updateData.avatar = avatar;
}
if (customStatus !== undefined) {
if (customStatus !== null && typeof customStatus === 'string') {
const trimmed = customStatus.trim();
if (trimmed.length > 128) {
return reply.code(400).send({ error: 'Custom status must be 128 characters or less', statusCode: 400 });
}
updateData.customStatus = trimmed || null;
} else {
updateData.customStatus = null;
}
}
if (Object.keys(updateData).length === 0) {
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
}
db.update(schema.users).set(updateData).where(eq(schema.users.id, request.userId)).run();
const updatedUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (!updatedUser) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
return reply.code(200).send(sanitizeUser(updatedUser));
});
app.get<{ Params: { id: string } }>('/api/users/:id', { preHandler: authenticate }, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const user = db.select().from(schema.users).where(eq(schema.users.id, id)).get();
if (!user) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
return reply.code(200).send(sanitizeUser(user));
});
}
+58
View File
@@ -0,0 +1,58 @@
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { config } from '../config.js';
import type { FastifyRequest, FastifyReply } from 'fastify';
const SALT_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
export interface JwtPayload {
userId: string;
username: string;
}
export function signJwt(payload: JwtPayload): string {
const options: jwt.SignOptions = {
expiresIn: config.jwtExpiresIn as unknown as jwt.SignOptions['expiresIn'],
};
return jwt.sign(payload, config.jwtSecret, options);
}
export function verifyJwt(token: string): JwtPayload {
const decoded = jwt.verify(token, config.jwtSecret) as JwtPayload;
return decoded;
}
export async function authenticate(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
reply.code(401).send({ error: 'Missing or invalid authorization header', statusCode: 401 });
return;
}
const token = authHeader.slice(7);
try {
const payload = verifyJwt(token);
(request as FastifyRequest & { userId: string; username: string }).userId = payload.userId;
(request as FastifyRequest & { userId: string; username: string }).username = payload.username;
} catch {
reply.code(401).send({ error: 'Invalid or expired token', statusCode: 401 });
}
}
declare module 'fastify' {
interface FastifyRequest {
userId: string;
username: string;
}
}
+55
View File
@@ -0,0 +1,55 @@
import { eq, and } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import type { MemberRole } from '@opencord/shared';
export function getMember(serverId: string, userId: string) {
const db = getDb();
return db.select().from(schema.serverMembers)
.where(and(
eq(schema.serverMembers.serverId, serverId),
eq(schema.serverMembers.userId, userId),
))
.get();
}
export function isMember(serverId: string, userId: string): boolean {
return getMember(serverId, userId) !== undefined;
}
export function getMemberRole(serverId: string, userId: string): MemberRole | null {
const member = getMember(serverId, userId);
return member ? (member.role as MemberRole) : null;
}
export function isOwner(serverId: string, userId: string): boolean {
const role = getMemberRole(serverId, userId);
return role === 'owner';
}
export function isAdmin(serverId: string, userId: string): boolean {
const role = getMemberRole(serverId, userId);
return role === 'owner' || role === 'admin';
}
export function isServerOwner(serverId: string, userId: string): boolean {
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, serverId)).get();
return server?.ownerId === userId;
}
export function getChannelServerId(channelId: string): string | null {
const db = getDb();
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, channelId)).get();
return channel?.serverId ?? null;
}
export function isDmMember(dmChannelId: string, userId: string): boolean {
const db = getDb();
const member = db.select().from(schema.dmMembers)
.where(and(
eq(schema.dmMembers.dmChannelId, dmChannelId),
eq(schema.dmMembers.userId, userId),
))
.get();
return member !== undefined;
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Discord-style Snowflake ID Generator
*
* Structure (64-bit):
* - 42 bits: milliseconds since custom epoch (Jan 1, 2024)
* - 10 bits: worker/process ID
* - 12 bits: sequence number (per-millisecond)
*
* This gives us:
* - ~139 years of IDs from epoch
* - 1024 workers
* - 4096 IDs per millisecond per worker
*/
const EPOCH = 1704067200000n; // Jan 1, 2024 00:00:00 UTC
const WORKER_ID = BigInt(process.pid % 1024);
let sequence = 0n;
let lastTimestamp = -1n;
export function generateSnowflake(): string {
let timestamp = BigInt(Date.now());
if (timestamp === lastTimestamp) {
sequence = (sequence + 1n) & 0xFFFn; // 12-bit mask
if (sequence === 0n) {
// Sequence exhausted, wait for next millisecond
while (timestamp <= lastTimestamp) {
timestamp = BigInt(Date.now());
}
}
} else {
sequence = 0n;
}
lastTimestamp = timestamp;
const id =
((timestamp - EPOCH) << 22n) |
(WORKER_ID << 12n) |
sequence;
return id.toString();
}
export function snowflakeToTimestamp(snowflake: string): number {
const id = BigInt(snowflake);
const timestamp = (id >> 22n) + EPOCH;
return Number(timestamp);
}
+409
View File
@@ -0,0 +1,409 @@
import { eq, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { connectionManager } from './handler.js';
import { isMember, getChannelServerId, isDmMember } from '../utils/permissions.js';
import type { User, MessageWithUser, Attachment, DmMessageWithUser } from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
return {
id: row.id,
username: row.username,
displayName: row.displayName,
avatar: row.avatar,
status: (row.status ?? 'offline') as User['status'],
customStatus: row.customStatus,
createdAt: row.createdAt,
};
}
function getMessageWithUser(messageId: string): MessageWithUser | null {
const db = getDb();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get();
if (!message) return null;
const user = db.select().from(schema.users).where(eq(schema.users.id, message.userId)).get();
if (!user) return null;
const attachmentRows = db.select()
.from(schema.attachments)
.where(eq(schema.attachments.messageId, messageId))
.all();
const attachments: Attachment[] = attachmentRows.map(a => ({
id: a.id,
messageId: a.messageId ?? messageId,
filename: a.filename,
originalName: a.originalName,
mimetype: a.mimetype,
size: a.size,
createdAt: a.createdAt,
}));
return {
id: message.id,
channelId: message.channelId,
userId: message.userId,
content: message.content,
editedAt: message.editedAt,
createdAt: message.createdAt,
user: sanitizeUser(user),
attachments,
};
}
// Typing timeout tracking
const typingTimeouts: Map<string, NodeJS.Timeout> = new Map();
export function handleClientEvent(
event: Record<string, unknown>,
userId: string,
username: string,
): void {
const type = event.type as string;
switch (type) {
case 'message_create':
handleMessageCreate(event, userId);
break;
case 'message_edit':
handleMessageEdit(event, userId);
break;
case 'message_delete':
handleMessageDelete(event, userId);
break;
case 'typing_start':
handleTypingStart(event, userId, username);
break;
case 'presence_update':
handlePresenceUpdate(event, userId);
break;
case 'voice_join':
handleVoiceJoin(event, userId);
break;
case 'voice_leave':
handleVoiceLeave(userId);
break;
case 'dm_message_create':
handleDmMessageCreate(event, userId);
break;
default:
connectionManager.sendToUser(userId, {
type: 'error',
message: `Unknown event type: ${type}`,
});
}
}
function handleMessageCreate(event: Record<string, unknown>, userId: string): void {
const channelId = event.channelId as string;
const content = event.content as string;
if (!channelId || typeof channelId !== 'string') {
connectionManager.sendToUser(userId, { type: 'error', message: 'channelId is required' });
return;
}
if (!content || typeof content !== 'string' || content.trim().length === 0) {
connectionManager.sendToUser(userId, { type: 'error', message: 'content is required' });
return;
}
const serverId = getChannelServerId(channelId);
if (!serverId) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Channel not found' });
return;
}
if (!isMember(serverId, userId)) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Not a member of this server' });
return;
}
const db = getDb();
const messageId = generateSnowflake();
const now = Date.now();
db.insert(schema.messages).values({
id: messageId,
channelId,
userId,
content: content.trim(),
createdAt: now,
}).run();
const messageWithUser = getMessageWithUser(messageId);
if (messageWithUser) {
// Broadcast to all server members (including sender)
connectionManager.sendToServer(serverId, {
type: 'message_created',
message: messageWithUser,
});
}
}
function handleMessageEdit(event: Record<string, unknown>, userId: string): void {
const messageId = event.messageId as string;
const content = event.content as string;
if (!messageId || typeof messageId !== 'string') {
connectionManager.sendToUser(userId, { type: 'error', message: 'messageId is required' });
return;
}
if (!content || typeof content !== 'string' || content.trim().length === 0) {
connectionManager.sendToUser(userId, { type: 'error', message: 'content is required' });
return;
}
const db = getDb();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get();
if (!message) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Message not found' });
return;
}
if (message.userId !== userId) {
connectionManager.sendToUser(userId, { type: 'error', message: 'You can only edit your own messages' });
return;
}
const now = Date.now();
db.update(schema.messages)
.set({ content: content.trim(), editedAt: now })
.where(eq(schema.messages.id, messageId))
.run();
const serverId = getChannelServerId(message.channelId);
if (!serverId) return;
const updatedMessage = getMessageWithUser(messageId);
if (updatedMessage) {
connectionManager.sendToServer(serverId, {
type: 'message_updated',
message: updatedMessage,
});
}
}
function handleMessageDelete(event: Record<string, unknown>, userId: string): void {
const messageId = event.messageId as string;
if (!messageId || typeof messageId !== 'string') {
connectionManager.sendToUser(userId, { type: 'error', message: 'messageId is required' });
return;
}
const db = getDb();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get();
if (!message) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Message not found' });
return;
}
const serverId = getChannelServerId(message.channelId);
if (!serverId) return;
// Allow author or admin to delete
const isAuthor = message.userId === userId;
const memberRow = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.serverId, serverId))
.all()
.find(m => m.userId === userId);
const isAdminRole = memberRow?.role === 'admin' || memberRow?.role === 'owner';
if (!isAuthor && !isAdminRole) {
connectionManager.sendToUser(userId, { type: 'error', message: 'You cannot delete this message' });
return;
}
// Delete attachments then message
db.delete(schema.attachments).where(eq(schema.attachments.messageId, messageId)).run();
db.delete(schema.messages).where(eq(schema.messages.id, messageId)).run();
connectionManager.sendToServer(serverId, {
type: 'message_deleted',
messageId,
channelId: message.channelId,
});
}
function handleTypingStart(event: Record<string, unknown>, userId: string, username: string): void {
const channelId = event.channelId as string;
if (!channelId || typeof channelId !== 'string') return;
const serverId = getChannelServerId(channelId);
if (!serverId) return;
if (!isMember(serverId, userId)) return;
// Clear previous typing timeout for this user+channel
const key = `${userId}:${channelId}`;
const existing = typingTimeouts.get(key);
if (existing) {
clearTimeout(existing);
}
// Broadcast typing event (exclude sender)
connectionManager.sendToServer(serverId, {
type: 'typing',
channelId,
userId,
username,
}, userId);
// Auto-expire typing after 5 seconds
const timeout = setTimeout(() => {
typingTimeouts.delete(key);
}, 5000);
typingTimeouts.set(key, timeout);
}
function handlePresenceUpdate(event: Record<string, unknown>, userId: string): void {
const status = event.status as string;
if (!status || !['online', 'idle', 'dnd'].includes(status)) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Status must be "online", "idle", or "dnd"' });
return;
}
const db = getDb();
db.update(schema.users).set({ status }).where(eq(schema.users.id, userId)).run();
// Broadcast to all servers user is in
const userServers = connectionManager.getUserServers(userId);
for (const serverId of userServers) {
connectionManager.sendToServer(serverId, {
type: 'presence_update',
userId,
status,
}, userId);
}
// Also send to self (other tabs)
connectionManager.sendToUser(userId, {
type: 'presence_update',
userId,
status,
});
}
function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
const channelId = event.channelId as string;
if (!channelId || typeof channelId !== 'string') {
connectionManager.sendToUser(userId, { type: 'error', message: 'channelId is required' });
return;
}
const serverId = getChannelServerId(channelId);
if (!serverId) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Channel not found' });
return;
}
if (!isMember(serverId, userId)) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Not a member of this server' });
return;
}
// Leave any current voice channel
const previousChannel = connectionManager.leaveAllVoice(userId);
if (previousChannel) {
const prevServerId = getChannelServerId(previousChannel);
if (prevServerId) {
connectionManager.sendToServer(prevServerId, {
type: 'voice_state_update',
channelId: previousChannel,
userId,
action: 'leave',
});
}
}
// Join new voice channel
connectionManager.joinVoice(channelId, userId);
// Broadcast to server
connectionManager.sendToServer(serverId, {
type: 'voice_state_update',
channelId,
userId,
action: 'join',
});
}
function handleVoiceLeave(userId: string): void {
const channelId = connectionManager.leaveAllVoice(userId);
if (channelId) {
const serverId = getChannelServerId(channelId);
if (serverId) {
connectionManager.sendToServer(serverId, {
type: 'voice_state_update',
channelId,
userId,
action: 'leave',
});
}
}
}
function handleDmMessageCreate(event: Record<string, unknown>, userId: string): void {
const dmChannelId = event.dmChannelId as string;
const content = event.content as string;
if (!dmChannelId || typeof dmChannelId !== 'string') {
connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId is required' });
return;
}
if (!content || typeof content !== 'string' || content.trim().length === 0) {
connectionManager.sendToUser(userId, { type: 'error', message: 'content is required' });
return;
}
if (!isDmMember(dmChannelId, userId)) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Not a member of this DM channel' });
return;
}
const db = getDb();
const messageId = generateSnowflake();
const now = Date.now();
db.insert(schema.dmMessages).values({
id: messageId,
dmChannelId,
userId,
content: content.trim(),
createdAt: now,
}).run();
const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
if (!user) return;
const dmMessage: DmMessageWithUser = {
id: messageId,
dmChannelId,
userId,
content: content.trim(),
createdAt: now,
user: sanitizeUser(user),
};
// Send to all DM members
const dmMembers = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, dmChannelId))
.all();
for (const member of dmMembers) {
connectionManager.sendToUser(member.userId, {
type: 'dm_message_created',
message: dmMessage,
});
}
}
+440
View File
@@ -0,0 +1,440 @@
import type { FastifyInstance } from 'fastify';
import type { WebSocket } from 'ws';
import { verifyJwt } from '../utils/auth.js';
import { getDb, schema } from '../db/index.js';
import { eq, inArray } from 'drizzle-orm';
import { handleClientEvent } from './events.js';
import type {
User,
ServerWithChannelsAndMembers,
MemberWithUser,
Channel,
DmChannel,
ServerEvent,
} from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
return {
id: row.id,
username: row.username,
displayName: row.displayName,
avatar: row.avatar,
status: (row.status ?? 'offline') as User['status'],
customStatus: row.customStatus,
createdAt: row.createdAt,
};
}
export interface AuthenticatedSocket {
ws: WebSocket;
userId: string;
username: string;
}
class ConnectionManager {
// userId → Set of WebSocket connections (multiple tabs)
private connections: Map<string, Set<WebSocket>> = new Map();
// userId → Set of server IDs the user belongs to
private userServers: Map<string, Set<string>> = new Map();
// channelId → Set of userIds in voice channel
private voiceStates: Map<string, Set<string>> = new Map();
// ws → userId (reverse lookup)
private wsToUser: Map<WebSocket, string> = new Map();
addConnection(userId: string, ws: WebSocket): void {
if (!this.connections.has(userId)) {
this.connections.set(userId, new Set());
}
this.connections.get(userId)!.add(ws);
this.wsToUser.set(ws, userId);
}
removeConnection(ws: WebSocket): string | undefined {
const userId = this.wsToUser.get(ws);
if (!userId) return undefined;
this.wsToUser.delete(ws);
const userConnections = this.connections.get(userId);
if (userConnections) {
userConnections.delete(ws);
if (userConnections.size === 0) {
this.connections.delete(userId);
}
}
return userId;
}
getUserConnections(userId: string): Set<WebSocket> {
return this.connections.get(userId) ?? new Set();
}
isUserOnline(userId: string): boolean {
const conns = this.connections.get(userId);
return conns !== undefined && conns.size > 0;
}
setUserServers(userId: string, serverIds: string[]): void {
this.userServers.set(userId, new Set(serverIds));
}
addUserServer(userId: string, serverId: string): void {
if (!this.userServers.has(userId)) {
this.userServers.set(userId, new Set());
}
this.userServers.get(userId)!.add(serverId);
}
getUserServers(userId: string): Set<string> {
return this.userServers.get(userId) ?? new Set();
}
// Voice state management
joinVoice(channelId: string, userId: string): void {
// Leave any existing voice channel first
this.leaveAllVoice(userId);
if (!this.voiceStates.has(channelId)) {
this.voiceStates.set(channelId, new Set());
}
this.voiceStates.get(channelId)!.add(userId);
}
leaveVoice(channelId: string, userId: string): void {
const users = this.voiceStates.get(channelId);
if (users) {
users.delete(userId);
if (users.size === 0) {
this.voiceStates.delete(channelId);
}
}
}
leaveAllVoice(userId: string): string | null {
for (const [channelId, users] of this.voiceStates) {
if (users.has(userId)) {
users.delete(userId);
if (users.size === 0) {
this.voiceStates.delete(channelId);
}
return channelId;
}
}
return null;
}
getVoiceUsers(channelId: string): Set<string> {
return this.voiceStates.get(channelId) ?? new Set();
}
getUserVoiceChannel(userId: string): string | null {
for (const [channelId, users] of this.voiceStates) {
if (users.has(userId)) {
return channelId;
}
}
return null;
}
// Send to a specific user (all their connections)
sendToUser(userId: string, event: ServerEvent): void {
const connections = this.getUserConnections(userId);
const message = JSON.stringify(event);
for (const ws of connections) {
if (ws.readyState === 1) { // WebSocket.OPEN
ws.send(message);
}
}
}
// Send to all members of a server
sendToServer(serverId: string, event: ServerEvent, excludeUserId?: string): void {
const message = JSON.stringify(event);
for (const [userId, serverIds] of this.userServers) {
if (serverIds.has(serverId) && userId !== excludeUserId) {
const connections = this.getUserConnections(userId);
for (const ws of connections) {
if (ws.readyState === 1) {
ws.send(message);
}
}
}
}
}
// Send to all connections of all online users (for global events)
sendToAll(event: ServerEvent, excludeUserId?: string): void {
const message = JSON.stringify(event);
for (const [userId, connections] of this.connections) {
if (userId !== excludeUserId) {
for (const ws of connections) {
if (ws.readyState === 1) {
ws.send(message);
}
}
}
}
}
getAllOnlineUserIds(): string[] {
return Array.from(this.connections.keys());
}
}
export const connectionManager = new ConnectionManager();
function buildReadyPayload(userId: string): {
user: User;
servers: ServerWithChannelsAndMembers[];
dmChannels: DmChannel[];
} {
const db = getDb();
// Get user
const userRow = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
if (!userRow) {
throw new Error('User not found');
}
const user = sanitizeUser(userRow);
// Get user's server memberships
const memberships = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.userId, userId))
.all();
const serverIds = memberships.map(m => m.serverId);
const servers: ServerWithChannelsAndMembers[] = [];
if (serverIds.length > 0) {
const serverRows = db.select()
.from(schema.servers)
.where(inArray(schema.servers.id, serverIds))
.all();
for (const serverRow of serverRows) {
const channels = db.select()
.from(schema.channels)
.where(eq(schema.channels.serverId, serverRow.id))
.all();
const memberRows = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.serverId, serverRow.id))
.all();
const memberUserIds = memberRows.map(m => m.userId);
const users = memberUserIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all()
: [];
const userMap = new Map(users.map(u => [u.id, u]));
const members: MemberWithUser[] = memberRows
.map(m => {
const u = userMap.get(m.userId);
if (!u) return null;
return {
serverId: m.serverId,
userId: m.userId,
role: (m.role ?? 'member') as MemberWithUser['role'],
nickname: m.nickname,
joinedAt: m.joinedAt,
user: sanitizeUser(u),
};
})
.filter((m): m is MemberWithUser => m !== null);
servers.push({
id: serverRow.id,
name: serverRow.name,
icon: serverRow.icon,
ownerId: serverRow.ownerId,
inviteCode: serverRow.inviteCode,
createdAt: serverRow.createdAt,
channels: channels.map(ch => ({
id: ch.id,
serverId: ch.serverId,
name: ch.name,
type: ch.type as Channel['type'],
topic: ch.topic,
position: ch.position ?? 0,
createdAt: ch.createdAt,
})),
members,
});
}
}
// Store user's server IDs for broadcasting
connectionManager.setUserServers(userId, serverIds);
// Get DM channels
const dmMemberships = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.userId, userId))
.all();
const dmChannels: DmChannel[] = [];
for (const dm of dmMemberships) {
const dmChannel = db.select()
.from(schema.dmChannels)
.where(eq(schema.dmChannels.id, dm.dmChannelId))
.get();
if (!dmChannel) continue;
const dmMemberRows = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, dm.dmChannelId))
.all();
const dmMemberUserIds = dmMemberRows.map(m => m.userId);
const dmUsers = dmMemberUserIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, dmMemberUserIds)).all()
: [];
// Get last message
const lastMessage = db.select()
.from(schema.dmMessages)
.where(eq(schema.dmMessages.dmChannelId, dm.dmChannelId))
.orderBy(schema.dmMessages.createdAt)
.all();
const last = lastMessage.length > 0 ? lastMessage[lastMessage.length - 1] : null;
dmChannels.push({
id: dmChannel.id,
createdAt: dmChannel.createdAt,
members: dmUsers.map(sanitizeUser),
lastMessage: last ? {
id: last.id,
dmChannelId: last.dmChannelId,
userId: last.userId,
content: last.content,
createdAt: last.createdAt,
} : null,
});
}
return { user, servers, dmChannels };
}
export async function registerWebSocket(app: FastifyInstance): Promise<void> {
app.get('/ws', { websocket: true }, (socket, request) => {
const ws = socket as unknown as WebSocket;
let authenticated = false;
let userId: string | undefined;
let username: string | undefined;
// Set auth timeout - must authenticate within 10 seconds
const authTimeout = setTimeout(() => {
if (!authenticated) {
ws.send(JSON.stringify({ type: 'error', message: 'Authentication timeout' }));
ws.close();
}
}, 10000);
ws.on('message', (data: Buffer | string) => {
let parsed: Record<string, unknown>;
try {
const raw = typeof data === 'string' ? data : data.toString('utf-8');
parsed = JSON.parse(raw) as Record<string, unknown>;
} catch {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
return;
}
if (!authenticated) {
// First message must be auth
if (parsed.type !== 'auth' || typeof parsed.token !== 'string') {
ws.send(JSON.stringify({ type: 'error', message: 'First message must be auth' }));
ws.close();
return;
}
try {
const payload = verifyJwt(parsed.token);
userId = payload.userId;
username = payload.username;
authenticated = true;
clearTimeout(authTimeout);
// Update user status to online
const db = getDb();
db.update(schema.users).set({ status: 'online' }).where(eq(schema.users.id, userId)).run();
// Add connection
connectionManager.addConnection(userId, ws);
// Build and send ready payload
const readyData = buildReadyPayload(userId);
ws.send(JSON.stringify({
type: 'ready',
...readyData,
}));
// Broadcast presence update to all servers
const userServers = connectionManager.getUserServers(userId);
for (const serverId of userServers) {
connectionManager.sendToServer(serverId, {
type: 'presence_update',
userId,
status: 'online',
}, userId);
}
} catch {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid token' }));
ws.close();
}
return;
}
// Handle authenticated events
if (userId && username) {
handleClientEvent(parsed, userId, username);
}
});
ws.on('close', () => {
clearTimeout(authTimeout);
if (userId) {
const removedUserId = connectionManager.removeConnection(ws);
// If user has no more connections, set offline
if (removedUserId && !connectionManager.isUserOnline(removedUserId)) {
const db = getDb();
db.update(schema.users).set({ status: 'offline' }).where(eq(schema.users.id, removedUserId)).run();
// Leave voice if in one
const leftChannel = connectionManager.leaveAllVoice(removedUserId);
if (leftChannel) {
// Get channel's server to broadcast
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, leftChannel)).get();
if (channel) {
connectionManager.sendToServer(channel.serverId, {
type: 'voice_state_update',
channelId: leftChannel,
userId: removedUserId,
action: 'leave',
});
}
}
// Broadcast offline to all servers
const userServers = connectionManager.getUserServers(removedUserId);
for (const serverId of userServers) {
connectionManager.sendToServer(serverId, {
type: 'presence_update',
userId: removedUserId,
status: 'offline',
});
}
}
}
});
ws.on('error', () => {
clearTimeout(authTimeout);
});
});
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"references": [
{ "path": "../shared" }
]
}