feat: initial mIRCcloud.com scaffold

- Express + TypeScript backend with IRC connection manager
- React + Vite frontend with mIRC-inspired theme
- Drizzle ORM + PostgreSQL schema (users, connections, channels, messages)
- Redis pub/sub for real-time WebSocket delivery across replicas
- IRC color code parser (16 + 99 extended palette)
- Kubernetes manifests for homecloud deployment
- Multi-stage Dockerfile with non-root user
- docker-compose for local development
This commit is contained in:
Jason Preston
2026-08-08 23:50:08 -06:00
commit c59ac7ad0f
52 changed files with 5723 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# Server
PORT=3000
# Database (PostgreSQL)
DATABASE_URL=postgresql://user:password@localhost:5432/mirccloud
# Redis
REDIS_URL=redis://localhost:6379
# Auth
JWT_SECRET=your-secret-key-change-in-production
JWT_EXPIRY=7d
# CORS
CORS_ORIGIN=http://localhost:5173
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'drizzle-kit';
import 'dotenv/config';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
+37
View File
@@ -0,0 +1,37 @@
{
"name": "mirccloud-server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate"
},
"dependencies": {
"typescript": "5.5.4",
"tsx": "4.16.2",
"@types/node": "20.14.12",
"express": "4.19.2",
"@types/express": "4.17.21",
"cors": "2.8.5",
"ws": "8.18.0",
"@types/ws": "8.5.11",
"irc-framework": "4.13.1",
"drizzle-orm": "0.32.1",
"postgres": "3.4.4",
"ioredis": "5.4.1",
"jsonwebtoken": "9.0.2",
"@types/jsonwebtoken": "9.0.6",
"bcrypt": "5.1.1",
"@types/bcrypt": "5.0.2",
"zod": "3.23.8",
"dotenv": "16.4.5",
"@types/cors": "2.8.17"
},
"devDependencies": {
"drizzle-kit": "0.23.1"
}
}
+72
View File
@@ -0,0 +1,72 @@
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { Request, Response, NextFunction } from 'express';
import { IncomingMessage } from 'http';
import { config } from '../config.js';
const BCRYPT_ROUNDS = 12;
export interface JwtPayload {
userId: number;
username: string;
}
export interface AuthenticatedRequest extends Request {
user?: JwtPayload;
}
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, BCRYPT_ROUNDS);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
export function generateToken(payload: JwtPayload): string {
return jwt.sign(payload, config.JWT_SECRET, {
expiresIn: config.JWT_EXPIRY,
});
}
export function verifyToken(token: string): JwtPayload {
return jwt.verify(token, config.JWT_SECRET) as JwtPayload;
}
export function authenticateHttp(
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): void {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing or invalid authorization header' });
return;
}
const token = authHeader.slice(7);
try {
const payload = verifyToken(token);
req.user = payload;
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
}
export function authenticateWs(request: IncomingMessage): JwtPayload | null {
const url = new URL(request.url || '', `http://${request.headers.host}`);
const token = url.searchParams.get('token');
if (!token) {
return null;
}
try {
return verifyToken(token);
} catch {
return null;
}
}
+10
View File
@@ -0,0 +1,10 @@
import 'dotenv/config';
export const config = {
PORT: parseInt(process.env.PORT || '3000', 10),
DATABASE_URL: process.env.DATABASE_URL || 'postgresql://localhost:5432/mirccloud',
REDIS_URL: process.env.REDIS_URL || 'redis://localhost:6379',
JWT_SECRET: process.env.JWT_SECRET || 'change-me-in-production',
JWT_EXPIRY: process.env.JWT_EXPIRY || '7d',
CORS_ORIGIN: process.env.CORS_ORIGIN || 'http://localhost:5173',
} as const;
+16
View File
@@ -0,0 +1,16 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { config } from '../config.js';
import * as schema from './schema.js';
const client = postgres(config.DATABASE_URL, {
max: 20,
idle_timeout: 20,
connect_timeout: 10,
});
export const db = drizzle(client, { schema });
export async function closeDb(): Promise<void> {
await client.end();
}
+170
View File
@@ -0,0 +1,170 @@
import {
pgTable,
serial,
bigserial,
varchar,
text,
boolean,
integer,
bigint,
timestamp,
pgEnum,
index,
uniqueIndex,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
export const bufferTypeEnum = pgEnum('buffer_type', [
'console',
'channel',
'conversation',
]);
export const messageTypeEnum = pgEnum('message_type', [
'message',
'notice',
'action',
'join',
'part',
'quit',
'kick',
'mode',
'topic',
'nick',
'error',
'system',
]);
export const users = pgTable('users', {
id: serial('id').primaryKey(),
username: varchar('username', { length: 64 }).notNull().unique(),
email: varchar('email', { length: 255 }).notNull().unique(),
passwordHash: text('password_hash').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export const connections = pgTable('connections', {
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 128 }).notNull(),
hostname: varchar('hostname', { length: 255 }).notNull(),
port: integer('port').notNull().default(6667),
tls: boolean('tls').notNull().default(false),
nick: varchar('nick', { length: 64 }).notNull(),
username: varchar('username', { length: 64 }),
realname: varchar('realname', { length: 255 }),
password: text('password'),
saslUsername: varchar('sasl_username', { length: 128 }),
saslPassword: text('sasl_password'),
autoConnect: boolean('auto_connect').notNull().default(false),
connected: boolean('connected').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export const channels = pgTable('channels', {
id: serial('id').primaryKey(),
connectionId: integer('connection_id')
.notNull()
.references(() => connections.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 255 }).notNull(),
key: varchar('key', { length: 255 }),
autoJoin: boolean('auto_join').notNull().default(false),
joined: boolean('joined').notNull().default(false),
topic: text('topic'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
export const messages = pgTable(
'messages',
{
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
connectionId: integer('connection_id')
.notNull()
.references(() => connections.id, { onDelete: 'cascade' }),
channelName: varchar('channel_name', { length: 255 }),
bufferType: bufferTypeEnum('buffer_type').notNull(),
fromNick: varchar('from_nick', { length: 64 }),
content: text('content').notNull(),
type: messageTypeEnum('type').notNull(),
timestamp: timestamp('timestamp', { withTimezone: true }).notNull().defaultNow(),
isSelf: boolean('is_self').notNull().default(false),
isHighlight: boolean('is_highlight').notNull().default(false),
},
(table) => ({
connectionChannelTimestampIdx: index('idx_messages_conn_channel_ts').on(
table.connectionId,
table.channelName,
table.timestamp
),
connectionTimestampIdx: index('idx_messages_conn_ts').on(
table.connectionId,
table.timestamp
),
})
);
export const readMarkers = pgTable(
'read_markers',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
connectionId: integer('connection_id')
.notNull()
.references(() => connections.id, { onDelete: 'cascade' }),
channelName: varchar('channel_name', { length: 255 }),
lastReadId: bigint('last_read_id', { mode: 'bigint' }),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
userConnectionChannelIdx: uniqueIndex('idx_read_markers_user_conn_channel').on(
table.userId,
table.connectionId,
table.channelName
),
})
);
export const usersRelations = relations(users, ({ many }) => ({
connections: many(connections),
readMarkers: many(readMarkers),
}));
export const connectionsRelations = relations(connections, ({ one, many }) => ({
user: one(users, {
fields: [connections.userId],
references: [users.id],
}),
channels: many(channels),
messages: many(messages),
}));
export const channelsRelations = relations(channels, ({ one }) => ({
connection: one(connections, {
fields: [channels.connectionId],
references: [connections.id],
}),
}));
export const messagesRelations = relations(messages, ({ one }) => ({
connection: one(connections, {
fields: [messages.connectionId],
references: [connections.id],
}),
}));
export const readMarkersRelations = relations(readMarkers, ({ one }) => ({
user: one(users, {
fields: [readMarkers.userId],
references: [users.id],
}),
connection: one(connections, {
fields: [readMarkers.connectionId],
references: [connections.id],
}),
}));
+66
View File
@@ -0,0 +1,66 @@
import express from 'express';
import cors from 'cors';
import { createServer } from 'http';
import { config } from './config.js';
import { closeDb } from './db/index.js';
import { connectionManager } from './irc/connection-manager.js';
import { createWebSocketServer, closeAllSockets } from './websocket/index.js';
import { closePublisher } from './irc/event-handlers.js';
import authRoutes from './routes/auth.js';
import connectionsRoutes from './routes/connections.js';
import messagesRoutes from './routes/messages.js';
const app = express();
app.use(cors({ origin: config.CORS_ORIGIN, credentials: true }));
app.use(express.json());
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.use('/api/auth', authRoutes);
app.use('/api/connections', connectionsRoutes);
app.use('/api/messages', messagesRoutes);
const server = createServer(app);
createWebSocketServer(server);
async function start(): Promise<void> {
try {
await connectionManager.loadPersistentConnections();
console.log('Loaded persistent IRC connections');
} catch (err) {
console.error('Failed to load persistent connections:', err);
}
server.listen(config.PORT, () => {
console.log(`mIRCcloud server running on port ${config.PORT}`);
});
}
async function shutdown(): Promise<void> {
console.log('Shutting down gracefully...');
closeAllSockets();
await connectionManager.shutdown();
await closePublisher();
await closeDb();
server.close(() => {
console.log('HTTP server closed');
process.exit(0);
});
setTimeout(() => {
console.error('Forced shutdown after timeout');
process.exit(1);
}, 10000);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
start();
+313
View File
@@ -0,0 +1,313 @@
import IrcFramework from 'irc-framework';
import { db } from '../db/index.js';
import { connections, channels } from '../db/schema.js';
import { eq, and } from 'drizzle-orm';
import {
handleRegistered,
handleMessage,
handleNotice,
handleJoin,
handlePart,
handleQuit,
handleKick,
handleNick,
handleTopic,
handleMode,
handleError,
} from './event-handlers.js';
interface ConnectionConfig {
id: number;
hostname: string;
port: number;
tls: boolean;
nick: string;
username: string | null;
realname: string | null;
password: string | null;
saslUsername: string | null;
saslPassword: string | null;
}
interface ReconnectState {
attempts: number;
timeout: ReturnType<typeof setTimeout> | null;
maxAttempts: number;
baseDelay: number;
}
const MAX_RECONNECT_ATTEMPTS = 10;
const BASE_RECONNECT_DELAY = 1000;
class ConnectionManager {
private static instance: ConnectionManager;
private userConnections: Map<number, Map<number, IrcFramework.Client>> = new Map();
private reconnectState: Map<string, ReconnectState> = new Map();
private shutdownRequested = false;
private constructor() {}
static getInstance(): ConnectionManager {
if (!ConnectionManager.instance) {
ConnectionManager.instance = new ConnectionManager();
}
return ConnectionManager.instance;
}
async loadPersistentConnections(): Promise<void> {
const autoConnectList = await db.query.connections.findMany({
where: eq(connections.autoConnect, true),
});
for (const conn of autoConnectList) {
await this.connect(conn.userId, {
id: conn.id,
hostname: conn.hostname,
port: conn.port,
tls: conn.tls,
nick: conn.nick,
username: conn.username,
realname: conn.realname,
password: conn.password,
saslUsername: conn.saslUsername,
saslPassword: conn.saslPassword,
});
}
}
async connect(userId: number, connConfig: ConnectionConfig): Promise<IrcFramework.Client> {
const existing = this.getConnection(userId, connConfig.id);
if (existing) {
return existing;
}
const client = new IrcFramework.Client();
const connectOptions: Record<string, unknown> = {
host: connConfig.hostname,
port: connConfig.port,
tls: connConfig.tls,
nick: connConfig.nick,
username: connConfig.username || connConfig.nick,
gecos: connConfig.realname || connConfig.nick,
auto_reconnect: false,
};
if (connConfig.password) {
connectOptions.password = connConfig.password;
}
if (connConfig.saslUsername && connConfig.saslPassword) {
connectOptions.account = {
account: connConfig.saslUsername,
password: connConfig.saslPassword,
};
}
const ctx = {
userId,
connectionId: connConfig.id,
nick: connConfig.nick,
};
client.on('registered', handleRegistered(ctx));
client.on('privmsg', handleMessage(ctx));
client.on('action', handleMessage(ctx));
client.on('notice', handleNotice(ctx));
client.on('join', handleJoin(ctx));
client.on('part', handlePart(ctx));
client.on('quit', handleQuit(ctx));
client.on('kick', handleKick(ctx));
client.on('nick', handleNick(ctx));
client.on('topic', handleTopic(ctx));
client.on('mode', handleMode(ctx));
client.on('registered', async () => {
await db
.update(connections)
.set({ connected: true })
.where(eq(connections.id, connConfig.id));
this.resetReconnectState(userId, connConfig.id);
const autoJoinChannels = await db.query.channels.findMany({
where: and(
eq(channels.connectionId, connConfig.id),
eq(channels.autoJoin, true)
),
});
for (const ch of autoJoinChannels) {
if (ch.key) {
client.join(ch.name, ch.key);
} else {
client.join(ch.name);
}
}
});
client.on('close', async () => {
await db
.update(connections)
.set({ connected: false })
.where(eq(connections.id, connConfig.id));
if (!this.shutdownRequested) {
this.scheduleReconnect(userId, connConfig);
}
});
client.on('socket close', async () => {
await db
.update(connections)
.set({ connected: false })
.where(eq(connections.id, connConfig.id));
if (!this.shutdownRequested) {
this.scheduleReconnect(userId, connConfig);
}
});
client.on('irc error', handleError(ctx));
client.connect(connectOptions);
let userMap = this.userConnections.get(userId);
if (!userMap) {
userMap = new Map();
this.userConnections.set(userId, userMap);
}
userMap.set(connConfig.id, client);
return client;
}
async disconnect(userId: number, connectionId: number): Promise<void> {
const client = this.getConnection(userId, connectionId);
if (!client) return;
this.cancelReconnect(userId, connectionId);
client.quit('Disconnecting');
const userMap = this.userConnections.get(userId);
if (userMap) {
userMap.delete(connectionId);
if (userMap.size === 0) {
this.userConnections.delete(userId);
}
}
await db
.update(connections)
.set({ connected: false })
.where(eq(connections.id, connectionId));
}
async reconnect(userId: number, connectionId: number): Promise<void> {
await this.disconnect(userId, connectionId);
const conn = await db.query.connections.findFirst({
where: eq(connections.id, connectionId),
});
if (conn && conn.userId === userId) {
await this.connect(userId, {
id: conn.id,
hostname: conn.hostname,
port: conn.port,
tls: conn.tls,
nick: conn.nick,
username: conn.username,
realname: conn.realname,
password: conn.password,
saslUsername: conn.saslUsername,
saslPassword: conn.saslPassword,
});
}
}
getConnection(userId: number, connectionId: number): IrcFramework.Client | undefined {
const userMap = this.userConnections.get(userId);
if (!userMap) return undefined;
return userMap.get(connectionId);
}
getUserConnections(userId: number): Map<number, IrcFramework.Client> {
return this.userConnections.get(userId) || new Map();
}
private scheduleReconnect(userId: number, connConfig: ConnectionConfig): void {
const key = `${userId}:${connConfig.id}`;
let state = this.reconnectState.get(key);
if (!state) {
state = {
attempts: 0,
timeout: null,
maxAttempts: MAX_RECONNECT_ATTEMPTS,
baseDelay: BASE_RECONNECT_DELAY,
};
this.reconnectState.set(key, state);
}
if (state.attempts >= state.maxAttempts) {
this.reconnectState.delete(key);
return;
}
const delay = Math.min(
state.baseDelay * Math.pow(2, state.attempts),
30000
);
state.attempts++;
state.timeout = setTimeout(async () => {
try {
const userMap = this.userConnections.get(userId);
if (userMap) {
userMap.delete(connConfig.id);
}
await this.connect(userId, connConfig);
} catch {
this.scheduleReconnect(userId, connConfig);
}
}, delay);
}
private resetReconnectState(userId: number, connectionId: number): void {
const key = `${userId}:${connectionId}`;
const state = this.reconnectState.get(key);
if (state) {
if (state.timeout) clearTimeout(state.timeout);
this.reconnectState.delete(key);
}
}
private cancelReconnect(userId: number, connectionId: number): void {
this.resetReconnectState(userId, connectionId);
}
async shutdown(): Promise<void> {
this.shutdownRequested = true;
for (const [, state] of this.reconnectState) {
if (state.timeout) clearTimeout(state.timeout);
}
this.reconnectState.clear();
const disconnectPromises: Promise<void>[] = [];
for (const [userId, userMap] of this.userConnections) {
for (const [connectionId] of userMap) {
disconnectPromises.push(this.disconnect(userId, connectionId));
}
}
await Promise.allSettled(disconnectPromises);
this.userConnections.clear();
}
}
export const connectionManager = ConnectionManager.getInstance();
+388
View File
@@ -0,0 +1,388 @@
import { db } from '../db/index.js';
import { messages, channels } from '../db/schema.js';
import { eq, and } from 'drizzle-orm';
import Redis from 'ioredis';
import { config } from '../config.js';
const publisher = new Redis(config.REDIS_URL);
interface EventContext {
userId: number;
connectionId: number;
nick: string;
}
interface IrcMessage {
nick: string;
ident: string;
hostname: string;
target: string;
message: string;
tags: Record<string, string>;
from_server: boolean;
type: string;
}
interface IrcJoinEvent {
nick: string;
ident: string;
hostname: string;
channel: string;
account: string;
}
interface IrcPartEvent {
nick: string;
ident: string;
hostname: string;
channel: string;
message: string;
}
interface IrcQuitEvent {
nick: string;
ident: string;
hostname: string;
message: string;
}
interface IrcKickEvent {
nick: string;
ident: string;
hostname: string;
channel: string;
kicked: string;
message: string;
}
interface IrcNickEvent {
nick: string;
ident: string;
hostname: string;
new_nick: string;
}
interface IrcTopicEvent {
nick: string;
channel: string;
topic: string;
}
interface IrcModeEvent {
nick: string;
target: string;
modes: Array<{ mode: string; param?: string }>;
raw_modes: string;
raw_params: string[];
}
async function persistAndPublish(
ctx: EventContext,
data: {
channelName: string | null;
bufferType: 'console' | 'channel' | 'conversation';
fromNick: string | null;
content: string;
type: 'message' | 'notice' | 'action' | 'join' | 'part' | 'quit' | 'kick' | 'mode' | 'topic' | 'nick' | 'error' | 'system';
isSelf: boolean;
isHighlight: boolean;
}
): Promise<void> {
const [msg] = await db
.insert(messages)
.values({
connectionId: ctx.connectionId,
channelName: data.channelName,
bufferType: data.bufferType,
fromNick: data.fromNick,
content: data.content,
type: data.type,
isSelf: data.isSelf,
isHighlight: data.isHighlight,
})
.returning();
const event = {
type: 'irc_event',
eventType: data.type,
connectionId: ctx.connectionId,
message: msg,
};
await publisher.publish(`irc:events:${ctx.userId}`, JSON.stringify(event));
}
export function handleRegistered(ctx: EventContext) {
return async () => {
await persistAndPublish(ctx, {
channelName: null,
bufferType: 'console',
fromNick: null,
content: `Connected to server as ${ctx.nick}`,
type: 'system',
isSelf: false,
isHighlight: false,
});
};
}
export function handleMessage(ctx: EventContext) {
return async (event: IrcMessage) => {
const isChannel = event.target.startsWith('#') || event.target.startsWith('&');
const isSelf = event.nick === ctx.nick;
const channelName = isChannel ? event.target : event.nick;
const bufferType = isChannel ? 'channel' : 'conversation';
const contentLower = event.message.toLowerCase();
const nickLower = ctx.nick.toLowerCase();
const isHighlight = !isSelf && contentLower.includes(nickLower);
let messageType: 'message' | 'action' = 'message';
let content = event.message;
if (event.type === 'action') {
messageType = 'action';
content = event.message;
}
await persistAndPublish(ctx, {
channelName,
bufferType,
fromNick: event.nick,
content,
type: messageType,
isSelf,
isHighlight,
});
};
}
export function handleNotice(ctx: EventContext) {
return async (event: IrcMessage) => {
const isChannel = event.target.startsWith('#') || event.target.startsWith('&');
const channelName = isChannel ? event.target : event.from_server ? null : event.nick;
const bufferType = isChannel ? 'channel' : event.from_server ? 'console' : 'conversation';
const isSelf = event.nick === ctx.nick;
await persistAndPublish(ctx, {
channelName,
bufferType: bufferType as 'console' | 'channel' | 'conversation',
fromNick: event.nick || null,
content: event.message,
type: 'notice',
isSelf,
isHighlight: false,
});
};
}
export function handleJoin(ctx: EventContext) {
return async (event: IrcJoinEvent) => {
const isSelf = event.nick === ctx.nick;
if (isSelf) {
const existing = await db.query.channels.findFirst({
where: and(
eq(channels.connectionId, ctx.connectionId),
eq(channels.name, event.channel)
),
});
if (existing) {
await db
.update(channels)
.set({ joined: true })
.where(eq(channels.id, existing.id));
} else {
await db.insert(channels).values({
connectionId: ctx.connectionId,
name: event.channel,
autoJoin: false,
joined: true,
});
}
}
await persistAndPublish(ctx, {
channelName: event.channel,
bufferType: 'channel',
fromNick: event.nick,
content: `${event.nick} (${event.ident}@${event.hostname}) has joined ${event.channel}`,
type: 'join',
isSelf,
isHighlight: false,
});
};
}
export function handlePart(ctx: EventContext) {
return async (event: IrcPartEvent) => {
const isSelf = event.nick === ctx.nick;
if (isSelf) {
await db
.update(channels)
.set({ joined: false })
.where(
and(
eq(channels.connectionId, ctx.connectionId),
eq(channels.name, event.channel)
)
);
}
const content = event.message
? `${event.nick} (${event.ident}@${event.hostname}) has left ${event.channel} (${event.message})`
: `${event.nick} (${event.ident}@${event.hostname}) has left ${event.channel}`;
await persistAndPublish(ctx, {
channelName: event.channel,
bufferType: 'channel',
fromNick: event.nick,
content,
type: 'part',
isSelf,
isHighlight: false,
});
};
}
export function handleQuit(ctx: EventContext) {
return async (event: IrcQuitEvent) => {
const isSelf = event.nick === ctx.nick;
const content = event.message
? `${event.nick} (${event.ident}@${event.hostname}) has quit (${event.message})`
: `${event.nick} (${event.ident}@${event.hostname}) has quit`;
await persistAndPublish(ctx, {
channelName: null,
bufferType: 'console',
fromNick: event.nick,
content,
type: 'quit',
isSelf,
isHighlight: false,
});
};
}
export function handleKick(ctx: EventContext) {
return async (event: IrcKickEvent) => {
const isSelf = event.kicked === ctx.nick;
if (isSelf) {
await db
.update(channels)
.set({ joined: false })
.where(
and(
eq(channels.connectionId, ctx.connectionId),
eq(channels.name, event.channel)
)
);
}
const content = event.message
? `${event.kicked} was kicked from ${event.channel} by ${event.nick} (${event.message})`
: `${event.kicked} was kicked from ${event.channel} by ${event.nick}`;
await persistAndPublish(ctx, {
channelName: event.channel,
bufferType: 'channel',
fromNick: event.nick,
content,
type: 'kick',
isSelf,
isHighlight: isSelf,
});
};
}
export function handleNick(ctx: EventContext) {
return async (event: IrcNickEvent) => {
const isSelf = event.nick === ctx.nick;
if (isSelf) {
ctx.nick = event.new_nick;
}
await persistAndPublish(ctx, {
channelName: null,
bufferType: 'console',
fromNick: event.nick,
content: `${event.nick} is now known as ${event.new_nick}`,
type: 'nick',
isSelf,
isHighlight: false,
});
};
}
export function handleTopic(ctx: EventContext) {
return async (event: IrcTopicEvent) => {
await db
.update(channels)
.set({ topic: event.topic })
.where(
and(
eq(channels.connectionId, ctx.connectionId),
eq(channels.name, event.channel)
)
);
const isSelf = event.nick === ctx.nick;
await persistAndPublish(ctx, {
channelName: event.channel,
bufferType: 'channel',
fromNick: event.nick,
content: event.nick
? `${event.nick} has changed the topic to: ${event.topic}`
: `Topic: ${event.topic}`,
type: 'topic',
isSelf,
isHighlight: false,
});
};
}
export function handleMode(ctx: EventContext) {
return async (event: IrcModeEvent) => {
const isChannel = event.target.startsWith('#') || event.target.startsWith('&');
const isSelf = event.nick === ctx.nick;
const modeStr = event.raw_params.length > 0
? `${event.raw_modes} ${event.raw_params.join(' ')}`
: event.raw_modes;
await persistAndPublish(ctx, {
channelName: isChannel ? event.target : null,
bufferType: isChannel ? 'channel' : 'console',
fromNick: event.nick,
content: `${event.nick} sets mode ${modeStr} on ${event.target}`,
type: 'mode',
isSelf,
isHighlight: false,
});
};
}
export function handleError(ctx: EventContext) {
return async (event: { message: string; error?: Error }) => {
await persistAndPublish(ctx, {
channelName: null,
bufferType: 'console',
fromNick: null,
content: event.message || 'Unknown error',
type: 'error',
isSelf: false,
isHighlight: false,
});
};
}
export function closePublisher(): Promise<void> {
return new Promise((resolve) => {
publisher.quit().then(() => resolve());
});
}
+157
View File
@@ -0,0 +1,157 @@
import { Router } from 'express';
import { z } from 'zod';
import { db } from '../db/index.js';
import { users } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import {
hashPassword,
verifyPassword,
generateToken,
authenticateHttp,
AuthenticatedRequest,
} from '../auth/index.js';
const router = Router();
const registerSchema = z.object({
username: z
.string()
.min(2)
.max(64)
.regex(/^[a-zA-Z0-9_-]+$/, 'Username must be alphanumeric with dashes/underscores'),
email: z.string().email().max(255),
password: z.string().min(8).max(128),
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string(),
});
router.post('/register', async (req, res) => {
try {
const body = registerSchema.parse(req.body);
const existingUser = await db.query.users.findFirst({
where: eq(users.email, body.email),
});
if (existingUser) {
res.status(409).json({ error: 'Email already registered' });
return;
}
const existingUsername = await db.query.users.findFirst({
where: eq(users.username, body.username),
});
if (existingUsername) {
res.status(409).json({ error: 'Username already taken' });
return;
}
const passwordHash = await hashPassword(body.password);
const [user] = await db
.insert(users)
.values({
username: body.username,
email: body.email,
passwordHash,
})
.returning({
id: users.id,
username: users.username,
email: users.email,
createdAt: users.createdAt,
});
const token = generateToken({ userId: user!.id, username: user!.username });
res.status(201).json({
user: {
id: user!.id,
username: user!.username,
email: user!.email,
createdAt: user!.createdAt,
},
token,
});
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: 'Validation failed', details: err.errors });
return;
}
console.error('Registration error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.post('/login', async (req, res) => {
try {
const body = loginSchema.parse(req.body);
const user = await db.query.users.findFirst({
where: eq(users.email, body.email),
});
if (!user) {
res.status(401).json({ error: 'Invalid email or password' });
return;
}
const valid = await verifyPassword(body.password, user.passwordHash);
if (!valid) {
res.status(401).json({ error: 'Invalid email or password' });
return;
}
const token = generateToken({ userId: user.id, username: user.username });
res.json({
user: {
id: user.id,
username: user.username,
email: user.email,
createdAt: user.createdAt,
},
token,
});
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: 'Validation failed', details: err.errors });
return;
}
console.error('Login error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/me', authenticateHttp, async (req, res) => {
try {
const authReq = req as AuthenticatedRequest;
const userId = authReq.user!.userId;
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
columns: {
id: true,
username: true,
email: true,
createdAt: true,
},
});
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
res.json({ user });
} catch (err) {
console.error('Get user error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;
+213
View File
@@ -0,0 +1,213 @@
import { Router } from 'express';
import { z } from 'zod';
import { db } from '../db/index.js';
import { connections } from '../db/schema.js';
import { eq, and } from 'drizzle-orm';
import { authenticateHttp, AuthenticatedRequest } from '../auth/index.js';
import { connectionManager } from '../irc/connection-manager.js';
const router = Router();
router.use(authenticateHttp);
const createConnectionSchema = z.object({
name: z.string().min(1).max(128),
hostname: z.string().min(1).max(255),
port: z.number().int().min(1).max(65535).default(6667),
tls: z.boolean().default(false),
nick: z.string().min(1).max(64),
username: z.string().max(64).nullable().optional(),
realname: z.string().max(255).nullable().optional(),
password: z.string().nullable().optional(),
saslUsername: z.string().max(128).nullable().optional(),
saslPassword: z.string().nullable().optional(),
autoConnect: z.boolean().default(false),
});
const updateConnectionSchema = createConnectionSchema.partial();
router.get('/', async (req, res) => {
try {
const authReq = req as AuthenticatedRequest;
const userId = authReq.user!.userId;
const userConnections = await db.query.connections.findMany({
where: eq(connections.userId, userId),
with: { channels: true },
});
res.json({ connections: userConnections });
} catch (err) {
console.error('List connections error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.post('/', async (req, res) => {
try {
const authReq = req as AuthenticatedRequest;
const userId = authReq.user!.userId;
const body = createConnectionSchema.parse(req.body);
const [conn] = await db
.insert(connections)
.values({
userId,
name: body.name,
hostname: body.hostname,
port: body.port,
tls: body.tls,
nick: body.nick,
username: body.username || null,
realname: body.realname || null,
password: body.password || null,
saslUsername: body.saslUsername || null,
saslPassword: body.saslPassword || null,
autoConnect: body.autoConnect,
})
.returning();
res.status(201).json({ connection: conn });
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: 'Validation failed', details: err.errors });
return;
}
console.error('Create connection error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.put('/:id', async (req, res) => {
try {
const authReq = req as AuthenticatedRequest;
const userId = authReq.user!.userId;
const connectionId = parseInt(req.params.id!, 10);
const body = updateConnectionSchema.parse(req.body);
const existing = await db.query.connections.findFirst({
where: and(
eq(connections.id, connectionId),
eq(connections.userId, userId)
),
});
if (!existing) {
res.status(404).json({ error: 'Connection not found' });
return;
}
const [updated] = await db
.update(connections)
.set({
...body,
updatedAt: new Date(),
})
.where(eq(connections.id, connectionId))
.returning();
res.json({ connection: updated });
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: 'Validation failed', details: err.errors });
return;
}
console.error('Update connection error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.delete('/:id', async (req, res) => {
try {
const authReq = req as AuthenticatedRequest;
const userId = authReq.user!.userId;
const connectionId = parseInt(req.params.id!, 10);
const existing = await db.query.connections.findFirst({
where: and(
eq(connections.id, connectionId),
eq(connections.userId, userId)
),
});
if (!existing) {
res.status(404).json({ error: 'Connection not found' });
return;
}
await connectionManager.disconnect(userId, connectionId);
await db.delete(connections).where(eq(connections.id, connectionId));
res.status(204).send();
} catch (err) {
console.error('Delete connection error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.post('/:id/connect', async (req, res) => {
try {
const authReq = req as AuthenticatedRequest;
const userId = authReq.user!.userId;
const connectionId = parseInt(req.params.id!, 10);
const conn = await db.query.connections.findFirst({
where: and(
eq(connections.id, connectionId),
eq(connections.userId, userId)
),
});
if (!conn) {
res.status(404).json({ error: 'Connection not found' });
return;
}
await connectionManager.connect(userId, {
id: conn.id,
hostname: conn.hostname,
port: conn.port,
tls: conn.tls,
nick: conn.nick,
username: conn.username,
realname: conn.realname,
password: conn.password,
saslUsername: conn.saslUsername,
saslPassword: conn.saslPassword,
});
res.json({ status: 'connecting', connectionId });
} catch (err) {
console.error('Connect error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.post('/:id/disconnect', async (req, res) => {
try {
const authReq = req as AuthenticatedRequest;
const userId = authReq.user!.userId;
const connectionId = parseInt(req.params.id!, 10);
const conn = await db.query.connections.findFirst({
where: and(
eq(connections.id, connectionId),
eq(connections.userId, userId)
),
});
if (!conn) {
res.status(404).json({ error: 'Connection not found' });
return;
}
await connectionManager.disconnect(userId, connectionId);
res.json({ status: 'disconnected', connectionId });
} catch (err) {
console.error('Disconnect error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;
+119
View File
@@ -0,0 +1,119 @@
import { Router } from 'express';
import { db } from '../db/index.js';
import { messages, connections } from '../db/schema.js';
import { eq, and, lt, desc, like, sql } from 'drizzle-orm';
import { authenticateHttp, AuthenticatedRequest } from '../auth/index.js';
const router = Router();
router.use(authenticateHttp);
router.get('/:connectionId/:buffer', async (req, res) => {
try {
const authReq = req as AuthenticatedRequest;
const userId = authReq.user!.userId;
const connectionId = parseInt(req.params.connectionId!, 10);
const buffer = req.params.buffer === '_console' ? null : req.params.buffer!;
const before = req.query.before as string | undefined;
const limit = Math.min(parseInt((req.query.limit as string) || '100', 10), 500);
const conn = await db.query.connections.findFirst({
where: and(
eq(connections.id, connectionId),
eq(connections.userId, userId)
),
});
if (!conn) {
res.status(404).json({ error: 'Connection not found' });
return;
}
const conditions = [eq(messages.connectionId, connectionId)];
if (buffer) {
conditions.push(eq(messages.channelName, buffer));
} else {
conditions.push(eq(messages.bufferType, 'console'));
}
if (before) {
conditions.push(lt(messages.id, BigInt(before)));
}
const result = await db.query.messages.findMany({
where: and(...conditions),
orderBy: [desc(messages.timestamp)],
limit,
});
res.json({ messages: result.reverse() });
} catch (err) {
console.error('Get messages error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/:connectionId/:buffer/search', async (req, res) => {
try {
const authReq = req as AuthenticatedRequest;
const userId = authReq.user!.userId;
const connectionId = parseInt(req.params.connectionId!, 10);
const buffer = req.params.buffer === '_console' ? null : req.params.buffer!;
const query = req.query.q as string;
const limit = Math.min(parseInt((req.query.limit as string) || '50', 10), 200);
const offset = parseInt((req.query.offset as string) || '0', 10);
if (!query || query.length < 2) {
res.status(400).json({ error: 'Search query must be at least 2 characters' });
return;
}
const conn = await db.query.connections.findFirst({
where: and(
eq(connections.id, connectionId),
eq(connections.userId, userId)
),
});
if (!conn) {
res.status(404).json({ error: 'Connection not found' });
return;
}
const conditions = [
eq(messages.connectionId, connectionId),
like(messages.content, `%${query}%`),
];
if (buffer) {
conditions.push(eq(messages.channelName, buffer));
} else {
conditions.push(eq(messages.bufferType, 'console'));
}
const result = await db.query.messages.findMany({
where: and(...conditions),
orderBy: [desc(messages.timestamp)],
limit,
offset,
});
const [countResult] = await db
.select({ count: sql<number>`count(*)::int` })
.from(messages)
.where(and(...conditions));
res.json({
messages: result.reverse(),
total: countResult?.count || 0,
limit,
offset,
});
} catch (err) {
console.error('Search messages error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;
+400
View File
@@ -0,0 +1,400 @@
import { WebSocketServer, WebSocket } from 'ws';
import { Server as HttpServer } from 'http';
import Redis from 'ioredis';
import { config } from '../config.js';
import { authenticateWs, JwtPayload } from '../auth/index.js';
import { connectionManager } from '../irc/connection-manager.js';
import { db } from '../db/index.js';
import { connections, messages, readMarkers } from '../db/schema.js';
import { eq, and, lt, desc } from 'drizzle-orm';
interface AuthenticatedSocket {
ws: WebSocket;
user: JwtPayload;
subscriber: Redis;
alive: boolean;
}
const activeSockets: Map<number, Set<AuthenticatedSocket>> = new Map();
export function createWebSocketServer(server: HttpServer): WebSocketServer {
const wss = new WebSocketServer({ server, path: '/ws' });
const heartbeatInterval = setInterval(() => {
for (const [, sockets] of activeSockets) {
for (const socket of sockets) {
if (!socket.alive) {
socket.ws.terminate();
continue;
}
socket.alive = false;
socket.ws.ping();
}
}
}, 30000);
wss.on('close', () => {
clearInterval(heartbeatInterval);
});
wss.on('connection', async (ws, req) => {
const user = authenticateWs(req);
if (!user) {
ws.close(4001, 'Authentication failed');
return;
}
const subscriber = new Redis(config.REDIS_URL);
const socket: AuthenticatedSocket = {
ws,
user,
subscriber,
alive: true,
};
let userSockets = activeSockets.get(user.userId);
if (!userSockets) {
userSockets = new Set();
activeSockets.set(user.userId, userSockets);
}
userSockets.add(socket);
await subscriber.subscribe(`irc:events:${user.userId}`);
subscriber.on('message', (_channel: string, message: string) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(message);
}
});
ws.on('pong', () => {
socket.alive = true;
});
ws.on('message', async (data) => {
try {
const msg = JSON.parse(data.toString());
await handleClientCommand(socket, msg);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Invalid message';
ws.send(JSON.stringify({ type: 'error', error: errorMsg }));
}
});
ws.on('close', async () => {
await subscriber.unsubscribe(`irc:events:${user.userId}`);
await subscriber.quit();
const sockets = activeSockets.get(user.userId);
if (sockets) {
sockets.delete(socket);
if (sockets.size === 0) {
activeSockets.delete(user.userId);
}
}
});
ws.on('error', () => {
ws.terminate();
});
await sendInitialState(socket);
});
return wss;
}
async function sendInitialState(socket: AuthenticatedSocket): Promise<void> {
const { user, ws } = socket;
const userConnections = await db.query.connections.findMany({
where: eq(connections.userId, user.userId),
with: {
channels: true,
},
});
const connectionsState = userConnections.map((conn) => ({
id: conn.id,
name: conn.name,
hostname: conn.hostname,
port: conn.port,
tls: conn.tls,
nick: conn.nick,
connected: conn.connected,
channels: conn.channels.map((ch) => ({
id: ch.id,
name: ch.name,
joined: ch.joined,
topic: ch.topic,
})),
}));
ws.send(
JSON.stringify({
type: 'initial_state',
connections: connectionsState,
})
);
for (const conn of userConnections) {
for (const ch of conn.channels) {
if (ch.joined) {
const recentMessages = await db.query.messages.findMany({
where: and(
eq(messages.connectionId, conn.id),
eq(messages.channelName, ch.name)
),
orderBy: [desc(messages.timestamp)],
limit: 50,
});
ws.send(
JSON.stringify({
type: 'backlog',
connectionId: conn.id,
buffer: ch.name,
messages: recentMessages.reverse(),
})
);
}
}
const consoleMessages = await db.query.messages.findMany({
where: and(
eq(messages.connectionId, conn.id),
eq(messages.bufferType, 'console')
),
orderBy: [desc(messages.timestamp)],
limit: 50,
});
if (consoleMessages.length > 0) {
ws.send(
JSON.stringify({
type: 'backlog',
connectionId: conn.id,
buffer: null,
messages: consoleMessages.reverse(),
})
);
}
}
}
async function handleClientCommand(
socket: AuthenticatedSocket,
msg: Record<string, unknown>
): Promise<void> {
const { user, ws } = socket;
switch (msg.command) {
case 'send_message': {
const connectionId = msg.connectionId as number;
const target = msg.target as string;
const text = msg.text as string;
const client = connectionManager.getConnection(user.userId, connectionId);
if (!client) {
ws.send(JSON.stringify({ type: 'error', error: 'Connection not found or not active' }));
return;
}
client.say(target, text);
break;
}
case 'join_channel': {
const connectionId = msg.connectionId as number;
const channel = msg.channel as string;
const key = msg.key as string | undefined;
const client = connectionManager.getConnection(user.userId, connectionId);
if (!client) {
ws.send(JSON.stringify({ type: 'error', error: 'Connection not found or not active' }));
return;
}
if (key) {
client.join(channel, key);
} else {
client.join(channel);
}
break;
}
case 'part_channel': {
const connectionId = msg.connectionId as number;
const channel = msg.channel as string;
const reason = msg.reason as string | undefined;
const client = connectionManager.getConnection(user.userId, connectionId);
if (!client) {
ws.send(JSON.stringify({ type: 'error', error: 'Connection not found or not active' }));
return;
}
client.part(channel, reason || '');
break;
}
case 'get_backlog': {
const connectionId = msg.connectionId as number;
const buffer = msg.buffer as string | null;
const before = msg.before as string | undefined;
const limit = Math.min((msg.limit as number) || 100, 500);
const conditions = [eq(messages.connectionId, connectionId)];
if (buffer) {
conditions.push(eq(messages.channelName, buffer));
} else {
conditions.push(eq(messages.bufferType, 'console'));
}
if (before) {
conditions.push(lt(messages.id, BigInt(before)));
}
const result = await db.query.messages.findMany({
where: and(...conditions),
orderBy: [desc(messages.timestamp)],
limit,
});
ws.send(
JSON.stringify({
type: 'backlog',
connectionId,
buffer,
messages: result.reverse(),
})
);
break;
}
case 'mark_read': {
const connectionId = msg.connectionId as number;
const channelName = (msg.channel as string) || null;
const lastReadId = msg.lastReadId as string;
const existing = await db.query.readMarkers.findFirst({
where: and(
eq(readMarkers.userId, user.userId),
eq(readMarkers.connectionId, connectionId),
channelName
? eq(readMarkers.channelName, channelName)
: eq(readMarkers.channelName, '')
),
});
if (existing) {
await db
.update(readMarkers)
.set({ lastReadId: BigInt(lastReadId), updatedAt: new Date() })
.where(eq(readMarkers.id, existing.id));
} else {
await db.insert(readMarkers).values({
userId: user.userId,
connectionId,
channelName,
lastReadId: BigInt(lastReadId),
});
}
ws.send(JSON.stringify({ type: 'mark_read_ack', connectionId, channel: channelName }));
break;
}
case 'add_connection': {
const connData = msg.data as Record<string, unknown>;
const [newConn] = await db
.insert(connections)
.values({
userId: user.userId,
name: connData.name as string,
hostname: connData.hostname as string,
port: (connData.port as number) || 6667,
tls: (connData.tls as boolean) || false,
nick: connData.nick as string,
username: (connData.username as string) || null,
realname: (connData.realname as string) || null,
password: (connData.password as string) || null,
saslUsername: (connData.saslUsername as string) || null,
saslPassword: (connData.saslPassword as string) || null,
autoConnect: (connData.autoConnect as boolean) || false,
})
.returning();
ws.send(JSON.stringify({ type: 'connection_added', connection: newConn }));
break;
}
case 'remove_connection': {
const connectionId = msg.connectionId as number;
const conn = await db.query.connections.findFirst({
where: and(
eq(connections.id, connectionId),
eq(connections.userId, user.userId)
),
});
if (!conn) {
ws.send(JSON.stringify({ type: 'error', error: 'Connection not found' }));
return;
}
await connectionManager.disconnect(user.userId, connectionId);
await db.delete(connections).where(eq(connections.id, connectionId));
ws.send(JSON.stringify({ type: 'connection_removed', connectionId }));
break;
}
case 'list_connections': {
const userConnections = await db.query.connections.findMany({
where: eq(connections.userId, user.userId),
with: { channels: true },
});
ws.send(
JSON.stringify({
type: 'connections_list',
connections: userConnections.map((conn) => ({
id: conn.id,
name: conn.name,
hostname: conn.hostname,
port: conn.port,
tls: conn.tls,
nick: conn.nick,
connected: conn.connected,
channels: conn.channels.map((ch) => ({
id: ch.id,
name: ch.name,
joined: ch.joined,
topic: ch.topic,
})),
})),
})
);
break;
}
default:
ws.send(JSON.stringify({ type: 'error', error: `Unknown command: ${msg.command}` }));
}
}
export function closeAllSockets(): void {
for (const [, sockets] of activeSockets) {
for (const socket of sockets) {
socket.subscriber.quit();
socket.ws.close(1001, 'Server shutting down');
}
}
activeSockets.clear();
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}