From c59ac7ad0f6ed738822167c07003b53e00f62f83 Mon Sep 17 00:00:00 2001 From: Jason Preston Date: Sat, 8 Aug 2026 23:50:08 -0600 Subject: [PATCH] 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 --- .dockerignore | 19 ++ .gitignore | 37 +++ Dockerfile | 50 +++ README.md | 157 +++++++++ client/index.html | 13 + client/package.json | 25 ++ client/src/App.tsx | 37 +++ client/src/components/Message.tsx | 173 ++++++++++ client/src/components/MessageInput.tsx | 269 +++++++++++++++ client/src/components/MessagePanel.tsx | 195 +++++++++++ client/src/components/TreeBar.tsx | 433 +++++++++++++++++++++++++ client/src/components/UserList.tsx | 164 ++++++++++ client/src/layouts/MainLayout.tsx | 223 +++++++++++++ client/src/main.tsx | 14 + client/src/pages/LoginPage.tsx | 157 +++++++++ client/src/pages/RegisterPage.tsx | 200 ++++++++++++ client/src/stores/auth.ts | 94 ++++++ client/src/stores/irc.ts | 235 ++++++++++++++ client/src/stores/websocket.ts | 188 +++++++++++ client/src/styles/global.css | 43 +++ client/src/styles/theme.css | 136 ++++++++ client/src/utils/irc-colors.ts | 220 +++++++++++++ client/tsconfig.json | 22 ++ client/tsconfig.node.json | 10 + client/vite.config.ts | 19 ++ docker-compose.yml | 59 ++++ kubernetes/configmap.yaml | 15 + kubernetes/deployment.yaml | 82 +++++ kubernetes/ingress.yaml | 45 +++ kubernetes/namespace.yaml | 7 + kubernetes/networkpolicy.yaml | 71 ++++ kubernetes/postgres.yaml | 113 +++++++ kubernetes/pvc.yaml | 16 + kubernetes/redis.yaml | 117 +++++++ kubernetes/secrets.yaml.example | 17 + kubernetes/service.yaml | 19 ++ package.json | 18 + server/.env.example | 15 + server/drizzle.config.ts | 11 + server/package.json | 37 +++ server/src/auth/index.ts | 72 ++++ server/src/config.ts | 10 + server/src/db/index.ts | 16 + server/src/db/schema.ts | 170 ++++++++++ server/src/index.ts | 66 ++++ server/src/irc/connection-manager.ts | 313 ++++++++++++++++++ server/src/irc/event-handlers.ts | 388 ++++++++++++++++++++++ server/src/routes/auth.ts | 157 +++++++++ server/src/routes/connections.ts | 213 ++++++++++++ server/src/routes/messages.ts | 119 +++++++ server/src/websocket/index.ts | 400 +++++++++++++++++++++++ server/tsconfig.json | 24 ++ 52 files changed, 5723 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 client/index.html create mode 100644 client/package.json create mode 100644 client/src/App.tsx create mode 100644 client/src/components/Message.tsx create mode 100644 client/src/components/MessageInput.tsx create mode 100644 client/src/components/MessagePanel.tsx create mode 100644 client/src/components/TreeBar.tsx create mode 100644 client/src/components/UserList.tsx create mode 100644 client/src/layouts/MainLayout.tsx create mode 100644 client/src/main.tsx create mode 100644 client/src/pages/LoginPage.tsx create mode 100644 client/src/pages/RegisterPage.tsx create mode 100644 client/src/stores/auth.ts create mode 100644 client/src/stores/irc.ts create mode 100644 client/src/stores/websocket.ts create mode 100644 client/src/styles/global.css create mode 100644 client/src/styles/theme.css create mode 100644 client/src/utils/irc-colors.ts create mode 100644 client/tsconfig.json create mode 100644 client/tsconfig.node.json create mode 100644 client/vite.config.ts create mode 100644 docker-compose.yml create mode 100644 kubernetes/configmap.yaml create mode 100644 kubernetes/deployment.yaml create mode 100644 kubernetes/ingress.yaml create mode 100644 kubernetes/namespace.yaml create mode 100644 kubernetes/networkpolicy.yaml create mode 100644 kubernetes/postgres.yaml create mode 100644 kubernetes/pvc.yaml create mode 100644 kubernetes/redis.yaml create mode 100644 kubernetes/secrets.yaml.example create mode 100644 kubernetes/service.yaml create mode 100644 package.json create mode 100644 server/.env.example create mode 100644 server/drizzle.config.ts create mode 100644 server/package.json create mode 100644 server/src/auth/index.ts create mode 100644 server/src/config.ts create mode 100644 server/src/db/index.ts create mode 100644 server/src/db/schema.ts create mode 100644 server/src/index.ts create mode 100644 server/src/irc/connection-manager.ts create mode 100644 server/src/irc/event-handlers.ts create mode 100644 server/src/routes/auth.ts create mode 100644 server/src/routes/connections.ts create mode 100644 server/src/routes/messages.ts create mode 100644 server/src/websocket/index.ts create mode 100644 server/tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..80b35b3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +node_modules +client/node_modules +server/node_modules +.git +.gitignore +*.md +docker-compose.yml +kubernetes/ +.env +.env.* +!.env.example +dist +client/dist +server/dist +.vscode +.idea +*.log +coverage +.nyc_output diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ce18904 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Dependencies +node_modules/ + +# Build output +dist/ +server/dist/ +client/dist/ + +# Environment files +.env +.env.local +.env.*.local +kubernetes/secrets.yaml + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Test coverage +coverage/ +.nyc_output/ + +# Docker +.docker/ + +# Misc +*.tgz diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d1f1adc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +# Stage 1: Build client +FROM node:20-alpine AS client-build +WORKDIR /app/client +COPY client/package.json client/package-lock.json* ./ +RUN npm ci +COPY client/ ./ +RUN npm run build + +# Stage 2: Build server +FROM node:20-alpine AS server-build +WORKDIR /app/server +COPY server/package.json server/package-lock.json* ./ +RUN npm ci +COPY server/ ./ +RUN npm run build + +# Stage 3: Production +FROM node:20-alpine AS production +RUN apk add --no-cache tini curl + +WORKDIR /app + +# Create non-root user +RUN addgroup -g 1001 -S appgroup && \ + adduser -S appuser -u 1001 -G appgroup + +# Copy server production dependencies +COPY server/package.json server/package-lock.json* ./ +RUN npm ci --omit=dev && npm cache clean --force + +# Copy server build output +COPY --from=server-build /app/server/dist ./dist + +# Copy client build output to be served as static files +COPY --from=client-build /app/client/dist ./public + +# Create data directory for uploads +RUN mkdir -p /data/uploads && chown -R appuser:appgroup /data/uploads + +# Switch to non-root user +USER appuser + +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:3000/api/health || exit 1 + +ENTRYPOINT ["/sbin/tini", "--"] +CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..65f1be1 --- /dev/null +++ b/README.md @@ -0,0 +1,157 @@ +# mIRCcloud.com + +A modern web-based IRC client inspired by the classic mIRC desktop application. Connect to IRC networks from your browser with a nostalgic Win32 aesthetic, persistent message history, and real-time WebSocket communication. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ Internet │ +│ mirccloud.com (DNS → 24.144.124.133) │ +└─────────────────┬───────────────────────────────────────┘ + │ +┌─────────────────▼───────────────────────────────────────┐ +│ DigitalOcean Edge Node │ +│ nginx-public ingress controller │ +└─────────────────┬───────────────────────────────────────┘ + │ WireGuard tunnel +┌─────────────────▼───────────────────────────────────────┐ +│ k3s Homecloud Cluster │ +│ │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ mirccloud namespace │ │ +│ │ │ │ +│ │ ┌─────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ App │ │ Postgres │ │ Redis │ │ │ +│ │ │ (x2) │──│ (16) │ │ (7-alp) │ │ │ +│ │ │ :3000 │ │ :5432 │ │ :6379 │ │ │ +│ │ └─────────┘ └──────────┘ └──────────┘ │ │ +│ │ │ │ │ +│ │ ┌────▼────┐ │ │ +│ │ │ NFS PVC │ (uploads, postgres, redis) │ │ +│ │ │Synology │ │ │ +│ │ └─────────┘ │ │ +│ └──────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +**Components:** +- **Client** — React + Vite SPA with mIRC-inspired dark theme +- **Server** — Express + WebSocket server handling IRC connections +- **PostgreSQL** — Message persistence, user accounts, connection configs +- **Redis** — Pub/sub for real-time event distribution across app replicas +- **NFS (Synology)** — Persistent storage for all stateful data + +## Local Development + +### Prerequisites + +- Node.js 20+ +- Docker & Docker Compose + +### Quick Start + +```bash +# Start infrastructure (postgres + redis) +docker compose up postgres redis -d + +# Install dependencies +npm install + +# Run database migrations +cd server && npx drizzle-kit push + +# Start the server (watches for changes) +npm run dev --workspace=server + +# In another terminal, start the client +npm run dev --workspace=client +``` + +### Using Docker Compose (full stack) + +```bash +docker compose up +``` + +The app will be available at http://localhost:3000 (API) and http://localhost:5173 (client dev server). + +## Deployment + +### Build & Push Container Image + +```bash +# Login to Gitea registry +docker login git.lab.fairings.org + +# Build and push +docker build -t git.lab.fairings.org/jpreston/mirccloud/app:latest . +docker push git.lab.fairings.org/jpreston/mirccloud/app:latest +``` + +### Deploy to Kubernetes + +```bash +# Create namespace +kubectl apply -f kubernetes/namespace.yaml + +# Create secrets (copy from example first) +cp kubernetes/secrets.yaml.example kubernetes/secrets.yaml +# Edit kubernetes/secrets.yaml with real base64-encoded values +kubectl apply -f kubernetes/secrets.yaml + +# Deploy everything +kubectl apply -f kubernetes/configmap.yaml +kubectl apply -f kubernetes/pvc.yaml +kubectl apply -f kubernetes/postgres.yaml +kubectl apply -f kubernetes/redis.yaml +kubectl apply -f kubernetes/deployment.yaml +kubectl apply -f kubernetes/service.yaml +kubectl apply -f kubernetes/ingress.yaml +kubectl apply -f kubernetes/networkpolicy.yaml +``` + +### DNS Setup + +Point these records to the edge node: +- `mirccloud.com` → A record → `24.144.124.133` +- `www.mirccloud.com` → A record → `24.144.124.133` + +TLS certificates are automatically provisioned by cert-manager using the `letsencrypt-prod` ClusterIssuer. + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `PORT` | Server listen port | `3000` | +| `DATABASE_URL` | PostgreSQL connection string | — | +| `REDIS_URL` | Redis connection string | — | +| `JWT_SECRET` | Secret for signing JWT tokens | — | +| `CORS_ORIGIN` | Allowed CORS origin | `http://localhost:5173` | +| `NODE_ENV` | Environment (development/production) | `development` | +| `POSTGRES_PASSWORD` | PostgreSQL password (used in k8s) | — | + +## Project Structure + +``` +mirccloud.com/ +├── client/ # React frontend (Vite) +│ ├── src/ +│ │ ├── components/ # UI components (TreeBar, MessagePanel, etc.) +│ │ ├── pages/ # Login, Register pages +│ │ ├── stores/ # Zustand state (auth, irc, websocket) +│ │ ├── styles/ # mIRC theme CSS +│ │ └── utils/ # IRC color parser, helpers +│ └── index.html +├── server/ # Express + WebSocket backend +│ └── src/ +│ ├── auth/ # JWT + bcrypt authentication +│ ├── db/ # Drizzle ORM schema + connection +│ ├── irc/ # IRC connection manager + event handlers +│ ├── routes/ # REST API routes +│ └── websocket/ # WebSocket server +├── kubernetes/ # Production deployment manifests +├── Dockerfile # Multi-stage production build +├── docker-compose.yml # Local development stack +└── package.json # Workspace root +``` diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..625b100 --- /dev/null +++ b/client/index.html @@ -0,0 +1,13 @@ + + + + + + mIRCcloud + + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..24af2ef --- /dev/null +++ b/client/package.json @@ -0,0 +1,25 @@ +{ + "name": "mirccloud-client", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "date-fns": "3.6.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-router-dom": "6.23.1", + "zustand": "4.5.2" + }, + "devDependencies": { + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", + "@vitejs/plugin-react": "4.3.1", + "typescript": "5.4.5", + "vite": "5.3.1" + } +} diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..20facd2 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,37 @@ +import { useEffect } from 'react'; +import { Routes, Route, Navigate } from 'react-router-dom'; +import { useAuthStore } from './stores/auth'; +import LoginPage from './pages/LoginPage'; +import RegisterPage from './pages/RegisterPage'; +import MainLayout from './layouts/MainLayout'; + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const token = useAuthStore((s) => s.token); + if (!token) { + return ; + } + return <>{children}; +} + +export default function App() { + const loadFromStorage = useAuthStore((s) => s.loadFromStorage); + + useEffect(() => { + loadFromStorage(); + }, [loadFromStorage]); + + return ( + + } /> + } /> + + + + } + /> + + ); +} diff --git a/client/src/components/Message.tsx b/client/src/components/Message.tsx new file mode 100644 index 0000000..4038d7c --- /dev/null +++ b/client/src/components/Message.tsx @@ -0,0 +1,173 @@ +import { renderIrcText } from '../utils/irc-colors'; +import { format } from 'date-fns'; +import { IrcMessage } from '../stores/irc'; + +interface MessageProps { + message: IrcMessage; +} + +export default function Message({ message }: MessageProps) { + const timestamp = format(new Date(message.timestamp), 'HH:mm'); + + switch (message.type) { + case 'action': + return ( +
+ [{timestamp}] + + * {message.nick} {renderIrcText(message.text)} + +
+ ); + + case 'join': + return ( +
+ [{timestamp}] + + → {message.nick} has joined {message.text || message.buffer} + +
+ ); + + case 'part': + return ( +
+ [{timestamp}] + + ← {message.nick} has left {message.buffer} + {message.text ? ` (${message.text})` : ''} + +
+ ); + + case 'quit': + return ( +
+ [{timestamp}] + + ← {message.nick} has quit + {message.text ? ` (${message.text})` : ''} + +
+ ); + + case 'kick': + return ( +
+ [{timestamp}] + + ✖ {message.nick} was kicked {message.text ? `(${message.text})` : ''} + +
+ ); + + case 'nick': + return ( +
+ [{timestamp}] + + — {message.nick} is now known as {message.text} + +
+ ); + + case 'topic': + return ( +
+ [{timestamp}] + + — {message.nick} has set the topic: {renderIrcText(message.text)} + +
+ ); + + case 'mode': + return ( +
+ [{timestamp}] + + — {message.nick} sets mode {message.text} + +
+ ); + + case 'notice': + return ( +
+ [{timestamp}] + + -{message.nick}- {renderIrcText(message.text)} + +
+ ); + + case 'system': + return ( +
+ [{timestamp}] + + — {renderIrcText(message.text)} + +
+ ); + + case 'privmsg': + default: + return ( +
+ [{timestamp}] + <{message.nick}> + {renderIrcText(message.text)} +
+ ); + } +} + +const styles: Record = { + row: { + display: 'flex', + padding: '1px 8px', + lineHeight: '1.5', + wordBreak: 'break-word', + gap: '6px', + }, + timestamp: { + color: 'var(--text-muted)', + flexShrink: 0, + fontSize: '12px', + }, + nick: { + color: 'var(--accent)', + fontWeight: 'bold', + flexShrink: 0, + whiteSpace: 'nowrap', + }, + text: { + color: 'var(--text-primary)', + wordBreak: 'break-word', + flex: 1, + }, + actionText: { + color: 'var(--action-color)', + fontStyle: 'italic', + flex: 1, + }, + joinText: { + color: 'var(--join-color)', + flex: 1, + }, + partText: { + color: 'var(--part-color)', + flex: 1, + }, + noticeText: { + color: 'var(--notice-color)', + flex: 1, + }, + systemText: { + color: 'var(--text-secondary)', + fontStyle: 'italic', + flex: 1, + }, +}; diff --git a/client/src/components/MessageInput.tsx b/client/src/components/MessageInput.tsx new file mode 100644 index 0000000..07bb1d3 --- /dev/null +++ b/client/src/components/MessageInput.tsx @@ -0,0 +1,269 @@ +import { useState, useRef, useCallback, KeyboardEvent } from 'react'; +import { useWebSocketStore } from '../stores/websocket'; +import { useIrcStore } from '../stores/irc'; + +export default function MessageInput() { + const [input, setInput] = useState(''); + const [history, setHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); + const [tabState, setTabState] = useState<{ prefix: string; matches: string[]; index: number } | null>(null); + const inputRef = useRef(null); + + const send = useWebSocketStore((s) => s.send); + const activeConnectionId = useIrcStore((s) => s.activeConnectionId); + const activeBuffer = useIrcStore((s) => s.activeBuffer); + const users = useIrcStore((s) => s.users); + const connections = useIrcStore((s) => s.connections); + + const activeConnection = connections.find((c) => c.id === activeConnectionId); + const nick = activeConnection?.nick || 'user'; + + const handleSend = useCallback(() => { + const trimmed = input.trim(); + if (!trimmed || !activeConnectionId || !activeBuffer) return; + + // Add to history + setHistory((prev) => { + const newHist = [trimmed, ...prev.slice(0, 99)]; + return newHist; + }); + setHistoryIndex(-1); + setTabState(null); + + // Parse commands + if (trimmed.startsWith('/')) { + const parts = trimmed.slice(1).split(' '); + const cmd = parts[0].toLowerCase(); + const args = parts.slice(1); + + switch (cmd) { + case 'join': + case 'j': { + const channel = args[0]; + if (channel) { + send('join', { connectionId: activeConnectionId, channel: channel.startsWith('#') ? channel : `#${channel}` }); + } + break; + } + case 'part': + case 'leave': { + const channel = args[0] || activeBuffer; + send('part', { connectionId: activeConnectionId, channel, message: args.slice(1).join(' ') || undefined }); + break; + } + case 'msg': + case 'privmsg': + case 'query': { + const target = args[0]; + const text = args.slice(1).join(' '); + if (target && text) { + send('privmsg', { connectionId: activeConnectionId, target, text }); + } + break; + } + case 'nick': { + const newNick = args[0]; + if (newNick) { + send('nick', { connectionId: activeConnectionId, nick: newNick }); + } + break; + } + case 'quit': { + send('quit', { connectionId: activeConnectionId, message: args.join(' ') || undefined }); + break; + } + case 'me': { + const actionText = args.join(' '); + if (actionText) { + send('action', { connectionId: activeConnectionId, target: activeBuffer, text: actionText }); + } + break; + } + case 'topic': { + const topic = args.join(' '); + send('topic', { connectionId: activeConnectionId, channel: activeBuffer, topic }); + break; + } + case 'kick': { + const kickTarget = args[0]; + const reason = args.slice(1).join(' '); + if (kickTarget) { + send('kick', { connectionId: activeConnectionId, channel: activeBuffer, nick: kickTarget, reason }); + } + break; + } + case 'mode': { + const modeStr = args.join(' '); + send('mode', { connectionId: activeConnectionId, target: activeBuffer, mode: modeStr }); + break; + } + case 'notice': { + const target = args[0]; + const text = args.slice(1).join(' '); + if (target && text) { + send('notice', { connectionId: activeConnectionId, target, text }); + } + break; + } + case 'raw': + case 'quote': { + send('raw', { connectionId: activeConnectionId, command: args.join(' ') }); + break; + } + case 'clear': { + useIrcStore.getState().setMessages(activeConnectionId, activeBuffer, []); + break; + } + default: + // Send unknown commands as raw + send('raw', { connectionId: activeConnectionId, command: trimmed.slice(1) }); + break; + } + } else { + // Regular message + send('privmsg', { connectionId: activeConnectionId, target: activeBuffer, text: trimmed }); + } + + setInput(''); + }, [input, activeConnectionId, activeBuffer, send]); + + const handleKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case 'Enter': + e.preventDefault(); + handleSend(); + break; + + case 'ArrowUp': + e.preventDefault(); + if (history.length > 0) { + const newIndex = Math.min(historyIndex + 1, history.length - 1); + setHistoryIndex(newIndex); + setInput(history[newIndex]); + } + break; + + case 'ArrowDown': + e.preventDefault(); + if (historyIndex > 0) { + const newIndex = historyIndex - 1; + setHistoryIndex(newIndex); + setInput(history[newIndex]); + } else { + setHistoryIndex(-1); + setInput(''); + } + break; + + case 'Tab': + e.preventDefault(); + handleTabComplete(); + break; + + default: + // Reset tab state on any other key + if (tabState && e.key !== 'Shift') { + setTabState(null); + } + break; + } + }; + + const handleTabComplete = () => { + if (!activeConnectionId || !activeBuffer) return; + + const key = `${activeConnectionId}:${activeBuffer}`; + const channelUsers = users[key] || []; + + if (channelUsers.length === 0) return; + + if (tabState) { + // Cycle through matches + const nextIndex = (tabState.index + 1) % tabState.matches.length; + const match = tabState.matches[nextIndex]; + const beforePrefix = input.slice(0, input.length - (tabState.matches[tabState.index].length + (input.startsWith(tabState.prefix) && tabState.prefix === '' ? 0 : 0))); + + // Replace the current completion with the next + const cursorPos = input.lastIndexOf(tabState.matches[tabState.index]); + if (cursorPos >= 0) { + const before = input.slice(0, cursorPos); + const suffix = input.length === cursorPos + tabState.matches[tabState.index].length + 2 + ? ': ' + : input.length === cursorPos + tabState.matches[tabState.index].length + 1 + ? ' ' + : ''; + setInput(before + match + (before === '' ? ': ' : ' ')); + setTabState({ ...tabState, index: nextIndex }); + } + } else { + // Start new tab completion + const words = input.split(' '); + const lastWord = words[words.length - 1].toLowerCase(); + + if (!lastWord) return; + + const matches = channelUsers + .map((u) => u.nick) + .filter((n) => n.toLowerCase().startsWith(lastWord)) + .sort(); + + if (matches.length === 0) return; + + const match = matches[0]; + const prefix = words.slice(0, -1).join(' '); + const separator = prefix ? ' ' : ''; + const suffix = prefix === '' ? ': ' : ' '; + + setInput((prefix ? prefix + ' ' : '') + match + suffix); + setTabState({ prefix: lastWord, matches, index: 0 }); + } + }; + + return ( +
+ [{nick}] + setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={activeBuffer ? `Message ${activeBuffer}...` : 'Select a channel'} + disabled={!activeConnectionId || !activeBuffer} + spellCheck={false} + autoComplete="off" + /> +
+ ); +} + +const styles: Record = { + container: { + display: 'flex', + alignItems: 'center', + padding: '4px 8px', + background: 'var(--bg-input)', + borderTop: '2px solid', + borderColor: '#808080 transparent transparent #808080', + gap: '6px', + flexShrink: 0, + }, + nickLabel: { + color: 'var(--accent)', + fontSize: '12px', + fontWeight: 'bold', + whiteSpace: 'nowrap', + flexShrink: 0, + }, + input: { + flex: 1, + padding: '4px 8px', + background: 'var(--bg-primary)', + border: '2px solid', + borderColor: '#808080 #DFDFDF #DFDFDF #808080', + color: 'var(--text-primary)', + fontSize: '14px', + fontFamily: 'var(--font-mono)', + outline: 'none', + }, +}; diff --git a/client/src/components/MessagePanel.tsx b/client/src/components/MessagePanel.tsx new file mode 100644 index 0000000..ba7a648 --- /dev/null +++ b/client/src/components/MessagePanel.tsx @@ -0,0 +1,195 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import { useIrcStore } from '../stores/irc'; +import { useWebSocketStore } from '../stores/websocket'; +import Message from './Message'; +import MessageInput from './MessageInput'; +import { renderIrcText } from '../utils/irc-colors'; + +export default function MessagePanel() { + const activeConnectionId = useIrcStore((s) => s.activeConnectionId); + const activeBuffer = useIrcStore((s) => s.activeBuffer); + const messages = useIrcStore((s) => s.messages); + const connections = useIrcStore((s) => s.connections); + const send = useWebSocketStore((s) => s.send); + + const messagesEndRef = useRef(null); + const scrollContainerRef = useRef(null); + const [autoScroll, setAutoScroll] = useState(true); + const [userScrolledUp, setUserScrolledUp] = useState(false); + + const key = activeConnectionId && activeBuffer ? `${activeConnectionId}:${activeBuffer}` : ''; + const currentMessages = messages[key] || []; + + const activeConnection = connections.find((c) => c.id === activeConnectionId); + const activeChannel = activeConnection?.channels.find((ch) => ch.name === activeBuffer); + const topic = activeChannel?.topic || ''; + + // Request backlog when buffer changes + useEffect(() => { + if (activeConnectionId && activeBuffer) { + send('request_backlog', { connectionId: activeConnectionId, buffer: activeBuffer }); + } + }, [activeConnectionId, activeBuffer, send]); + + // Auto-scroll to bottom on new messages + useEffect(() => { + if (autoScroll && messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: 'instant' }); + } + }, [currentMessages.length, autoScroll]); + + const handleScroll = useCallback(() => { + const container = scrollContainerRef.current; + if (!container) return; + + const { scrollTop, scrollHeight, clientHeight } = container; + const isAtBottom = scrollHeight - scrollTop - clientHeight < 30; + + setAutoScroll(isAtBottom); + setUserScrolledUp(!isAtBottom); + }, []); + + const scrollToBottom = () => { + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); + setAutoScroll(true); + setUserScrolledUp(false); + } + }; + + if (!activeConnectionId || !activeBuffer) { + return ( +
+
+
mIRCcloud
+
Select a channel or conversation from the tree to begin.
+
Right-click a connection to join a channel.
+
+
+ ); + } + + return ( +
+ {/* Topic bar */} + {topic && ( +
+ Topic: + {renderIrcText(topic)} +
+ )} + + {/* Message area */} +
+ {currentMessages.length === 0 && ( +
+ No messages yet in {activeBuffer} +
+ )} + {currentMessages.map((msg) => ( + + ))} +
+
+ + {/* Scroll-to-bottom indicator */} + {userScrolledUp && ( + + )} + + {/* Input */} + +
+ ); +} + +const styles: Record = { + container: { + display: 'flex', + flexDirection: 'column', + height: '100%', + position: 'relative', + }, + emptyContainer: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + background: 'var(--bg-primary)', + }, + emptyContent: { + textAlign: 'center', + }, + emptyLogo: { + color: 'var(--accent)', + fontSize: '32px', + fontWeight: 'bold', + marginBottom: '16px', + }, + emptyText: { + color: 'var(--text-secondary)', + fontSize: '14px', + marginBottom: '8px', + }, + emptyHint: { + color: 'var(--text-muted)', + fontSize: '12px', + }, + topicBar: { + display: 'flex', + alignItems: 'center', + padding: '4px 8px', + background: 'var(--bg-topic)', + borderBottom: '1px solid var(--border-light)', + fontSize: '12px', + overflow: 'hidden', + whiteSpace: 'nowrap', + flexShrink: 0, + minHeight: 'var(--topic-height)', + }, + topicLabel: { + color: 'var(--text-muted)', + marginRight: '6px', + flexShrink: 0, + }, + topicText: { + color: 'var(--text-secondary)', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + messageArea: { + flex: 1, + overflow: 'auto', + background: 'var(--bg-primary)', + padding: '4px 0', + }, + noMessages: { + padding: '20px', + textAlign: 'center', + color: 'var(--text-muted)', + fontSize: '12px', + fontStyle: 'italic', + }, + scrollButton: { + position: 'absolute', + bottom: '44px', + left: '50%', + transform: 'translateX(-50%)', + padding: '4px 12px', + background: 'var(--accent)', + color: '#fff', + border: 'none', + borderRadius: '4px', + fontSize: '11px', + cursor: 'pointer', + fontFamily: 'var(--font-mono)', + zIndex: 10, + boxShadow: '0 2px 4px rgba(0,0,0,0.3)', + }, +}; diff --git a/client/src/components/TreeBar.tsx b/client/src/components/TreeBar.tsx new file mode 100644 index 0000000..4f6b874 --- /dev/null +++ b/client/src/components/TreeBar.tsx @@ -0,0 +1,433 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useIrcStore, IrcConnection, IrcChannel } from '../stores/irc'; +import { useWebSocketStore } from '../stores/websocket'; + +interface ContextMenu { + x: number; + y: number; + type: 'connection' | 'channel'; + connectionId: string; + channel?: string; +} + +export default function TreeBar() { + const connections = useIrcStore((s) => s.connections); + const activeConnectionId = useIrcStore((s) => s.activeConnectionId); + const activeBuffer = useIrcStore((s) => s.activeBuffer); + const setActiveBuffer = useIrcStore((s) => s.setActiveBuffer); + const send = useWebSocketStore((s) => s.send); + + const [expanded, setExpanded] = useState>({}); + const [contextMenu, setContextMenu] = useState(null); + const [joinDialogConn, setJoinDialogConn] = useState(null); + const [joinChannel, setJoinChannel] = useState(''); + + // Auto-expand all connections initially + useEffect(() => { + const newExpanded: Record = {}; + connections.forEach((c) => { + if (expanded[c.id] === undefined) { + newExpanded[c.id] = true; + } + }); + if (Object.keys(newExpanded).length > 0) { + setExpanded((prev) => ({ ...prev, ...newExpanded })); + } + }, [connections]); + + const toggleExpanded = (id: string) => { + setExpanded((prev) => ({ ...prev, [id]: !prev[id] })); + }; + + const handleContextMenu = ( + e: React.MouseEvent, + type: 'connection' | 'channel', + connectionId: string, + channel?: string + ) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY, type, connectionId, channel }); + }; + + const closeContextMenu = useCallback(() => { + setContextMenu(null); + }, []); + + useEffect(() => { + if (contextMenu) { + document.addEventListener('click', closeContextMenu); + return () => document.removeEventListener('click', closeContextMenu); + } + }, [contextMenu, closeContextMenu]); + + const handleJoinChannel = () => { + if (joinDialogConn && joinChannel.trim()) { + const channel = joinChannel.startsWith('#') ? joinChannel : `#${joinChannel}`; + send('join', { connectionId: joinDialogConn, channel }); + setJoinDialogConn(null); + setJoinChannel(''); + } + }; + + const handleDisconnect = (connectionId: string) => { + send('disconnect', { connectionId }); + }; + + const handlePartChannel = (connectionId: string, channel: string) => { + send('part', { connectionId, channel }); + }; + + return ( +
+
Connections
+
+ {connections.length === 0 && ( +
No connections
+ )} + {connections.map((conn) => ( + toggleExpanded(conn.id)} + onSelect={(buffer) => setActiveBuffer(conn.id, buffer)} + onContextMenu={handleContextMenu} + /> + ))} +
+ + {/* Context Menu */} + {contextMenu && ( +
+ {contextMenu.type === 'connection' && ( + <> + +
+ + + )} + {contextMenu.type === 'channel' && contextMenu.channel && ( + <> + +
+ + + )} +
+ )} + + {/* Join Channel Dialog */} + {joinDialogConn && ( +
+
+
Join Channel
+ setJoinChannel(e.target.value)} + placeholder="#channel" + autoFocus + onKeyDown={(e) => { if (e.key === 'Enter') handleJoinChannel(); if (e.key === 'Escape') setJoinDialogConn(null); }} + /> +
+ + +
+
+
+ )} +
+ ); +} + +function ConnectionNode({ + connection, + expanded, + isActiveConnection, + activeBuffer, + onToggle, + onSelect, + onContextMenu, +}: { + connection: IrcConnection; + expanded: boolean; + isActiveConnection: boolean; + activeBuffer: string | null; + onToggle: () => void; + onSelect: (buffer: string) => void; + onContextMenu: (e: React.MouseEvent, type: 'connection' | 'channel', connId: string, channel?: string) => void; +}) { + return ( +
+
onContextMenu(e, 'connection', connection.id)} + > + + onSelect(connection.name)} + > + {connection.connected ? '⚡' : '⊘'} {connection.name || connection.server} + +
+ + {expanded && ( +
+ {connection.channels.map((ch) => ( + onSelect(ch.name)} + onContextMenu={(e) => onContextMenu(e, 'channel', connection.id, ch.name)} + /> + ))} + {connection.queries.map((query) => ( +
onSelect(query)} + > + 💬 + {query} +
+ ))} +
+ )} +
+ ); +} + +function ChannelNode({ + channel, + isActive, + onSelect, + onContextMenu, +}: { + channel: IrcChannel; + isActive: boolean; + onSelect: () => void; + onContextMenu: (e: React.MouseEvent) => void; +}) { + return ( +
0 ? 'var(--unread)' : 'var(--text-treebar)', + fontWeight: channel.unread > 0 ? 'bold' : 'normal', + }} + onClick={onSelect} + onContextMenu={onContextMenu} + > + # + {channel.name.replace(/^#/, '')} + {channel.unread > 0 && ( + {channel.unread} + )} +
+ ); +} + +const styles: Record = { + container: { + display: 'flex', + flexDirection: 'column', + height: '100%', + background: 'var(--bg-treebar)', + fontFamily: 'var(--font-mono)', + fontSize: '12px', + }, + header: { + padding: '4px 8px', + background: '#A0A0A0', + color: '#000', + fontWeight: 'bold', + fontSize: '11px', + borderBottom: '1px solid #808080', + }, + tree: { + flex: 1, + overflow: 'auto', + padding: '2px 0', + }, + empty: { + padding: '12px', + textAlign: 'center', + color: 'var(--text-muted)', + fontSize: '11px', + }, + connectionRow: { + display: 'flex', + alignItems: 'center', + padding: '2px 4px', + cursor: 'pointer', + userSelect: 'none', + }, + expandBtn: { + width: '14px', + height: '14px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + border: '1px solid #808080', + background: '#FFFFFF', + color: '#000', + fontSize: '10px', + lineHeight: '1', + cursor: 'pointer', + marginRight: '4px', + fontFamily: 'var(--font-mono)', + flexShrink: 0, + }, + connectionLabel: { + cursor: 'pointer', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + children: { + marginLeft: '12px', + borderLeft: '1px dotted #808080', + paddingLeft: '4px', + }, + channelRow: { + display: 'flex', + alignItems: 'center', + padding: '1px 4px', + cursor: 'pointer', + userSelect: 'none', + whiteSpace: 'nowrap', + }, + channelIcon: { + marginRight: '3px', + fontWeight: 'bold', + opacity: 0.6, + }, + channelName: { + overflow: 'hidden', + textOverflow: 'ellipsis', + flex: 1, + }, + queryIcon: { + marginRight: '4px', + fontSize: '10px', + }, + unreadBadge: { + marginLeft: 'auto', + padding: '0 4px', + background: 'var(--unread)', + color: '#fff', + borderRadius: '3px', + fontSize: '9px', + fontWeight: 'bold', + }, + contextMenu: { + position: 'fixed', + background: 'var(--bg-panel)', + border: '1px solid var(--border-light)', + boxShadow: '2px 2px 4px rgba(0,0,0,0.5)', + zIndex: 1000, + minWidth: '150px', + padding: '2px 0', + }, + contextMenuItem: { + display: 'block', + width: '100%', + padding: '4px 16px', + textAlign: 'left', + color: 'var(--text-primary)', + fontSize: '12px', + cursor: 'pointer', + border: 'none', + background: 'none', + fontFamily: 'var(--font-mono)', + whiteSpace: 'nowrap', + }, + contextMenuSep: { + height: '1px', + background: 'var(--border-light)', + margin: '2px 0', + }, + dialogOverlay: { + position: 'fixed', + inset: 0, + background: 'rgba(0,0,0,0.5)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 2000, + }, + dialog: { + background: 'var(--bg-panel)', + border: '2px solid', + borderColor: '#DFDFDF #808080 #808080 #DFDFDF', + padding: '16px', + minWidth: '250px', + }, + dialogTitle: { + color: 'var(--text-primary)', + fontWeight: 'bold', + marginBottom: '12px', + fontSize: '13px', + }, + dialogInput: { + width: '100%', + padding: '6px 8px', + background: '#0C0C0C', + border: '2px solid', + borderColor: '#808080 #DFDFDF #DFDFDF #808080', + color: '#FFFFFF', + fontSize: '13px', + fontFamily: 'var(--font-mono)', + outline: 'none', + marginBottom: '12px', + }, + dialogButtons: { + display: 'flex', + gap: '8px', + justifyContent: 'flex-end', + }, + dialogButton: { + padding: '4px 16px', + background: '#3C3C3C', + border: '2px solid', + borderColor: '#DFDFDF #808080 #808080 #DFDFDF', + color: 'var(--text-primary)', + fontSize: '12px', + cursor: 'pointer', + fontFamily: 'var(--font-mono)', + }, +}; diff --git a/client/src/components/UserList.tsx b/client/src/components/UserList.tsx new file mode 100644 index 0000000..84fc749 --- /dev/null +++ b/client/src/components/UserList.tsx @@ -0,0 +1,164 @@ +import { useMemo } from 'react'; +import { useIrcStore, ChannelUser } from '../stores/irc'; + +export default function UserList() { + const activeConnectionId = useIrcStore((s) => s.activeConnectionId); + const activeBuffer = useIrcStore((s) => s.activeBuffer); + const users = useIrcStore((s) => s.users); + + const key = activeConnectionId && activeBuffer ? `${activeConnectionId}:${activeBuffer}` : ''; + const channelUsers = users[key] || []; + + const grouped = useMemo(() => { + const ops: ChannelUser[] = []; + const halfops: ChannelUser[] = []; + const voiced: ChannelUser[] = []; + const regular: ChannelUser[] = []; + + const sortNicks = (a: ChannelUser, b: ChannelUser) => + a.nick.toLowerCase().localeCompare(b.nick.toLowerCase()); + + for (const user of channelUsers) { + const modes = user.modes || user.prefix || ''; + if (modes.includes('~') || modes.includes('&') || modes.includes('@')) { + ops.push(user); + } else if (modes.includes('%')) { + halfops.push(user); + } else if (modes.includes('+')) { + voiced.push(user); + } else { + regular.push(user); + } + } + + ops.sort(sortNicks); + halfops.sort(sortNicks); + voiced.sort(sortNicks); + regular.sort(sortNicks); + + return { ops, halfops, voiced, regular }; + }, [channelUsers]); + + const getPrefix = (user: ChannelUser): string => { + const modes = user.modes || user.prefix || ''; + if (modes.includes('~')) return '~'; + if (modes.includes('&')) return '&'; + if (modes.includes('@')) return '@'; + if (modes.includes('%')) return '%'; + if (modes.includes('+')) return '+'; + return ''; + }; + + const getPrefixColor = (prefix: string): string => { + switch (prefix) { + case '~': return '#FF5555'; + case '&': return '#FF8800'; + case '@': return '#00CC00'; + case '%': return '#5555FF'; + case '+': return '#AAAAAA'; + default: return 'var(--text-primary)'; + } + }; + + return ( +
+
+ Users ({channelUsers.length}) +
+
+ {grouped.ops.length > 0 && ( +
+
Operators ({grouped.ops.length})
+ {grouped.ops.map((user) => ( + + ))} +
+ )} + {grouped.halfops.length > 0 && ( +
+
Half-Ops ({grouped.halfops.length})
+ {grouped.halfops.map((user) => ( + + ))} +
+ )} + {grouped.voiced.length > 0 && ( +
+
Voiced ({grouped.voiced.length})
+ {grouped.voiced.map((user) => ( + + ))} +
+ )} + {grouped.regular.length > 0 && ( +
+
Users ({grouped.regular.length})
+ {grouped.regular.map((user) => ( + + ))} +
+ )} +
+
+ ); +} + +function UserItem({ user, prefix, prefixColor }: { user: ChannelUser; prefix: string; prefixColor: string }) { + return ( +
+ {prefix && {prefix}} + {user.nick} +
+ ); +} + +const styles: Record = { + container: { + display: 'flex', + flexDirection: 'column', + height: '100%', + background: 'var(--bg-secondary)', + }, + header: { + padding: '4px 8px', + background: 'var(--bg-toolbar)', + color: 'var(--text-secondary)', + fontSize: '11px', + fontWeight: 'bold', + borderBottom: '1px solid var(--border-light)', + flexShrink: 0, + }, + list: { + flex: 1, + overflow: 'auto', + padding: '4px 0', + }, + group: { + marginBottom: '4px', + }, + groupLabel: { + padding: '2px 8px', + color: 'var(--text-muted)', + fontSize: '10px', + fontWeight: 'bold', + textTransform: 'uppercase', + }, + userRow: { + display: 'flex', + alignItems: 'center', + padding: '1px 8px', + cursor: 'pointer', + fontSize: '12px', + }, + prefix: { + width: '12px', + fontWeight: 'bold', + flexShrink: 0, + }, + nick: { + color: 'var(--text-primary)', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, +}; diff --git a/client/src/layouts/MainLayout.tsx b/client/src/layouts/MainLayout.tsx new file mode 100644 index 0000000..7335a7d --- /dev/null +++ b/client/src/layouts/MainLayout.tsx @@ -0,0 +1,223 @@ +import { useEffect, useState, useCallback, useRef } from 'react'; +import { useAuthStore } from '../stores/auth'; +import { useIrcStore } from '../stores/irc'; +import { useWebSocketStore } from '../stores/websocket'; +import TreeBar from '../components/TreeBar'; +import MessagePanel from '../components/MessagePanel'; +import UserList from '../components/UserList'; + +export default function MainLayout() { + const token = useAuthStore((s) => s.token); + const logout = useAuthStore((s) => s.logout); + const { connected, connect, disconnect } = useWebSocketStore(); + const activeConnectionId = useIrcStore((s) => s.activeConnectionId); + const activeBuffer = useIrcStore((s) => s.activeBuffer); + const connections = useIrcStore((s) => s.connections); + + const [treeWidth, setTreeWidth] = useState(200); + const [userListWidth, setUserListWidth] = useState(160); + const [resizing, setResizing] = useState<'tree' | 'userlist' | null>(null); + const containerRef = useRef(null); + + useEffect(() => { + if (token) { + connect(token); + } + return () => { + disconnect(); + }; + }, [token, connect, disconnect]); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!resizing || !containerRef.current) return; + + const rect = containerRef.current.getBoundingClientRect(); + + if (resizing === 'tree') { + const newWidth = Math.max(120, Math.min(400, e.clientX - rect.left)); + setTreeWidth(newWidth); + } else if (resizing === 'userlist') { + const newWidth = Math.max(100, Math.min(300, rect.right - e.clientX)); + setUserListWidth(newWidth); + } + }, + [resizing] + ); + + const handleMouseUp = useCallback(() => { + setResizing(null); + }, []); + + useEffect(() => { + if (resizing) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + } + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + }, [resizing, handleMouseMove, handleMouseUp]); + + const activeConnection = connections.find((c) => c.id === activeConnectionId); + const isChannel = activeBuffer?.startsWith('#') || activeBuffer?.startsWith('&'); + + const handleLogout = () => { + disconnect(); + logout(); + }; + + return ( +
+ {/* Toolbar */} +
+
+ mIRCcloud + + + {connected ? 'Connected' : 'Disconnected'} + + {activeConnection && ( + + — {activeConnection.nick}@{activeConnection.server} + + )} +
+
+ +
+
+ + {/* Main panels */} +
+ {/* TreeBar */} +
+ +
+ + {/* Tree resize handle */} +
setResizing('tree')} + /> + + {/* Message panel */} +
+ +
+ + {/* User list resize handle */} + {isChannel && ( +
setResizing('userlist')} + /> + )} + + {/* User list (only for channels) */} + {isChannel && ( +
+ +
+ )} +
+
+ ); +} + +const styles = { + container: { + display: 'flex', + flexDirection: 'column' as const, + height: '100vh', + background: 'var(--bg-primary)', + color: 'var(--text-primary)', + fontFamily: 'var(--font-mono)', + }, + toolbar: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + height: 'var(--toolbar-height)', + padding: '0 8px', + background: 'var(--bg-toolbar)', + borderBottom: '2px solid', + borderColor: '#808080', + borderImage: 'none', + borderBottomColor: '#1A1A1A', + flexShrink: 0, + } as React.CSSProperties, + toolbarLeft: { + display: 'flex', + alignItems: 'center', + gap: '8px', + } as React.CSSProperties, + toolbarRight: { + display: 'flex', + alignItems: 'center', + gap: '8px', + } as React.CSSProperties, + brand: { + fontWeight: 'bold', + color: 'var(--accent)', + fontSize: '13px', + } as React.CSSProperties, + statusDot: (connected: boolean): React.CSSProperties => ({ + width: '8px', + height: '8px', + borderRadius: '50%', + background: connected ? '#00FF00' : '#FF4444', + }), + statusText: { + fontSize: '11px', + color: 'var(--text-secondary)', + } as React.CSSProperties, + connectionInfo: { + fontSize: '11px', + color: 'var(--text-muted)', + } as React.CSSProperties, + toolbarButton: { + padding: '2px 12px', + background: '#3C3C3C', + border: '2px solid', + borderColor: '#DFDFDF #808080 #808080 #DFDFDF', + color: 'var(--text-primary)', + fontSize: '11px', + cursor: 'pointer', + fontFamily: 'var(--font-mono)', + } as React.CSSProperties, + panels: { + display: 'flex', + flex: 1, + overflow: 'hidden', + } as React.CSSProperties, + treePanel: { + flexShrink: 0, + overflow: 'hidden', + borderRight: '1px solid var(--border-light)', + } as React.CSSProperties, + messagePanel: { + flex: 1, + overflow: 'hidden', + display: 'flex', + flexDirection: 'column' as const, + } as React.CSSProperties, + userListPanel: { + flexShrink: 0, + overflow: 'hidden', + borderLeft: '1px solid var(--border-light)', + } as React.CSSProperties, + resizeHandle: { + width: '4px', + cursor: 'col-resize', + background: 'var(--border-light)', + flexShrink: 0, + } as React.CSSProperties, +}; diff --git a/client/src/main.tsx b/client/src/main.tsx new file mode 100644 index 0000000..53f311a --- /dev/null +++ b/client/src/main.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import App from './App'; +import './styles/global.css'; +import './styles/theme.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx new file mode 100644 index 0000000..836498a --- /dev/null +++ b/client/src/pages/LoginPage.tsx @@ -0,0 +1,157 @@ +import { useState, FormEvent } from 'react'; +import { Link, Navigate } from 'react-router-dom'; +import { useAuthStore } from '../stores/auth'; + +export default function LoginPage() { + const { token, login, isLoading, error, clearError } = useAuthStore(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + + if (token) { + return ; + } + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + login(email, password); + }; + + return ( +
+
+

