chore: Initial commit of Opencord base state

This commit is contained in:
Jannis Braun
2026-02-18 02:49:21 +01:00
commit 4fd17084a5
124 changed files with 17955 additions and 0 deletions
+120
View File
@@ -0,0 +1,120 @@
import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { hashPassword, verifyPassword, signJwt } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { config } from '../config.js';
import type { RegisterRequest, LoginRequest, AuthResponse, User } from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
return {
id: row.id,
username: row.username,
displayName: row.displayName,
avatar: row.avatar,
status: (row.status ?? 'offline') as User['status'],
customStatus: row.customStatus,
createdAt: row.createdAt,
};
}
export async function authRoutes(app: FastifyInstance): Promise<void> {
app.post<{ Body: RegisterRequest }>('/api/auth/register', async (request, reply) => {
const { username, password, displayName } = request.body;
if (!username || typeof username !== 'string') {
return reply.code(400).send({ error: 'Username is required', statusCode: 400 });
}
if (!password || typeof password !== 'string') {
return reply.code(400).send({ error: 'Password is required', statusCode: 400 });
}
const trimmedUsername = username.trim();
if (trimmedUsername.length < 3 || trimmedUsername.length > 32) {
return reply.code(400).send({ error: 'Username must be between 3 and 32 characters', statusCode: 400 });
}
if (!/^[a-zA-Z0-9_]+$/.test(trimmedUsername)) {
return reply.code(400).send({ error: 'Username can only contain letters, numbers, and underscores', statusCode: 400 });
}
if (password.length < 6) {
return reply.code(400).send({ error: 'Password must be at least 6 characters', statusCode: 400 });
}
if (!config.registrationOpen) {
return reply.code(403).send({ error: 'Registration is currently closed', statusCode: 403 });
}
const db = getDb();
const existing = db.select().from(schema.users).where(eq(schema.users.username, trimmedUsername)).get();
if (existing) {
return reply.code(409).send({ error: 'Username already taken', statusCode: 409 });
}
const passwordHash = await hashPassword(password);
const userId = generateSnowflake();
const now = Date.now();
db.insert(schema.users).values({
id: userId,
username: trimmedUsername,
displayName: displayName?.trim() || null,
passwordHash,
status: 'online',
createdAt: now,
}).run();
const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
if (!user) {
return reply.code(500).send({ error: 'Failed to create user', statusCode: 500 });
}
const token = signJwt({ userId: user.id, username: user.username });
const response: AuthResponse = {
token,
user: sanitizeUser(user),
};
return reply.code(201).send(response);
});
app.post<{ Body: LoginRequest }>('/api/auth/login', async (request, reply) => {
const { username, password } = request.body;
if (!username || typeof username !== 'string') {
return reply.code(400).send({ error: 'Username is required', statusCode: 400 });
}
if (!password || typeof password !== 'string') {
return reply.code(400).send({ error: 'Password is required', statusCode: 400 });
}
const db = getDb();
const user = db.select().from(schema.users).where(eq(schema.users.username, username.trim())).get();
if (!user) {
return reply.code(401).send({ error: 'Invalid username or password', statusCode: 401 });
}
const validPassword = await verifyPassword(password, user.passwordHash);
if (!validPassword) {
return reply.code(401).send({ error: 'Invalid username or password', statusCode: 401 });
}
db.update(schema.users).set({ status: 'online' }).where(eq(schema.users.id, user.id)).run();
const token = signJwt({ userId: user.id, username: user.username });
const response: AuthResponse = {
token,
user: sanitizeUser({ ...user, status: 'online' }),
};
return reply.code(200).send(response);
});
}
+188
View File
@@ -0,0 +1,188 @@
import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, isAdmin, getChannelServerId } from '../utils/permissions.js';
import type {
CreateChannelRequest,
UpdateChannelRequest,
Channel,
} from '@opencord/shared';
function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
return {
id: row.id,
serverId: row.serverId,
name: row.name,
type: row.type as Channel['type'],
topic: row.topic,
position: row.position ?? 0,
createdAt: row.createdAt,
};
}
export async function channelRoutes(app: FastifyInstance): Promise<void> {
// GET /api/servers/:id/channels - List channels in a server
app.get<{ Params: { id: string } }>('/api/servers/:id/channels', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
}
const channels = db.select()
.from(schema.channels)
.where(eq(schema.channels.serverId, id))
.all();
// Sort by position
channels.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
return reply.code(200).send(channels.map(rowToChannel));
});
// POST /api/servers/:id/channels - Create a channel (admin+)
app.post<{ Params: { id: string }; Body: CreateChannelRequest }>('/api/servers/:id/channels', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { name, type, topic } = request.body;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isAdmin(id, request.userId)) {
return reply.code(403).send({ error: 'Only admins can create channels', statusCode: 403 });
}
if (!name || typeof name !== 'string') {
return reply.code(400).send({ error: 'Channel name is required', statusCode: 400 });
}
const trimmedName = name.trim().toLowerCase().replace(/\s+/g, '-');
if (trimmedName.length < 1 || trimmedName.length > 100) {
return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 });
}
if (!type || !['text', 'voice', 'video'].includes(type)) {
return reply.code(400).send({ error: 'Channel type must be "text", "voice", or "video"', statusCode: 400 });
}
// Get max position for ordering
const existingChannels = db.select()
.from(schema.channels)
.where(eq(schema.channels.serverId, id))
.all();
const maxPosition = existingChannels.reduce((max, ch) => Math.max(max, ch.position ?? 0), -1);
const channelId = generateSnowflake();
const now = Date.now();
db.insert(schema.channels).values({
id: channelId,
serverId: id,
name: trimmedName,
type,
topic: topic?.trim() || null,
position: maxPosition + 1,
createdAt: now,
}).run();
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, channelId)).get();
if (!channel) {
return reply.code(500).send({ error: 'Failed to create channel', statusCode: 500 });
}
return reply.code(201).send(rowToChannel(channel));
});
// PATCH /api/channels/:id - Update a channel (admin+)
app.patch<{ Params: { id: string }; Body: UpdateChannelRequest }>('/api/channels/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { name, topic, position } = request.body;
const db = getDb();
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
if (!channel) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
const serverId = channel.serverId;
if (!isAdmin(serverId, request.userId)) {
return reply.code(403).send({ error: 'Only admins can update channels', statusCode: 403 });
}
const updates: Partial<typeof schema.channels.$inferInsert> = {};
if (name !== undefined) {
const trimmedName = name.trim().toLowerCase().replace(/\s+/g, '-');
if (trimmedName.length < 1 || trimmedName.length > 100) {
return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 });
}
updates.name = trimmedName;
}
if (topic !== undefined) {
updates.topic = topic.trim() || null;
}
if (position !== undefined) {
if (typeof position !== 'number' || position < 0) {
return reply.code(400).send({ error: 'Position must be a non-negative number', statusCode: 400 });
}
updates.position = position;
}
if (Object.keys(updates).length === 0) {
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
}
db.update(schema.channels).set(updates).where(eq(schema.channels.id, id)).run();
const updated = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
if (!updated) {
return reply.code(500).send({ error: 'Failed to update channel', statusCode: 500 });
}
return reply.code(200).send(rowToChannel(updated));
});
// DELETE /api/channels/:id - Delete a channel (admin+)
app.delete<{ Params: { id: string } }>('/api/channels/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
if (!channel) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
const serverId = channel.serverId;
if (!isAdmin(serverId, request.userId)) {
return reply.code(403).send({ error: 'Only admins can delete channels', statusCode: 403 });
}
// Delete messages in channel (attachments cascade), then channel
db.delete(schema.messages).where(eq(schema.messages.channelId, id)).run();
db.delete(schema.channels).where(eq(schema.channels.id, id)).run();
return reply.code(200).send({ success: true });
});
}
+313
View File
@@ -0,0 +1,313 @@
import type { FastifyInstance } from 'fastify';
import { eq, and, desc, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isDmMember } from '../utils/permissions.js';
import { connectionManager } from '../ws/handler.js';
import type {
User,
DmChannel,
DmMessage,
DmMessageWithUser,
CreateDmRequest,
CreateDmMessageRequest,
PaginatedQuery,
} from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
return {
id: row.id,
username: row.username,
displayName: row.displayName,
avatar: row.avatar,
status: (row.status ?? 'offline') as User['status'],
customStatus: row.customStatus,
createdAt: row.createdAt,
};
}
export async function dmRoutes(app: FastifyInstance): Promise<void> {
// GET /api/dm - List user's DM channels
app.get('/api/dm', {
preHandler: authenticate,
}, async (request, reply) => {
const db = getDb();
const memberships = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.userId, request.userId))
.all();
const dmChannels: DmChannel[] = [];
for (const membership of memberships) {
const dmChannel = db.select()
.from(schema.dmChannels)
.where(eq(schema.dmChannels.id, membership.dmChannelId))
.get();
if (!dmChannel) continue;
const dmMemberRows = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, membership.dmChannelId))
.all();
const memberUserIds = dmMemberRows.map(m => m.userId);
const users = memberUserIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all()
: [];
// Get last message
const allMessages = db.select()
.from(schema.dmMessages)
.where(eq(schema.dmMessages.dmChannelId, membership.dmChannelId))
.orderBy(desc(schema.dmMessages.createdAt))
.limit(1)
.all();
const lastMessage = allMessages[0] ?? null;
dmChannels.push({
id: dmChannel.id,
createdAt: dmChannel.createdAt,
members: users.map(sanitizeUser),
lastMessage: lastMessage ? {
id: lastMessage.id,
dmChannelId: lastMessage.dmChannelId,
userId: lastMessage.userId,
content: lastMessage.content,
createdAt: lastMessage.createdAt,
} : null,
});
}
// Sort by last message timestamp (newest first)
dmChannels.sort((a, b) => {
const aTime = a.lastMessage?.createdAt ?? a.createdAt;
const bTime = b.lastMessage?.createdAt ?? b.createdAt;
return bTime - aTime;
});
return reply.code(200).send(dmChannels);
});
// POST /api/dm - Create or get existing DM channel
app.post<{ Body: CreateDmRequest }>('/api/dm', {
preHandler: authenticate,
}, async (request, reply) => {
const { userId } = request.body;
if (!userId || typeof userId !== 'string') {
return reply.code(400).send({ error: 'userId is required', statusCode: 400 });
}
if (userId === request.userId) {
return reply.code(400).send({ error: 'Cannot create DM with yourself', statusCode: 400 });
}
const db = getDb();
// Check if target user exists
const targetUser = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
if (!targetUser) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
// Check if DM channel already exists between these two users
const myDms = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.userId, request.userId))
.all();
for (const myDm of myDms) {
const otherMember = db.select()
.from(schema.dmMembers)
.where(and(
eq(schema.dmMembers.dmChannelId, myDm.dmChannelId),
eq(schema.dmMembers.userId, userId),
))
.get();
if (otherMember) {
// DM channel already exists
const dmChannel = db.select()
.from(schema.dmChannels)
.where(eq(schema.dmChannels.id, myDm.dmChannelId))
.get();
if (!dmChannel) continue;
const dmMemberRows = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, myDm.dmChannelId))
.all();
const memberUserIds = dmMemberRows.map(m => m.userId);
const users = db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all();
const result: DmChannel = {
id: dmChannel.id,
createdAt: dmChannel.createdAt,
members: users.map(sanitizeUser),
lastMessage: null,
};
return reply.code(200).send(result);
}
}
// Create new DM channel
const dmChannelId = generateSnowflake();
const now = Date.now();
db.insert(schema.dmChannels).values({
id: dmChannelId,
createdAt: now,
}).run();
db.insert(schema.dmMembers).values({
dmChannelId,
userId: request.userId,
}).run();
db.insert(schema.dmMembers).values({
dmChannelId,
userId,
}).run();
const currentUserRow = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
const members = [currentUserRow, targetUser]
.filter((u): u is NonNullable<typeof u> => u !== undefined)
.map(sanitizeUser);
const result: DmChannel = {
id: dmChannelId,
createdAt: now,
members,
lastMessage: null,
};
return reply.code(201).send(result);
});
// GET /api/dm/:id/messages - Get DM messages with pagination
app.get<{ Params: { id: string }; Querystring: PaginatedQuery }>('/api/dm/:id/messages', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const before = request.query.before;
const limit = Math.min(Math.max(Number(request.query.limit) || 50, 1), 100);
if (!isDmMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
}
const db = getDb();
let messageRows: (typeof schema.dmMessages.$inferSelect)[];
if (before) {
messageRows = db.select()
.from(schema.dmMessages)
.where(eq(schema.dmMessages.dmChannelId, id))
.orderBy(desc(schema.dmMessages.createdAt))
.all()
.filter(m => m.id < before)
.slice(0, limit);
} else {
messageRows = db.select()
.from(schema.dmMessages)
.where(eq(schema.dmMessages.dmChannelId, id))
.orderBy(desc(schema.dmMessages.createdAt))
.limit(limit)
.all();
}
messageRows.reverse();
if (messageRows.length === 0) {
return reply.code(200).send([]);
}
// Batch fetch users
const userIds = [...new Set(messageRows.map(m => m.userId))];
const users = db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all();
const userMap = new Map(users.map(u => [u.id, u]));
const messages: DmMessageWithUser[] = messageRows
.map(m => {
const user = userMap.get(m.userId);
if (!user) return null;
return {
id: m.id,
dmChannelId: m.dmChannelId,
userId: m.userId,
content: m.content,
createdAt: m.createdAt,
user: sanitizeUser(user),
};
})
.filter((m): m is DmMessageWithUser => m !== null);
return reply.code(200).send(messages);
});
// POST /api/dm/:id/messages - Send a DM message
app.post<{ Params: { id: string }; Body: CreateDmMessageRequest }>('/api/dm/:id/messages', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { content } = request.body;
if (!isDmMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
}
if (!content || typeof content !== 'string' || content.trim().length === 0) {
return reply.code(400).send({ error: 'Message content is required', statusCode: 400 });
}
const db = getDb();
const messageId = generateSnowflake();
const now = Date.now();
db.insert(schema.dmMessages).values({
id: messageId,
dmChannelId: id,
userId: request.userId,
content: content.trim(),
createdAt: now,
}).run();
const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (!user) {
return reply.code(500).send({ error: 'User not found', statusCode: 500 });
}
const message: DmMessageWithUser = {
id: messageId,
dmChannelId: id,
userId: request.userId,
content: content.trim(),
createdAt: now,
user: sanitizeUser(user),
};
// Broadcast via WebSocket to all DM members
const dmMembers = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, id))
.all();
for (const member of dmMembers) {
connectionManager.sendToUser(member.userId, {
type: 'dm_message_created',
message,
});
}
return reply.code(201).send(message);
});
}
+47
View File
@@ -0,0 +1,47 @@
import type { FastifyInstance } from 'fastify';
import { AccessToken } from 'livekit-server-sdk';
import { authenticate } from '../utils/auth.js';
import { config } from '../config.js';
import { getChannelServerId, isMember } from '../utils/permissions.js';
import type { LiveKitTokenRequest, LiveKitTokenResponse } from '@opencord/shared';
export async function livekitRoutes(app: FastifyInstance): Promise<void> {
app.post<{ Body: LiveKitTokenRequest }>('/api/livekit/token', {
preHandler: authenticate,
}, async (request, reply) => {
const { channelId } = request.body;
if (!channelId || typeof channelId !== 'string') {
return reply.code(400).send({ error: 'channelId is required', statusCode: 400 });
}
const serverId = getChannelServerId(channelId);
if (!serverId) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
if (!isMember(serverId, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
}
const identity = `${request.userId}:${request.username}`;
const token = new AccessToken(config.livekit.apiKey, config.livekit.apiSecret, {
identity,
ttl: '1h',
});
token.addGrant({
room: channelId,
roomJoin: true,
canPublish: true,
canSubscribe: true,
canPublishData: true,
});
const jwt = await token.toJwt();
const response: LiveKitTokenResponse = { token: jwt };
return reply.code(200).send(response);
});
}
+294
View File
@@ -0,0 +1,294 @@
import type { FastifyInstance } from 'fastify';
import { eq, lt, desc, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, getChannelServerId, isAdmin } from '../utils/permissions.js';
import { connectionManager } from '../ws/handler.js';
import type {
CreateMessageRequest,
UpdateMessageRequest,
PaginatedQuery,
User,
MessageWithUser,
Attachment,
} from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
return {
id: row.id,
username: row.username,
displayName: row.displayName,
avatar: row.avatar,
status: (row.status ?? 'offline') as User['status'],
customStatus: row.customStatus,
createdAt: row.createdAt,
};
}
function buildMessageWithUser(
message: typeof schema.messages.$inferSelect,
user: typeof schema.users.$inferSelect,
attachmentRows: (typeof schema.attachments.$inferSelect)[],
): MessageWithUser {
return {
id: message.id,
channelId: message.channelId,
userId: message.userId,
content: message.content,
editedAt: message.editedAt,
createdAt: message.createdAt,
user: sanitizeUser(user),
attachments: attachmentRows.map(a => ({
id: a.id,
messageId: a.messageId ?? message.id,
filename: a.filename,
originalName: a.originalName,
mimetype: a.mimetype,
size: a.size,
createdAt: a.createdAt,
})),
};
}
export async function messageRoutes(app: FastifyInstance): Promise<void> {
// GET /api/channels/:id/messages - Get messages with cursor pagination
app.get<{ Params: { id: string }; Querystring: PaginatedQuery }>('/api/channels/:id/messages', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const before = request.query.before;
const limit = Math.min(Math.max(Number(request.query.limit) || 50, 1), 100);
const serverId = getChannelServerId(id);
if (!serverId) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
if (!isMember(serverId, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
}
const db = getDb();
let messageRows: (typeof schema.messages.$inferSelect)[];
if (before) {
messageRows = db.select()
.from(schema.messages)
.where(eq(schema.messages.channelId, id))
.orderBy(desc(schema.messages.createdAt))
.all()
.filter(m => m.id < before)
.slice(0, limit);
} else {
messageRows = db.select()
.from(schema.messages)
.where(eq(schema.messages.channelId, id))
.orderBy(desc(schema.messages.createdAt))
.limit(limit)
.all();
}
// Reverse to get chronological order
messageRows.reverse();
if (messageRows.length === 0) {
return reply.code(200).send([]);
}
// Batch fetch users
const userIds = [...new Set(messageRows.map(m => m.userId))];
const users = db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all();
const userMap = new Map(users.map(u => [u.id, u]));
// Batch fetch attachments
const messageIds = messageRows.map(m => m.id);
const allAttachments = db.select()
.from(schema.attachments)
.where(inArray(schema.attachments.messageId, messageIds))
.all();
const attachmentMap = new Map<string, (typeof schema.attachments.$inferSelect)[]>();
for (const att of allAttachments) {
const mid = att.messageId ?? '';
if (!attachmentMap.has(mid)) {
attachmentMap.set(mid, []);
}
attachmentMap.get(mid)!.push(att);
}
const messages: MessageWithUser[] = messageRows
.map(m => {
const user = userMap.get(m.userId);
if (!user) return null;
return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? []);
})
.filter((m): m is MessageWithUser => m !== null);
return reply.code(200).send(messages);
});
// POST /api/channels/:id/messages - Create a message
app.post<{ Params: { id: string }; Body: CreateMessageRequest }>('/api/channels/:id/messages', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { content, attachments: attachmentIds } = request.body;
const serverId = getChannelServerId(id);
if (!serverId) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
if (!isMember(serverId, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
}
if ((!content || typeof content !== 'string' || content.trim().length === 0) &&
(!attachmentIds || attachmentIds.length === 0)) {
return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 });
}
const db = getDb();
const messageId = generateSnowflake();
const now = Date.now();
db.insert(schema.messages).values({
id: messageId,
channelId: id,
userId: request.userId,
content: content?.trim() || null,
createdAt: now,
}).run();
// Link attachments to message
if (attachmentIds && attachmentIds.length > 0) {
for (const attId of attachmentIds) {
db.update(schema.attachments)
.set({ messageId })
.where(eq(schema.attachments.id, attId))
.run();
}
}
const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (!user) {
return reply.code(500).send({ error: 'User not found', statusCode: 500 });
}
const attachmentRows = db.select()
.from(schema.attachments)
.where(eq(schema.attachments.messageId, messageId))
.all();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get();
if (!message) {
return reply.code(500).send({ error: 'Failed to create message', statusCode: 500 });
}
const messageWithUser = buildMessageWithUser(message, user, attachmentRows);
// Broadcast via WebSocket
connectionManager.sendToServer(serverId, {
type: 'message_created',
message: messageWithUser,
});
return reply.code(201).send(messageWithUser);
});
// PATCH /api/messages/:id - Edit a message (author only)
app.patch<{ Params: { id: string }; Body: UpdateMessageRequest }>('/api/messages/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { content } = request.body;
if (!content || typeof content !== 'string' || content.trim().length === 0) {
return reply.code(400).send({ error: 'Content is required', statusCode: 400 });
}
const db = getDb();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, id)).get();
if (!message) {
return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
}
if (message.userId !== request.userId) {
return reply.code(403).send({ error: 'You can only edit your own messages', statusCode: 403 });
}
const now = Date.now();
db.update(schema.messages)
.set({ content: content.trim(), editedAt: now })
.where(eq(schema.messages.id, id))
.run();
const updatedMessage = db.select().from(schema.messages).where(eq(schema.messages.id, id)).get();
if (!updatedMessage) {
return reply.code(500).send({ error: 'Failed to update message', statusCode: 500 });
}
const user = db.select().from(schema.users).where(eq(schema.users.id, message.userId)).get();
if (!user) {
return reply.code(500).send({ error: 'User not found', statusCode: 500 });
}
const attachmentRows = db.select()
.from(schema.attachments)
.where(eq(schema.attachments.messageId, id))
.all();
const messageWithUser = buildMessageWithUser(updatedMessage, user, attachmentRows);
// Broadcast edit
const serverId = getChannelServerId(message.channelId);
if (serverId) {
connectionManager.sendToServer(serverId, {
type: 'message_updated',
message: messageWithUser,
});
}
return reply.code(200).send(messageWithUser);
});
// DELETE /api/messages/:id - Delete a message (author or admin)
app.delete<{ Params: { id: string } }>('/api/messages/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, id)).get();
if (!message) {
return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
}
const serverId = getChannelServerId(message.channelId);
if (!serverId) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
const isAuthor = message.userId === request.userId;
const isAdminUser = isAdmin(serverId, request.userId);
if (!isAuthor && !isAdminUser) {
return reply.code(403).send({ error: 'You cannot delete this message', statusCode: 403 });
}
// Delete attachments then message
db.delete(schema.attachments).where(eq(schema.attachments.messageId, id)).run();
db.delete(schema.messages).where(eq(schema.messages.id, id)).run();
// Broadcast deletion
connectionManager.sendToServer(serverId, {
type: 'message_deleted',
messageId: id,
channelId: message.channelId,
});
return reply.code(200).send({ success: true });
});
}
+528
View File
@@ -0,0 +1,528 @@
import type { FastifyInstance } from 'fastify';
import { eq, and, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, isOwner, isAdmin } from '../utils/permissions.js';
import crypto from 'crypto';
import type {
CreateServerRequest,
UpdateServerRequest,
JoinServerRequest,
UpdateMemberRequest,
User,
Server,
Channel,
MemberWithUser,
ServerWithChannelsAndMembers,
} from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
return {
id: row.id,
username: row.username,
displayName: row.displayName,
avatar: row.avatar,
status: (row.status ?? 'offline') as User['status'],
customStatus: row.customStatus,
createdAt: row.createdAt,
};
}
function rowToServer(row: typeof schema.servers.$inferSelect): Server {
return {
id: row.id,
name: row.name,
icon: row.icon,
ownerId: row.ownerId,
inviteCode: row.inviteCode,
createdAt: row.createdAt,
};
}
function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
return {
id: row.id,
serverId: row.serverId,
name: row.name,
type: row.type as Channel['type'],
topic: row.topic,
position: row.position ?? 0,
createdAt: row.createdAt,
};
}
function generateInviteCode(): string {
return crypto.randomBytes(4).toString('hex');
}
export async function serverRoutes(app: FastifyInstance): Promise<void> {
// POST /api/servers - Create a new server
app.post<{ Body: CreateServerRequest }>('/api/servers', {
preHandler: authenticate,
}, async (request, reply) => {
const { name, icon } = request.body;
if (!name || typeof name !== 'string') {
return reply.code(400).send({ error: 'Server name is required', statusCode: 400 });
}
const trimmedName = name.trim();
if (trimmedName.length < 1 || trimmedName.length > 100) {
return reply.code(400).send({ error: 'Server name must be between 1 and 100 characters', statusCode: 400 });
}
const db = getDb();
const serverId = generateSnowflake();
const channelId = generateSnowflake();
const now = Date.now();
const inviteCode = generateInviteCode();
// Create the server
db.insert(schema.servers).values({
id: serverId,
name: trimmedName,
icon: icon ?? null,
ownerId: request.userId,
inviteCode,
createdAt: now,
}).run();
// Add owner as member with 'owner' role
db.insert(schema.serverMembers).values({
serverId,
userId: request.userId,
role: 'owner',
joinedAt: now,
}).run();
// Create default #general text channel
db.insert(schema.channels).values({
id: channelId,
serverId,
name: 'general',
type: 'text',
position: 0,
createdAt: now,
}).run();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, serverId)).get();
if (!server) {
return reply.code(500).send({ error: 'Failed to create server', statusCode: 500 });
}
return reply.code(201).send(rowToServer(server));
});
// GET /api/servers - List user's servers
app.get('/api/servers', {
preHandler: authenticate,
}, async (request, reply) => {
const db = getDb();
const memberships = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.userId, request.userId))
.all();
if (memberships.length === 0) {
return reply.code(200).send([]);
}
const serverIds = memberships.map(m => m.serverId);
const servers = db.select()
.from(schema.servers)
.where(inArray(schema.servers.id, serverIds))
.all();
return reply.code(200).send(servers.map(rowToServer));
});
// GET /api/servers/:id - Get server detail with channels and members
app.get<{ Params: { id: string } }>('/api/servers/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
}
const channels = db.select()
.from(schema.channels)
.where(eq(schema.channels.serverId, id))
.all();
const memberRows = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.serverId, id))
.all();
const memberUserIds = memberRows.map(m => m.userId);
const users = memberUserIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all()
: [];
const userMap = new Map(users.map(u => [u.id, u]));
const members: MemberWithUser[] = memberRows
.map(m => {
const user = userMap.get(m.userId);
if (!user) return null;
return {
serverId: m.serverId,
userId: m.userId,
role: (m.role ?? 'member') as MemberWithUser['role'],
nickname: m.nickname,
joinedAt: m.joinedAt,
user: sanitizeUser(user),
};
})
.filter((m): m is MemberWithUser => m !== null);
const result: ServerWithChannelsAndMembers = {
...rowToServer(server),
channels: channels.map(rowToChannel),
members,
};
return reply.code(200).send(result);
});
// PATCH /api/servers/:id - Update server (owner only)
app.patch<{ Params: { id: string }; Body: UpdateServerRequest }>('/api/servers/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { name, icon } = request.body;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isOwner(id, request.userId)) {
return reply.code(403).send({ error: 'Only the server owner can update the server', statusCode: 403 });
}
const updates: Partial<typeof schema.servers.$inferInsert> = {};
if (name !== undefined) {
const trimmedName = name.trim();
if (trimmedName.length < 1 || trimmedName.length > 100) {
return reply.code(400).send({ error: 'Server name must be between 1 and 100 characters', statusCode: 400 });
}
updates.name = trimmedName;
}
if (icon !== undefined) {
updates.icon = icon;
}
if (Object.keys(updates).length === 0) {
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
}
db.update(schema.servers).set(updates).where(eq(schema.servers.id, id)).run();
const updated = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!updated) {
return reply.code(500).send({ error: 'Failed to update server', statusCode: 500 });
}
return reply.code(200).send(rowToServer(updated));
});
// DELETE /api/servers/:id - Delete server (owner only)
app.delete<{ Params: { id: string } }>('/api/servers/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isOwner(id, request.userId)) {
return reply.code(403).send({ error: 'Only the server owner can delete the server', statusCode: 403 });
}
// Delete all channels (messages cascade), members, then server
db.delete(schema.channels).where(eq(schema.channels.serverId, id)).run();
db.delete(schema.serverMembers).where(eq(schema.serverMembers.serverId, id)).run();
db.delete(schema.servers).where(eq(schema.servers.id, id)).run();
return reply.code(200).send({ success: true });
});
// POST /api/servers/:id/invite - Generate invite code (admin+)
app.post<{ Params: { id: string } }>('/api/servers/:id/invite', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isAdmin(id, request.userId)) {
return reply.code(403).send({ error: 'Only admins can generate invite codes', statusCode: 403 });
}
const inviteCode = generateInviteCode();
db.update(schema.servers).set({ inviteCode }).where(eq(schema.servers.id, id)).run();
return reply.code(200).send({ inviteCode });
});
// POST /api/servers/:id/join - Join server by invite code
app.post<{ Params: { id: string }; Body: JoinServerRequest }>('/api/servers/:id/join', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { inviteCode } = request.body;
if (!inviteCode || typeof inviteCode !== 'string') {
return reply.code(400).send({ error: 'Invite code is required', statusCode: 400 });
}
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (server.inviteCode !== inviteCode) {
return reply.code(400).send({ error: 'Invalid invite code', statusCode: 400 });
}
if (isMember(id, request.userId)) {
return reply.code(409).send({ error: 'You are already a member of this server', statusCode: 409 });
}
const now = Date.now();
db.insert(schema.serverMembers).values({
serverId: id,
userId: request.userId,
role: 'member',
joinedAt: now,
}).run();
return reply.code(200).send(rowToServer(server));
});
// POST /api/servers/join - Join server by invite code (no server ID needed)
app.post<{ Body: JoinServerRequest }>('/api/servers/join', {
preHandler: authenticate,
}, async (request, reply) => {
const { inviteCode } = request.body;
if (!inviteCode || typeof inviteCode !== 'string') {
return reply.code(400).send({ error: 'Invite code is required', statusCode: 400 });
}
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.inviteCode, inviteCode)).get();
if (!server) {
return reply.code(404).send({ error: 'Invalid invite code', statusCode: 404 });
}
if (isMember(server.id, request.userId)) {
return reply.code(409).send({ error: 'You are already a member of this server', statusCode: 409 });
}
const now = Date.now();
db.insert(schema.serverMembers).values({
serverId: server.id,
userId: request.userId,
role: 'member',
joinedAt: now,
}).run();
return reply.code(200).send(rowToServer(server));
});
// GET /api/servers/:id/members - List server members
app.get<{ Params: { id: string } }>('/api/servers/:id/members', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
}
const memberRows = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.serverId, id))
.all();
const memberUserIds = memberRows.map(m => m.userId);
const users = memberUserIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all()
: [];
const userMap = new Map(users.map(u => [u.id, u]));
const members: MemberWithUser[] = memberRows
.map(m => {
const user = userMap.get(m.userId);
if (!user) return null;
return {
serverId: m.serverId,
userId: m.userId,
role: (m.role ?? 'member') as MemberWithUser['role'],
nickname: m.nickname,
joinedAt: m.joinedAt,
user: sanitizeUser(user),
};
})
.filter((m): m is MemberWithUser => m !== null);
return reply.code(200).send(members);
});
// PATCH /api/servers/:id/members/:uid - Update member role (owner only)
app.patch<{ Params: { id: string; uid: string }; Body: UpdateMemberRequest }>('/api/servers/:id/members/:uid', {
preHandler: authenticate,
}, async (request, reply) => {
const { id, uid } = request.params;
const { role } = request.body;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isOwner(id, request.userId)) {
return reply.code(403).send({ error: 'Only the server owner can change member roles', statusCode: 403 });
}
if (uid === request.userId) {
return reply.code(400).send({ error: 'You cannot change your own role', statusCode: 400 });
}
if (!role || !['admin', 'member'].includes(role)) {
return reply.code(400).send({ error: 'Role must be "admin" or "member"', statusCode: 400 });
}
const member = db.select()
.from(schema.serverMembers)
.where(and(
eq(schema.serverMembers.serverId, id),
eq(schema.serverMembers.userId, uid),
))
.get();
if (!member) {
return reply.code(404).send({ error: 'Member not found', statusCode: 404 });
}
db.update(schema.serverMembers)
.set({ role })
.where(and(
eq(schema.serverMembers.serverId, id),
eq(schema.serverMembers.userId, uid),
))
.run();
const updatedMember = db.select()
.from(schema.serverMembers)
.where(and(
eq(schema.serverMembers.serverId, id),
eq(schema.serverMembers.userId, uid),
))
.get();
if (!updatedMember) {
return reply.code(500).send({ error: 'Failed to update member', statusCode: 500 });
}
const user = db.select().from(schema.users).where(eq(schema.users.id, uid)).get();
if (!user) {
return reply.code(500).send({ error: 'User not found', statusCode: 500 });
}
const result: MemberWithUser = {
serverId: updatedMember.serverId,
userId: updatedMember.userId,
role: (updatedMember.role ?? 'member') as MemberWithUser['role'],
nickname: updatedMember.nickname,
joinedAt: updatedMember.joinedAt,
user: sanitizeUser(user),
};
return reply.code(200).send(result);
});
// DELETE /api/servers/:id/members/:uid - Kick member (owner) or leave (self)
app.delete<{ Params: { id: string; uid: string } }>('/api/servers/:id/members/:uid', {
preHandler: authenticate,
}, async (request, reply) => {
const { id, uid } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
const isSelf = uid === request.userId;
const isServerOwnerUser = isOwner(id, request.userId);
if (!isSelf && !isServerOwnerUser) {
return reply.code(403).send({ error: 'Only the server owner can kick members', statusCode: 403 });
}
// Owner cannot leave their own server - they must delete it
if (isSelf && isServerOwnerUser) {
return reply.code(400).send({ error: 'Server owner cannot leave. Transfer ownership or delete the server.', statusCode: 400 });
}
const member = db.select()
.from(schema.serverMembers)
.where(and(
eq(schema.serverMembers.serverId, id),
eq(schema.serverMembers.userId, uid),
))
.get();
if (!member) {
return reply.code(404).send({ error: 'Member not found', statusCode: 404 });
}
// Cannot kick the owner
if (member.role === 'owner') {
return reply.code(400).send({ error: 'Cannot remove the server owner', statusCode: 400 });
}
db.delete(schema.serverMembers)
.where(and(
eq(schema.serverMembers.serverId, id),
eq(schema.serverMembers.userId, uid),
))
.run();
return reply.code(200).send({ success: true });
});
}
+106
View File
@@ -0,0 +1,106 @@
import type { FastifyInstance } from 'fastify';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { config } from '../config.js';
import { getDb, schema } from '../db/index.js';
import { eq } from 'drizzle-orm';
import fs from 'fs';
import path from 'path';
import { pipeline } from 'stream/promises';
import type { Attachment } from '@opencord/shared';
export async function uploadRoutes(app: FastifyInstance): Promise<void> {
// Ensure upload directory exists
if (!fs.existsSync(config.uploadDir)) {
fs.mkdirSync(config.uploadDir, { recursive: true });
}
// POST /api/uploads - Upload a file
app.post('/api/uploads', {
preHandler: authenticate,
}, async (request, reply) => {
const data = await request.file();
if (!data) {
return reply.code(400).send({ error: 'No file provided', statusCode: 400 });
}
const originalName = data.filename;
const mimetype = data.mimetype;
// Generate unique filename
const id = generateSnowflake();
const ext = path.extname(originalName);
const filename = `${id}${ext}`;
const filepath = path.join(config.uploadDir, filename);
// Save file to disk
const writeStream = fs.createWriteStream(filepath);
await pipeline(data.file, writeStream);
// Get file size
const stats = fs.statSync(filepath);
const size = stats.size;
// Check size limit
if (size > config.maxUploadSize) {
fs.unlinkSync(filepath);
return reply.code(413).send({ error: 'File too large', statusCode: 413 });
}
const now = Date.now();
const db = getDb();
// Save attachment record
db.insert(schema.attachments).values({
id,
filename,
originalName,
mimetype,
size,
createdAt: now,
}).run();
const attachment: Attachment = {
id,
messageId: '',
filename,
originalName,
mimetype,
size,
createdAt: now,
};
return reply.code(201).send(attachment);
});
// GET /api/uploads/:filename - Serve uploaded file
app.get<{ Params: { filename: string } }>('/api/uploads/:filename', async (request, reply) => {
const { filename } = request.params;
// Prevent directory traversal
const safeName = path.basename(filename);
const filepath = path.join(config.uploadDir, safeName);
if (!fs.existsSync(filepath)) {
return reply.code(404).send({ error: 'File not found', statusCode: 404 });
}
// Get mimetype from DB or guess from extension
const db = getDb();
const attachment = db.select().from(schema.attachments).where(eq(schema.attachments.filename, safeName)).get();
const mimetype = attachment?.mimetype ?? 'application/octet-stream';
const originalName = attachment?.originalName ?? safeName;
// Set caching headers
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
reply.header('Content-Type', mimetype);
// For non-image files, set Content-Disposition to download
if (!mimetype.startsWith('image/') && !mimetype.startsWith('video/') && !mimetype.startsWith('audio/')) {
reply.header('Content-Disposition', `attachment; filename="${encodeURIComponent(originalName)}"`);
}
const stream = fs.createReadStream(filepath);
return reply.send(stream);
});
}
+90
View File
@@ -0,0 +1,90 @@
import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import type { User, UpdateUserRequest } from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
return {
id: row.id,
username: row.username,
displayName: row.displayName,
avatar: row.avatar,
status: (row.status ?? 'offline') as User['status'],
customStatus: row.customStatus,
createdAt: row.createdAt,
};
}
export async function userRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
const db = getDb();
const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (!user) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
return reply.code(200).send(sanitizeUser(user));
});
app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
const { displayName, avatar, customStatus } = request.body;
const db = getDb();
const updateData: Record<string, string | null | undefined> = {};
if (displayName !== undefined) {
if (displayName !== null && typeof displayName === 'string') {
const trimmed = displayName.trim();
if (trimmed.length > 32) {
return reply.code(400).send({ error: 'Display name must be 32 characters or less', statusCode: 400 });
}
updateData.displayName = trimmed || null;
} else {
updateData.displayName = null;
}
}
if (avatar !== undefined) {
updateData.avatar = avatar;
}
if (customStatus !== undefined) {
if (customStatus !== null && typeof customStatus === 'string') {
const trimmed = customStatus.trim();
if (trimmed.length > 128) {
return reply.code(400).send({ error: 'Custom status must be 128 characters or less', statusCode: 400 });
}
updateData.customStatus = trimmed || null;
} else {
updateData.customStatus = null;
}
}
if (Object.keys(updateData).length === 0) {
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
}
db.update(schema.users).set(updateData).where(eq(schema.users.id, request.userId)).run();
const updatedUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (!updatedUser) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
return reply.code(200).send(sanitizeUser(updatedUser));
});
app.get<{ Params: { id: string } }>('/api/users/:id', { preHandler: authenticate }, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const user = db.select().from(schema.users).where(eq(schema.users.id, id)).get();
if (!user) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
return reply.code(200).send(sanitizeUser(user));
});
}