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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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],
|
||||
}),
|
||||
}));
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user