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,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;
|
||||
Reference in New Issue
Block a user