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