mIRCcloud

+

IRC in the cloud — powered by nostalgia

+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setEmail(e.target.value)} + style={styles.input} + placeholder="user@example.com" + required + autoFocus + /> +
+ +
+ + setPassword(e.target.value)} + style={styles.input} + placeholder="••••••••" + required + /> +
+ + +
+ +

+ Don't have an account?{' '} + + Register + +

+
+
+ ); +} + +const styles: Record = { + container: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100vh', + background: '#0C0C0C', + fontFamily: "Consolas, 'Courier New', monospace", + }, + card: { + background: '#1A1A1A', + border: '2px solid', + borderColor: '#505050 #1A1A1A #1A1A1A #505050', + padding: '40px', + width: '100%', + maxWidth: '400px', + }, + title: { + color: '#4A9EFF', + fontSize: '28px', + marginBottom: '4px', + textAlign: 'center' as const, + }, + subtitle: { + color: '#707070', + fontSize: '12px', + marginBottom: '24px', + textAlign: 'center' as const, + }, + form: { + display: 'flex', + flexDirection: 'column' as const, + gap: '16px', + }, + field: { + display: 'flex', + flexDirection: 'column' as const, + gap: '4px', + }, + label: { + color: '#B0B0B0', + fontSize: '12px', + }, + input: { + padding: '8px 10px', + background: '#0C0C0C', + border: '2px solid', + borderColor: '#808080 #DFDFDF #DFDFDF #808080', + color: '#FFFFFF', + fontSize: '14px', + fontFamily: "Consolas, 'Courier New', monospace", + outline: 'none', + }, + button: { + padding: '10px', + background: '#3C3C3C', + border: '2px solid', + borderColor: '#DFDFDF #808080 #808080 #DFDFDF', + color: '#FFFFFF', + fontSize: '14px', + fontFamily: "Consolas, 'Courier New', monospace", + cursor: 'pointer', + marginTop: '8px', + }, + error: { + padding: '8px', + background: '#3C0000', + border: '1px solid #FF4444', + color: '#FF8888', + fontSize: '12px', + cursor: 'pointer', + }, + footer: { + color: '#707070', + fontSize: '12px', + textAlign: 'center' as const, + marginTop: '20px', + }, + link: { + color: '#4A9EFF', + textDecoration: 'underline', + }, +}; diff --git a/client/src/pages/RegisterPage.tsx b/client/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..6d6c77d --- /dev/null +++ b/client/src/pages/RegisterPage.tsx @@ -0,0 +1,200 @@ +import { useState, FormEvent } from 'react'; +import { Link, Navigate } from 'react-router-dom'; +import { useAuthStore } from '../stores/auth'; + +export default function RegisterPage() { + const { token, register, isLoading, error, clearError } = useAuthStore(); + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [localError, setLocalError] = useState(''); + + if (token) { + return ; + } + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + setLocalError(''); + + if (password !== confirmPassword) { + setLocalError('Passwords do not match'); + return; + } + + if (password.length < 6) { + setLocalError('Password must be at least 6 characters'); + return; + } + + register(username, email, password); + }; + + const displayError = localError || error; + + return ( +
+
+

mIRCcloud

+

Create your account

+ +
+ {displayError && ( +
{ setLocalError(''); clearError(); }}> + {displayError} +
+ )} + +
+ + setUsername(e.target.value)} + style={styles.input} + placeholder="YourNick" + required + autoFocus + /> +
+ +
+ + setEmail(e.target.value)} + style={styles.input} + placeholder="user@example.com" + required + /> +
+ +
+ + setPassword(e.target.value)} + style={styles.input} + placeholder="••••••••" + required + /> +
+ +
+ + setConfirmPassword(e.target.value)} + style={styles.input} + placeholder="••••••••" + required + /> +
+ + +
+ +

