chore: Initial commit of Opencord base state
This commit is contained in:
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user