+ Already have an account?{' '} + + Login + +

+
+
+ ); +} + +const styles: Record = { + container: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100vh', + background: '#0C0C0C', + fontFamily: "Consolas, 'Courier New', monospace", + }, + card: { + background: '#1A1A1A', + border: '2px solid', + borderColor: '#505050 #1A1A1A #1A1A1A #505050', + padding: '40px', + width: '100%', + maxWidth: '400px', + }, + title: { + color: '#4A9EFF', + fontSize: '28px', + marginBottom: '4px', + textAlign: 'center' as const, + }, + subtitle: { + color: '#707070', + fontSize: '12px', + marginBottom: '24px', + textAlign: 'center' as const, + }, + form: { + display: 'flex', + flexDirection: 'column' as const, + gap: '16px', + }, + field: { + display: 'flex', + flexDirection: 'column' as const, + gap: '4px', + }, + label: { + color: '#B0B0B0', + fontSize: '12px', + }, + input: { + padding: '8px 10px', + background: '#0C0C0C', + border: '2px solid', + borderColor: '#808080 #DFDFDF #DFDFDF #808080', + color: '#FFFFFF', + fontSize: '14px', + fontFamily: "Consolas, 'Courier New', monospace", + outline: 'none', + }, + button: { + padding: '10px', + background: '#3C3C3C', + border: '2px solid', + borderColor: '#DFDFDF #808080 #808080 #DFDFDF', + color: '#FFFFFF', + fontSize: '14px', + fontFamily: "Consolas, 'Courier New', monospace", + cursor: 'pointer', + marginTop: '8px', + }, + error: { + padding: '8px', + background: '#3C0000', + border: '1px solid #FF4444', + color: '#FF8888', + fontSize: '12px', + cursor: 'pointer', + }, + footer: { + color: '#707070', + fontSize: '12px', + textAlign: 'center' as const, + marginTop: '20px', + }, + link: { + color: '#4A9EFF', + textDecoration: 'underline', + }, +}; diff --git a/client/src/stores/auth.ts b/client/src/stores/auth.ts new file mode 100644 index 0000000..0ec95cc --- /dev/null +++ b/client/src/stores/auth.ts @@ -0,0 +1,94 @@ +import { create } from 'zustand'; + +interface User { + id: string; + username: string; + email: string; +} + +interface AuthState { + token: string | null; + user: User | null; + isLoading: boolean; + error: string | null; + login: (email: string, password: string) => Promise; + register: (username: string, email: string, password: string) => Promise; + logout: () => void; + loadFromStorage: () => void; + clearError: () => void; +} + +export const useAuthStore = create((set, get) => ({ + token: null, + user: null, + isLoading: false, + error: null, + + login: async (email: string, password: string) => { + set({ isLoading: true, error: null }); + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({ message: 'Login failed' })); + throw new Error(data.message || `Login failed (${res.status})`); + } + + const data = await res.json(); + localStorage.setItem('token', data.token); + localStorage.setItem('user', JSON.stringify(data.user)); + set({ token: data.token, user: data.user, isLoading: false }); + } catch (err: any) { + set({ error: err.message, isLoading: false }); + } + }, + + register: async (username: string, email: string, password: string) => { + set({ isLoading: true, error: null }); + try { + const res = await fetch('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, email, password }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({ message: 'Registration failed' })); + throw new Error(data.message || `Registration failed (${res.status})`); + } + + const data = await res.json(); + localStorage.setItem('token', data.token); + localStorage.setItem('user', JSON.stringify(data.user)); + set({ token: data.token, user: data.user, isLoading: false }); + } catch (err: any) { + set({ error: err.message, isLoading: false }); + } + }, + + logout: () => { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + set({ token: null, user: null }); + }, + + loadFromStorage: () => { + const token = localStorage.getItem('token'); + const userStr = localStorage.getItem('user'); + if (token && userStr) { + try { + const user = JSON.parse(userStr); + set({ token, user }); + } catch { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + } + } + }, + + clearError: () => set({ error: null }), +})); diff --git a/client/src/stores/irc.ts b/client/src/stores/irc.ts new file mode 100644 index 0000000..927f3f5 --- /dev/null +++ b/client/src/stores/irc.ts @@ -0,0 +1,235 @@ +import { create } from 'zustand'; + +export interface IrcMessage { + id: string; + connectionId: string; + buffer: string; + type: 'privmsg' | 'notice' | 'action' | 'join' | 'part' | 'quit' | 'kick' | 'nick' | 'topic' | 'mode' | 'system'; + nick: string; + text: string; + timestamp: number; +} + +export interface IrcChannel { + name: string; + topic: string; + unread: number; + mentioned: boolean; + joined: boolean; +} + +export interface IrcConnection { + id: string; + name: string; + server: string; + port: number; + nick: string; + connected: boolean; + channels: IrcChannel[]; + queries: string[]; +} + +export interface ChannelUser { + nick: string; + modes: string; // e.g. '@', '+', '%', '~', '&' + prefix: string; +} + +interface IrcState { + connections: IrcConnection[]; + activeConnectionId: string | null; + activeBuffer: string | null; // channel name or nick for PM + messages: Record; // key: `${connectionId}:${buffer}` + users: Record; // key: `${connectionId}:${channel}` + + setActiveBuffer: (connectionId: string, buffer: string) => void; + addMessage: (message: IrcMessage) => void; + setConnections: (connections: IrcConnection[]) => void; + setMessages: (connectionId: string, buffer: string, messages: IrcMessage[]) => void; + addConnection: (connection: IrcConnection) => void; + removeConnection: (connectionId: string) => void; + updateConnection: (connectionId: string, updates: Partial) => void; + setChannelUsers: (connectionId: string, channel: string, users: ChannelUser[]) => void; + addChannelUser: (connectionId: string, channel: string, user: ChannelUser) => void; + removeChannelUser: (connectionId: string, channel: string, nick: string) => void; + updateChannelUser: (connectionId: string, channel: string, oldNick: string, newNick: string) => void; + setTopic: (connectionId: string, channel: string, topic: string) => void; + incrementUnread: (connectionId: string, buffer: string, mentioned?: boolean) => void; + clearUnread: (connectionId: string, buffer: string) => void; + addChannel: (connectionId: string, channel: IrcChannel) => void; + removeChannel: (connectionId: string, channelName: string) => void; + getBufferKey: (connectionId: string, buffer: string) => string; +} + +export const useIrcStore = create((set, get) => ({ + connections: [], + activeConnectionId: null, + activeBuffer: null, + messages: {}, + users: {}, + + getBufferKey: (connectionId: string, buffer: string) => `${connectionId}:${buffer}`, + + setActiveBuffer: (connectionId: string, buffer: string) => { + set({ activeConnectionId: connectionId, activeBuffer: buffer }); + // Clear unread when switching to buffer + get().clearUnread(connectionId, buffer); + }, + + addMessage: (message: IrcMessage) => { + const key = `${message.connectionId}:${message.buffer}`; + set((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), message], + }, + })); + + // Increment unread if not active buffer + const { activeConnectionId, activeBuffer } = get(); + if (message.connectionId !== activeConnectionId || message.buffer !== activeBuffer) { + if (message.type === 'privmsg' || message.type === 'action' || message.type === 'notice') { + get().incrementUnread(message.connectionId, message.buffer); + } + } + }, + + setConnections: (connections: IrcConnection[]) => { + set({ connections }); + }, + + setMessages: (connectionId: string, buffer: string, messages: IrcMessage[]) => { + const key = `${connectionId}:${buffer}`; + set((state) => ({ + messages: { ...state.messages, [key]: messages }, + })); + }, + + addConnection: (connection: IrcConnection) => { + set((state) => ({ + connections: [...state.connections, connection], + })); + }, + + removeConnection: (connectionId: string) => { + set((state) => ({ + connections: state.connections.filter((c) => c.id !== connectionId), + activeConnectionId: state.activeConnectionId === connectionId ? null : state.activeConnectionId, + activeBuffer: state.activeConnectionId === connectionId ? null : state.activeBuffer, + })); + }, + + updateConnection: (connectionId: string, updates: Partial) => { + set((state) => ({ + connections: state.connections.map((c) => + c.id === connectionId ? { ...c, ...updates } : c + ), + })); + }, + + setChannelUsers: (connectionId: string, channel: string, users: ChannelUser[]) => { + const key = `${connectionId}:${channel}`; + set((state) => ({ + users: { ...state.users, [key]: users }, + })); + }, + + addChannelUser: (connectionId: string, channel: string, user: ChannelUser) => { + const key = `${connectionId}:${channel}`; + set((state) => ({ + users: { + ...state.users, + [key]: [...(state.users[key] || []), user], + }, + })); + }, + + removeChannelUser: (connectionId: string, channel: string, nick: string) => { + const key = `${connectionId}:${channel}`; + set((state) => ({ + users: { + ...state.users, + [key]: (state.users[key] || []).filter((u) => u.nick !== nick), + }, + })); + }, + + updateChannelUser: (connectionId: string, channel: string, oldNick: string, newNick: string) => { + const key = `${connectionId}:${channel}`; + set((state) => ({ + users: { + ...state.users, + [key]: (state.users[key] || []).map((u) => + u.nick === oldNick ? { ...u, nick: newNick } : u + ), + }, + })); + }, + + setTopic: (connectionId: string, channel: string, topic: string) => { + set((state) => ({ + connections: state.connections.map((c) => + c.id === connectionId + ? { + ...c, + channels: c.channels.map((ch) => + ch.name === channel ? { ...ch, topic } : ch + ), + } + : c + ), + })); + }, + + incrementUnread: (connectionId: string, buffer: string, mentioned = false) => { + set((state) => ({ + connections: state.connections.map((c) => + c.id === connectionId + ? { + ...c, + channels: c.channels.map((ch) => + ch.name === buffer + ? { ...ch, unread: ch.unread + 1, mentioned: mentioned || ch.mentioned } + : ch + ), + } + : c + ), + })); + }, + + clearUnread: (connectionId: string, buffer: string) => { + set((state) => ({ + connections: state.connections.map((c) => + c.id === connectionId + ? { + ...c, + channels: c.channels.map((ch) => + ch.name === buffer ? { ...ch, unread: 0, mentioned: false } : ch + ), + } + : c + ), + })); + }, + + addChannel: (connectionId: string, channel: IrcChannel) => { + set((state) => ({ + connections: state.connections.map((c) => + c.id === connectionId + ? { ...c, channels: [...c.channels, channel] } + : c + ), + })); + }, + + removeChannel: (connectionId: string, channelName: string) => { + set((state) => ({ + connections: state.connections.map((c) => + c.id === connectionId + ? { ...c, channels: c.channels.filter((ch) => ch.name !== channelName) } + : c + ), + })); + }, +})); diff --git a/client/src/stores/websocket.ts b/client/src/stores/websocket.ts new file mode 100644 index 0000000..279e58d --- /dev/null +++ b/client/src/stores/websocket.ts @@ -0,0 +1,188 @@ +import { create } from 'zustand'; +import { useIrcStore, IrcMessage, IrcConnection, ChannelUser, IrcChannel } from './irc'; + +interface WebSocketState { + connected: boolean; + ws: WebSocket | null; + reconnectAttempts: number; + maxReconnectAttempts: number; + reconnectTimer: ReturnType | null; + connect: (token: string) => void; + disconnect: () => void; + send: (type: string, payload: any) => void; +} + +export const useWebSocketStore = create((set, get) => ({ + connected: false, + ws: null, + reconnectAttempts: 0, + maxReconnectAttempts: 10, + reconnectTimer: null, + + connect: (token: string) => { + const { ws: existingWs, reconnectTimer } = get(); + + if (reconnectTimer) { + clearTimeout(reconnectTimer); + set({ reconnectTimer: null }); + } + + if (existingWs) { + existingWs.close(); + } + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`; + const ws = new WebSocket(wsUrl); + + ws.onopen = () => { + set({ connected: true, reconnectAttempts: 0 }); + }; + + ws.onclose = (event) => { + set({ connected: false, ws: null }); + + // Auto-reconnect unless intentionally closed + if (event.code !== 1000) { + const { reconnectAttempts, maxReconnectAttempts } = get(); + if (reconnectAttempts < maxReconnectAttempts) { + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); + const timer = setTimeout(() => { + set({ reconnectAttempts: reconnectAttempts + 1 }); + get().connect(token); + }, delay); + set({ reconnectTimer: timer }); + } + } + }; + + ws.onerror = () => { + // Error handling is done in onclose + }; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + handleMessage(data); + } catch (err) { + console.error('Failed to parse WebSocket message:', err); + } + }; + + set({ ws }); + }, + + disconnect: () => { + const { ws, reconnectTimer } = get(); + if (reconnectTimer) { + clearTimeout(reconnectTimer); + } + if (ws) { + ws.close(1000, 'Client disconnect'); + } + set({ ws: null, connected: false, reconnectTimer: null, reconnectAttempts: 0 }); + }, + + send: (type: string, payload: any) => { + const { ws, connected } = get(); + if (ws && connected) { + ws.send(JSON.stringify({ type, ...payload })); + } + }, +})); + +function handleMessage(data: any) { + const irc = useIrcStore.getState(); + + switch (data.type) { + case 'connections': + irc.setConnections(data.connections as IrcConnection[]); + break; + + case 'connection_added': + irc.addConnection(data.connection as IrcConnection); + break; + + case 'connection_removed': + irc.removeConnection(data.connectionId); + break; + + case 'connection_update': + irc.updateConnection(data.connectionId, data.updates); + break; + + case 'message': { + const msg: IrcMessage = { + id: data.id || `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + connectionId: data.connectionId, + buffer: data.buffer, + type: data.messageType || 'privmsg', + nick: data.nick, + text: data.text, + timestamp: data.timestamp || Date.now(), + }; + irc.addMessage(msg); + break; + } + + case 'messages': { + const messages: IrcMessage[] = data.messages.map((m: any) => ({ + id: m.id || `${m.timestamp}-${Math.random().toString(36).substr(2, 9)}`, + connectionId: data.connectionId, + buffer: data.buffer, + type: m.messageType || m.type || 'privmsg', + nick: m.nick, + text: m.text, + timestamp: m.timestamp || Date.now(), + })); + irc.setMessages(data.connectionId, data.buffer, messages); + break; + } + + case 'channel_users': + irc.setChannelUsers(data.connectionId, data.channel, data.users as ChannelUser[]); + break; + + case 'user_join': { + const user: ChannelUser = { nick: data.nick, modes: '', prefix: '' }; + irc.addChannelUser(data.connectionId, data.channel, user); + break; + } + + case 'user_part': + case 'user_quit': + irc.removeChannelUser(data.connectionId, data.channel, data.nick); + break; + + case 'user_nick': + irc.updateChannelUser(data.connectionId, data.channel, data.oldNick, data.newNick); + break; + + case 'topic': + irc.setTopic(data.connectionId, data.channel, data.topic); + break; + + case 'channel_joined': { + const channel: IrcChannel = { + name: data.channel, + topic: data.topic || '', + unread: 0, + mentioned: false, + joined: true, + }; + irc.addChannel(data.connectionId, channel); + break; + } + + case 'channel_parted': + irc.removeChannel(data.connectionId, data.channel); + break; + + case 'nick_changed': + irc.updateConnection(data.connectionId, { nick: data.newNick }); + break; + + default: + console.log('Unhandled WebSocket message type:', data.type, data); + } +} diff --git a/client/src/styles/global.css b/client/src/styles/global.css new file mode 100644 index 0000000..389d9eb --- /dev/null +++ b/client/src/styles/global.css @@ -0,0 +1,43 @@ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body, #root { + height: 100%; + width: 100%; + overflow: hidden; +} + +body { + font-family: Consolas, 'Courier New', monospace; + font-size: 14px; + line-height: 1.4; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +input, button, textarea, select { + font: inherit; +} + +a { + color: inherit; + text-decoration: none; +} + +ul, ol { + list-style: none; +} + +button { + cursor: pointer; + border: none; + background: none; +} + +:focus-visible { + outline: 1px dotted #fff; + outline-offset: 1px; +} diff --git a/client/src/styles/theme.css b/client/src/styles/theme.css new file mode 100644 index 0000000..24d6fef --- /dev/null +++ b/client/src/styles/theme.css @@ -0,0 +1,136 @@ +:root { + /* mIRC 16-color palette */ + --irc-white: #FFFFFF; + --irc-black: #000000; + --irc-navy: #00007F; + --irc-green: #009300; + --irc-red: #FF0000; + --irc-maroon: #7F0000; + --irc-purple: #9C009C; + --irc-orange: #FC7F00; + --irc-yellow: #FFFF00; + --irc-lime: #00FC00; + --irc-teal: #009393; + --irc-cyan: #00FFFF; + --irc-blue: #0000FC; + --irc-pink: #FF00FF; + --irc-gray: #7F7F7F; + --irc-silver: #D2D2D2; + + /* App theme colors */ + --bg-primary: #0C0C0C; + --bg-secondary: #1A1A1A; + --bg-panel: #2D2D2D; + --bg-treebar: #C0C0C0; + --bg-input: #1A1A1A; + --bg-toolbar: #3C3C3C; + --bg-topic: #1E1E2E; + + --text-primary: #FFFFFF; + --text-secondary: #B0B0B0; + --text-muted: #707070; + --text-treebar: #000000; + + --border-light: #505050; + --border-dark: #1A1A1A; + --border-outset-light: #DFDFDF; + --border-outset-dark: #808080; + --border-inset-light: #808080; + --border-inset-dark: #DFDFDF; + + --accent: #4A9EFF; + --accent-hover: #6BB3FF; + --highlight: #FFD700; + --unread: #FF4444; + --action-color: #CC44CC; + --join-color: #00AA00; + --part-color: #AA0000; + --notice-color: #FF8800; + --nick-colors: #FF5555, #55FF55, #5555FF, #FFFF55, #FF55FF, #55FFFF, #FF8800, #88FF00; + + --font-mono: Consolas, 'Courier New', monospace; + --font-size: 14px; + --font-size-small: 12px; + + --panel-border-width: 2px; + --toolbar-height: 32px; + --topic-height: 28px; + --input-height: 32px; + --treebar-width: 200px; + --userlist-width: 160px; +} + +/* Win32-style inset border */ +.border-inset { + border: var(--panel-border-width) solid; + border-color: var(--border-inset-light) var(--border-inset-dark) var(--border-inset-dark) var(--border-inset-light); +} + +/* Win32-style outset border */ +.border-outset { + border: var(--panel-border-width) solid; + border-color: var(--border-outset-light) var(--border-outset-dark) var(--border-outset-dark) var(--border-outset-light); +} + +/* Classic scrollbar styling */ +::-webkit-scrollbar { + width: 16px; + height: 16px; +} + +::-webkit-scrollbar-track { + background: #2A2A2A; + border: 1px solid #1A1A1A; +} + +::-webkit-scrollbar-thumb { + background: #555555; + border: 1px solid; + border-color: #707070 #3A3A3A #3A3A3A #707070; +} + +::-webkit-scrollbar-thumb:hover { + background: #666666; +} + +::-webkit-scrollbar-button { + background: #3C3C3C; + border: 1px solid; + border-color: #606060 #2A2A2A #2A2A2A #606060; + display: block; + height: 16px; + width: 16px; +} + +/* Context menu */ +.context-menu { + position: fixed; + background: var(--bg-panel); + border: 1px solid var(--border-light); + box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); + z-index: 1000; + min-width: 150px; + padding: 2px 0; +} + +.context-menu-item { + display: block; + width: 100%; + padding: 4px 20px; + text-align: left; + color: var(--text-primary); + font-size: var(--font-size-small); + cursor: pointer; + white-space: nowrap; +} + +.context-menu-item:hover { + background: var(--accent); + color: white; +} + +.context-menu-separator { + height: 1px; + background: var(--border-light); + margin: 2px 0; +} diff --git a/client/src/utils/irc-colors.ts b/client/src/utils/irc-colors.ts new file mode 100644 index 0000000..4442299 --- /dev/null +++ b/client/src/utils/irc-colors.ts @@ -0,0 +1,220 @@ +import React from 'react'; + +// mIRC 16 standard colors +const IRC_COLORS_16: string[] = [ + '#FFFFFF', // 0 - white + '#000000', // 1 - black + '#00007F', // 2 - navy + '#009300', // 3 - green + '#FF0000', // 4 - red + '#7F0000', // 5 - maroon + '#9C009C', // 6 - purple + '#FC7F00', // 7 - orange + '#FFFF00', // 8 - yellow + '#00FC00', // 9 - lime + '#009393', // 10 - teal + '#00FFFF', // 11 - cyan + '#0000FC', // 12 - blue + '#FF00FF', // 13 - pink + '#7F7F7F', // 14 - gray + '#D2D2D2', // 15 - silver/light gray +]; + +// Extended 99-color palette (mIRC extended colors 16-98) +const IRC_COLORS_EXTENDED: string[] = [ + '#470000', '#472100', '#474700', '#324700', '#004700', '#00472C', '#004747', '#002747', '#000047', '#2E0047', '#470047', '#47002A', + '#740000', '#743A00', '#747400', '#517400', '#007400', '#007449', '#007474', '#004074', '#000074', '#4B0074', '#740074', '#740045', + '#B50000', '#B56300', '#B5B500', '#7DB500', '#00B500', '#00B571', '#00B5B5', '#0063B5', '#0000B5', '#7500B5', '#B500B5', '#B5006B', + '#FF0000', '#FF8C00', '#FFFF00', '#B2FF00', '#00FF00', '#00FFA0', '#00FFFF', '#008CFF', '#0000FF', '#A500FF', '#FF00FF', '#FF0098', + '#FF5959', '#FFB459', '#FFFF71', '#CFFF60', '#6FFF6F', '#65FFC9', '#6DFFFF', '#59B4FF', '#5959FF', '#C459FF', '#FF66FF', '#FF59BC', + '#FF9C9C', '#FFD39C', '#FFFF9C', '#E2FF9C', '#9CFF9C', '#9CFFDB', '#9CFFFF', '#9CD3FF', '#9C9CFF', '#DC9CFF', '#FF9CFF', '#FF94D3', + '#000000', '#131313', '#282828', '#363636', '#4D4D4D', '#656565', '#818181', '#9F9F9F', '#BCBCBC', '#E2E2E2', '#FFFFFF', +]; + +function getIrcColor(code: number): string | undefined { + if (code >= 0 && code <= 15) return IRC_COLORS_16[code]; + if (code >= 16 && code <= 98) return IRC_COLORS_EXTENDED[code - 16]; + return undefined; +} + +interface TextState { + bold: boolean; + italic: boolean; + underline: boolean; + strikethrough: boolean; + reverse: boolean; + fg: string | undefined; + bg: string | undefined; +} + +function defaultState(): TextState { + return { + bold: false, + italic: false, + underline: false, + strikethrough: false, + reverse: false, + fg: undefined, + bg: undefined, + }; +} + +interface StyledSegment { + text: string; + style: TextState; +} + +export function parseIrcFormatting(input: string): StyledSegment[] { + const segments: StyledSegment[] = []; + let state = defaultState(); + let currentText = ''; + let i = 0; + + while (i < input.length) { + const charCode = input.charCodeAt(i); + + switch (charCode) { + case 0x02: // Bold + if (currentText) { + segments.push({ text: currentText, style: { ...state } }); + currentText = ''; + } + state.bold = !state.bold; + i++; + break; + + case 0x1D: // Italic + if (currentText) { + segments.push({ text: currentText, style: { ...state } }); + currentText = ''; + } + state.italic = !state.italic; + i++; + break; + + case 0x1F: // Underline + if (currentText) { + segments.push({ text: currentText, style: { ...state } }); + currentText = ''; + } + state.underline = !state.underline; + i++; + break; + + case 0x1E: // Strikethrough + if (currentText) { + segments.push({ text: currentText, style: { ...state } }); + currentText = ''; + } + state.strikethrough = !state.strikethrough; + i++; + break; + + case 0x16: // Reverse + if (currentText) { + segments.push({ text: currentText, style: { ...state } }); + currentText = ''; + } + state.reverse = !state.reverse; + i++; + break; + + case 0x0F: // Reset + if (currentText) { + segments.push({ text: currentText, style: { ...state } }); + currentText = ''; + } + state = defaultState(); + i++; + break; + + case 0x03: // Color + if (currentText) { + segments.push({ text: currentText, style: { ...state } }); + currentText = ''; + } + i++; + // Parse foreground color (1-2 digits) + const fgMatch = input.slice(i).match(/^(\d{1,2})/); + if (fgMatch) { + const fgCode = parseInt(fgMatch[1], 10); + state.fg = getIrcColor(fgCode); + i += fgMatch[1].length; + + // Check for background color + if (input[i] === ',') { + const bgMatch = input.slice(i + 1).match(/^(\d{1,2})/); + if (bgMatch) { + const bgCode = parseInt(bgMatch[1], 10); + state.bg = getIrcColor(bgCode); + i += 1 + bgMatch[1].length; + } + } + } else { + // No digits after \x03 = reset colors + state.fg = undefined; + state.bg = undefined; + } + break; + + default: + currentText += input[i]; + i++; + break; + } + } + + if (currentText) { + segments.push({ text: currentText, style: { ...state } }); + } + + return segments; +} + +export function renderIrcText(input: string): React.ReactNode[] { + const segments = parseIrcFormatting(input); + return segments.map((segment, idx) => { + const style: React.CSSProperties = {}; + + if (segment.style.reverse) { + style.backgroundColor = segment.style.fg || 'var(--text-primary)'; + style.color = segment.style.bg || 'var(--bg-primary)'; + } else { + if (segment.style.fg) style.color = segment.style.fg; + if (segment.style.bg) style.backgroundColor = segment.style.bg; + } + if (segment.style.bold) style.fontWeight = 'bold'; + if (segment.style.italic) style.fontStyle = 'italic'; + + const decorations: string[] = []; + if (segment.style.underline) decorations.push('underline'); + if (segment.style.strikethrough) decorations.push('line-through'); + if (decorations.length) style.textDecoration = decorations.join(' '); + + // Linkify URLs in the text + const urlRegex = /(https?:\/\/[^\s<>"{}|\\^`\[\]]+)/g; + const parts = segment.text.split(urlRegex); + + const content = parts.map((part, partIdx) => { + if (urlRegex.test(part) || part.match(/^https?:\/\//)) { + return React.createElement('a', { + key: `${idx}-${partIdx}`, + href: part, + target: '_blank', + rel: 'noopener noreferrer', + style: { ...style, color: style.color || 'var(--accent)', textDecoration: 'underline' }, + }, part); + } + return part; + }); + + return React.createElement('span', { key: idx, style }, ...content); + }); +} + +// Strip all IRC formatting codes from text (for plain text display) +export function stripIrcFormatting(input: string): string { + return input + .replace(/\x03(\d{1,2}(,\d{1,2})?)?/g, '') + .replace(/[\x02\x1D\x1F\x1E\x16\x0F]/g, ''); +} diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..1ce22c2 --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/client/tsconfig.node.json b/client/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/client/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/client/vite.config.ts b/client/vite.config.ts new file mode 100644 index 0000000..bd45474 --- /dev/null +++ b/client/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true, + }, + '/ws': { + target: 'ws://localhost:3000', + ws: true, + }, + }, + }, +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..782f9c3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,59 @@ +services: + app: + build: + context: . + target: server-build + command: npx tsx watch src/index.ts + working_dir: /app/server + ports: + - "3000:3000" + - "5173:5173" + volumes: + - ./server/src:/app/server/src + - ./client/src:/app/client/src + - ./client/index.html:/app/client/index.html + environment: + - NODE_ENV=development + - PORT=3000 + - DATABASE_URL=postgresql://mirccloud:mirccloud@postgres:5432/mirccloud + - REDIS_URL=redis://redis:6379 + - JWT_SECRET=dev-secret-change-me + - CORS_ORIGIN=http://localhost:5173 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + postgres: + image: postgres:16 + ports: + - "5432:5432" + environment: + - POSTGRES_USER=mirccloud + - POSTGRES_PASSWORD=mirccloud + - POSTGRES_DB=mirccloud + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mirccloud"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + command: redis-server --appendonly yes + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + postgres_data: + redis_data: diff --git a/kubernetes/configmap.yaml b/kubernetes/configmap.yaml new file mode 100644 index 0000000..b7e67ba --- /dev/null +++ b/kubernetes/configmap.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: mirccloud-config + namespace: mirccloud + labels: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/component: app + app.kubernetes.io/part-of: mirccloud +data: + PORT: "3000" + DATABASE_URL: "postgresql://mirccloud:$(POSTGRES_PASSWORD)@mirccloud-postgres:5432/mirccloud" + REDIS_URL: "redis://mirccloud-redis:6379" + CORS_ORIGIN: "https://mirccloud.com" + NODE_ENV: "production" diff --git a/kubernetes/deployment.yaml b/kubernetes/deployment.yaml new file mode 100644 index 0000000..4b8a980 --- /dev/null +++ b/kubernetes/deployment.yaml @@ -0,0 +1,82 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mirccloud-app + namespace: mirccloud + labels: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/component: app + app.kubernetes.io/part-of: mirccloud +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/component: app + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + metadata: + labels: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/component: app + app.kubernetes.io/part-of: mirccloud + spec: + serviceAccountName: default + securityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + fsGroup: 1001 + containers: + - name: app + image: git.lab.fairings.org/jpreston/mirccloud/app:latest + imagePullPolicy: Always + ports: + - name: http + containerPort: 3000 + protocol: TCP + envFrom: + - secretRef: + name: mirccloud-secrets + - configMapRef: + name: mirccloud-config + resources: + requests: + cpu: 200m + memory: 256Mi + limits: + cpu: 1000m + memory: 512Mi + livenessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 15 + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + volumeMounts: + - name: uploads + mountPath: /data/uploads + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: + - ALL + volumes: + - name: uploads + persistentVolumeClaim: + claimName: mirccloud-uploads diff --git a/kubernetes/ingress.yaml b/kubernetes/ingress.yaml new file mode 100644 index 0000000..528caed --- /dev/null +++ b/kubernetes/ingress.yaml @@ -0,0 +1,45 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: mirccloud-ingress + namespace: mirccloud + labels: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/component: ingress + app.kubernetes.io/part-of: mirccloud + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-body-size: "50m" + nginx.ingress.kubernetes.io/websocket-services: mirccloud-app + nginx.ingress.kubernetes.io/proxy-http-version: "1.1" + nginx.ingress.kubernetes.io/upstream-hash-by: "$remote_addr" +spec: + ingressClassName: nginx-public + tls: + - hosts: + - mirccloud.com + - www.mirccloud.com + secretName: mirccloud-com-tls + rules: + - host: mirccloud.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: mirccloud-app + port: + number: 80 + - host: www.mirccloud.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: mirccloud-app + port: + number: 80 diff --git a/kubernetes/namespace.yaml b/kubernetes/namespace.yaml new file mode 100644 index 0000000..8fd89a7 --- /dev/null +++ b/kubernetes/namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: mirccloud + labels: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/part-of: mirccloud diff --git a/kubernetes/networkpolicy.yaml b/kubernetes/networkpolicy.yaml new file mode 100644 index 0000000..8947d1f --- /dev/null +++ b/kubernetes/networkpolicy.yaml @@ -0,0 +1,71 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: mirccloud-app-ingress + namespace: mirccloud + labels: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/part-of: mirccloud +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/component: app + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingress-nginx + ports: + - protocol: TCP + port: 3000 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: mirccloud-postgres-ingress + namespace: mirccloud + labels: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/part-of: mirccloud +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/component: postgres + policyTypes: + - Ingress + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: mirccloud + ports: + - protocol: TCP + port: 5432 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: mirccloud-redis-ingress + namespace: mirccloud + labels: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/part-of: mirccloud +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/component: redis + policyTypes: + - Ingress + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: mirccloud + ports: + - protocol: TCP + port: 6379 diff --git a/kubernetes/postgres.yaml b/kubernetes/postgres.yaml new file mode 100644 index 0000000..8143ac8 --- /dev/null +++ b/kubernetes/postgres.yaml @@ -0,0 +1,113 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: mirccloud-postgres + namespace: mirccloud + labels: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/component: postgres + app.kubernetes.io/part-of: mirccloud +spec: + serviceName: mirccloud-postgres + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/component: postgres + template: + metadata: + labels: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/component: postgres + app.kubernetes.io/part-of: mirccloud + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + fsGroup: 1001 + containers: + - name: postgres + image: bitnami/postgresql:16 + ports: + - name: tcp-postgres + containerPort: 5432 + protocol: TCP + env: + - name: POSTGRESQL_USERNAME + value: mirccloud + - name: POSTGRESQL_DATABASE + value: mirccloud + - name: POSTGRESQL_PASSWORD + valueFrom: + secretKeyRef: + name: mirccloud-secrets + key: POSTGRES_PASSWORD + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: 1000m + memory: 1Gi + volumeMounts: + - name: postgres-data + mountPath: /bitnami/postgresql + livenessProbe: + exec: + command: + - /bin/sh + - -c + - pg_isready -U mirccloud -d mirccloud + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + readinessProbe: + exec: + command: + - /bin/sh + - -c + - pg_isready -U mirccloud -d mirccloud + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + volumeClaimTemplates: + - metadata: + name: postgres-data + labels: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/component: postgres + spec: + accessModes: + - ReadWriteOnce + storageClassName: nfs-synology + resources: + requests: + storage: 20Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: mirccloud-postgres + namespace: mirccloud + labels: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/component: postgres + app.kubernetes.io/part-of: mirccloud +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/component: postgres + ports: + - name: tcp-postgres + port: 5432 + targetPort: 5432 + protocol: TCP diff --git a/kubernetes/pvc.yaml b/kubernetes/pvc.yaml new file mode 100644 index 0000000..32f3a62 --- /dev/null +++ b/kubernetes/pvc.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: mirccloud-uploads + namespace: mirccloud + labels: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/component: app + app.kubernetes.io/part-of: mirccloud +spec: + accessModes: + - ReadWriteMany + storageClassName: nfs-synology + resources: + requests: + storage: 5Gi diff --git a/kubernetes/redis.yaml b/kubernetes/redis.yaml new file mode 100644 index 0000000..7feeb63 --- /dev/null +++ b/kubernetes/redis.yaml @@ -0,0 +1,117 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mirccloud-redis + namespace: mirccloud + labels: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/component: redis + app.kubernetes.io/part-of: mirccloud +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/component: redis + template: + metadata: + labels: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/component: redis + app.kubernetes.io/part-of: mirccloud + spec: + securityContext: + runAsNonRoot: true + runAsUser: 999 + runAsGroup: 999 + fsGroup: 999 + containers: + - name: redis + image: redis:7-alpine + command: + - redis-server + - --appendonly + - "yes" + - --maxmemory + - 100mb + - --maxmemory-policy + - allkeys-lru + ports: + - name: tcp-redis + containerPort: 6379 + protocol: TCP + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi + volumeMounts: + - name: redis-data + mountPath: /data + livenessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: + - ALL + volumes: + - name: redis-data + persistentVolumeClaim: + claimName: mirccloud-redis +--- +apiVersion: v1 +kind: Service +metadata: + name: mirccloud-redis + namespace: mirccloud + labels: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/component: redis + app.kubernetes.io/part-of: mirccloud +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/component: redis + ports: + - name: tcp-redis + port: 6379 + targetPort: 6379 + protocol: TCP +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: mirccloud-redis + namespace: mirccloud + labels: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/component: redis + app.kubernetes.io/part-of: mirccloud +spec: + accessModes: + - ReadWriteOnce + storageClassName: nfs-synology + resources: + requests: + storage: 1Gi diff --git a/kubernetes/secrets.yaml.example b/kubernetes/secrets.yaml.example new file mode 100644 index 0000000..f4866c7 --- /dev/null +++ b/kubernetes/secrets.yaml.example @@ -0,0 +1,17 @@ +# DO NOT commit real secrets to git! +# Copy this file to secrets.yaml and fill in real base64-encoded values. +# +# To encode a value: echo -n 'your-value' | base64 +# +apiVersion: v1 +kind: Secret +metadata: + name: mirccloud-secrets + namespace: mirccloud + labels: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/part-of: mirccloud +type: Opaque +data: + JWT_SECRET: Y2hhbmdlLW1lLXRvLWEtcmVhbC1zZWNyZXQ= + POSTGRES_PASSWORD: Y2hhbmdlLW1lLXRvLWEtcmVhbC1wYXNzd29yZA== diff --git a/kubernetes/service.yaml b/kubernetes/service.yaml new file mode 100644 index 0000000..adab93d --- /dev/null +++ b/kubernetes/service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: mirccloud-app + namespace: mirccloud + labels: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/component: app + app.kubernetes.io/part-of: mirccloud +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: mirccloud + app.kubernetes.io/component: app + ports: + - name: http + port: 80 + targetPort: 3000 + protocol: TCP diff --git a/package.json b/package.json new file mode 100644 index 0000000..37ddcbc --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "mirccloud", + "version": "1.0.0", + "private": true, + "description": "Self-hosted always-connected web IRC client with mIRC aesthetics", + "workspaces": [ + "server", + "client" + ], + "scripts": { + "dev": "npm run dev --workspace=server", + "dev:client": "npm run dev --workspace=client", + "build": "npm run build --workspace=client && npm run build --workspace=server", + "start": "npm run start --workspace=server", + "db:generate": "npm run db:generate --workspace=server", + "db:migrate": "npm run db:migrate --workspace=server" + } +} diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..aebb985 --- /dev/null +++ b/server/.env.example @@ -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 diff --git a/server/drizzle.config.ts b/server/drizzle.config.ts new file mode 100644 index 0000000..789f7a2 --- /dev/null +++ b/server/drizzle.config.ts @@ -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!, + }, +}); diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..250a7e0 --- /dev/null +++ b/server/package.json @@ -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" + } +} diff --git a/server/src/auth/index.ts b/server/src/auth/index.ts new file mode 100644 index 0000000..d8c88ab --- /dev/null +++ b/server/src/auth/index.ts @@ -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 { + return bcrypt.hash(password, BCRYPT_ROUNDS); +} + +export async function verifyPassword(password: string, hash: string): Promise { + 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; + } +} diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 0000000..a305bbf --- /dev/null +++ b/server/src/config.ts @@ -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; diff --git a/server/src/db/index.ts b/server/src/db/index.ts new file mode 100644 index 0000000..4158bd1 --- /dev/null +++ b/server/src/db/index.ts @@ -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 { + await client.end(); +} diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts new file mode 100644 index 0000000..fc2b9d6 --- /dev/null +++ b/server/src/db/schema.ts @@ -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], + }), +})); diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..4c5d69c --- /dev/null +++ b/server/src/index.ts @@ -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 { + 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 { + 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(); diff --git a/server/src/irc/connection-manager.ts b/server/src/irc/connection-manager.ts new file mode 100644 index 0000000..4866681 --- /dev/null +++ b/server/src/irc/connection-manager.ts @@ -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 | null; + maxAttempts: number; + baseDelay: number; +} + +const MAX_RECONNECT_ATTEMPTS = 10; +const BASE_RECONNECT_DELAY = 1000; + +class ConnectionManager { + private static instance: ConnectionManager; + private userConnections: Map> = new Map(); + private reconnectState: Map = new Map(); + private shutdownRequested = false; + + private constructor() {} + + static getInstance(): ConnectionManager { + if (!ConnectionManager.instance) { + ConnectionManager.instance = new ConnectionManager(); + } + return ConnectionManager.instance; + } + + async loadPersistentConnections(): Promise { + 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 { + const existing = this.getConnection(userId, connConfig.id); + if (existing) { + return existing; + } + + const client = new IrcFramework.Client(); + + const connectOptions: Record = { + 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 { + 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 { + 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 { + 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 { + this.shutdownRequested = true; + + for (const [, state] of this.reconnectState) { + if (state.timeout) clearTimeout(state.timeout); + } + this.reconnectState.clear(); + + const disconnectPromises: Promise[] = []; + + 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(); diff --git a/server/src/irc/event-handlers.ts b/server/src/irc/event-handlers.ts new file mode 100644 index 0000000..0dacc77 --- /dev/null +++ b/server/src/irc/event-handlers.ts @@ -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; + 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 { + 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 { + return new Promise((resolve) => { + publisher.quit().then(() => resolve()); + }); +} diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts new file mode 100644 index 0000000..f1fa4a4 --- /dev/null +++ b/server/src/routes/auth.ts @@ -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; diff --git a/server/src/routes/connections.ts b/server/src/routes/connections.ts new file mode 100644 index 0000000..efdb532 --- /dev/null +++ b/server/src/routes/connections.ts @@ -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; diff --git a/server/src/routes/messages.ts b/server/src/routes/messages.ts new file mode 100644 index 0000000..6390dba --- /dev/null +++ b/server/src/routes/messages.ts @@ -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`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; diff --git a/server/src/websocket/index.ts b/server/src/websocket/index.ts new file mode 100644 index 0000000..4b388aa --- /dev/null +++ b/server/src/websocket/index.ts @@ -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> = 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 { + 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 +): Promise { + 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; + + 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(); +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..e3454b4 --- /dev/null +++ b/server/tsconfig.json @@ -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"] +}