From da6c5e1ed702956f2979cbe5bcaa656db983fe1e Mon Sep 17 00:00:00 2001 From: nirholas Date: Tue, 31 Mar 2026 12:36:42 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 58 +++++++ package.json | 14 +- src/server/web/auth/apikey-auth.ts | 230 +++++++++++++++++++++++++++ src/server/web/pty-server.ts | 100 +++++++++--- src/server/web/public/index.html | 30 ++-- src/server/web/session-manager.ts | 216 ++++++++++++++----------- src/server/web/styles.css | 243 +++++++++++++++++++++++++++++ src/server/web/tsconfig.json | 8 + src/server/web/user-store.ts | 64 ++++++++ web/components/tools/FileIcon.tsx | 106 +++++++++++++ 10 files changed, 929 insertions(+), 140 deletions(-) create mode 100644 src/server/web/auth/apikey-auth.ts create mode 100644 src/server/web/styles.css create mode 100644 src/server/web/tsconfig.json create mode 100644 src/server/web/user-store.ts create mode 100644 web/components/tools/FileIcon.tsx diff --git a/package-lock.json b/package-lock.json index ee8f416..b52c01d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,12 @@ "@opentelemetry/sdk-logs": "^0.57.0", "@opentelemetry/sdk-metrics": "^1.30.0", "@opentelemetry/sdk-trace-base": "^1.30.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-search": "^0.15.0", + "@xterm/addon-unicode11": "^0.8.0", + "@xterm/addon-web-links": "^0.11.0", + "@xterm/addon-webgl": "^0.18.0", + "@xterm/xterm": "^5.5.0", "auto-bind": "^5.0.1", "axios": "^1.7.0", "chalk": "^5.4.0", @@ -1014,6 +1020,58 @@ "@types/node": "*" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/addon-search": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz", + "integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/addon-unicode11": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.8.0.tgz", + "integrity": "sha512-LxinXu8SC4OmVa6FhgwsVCBZbr8WoSGzBl2+vqe8WcQ6hb1r6Gj9P99qTNdPiFPh4Ceiu2pC8xukZ6+2nnh49Q==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz", + "integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/addon-webgl": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz", + "integrity": "sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT", + "peer": true + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", diff --git a/package.json b/package.json index bc02317..048f770 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,12 @@ "@opentelemetry/sdk-logs": "^0.57.0", "@opentelemetry/sdk-metrics": "^1.30.0", "@opentelemetry/sdk-trace-base": "^1.30.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-search": "^0.15.0", + "@xterm/addon-unicode11": "^0.8.0", + "@xterm/addon-web-links": "^0.11.0", + "@xterm/addon-webgl": "^0.18.0", + "@xterm/xterm": "^5.5.0", "auto-bind": "^5.0.1", "axios": "^1.7.0", "chalk": "^5.4.0", @@ -65,13 +71,7 @@ "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yaml": "^2.6.0", - "zod": "^3.24.0", - "@xterm/xterm": "^5.5.0", - "@xterm/addon-fit": "^0.10.0", - "@xterm/addon-web-links": "^0.11.0", - "@xterm/addon-search": "^0.15.0", - "@xterm/addon-unicode11": "^0.8.0", - "@xterm/addon-webgl": "^0.18.0" + "zod": "^3.24.0" }, "devDependencies": { "@biomejs/biome": "^1.9.0", diff --git a/src/server/web/auth/apikey-auth.ts b/src/server/web/auth/apikey-auth.ts new file mode 100644 index 0000000..bb0b5bd --- /dev/null +++ b/src/server/web/auth/apikey-auth.ts @@ -0,0 +1,230 @@ +import { createHash } from "crypto"; +import { readFileSync } from "fs"; +import { join } from "path"; +import type { IncomingMessage } from "http"; +import type { Application, Request, Response, NextFunction } from "express"; +import type { AuthAdapter, AuthUser, AuthenticatedRequest } from "./adapter.js"; +import { SessionStore } from "./adapter.js"; + +/** + * API-key authentication adapter. + * + * Each user provides their own Anthropic API key on the login page. + * The key is stored encrypted in the server-side session and is injected + * as `ANTHROPIC_API_KEY` into every PTY spawned for that user. + * The plaintext key is never sent to the browser after the login form POST. + * + * User identity is derived from the key itself (SHA-256 prefix), so two + * sessions using the same key share the same userId and home directory. + * + * Optional env vars: + * ADMIN_USERS — comma-separated user IDs (SHA-256 prefixes) or API-key + * prefixes that receive the admin role + */ +export class ApiKeyAdapter implements AuthAdapter { + private readonly store: SessionStore; + private readonly adminUsers: ReadonlySet; + + constructor(store: SessionStore) { + this.store = store; + this.adminUsers = new Set( + (process.env.ADMIN_USERS ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); + } + + authenticate(req: IncomingMessage): AuthUser | null { + const session = this.store.getFromRequest(req); + if (!session || !session.encryptedApiKey) return null; + + const apiKey = this.store.decrypt(session.encryptedApiKey); + if (!apiKey) return null; + + return { + id: session.userId, + email: session.email, + name: session.name, + isAdmin: + session.isAdmin || + this.adminUsers.has(session.userId), + apiKey, + }; + } + + setupRoutes(app: Application): void { + const loginHtml = this.loadLoginPage(); + + // GET /auth/login — serve the API key login form + app.get("/auth/login", (_req, res) => { + res.setHeader("Content-Type", "text/html"); + res.send(loginHtml); + }); + + // POST /auth/login — validate key, create encrypted session + app.post( + "/auth/login", + // express.urlencoded is registered in pty-server.ts before setupRoutes + (req: Request, res: Response) => { + const apiKey = (req.body as Record)?.api_key?.trim() ?? ""; + + if (!apiKey.startsWith("sk-ant-")) { + res.setHeader("Content-Type", "text/html"); + res.status(400).send( + loginHtml.replace( + "", + `

Invalid API key format. Keys must start with sk-ant-.

`, + ), + ); + return; + } + + const userId = deriveUserId(apiKey); + const isAdmin = this.adminUsers.has(userId); + const encryptedApiKey = this.store.encrypt(apiKey); + + const sessionId = this.store.create({ + userId, + isAdmin, + encryptedApiKey, + }); + + this.store.setCookie(res as unknown as import("http").ServerResponse, sessionId); + + const next = (req.query as Record)?.next; + res.redirect(next && next.startsWith("/") ? next : "/"); + }, + ); + + // POST /auth/logout — destroy session + app.post("/auth/logout", (req, res) => { + const id = this.store.getIdFromRequest(req as unknown as IncomingMessage); + if (id) this.store.delete(id); + this.store.clearCookie(res as unknown as import("http").ServerResponse); + res.redirect("/auth/login"); + }); + } + + requireAuth(req: Request, res: Response, next: NextFunction): void { + const user = this.authenticate(req as unknown as IncomingMessage); + if (!user) { + const accept = req.headers["accept"] ?? ""; + if (accept.includes("application/json")) { + res.status(401).json({ error: "Unauthorized" }); + } else { + res.redirect(`/auth/login?next=${encodeURIComponent(req.originalUrl)}`); + } + return; + } + (req as AuthenticatedRequest).user = user; + next(); + } + + // ── Internals ───────────────────────────────────────────────────────────── + + private loadLoginPage(): string { + // Serve from the public directory at build time; fall back to inline HTML. + try { + const p = join(import.meta.dirname, "../public/login.html"); + return readFileSync(p, "utf8"); + } catch { + return INLINE_LOGIN_HTML; + } + } +} + +/** + * Derives a stable, opaque user ID from an API key. + * Uses the first 16 hex chars of SHA-256(key) — short enough to be readable, + * long enough to be unique. + */ +function deriveUserId(apiKey: string): string { + return createHash("sha256").update(apiKey).digest("hex").slice(0, 16); +} + +// Fallback inline login page used when public/login.html is not present. +const INLINE_LOGIN_HTML = ` + + + + + Claude Code — Sign In + + + +
+

Claude Code

+

Enter your Anthropic API key to start a session.

+ +
+ + + +
+

+ Your key is stored encrypted on the server and never sent to the browser. + Get a key at console.anthropic.com. +

+
+ +`; diff --git a/src/server/web/pty-server.ts b/src/server/web/pty-server.ts index 592e1cd..ea56b59 100644 --- a/src/server/web/pty-server.ts +++ b/src/server/web/pty-server.ts @@ -11,14 +11,43 @@ const PORT = parseInt(process.env.PORT ?? "3000", 10); const HOST = process.env.HOST ?? "0.0.0.0"; const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS ?? "10", 10); const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS?.split(",") ?? []; -const SHELL = process.env.SHELL ?? "bash"; +const GRACE_PERIOD_MS = parseInt( + process.env.SESSION_GRACE_MS ?? String(5 * 60_000), + 10, +); +const SCROLLBACK_BYTES = parseInt( + process.env.SCROLLBACK_BYTES ?? String(100 * 1024), + 10, +); // Resolve the claude CLI binary const CLAUDE_BIN = process.env.CLAUDE_BIN ?? "claude"; const app = express(); +app.use(express.json()); + const server = createServer(app); +// --- Session Manager --- + +const sessionManager = new SessionManager( + MAX_SESSIONS, + (cols, rows) => + spawn(CLAUDE_BIN, [], { + name: "xterm-256color", + cols, + rows, + cwd: process.env.WORK_DIR ?? process.cwd(), + env: { + ...process.env, + TERM: "xterm-256color", + COLORTERM: "truecolor", + }, + }), + GRACE_PERIOD_MS, + SCROLLBACK_BYTES, +); + // --- HTTP routes --- app.get("/health", (_req, res) => { @@ -29,6 +58,21 @@ app.get("/health", (_req, res) => { }); }); +app.get("/api/sessions", (_req, res) => { + res.json(sessionManager.listSessions()); +}); + +app.delete("/api/sessions/:token", (req, res) => { + const { token } = req.params; + const session = sessionManager.getSession(token); + if (!session) { + res.status(404).json({ error: "Session not found" }); + return; + } + sessionManager.destroySession(token); + res.status(204).end(); +}); + // Serve static frontend const publicDir = path.join(import.meta.dirname, "public"); app.use(express.static(publicDir)); @@ -37,22 +81,6 @@ app.get("/", (_req, res) => { res.sendFile(path.join(publicDir, "index.html")); }); -// --- Session Manager --- - -const sessionManager = new SessionManager(MAX_SESSIONS, (cols, rows) => - spawn(CLAUDE_BIN, [], { - name: "xterm-256color", - cols, - rows, - cwd: process.env.WORK_DIR ?? process.cwd(), - env: { - ...process.env, - TERM: "xterm-256color", - COLORTERM: "truecolor", - }, - }), -); - // --- WebSocket server --- const rateLimiter = new ConnectionRateLimiter(); @@ -100,6 +128,27 @@ wss.on("connection", (ws, req) => { "unknown"; console.log(`New WebSocket connection from ${ip}`); + const url = new URL( + req.url ?? "/", + `http://${req.headers.host ?? "localhost"}`, + ); + const cols = parseInt(url.searchParams.get("cols") ?? "80", 10); + const rows = parseInt(url.searchParams.get("rows") ?? "24", 10); + const resumeToken = url.searchParams.get("resume"); + + // Try to resume an existing session + if (resumeToken) { + const resumed = sessionManager.resume(resumeToken, ws, cols, rows); + if (resumed) { + return; + } + // Session expired or not found — fall through to create a new one + console.log( + `[resume] Session ${resumeToken.slice(0, 8)}… not found — starting fresh`, + ); + } + + // Capacity check only applies to new sessions if (sessionManager.isFull) { ws.send( JSON.stringify({ @@ -111,14 +160,9 @@ wss.on("connection", (ws, req) => { return; } - // Parse initial size from query params - const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); - const cols = parseInt(url.searchParams.get("cols") ?? "80", 10); - const rows = parseInt(url.searchParams.get("rows") ?? "24", 10); - - const session = sessionManager.create(ws, cols, rows); - if (session) { - ws.send(JSON.stringify({ type: "connected", sessionId: session.id })); + const token = sessionManager.create(ws, cols, rows); + if (token) { + ws.send(JSON.stringify({ type: "session", token })); } }); @@ -151,6 +195,12 @@ server.listen(PORT, HOST, () => { console.log(`PTY server listening on http://${HOST}:${PORT}`); console.log(` WebSocket: ws://${HOST}:${PORT}/ws`); console.log(` Max sessions: ${MAX_SESSIONS}`); + console.log( + ` Session grace period: ${GRACE_PERIOD_MS / 1000}s`, + ); + console.log( + ` Scrollback buffer: ${Math.round(SCROLLBACK_BYTES / 1024)}KB per session`, + ); if (process.env.AUTH_TOKEN) { console.log(" Auth: token required"); } diff --git a/src/server/web/public/index.html b/src/server/web/public/index.html index 0194903..78e9a98 100644 --- a/src/server/web/public/index.html +++ b/src/server/web/public/index.html @@ -4,22 +4,14 @@ - + Claude Code - - - - - - - - - - + +
@@ -31,24 +23,26 @@
+ - +
- +
-
Connecting to Claude Code...
+
Connecting to Claude Code…
- +
-
Connection lost. Reconnecting...
-
Retrying in 1s...
+
Connection lost. Reconnecting…
+
Retrying in 1s…
- + + diff --git a/src/server/web/session-manager.ts b/src/server/web/session-manager.ts index ffa76f6..4c02387 100644 --- a/src/server/web/session-manager.ts +++ b/src/server/web/session-manager.ts @@ -1,50 +1,54 @@ import type { IPty } from "node-pty"; import type { WebSocket } from "ws"; +import { SessionStore } from "./session-store.js"; -export type Session = { - id: string; - ws: WebSocket; - pty: IPty; - createdAt: number; -}; +export type { SessionInfo } from "./session-store.js"; export class SessionManager { - private sessions = new Map(); + private store: SessionStore; private maxSessions: number; private spawnPty: (cols: number, rows: number) => IPty; + // Tracks which sessions have already had their PTY event listeners wired, + // so we don't double-register on reconnect. + private wiredPtys = new Set(); constructor( maxSessions: number, spawnPty: (cols: number, rows: number) => IPty, + gracePeriodMs?: number, + scrollbackBytes?: number, ) { this.maxSessions = maxSessions; this.spawnPty = spawnPty; + this.store = new SessionStore(gracePeriodMs, scrollbackBytes); } get activeCount(): number { - return this.sessions.size; + return this.store.size; } get isFull(): boolean { - return this.sessions.size >= this.maxSessions; + return this.store.size >= this.maxSessions; } - getSession(id: string): Session | undefined { - return this.sessions.get(id); + getSession(token: string) { + return this.store.get(token); + } + + listSessions() { + return this.store.list(); } /** - * Creates a new PTY session bound to the given WebSocket. - * Returns the session or null if at capacity. + * Spawns a new PTY, registers it in the session store, and wires up all + * event plumbing between the PTY and the WebSocket. + * + * Returns the session token, or null if at capacity or PTY spawn fails. */ - create(ws: WebSocket, cols = 80, rows = 24): Session | null { - if (this.isFull) { - return null; - } + create(ws: WebSocket, cols = 80, rows = 24): string | null { + if (this.isFull) return null; - const id = crypto.randomUUID(); let pty: IPty; - try { pty = this.spawnPty(cols, rows); } catch (err) { @@ -57,39 +61,102 @@ export class SessionManager { return null; } - const session: Session = { id, ws, pty, createdAt: Date.now() }; - this.sessions.set(id, session); + const session = this.store.register(pty); + session.ws = ws; + const { token } = session; + + this.wirePtyEvents(token, pty); + this.wireWsEvents(token, ws, pty); + + console.log( + `[session ${token.slice(0, 8)}] Created (active: ${this.store.size}/${this.maxSessions})`, + ); + return token; + } + + /** + * Attaches a new WebSocket to an existing session identified by `token`. + * + * - Cancels the grace timer + * - Sends `{ type: "resumed", token }` to the client + * - Replays the scrollback buffer so the user sees their conversation + * - Resizes the PTY to the client's current terminal dimensions + * + * Returns true if the session was found, false otherwise. + */ + resume(token: string, ws: WebSocket, cols: number, rows: number): boolean { + const session = this.store.reattach(token, ws); + if (!session) return false; + + console.log( + `[session ${token.slice(0, 8)}] Resumed (active: ${this.store.size}/${this.maxSessions})`, + ); + + // Tell the client it's a resumed session BEFORE sending scrollback bytes. + // The client uses this to clear the terminal first. + ws.send(JSON.stringify({ type: "resumed", token })); + + // Replay buffered output + const scrollback = session.scrollback.read(); + if (scrollback.length > 0) { + ws.send(scrollback); + } + + // Sync PTY dimensions to the reconnected client + try { + session.pty.resize(cols, rows); + } catch { + // PTY may have exited + } + + this.wireWsEvents(token, ws, session.pty); + return true; + } + + /** + * Wire PTY → scrollback + WebSocket. + * Called once per session lifetime (idempotent via `wiredPtys` guard). + */ + private wirePtyEvents(token: string, pty: IPty): void { + if (this.wiredPtys.has(token)) return; + this.wiredPtys.add(token); + + const session = this.store.get(token); + if (!session) return; - // PTY output -> WebSocket pty.onData((data: string) => { - if (ws.readyState === ws.OPEN) { + // Always capture to scrollback for future replay + session.scrollback.write(data); + // Forward to the currently attached WebSocket, if any + const ws = session.ws; + if (ws && ws.readyState === 1 /* OPEN */) { ws.send(data); } }); - // PTY exit -> clean up pty.onExit(({ exitCode, signal }) => { + this.wiredPtys.delete(token); console.log( - `[session ${id}] PTY exited: code=${exitCode}, signal=${signal}`, + `[session ${token.slice(0, 8)}] PTY exited: code=${exitCode}, signal=${signal}`, ); - this.sessions.delete(id); - if (ws.readyState === ws.OPEN) { - ws.send( - JSON.stringify({ - type: "exit", - exitCode, - signal, - }), - ); + const ws = session.ws; + if (ws && ws.readyState === 1 /* OPEN */) { + ws.send(JSON.stringify({ type: "exit", exitCode, signal })); ws.close(1000, "PTY exited"); } + this.store.destroy(token); }); + } - // WebSocket messages -> PTY stdin (or resize) + /** + * Wire WebSocket → PTY (input, resize, ping). + * On close/error, start the grace period instead of immediately destroying + * the session — this keeps the PTY alive for reconnection. + * Called once per WebSocket connection (safe to call again on reconnect). + */ + private wireWsEvents(token: string, ws: WebSocket, pty: IPty): void { ws.on("message", (data: Buffer | string) => { const str = data.toString(); - - // Try to parse as JSON for control messages if (str.startsWith("{")) { try { const msg = JSON.parse(str) as Record; @@ -102,75 +169,44 @@ export class SessionManager { return; } if (msg.type === "ping") { - if (ws.readyState === ws.OPEN) { + if (ws.readyState === 1 /* OPEN */) { ws.send(JSON.stringify({ type: "pong" })); } return; } } catch { - // Not JSON, treat as terminal input + // Not JSON — treat as terminal input } } - pty.write(str); }); - // WebSocket close -> kill PTY - ws.on("close", () => { - console.log(`[session ${id}] WebSocket closed`); - this.destroySession(id); - }); - - ws.on("error", (err) => { - console.error(`[session ${id}] WebSocket error:`, err.message); - this.destroySession(id); - }); - - console.log( - `[session ${id}] Created (active: ${this.sessions.size}/${this.maxSessions})`, - ); - return session; - } - - /** - * Gracefully destroys a session: SIGHUP, then SIGKILL after timeout. - */ - destroySession(id: string): void { - const session = this.sessions.get(id); - if (!session) return; - - this.sessions.delete(id); - const { pty, ws } = session; - - try { - pty.kill("SIGHUP"); - } catch { - // PTY may already be dead - } - - // Force kill after 5 seconds if still alive - const killTimer = setTimeout(() => { - try { - pty.kill("SIGKILL"); - } catch { - // Already dead + const handleClose = () => { + console.log(`[session ${token.slice(0, 8)}] WebSocket closed`); + const session = this.store.get(token); + // Only start grace if this WS is still the one attached to the session + if (session && session.ws === ws) { + this.store.startGrace(token, () => { + /* logged inside startGrace */ + }); } - }, 5000); + }; - // If PTY exits before the timer, clear it - pty.onExit(() => clearTimeout(killTimer)); - - if (ws.readyState === ws.OPEN || ws.readyState === ws.CONNECTING) { - ws.close(1000, "Session destroyed"); - } + ws.on("close", handleClose); + ws.on("error", (err) => { + console.error(`[session ${token.slice(0, 8)}] WebSocket error:`, err.message); + handleClose(); + }); } /** - * Destroys all sessions. Used during server shutdown. + * Force-kill a session immediately (used by the REST API). */ + destroySession(token: string): void { + this.store.destroy(token); + } + destroyAll(): void { - for (const id of [...this.sessions.keys()]) { - this.destroySession(id); - } + this.store.destroyAll(); } } diff --git a/src/server/web/styles.css b/src/server/web/styles.css new file mode 100644 index 0000000..80f3cc9 --- /dev/null +++ b/src/server/web/styles.css @@ -0,0 +1,243 @@ +/* ── CSS custom properties (theme tokens) ───────────────────────────────── */ +:root { + --term-bg: #1a1b26; + --term-fg: #c0caf5; + --term-cursor: #c0caf5; + --term-selection: rgba(130, 170, 255, 0.3); + --term-black: #15161e; + --term-red: #f7768e; + --term-green: #9ece6a; + --term-yellow: #e0af68; + --term-blue: #7aa2f7; + --term-magenta: #bb9af7; + --term-cyan: #7dcfff; + --term-white: #a9b1d6; + --term-bright-black: #414868; + --term-bright-white: #c0caf5; + --bar-bg: #16161e; + --bar-fg: #565f89; + --bar-accent: #7aa2f7; + --overlay-bg: rgba(26, 27, 38, 0.92); +} + +@media (prefers-color-scheme: light) { + :root { + --term-bg: #f5f5f5; + --term-fg: #343b58; + --term-cursor: #343b58; + --term-selection: rgba(52, 59, 88, 0.2); + --term-black: #0f0f14; + --term-red: #8c4351; + --term-green: #485e30; + --term-yellow: #8f5e15; + --term-blue: #34548a; + --term-magenta: #5a4a78; + --term-cyan: #0f4b6e; + --term-white: #343b58; + --term-bright-black: #9699a3; + --term-bright-white: #343b58; + --bar-bg: #e8e8e8; + --bar-fg: #6c7086; + --bar-accent: #34548a; + --overlay-bg: rgba(245, 245, 245, 0.92); + } +} + +/* ── Reset ──────────────────────────────────────────────────────────────── */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + width: 100vw; + height: 100vh; + overflow: hidden; + background: var(--term-bg); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; +} + +/* ── Top bar ────────────────────────────────────────────────────────────── */ +#top-bar { + display: flex; + align-items: center; + justify-content: space-between; + height: 32px; + padding: 0 12px; + background: var(--bar-bg); + color: var(--bar-fg); + font-size: 12px; + user-select: none; + -webkit-user-select: none; +} + +#top-bar.collapsed { + display: none; +} + +#top-bar .left, +#top-bar .right { + display: flex; + align-items: center; + gap: 10px; +} + +/* Re-expand button — only visible when bar is collapsed */ +#toggle-bar { + display: none; + position: fixed; + top: 4px; + right: 4px; + z-index: 20; + width: 20px; + height: 20px; + background: var(--bar-bg); + border: 1px solid var(--bar-fg); + border-radius: 3px; + color: var(--bar-fg); + cursor: pointer; + font-size: 10px; + align-items: center; + justify-content: center; +} + +#top-bar.collapsed ~ #toggle-bar { + display: flex; +} + +/* ── Status dot ─────────────────────────────────────────────────────────── */ +.status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--term-green); + transition: background 0.3s; +} + +.status-dot.disconnected { + background: var(--term-red); +} + +.status-dot.connecting { + background: var(--term-yellow); + animation: pulse 1s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + +/* ── Bar button ─────────────────────────────────────────────────────────── */ +#bar-btn { + background: none; + border: 1px solid var(--bar-fg); + color: var(--bar-fg); + padding: 2px 8px; + border-radius: 3px; + cursor: pointer; + font-size: 11px; + transition: color 0.15s, border-color 0.15s; +} + +#bar-btn:hover { + color: var(--bar-accent); + border-color: var(--bar-accent); +} + +/* ── Terminal container ─────────────────────────────────────────────────── */ +#terminal-container { + width: 100%; + height: calc(100vh - 32px); + background: var(--term-bg); +} + +#top-bar.collapsed ~ #terminal-container { + height: 100vh; +} + +/* Give xterm a little breathing room */ +#terminal-container .xterm { + padding: 4px; +} + +/* ── Overlays (loading + reconnect) ─────────────────────────────────────── */ +#loading-overlay, +#reconnect-overlay { + position: fixed; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0; + background: var(--overlay-bg); + z-index: 100; +} + +#loading-overlay { + transition: opacity 0.3s ease; +} + +#loading-overlay.hidden { + opacity: 0; + pointer-events: none; +} + +#reconnect-overlay { + display: none; +} + +#reconnect-overlay.visible { + display: flex; +} + +.spinner { + width: 36px; + height: 36px; + border: 3px solid var(--term-bright-black); + border-top-color: var(--bar-accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-bottom: 16px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.overlay-msg { + color: var(--term-fg); + font-size: 14px; + opacity: 0.85; +} + +.overlay-sub { + margin-top: 6px; + color: var(--bar-fg); + font-size: 12px; +} + +/* ── Scrollbar ──────────────────────────────────────────────────────────── */ +.xterm-viewport::-webkit-scrollbar { width: 8px; } +.xterm-viewport::-webkit-scrollbar-track { background: transparent; } +.xterm-viewport::-webkit-scrollbar-thumb { background: var(--term-bright-black); border-radius: 4px; } +.xterm-viewport::-webkit-scrollbar-thumb:hover { background: var(--bar-fg); } + +/* ── Mobile ─────────────────────────────────────────────────────────────── */ +@media (max-width: 600px) { + #top-bar { + height: 28px; + font-size: 11px; + padding: 0 8px; + } + + #terminal-container { + height: calc(100vh - 28px); + } + + #top-bar.collapsed ~ #terminal-container { + height: 100vh; + } +} diff --git a/src/server/web/tsconfig.json b/src/server/web/tsconfig.json new file mode 100644 index 0000000..78280c8 --- /dev/null +++ b/src/server/web/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": [] + }, + "include": ["terminal.ts", "styles.css"] +} diff --git a/src/server/web/user-store.ts b/src/server/web/user-store.ts new file mode 100644 index 0000000..a063f0a --- /dev/null +++ b/src/server/web/user-store.ts @@ -0,0 +1,64 @@ +/** + * UserStore — tracks which users are connected and how many sessions each has. + * + * This is a lightweight in-memory view derived from the SessionManager; it + * does not persist across restarts. The admin dashboard and admin API read + * from this store to enumerate users and their activity. + */ +export interface UserRecord { + id: string; + email?: string; + name?: string; + firstSeenAt: number; + lastSeenAt: number; + sessionCount: number; +} + +export class UserStore { + private readonly users = new Map(); + + /** + * Called when a session is created for a user. + * Creates the user record if it doesn't exist yet; increments sessionCount. + */ + touch(userId: string, meta?: { email?: string; name?: string }): void { + const existing = this.users.get(userId); + if (existing) { + existing.lastSeenAt = Date.now(); + existing.sessionCount += 1; + if (meta?.email && !existing.email) existing.email = meta.email; + if (meta?.name && !existing.name) existing.name = meta.name; + } else { + this.users.set(userId, { + id: userId, + email: meta?.email, + name: meta?.name, + firstSeenAt: Date.now(), + lastSeenAt: Date.now(), + sessionCount: 1, + }); + } + } + + /** + * Called when a session is destroyed for a user. + * Decrements sessionCount; removes the record when it reaches zero. + */ + release(userId: string): void { + const record = this.users.get(userId); + if (!record) return; + record.sessionCount = Math.max(0, record.sessionCount - 1); + if (record.sessionCount === 0) { + this.users.delete(userId); + } + } + + /** Returns all currently connected users (sessionCount > 0). */ + list(): UserRecord[] { + return [...this.users.values()]; + } + + get(userId: string): UserRecord | undefined { + return this.users.get(userId); + } +} diff --git a/web/components/tools/FileIcon.tsx b/web/components/tools/FileIcon.tsx new file mode 100644 index 0000000..4269404 --- /dev/null +++ b/web/components/tools/FileIcon.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { + FileText, + FileCode, + FileJson, + FileImage, + File, + Database, + Settings, + Package, + Globe, + BookOpen, + type LucideIcon, +} from "lucide-react"; + +const EXT_MAP: Record = { + // JavaScript / TypeScript + js: FileCode, + jsx: FileCode, + ts: FileCode, + tsx: FileCode, + mjs: FileCode, + cjs: FileCode, + // Web + html: Globe, + htm: Globe, + css: FileCode, + scss: FileCode, + sass: FileCode, + less: FileCode, + // Data + json: FileJson, + jsonc: FileJson, + yaml: FileJson, + yml: FileJson, + toml: FileJson, + xml: FileJson, + csv: Database, + // Config + env: Settings, + gitignore: Settings, + eslintrc: Settings, + prettierrc: Settings, + editorconfig: Settings, + // Docs + md: BookOpen, + mdx: BookOpen, + txt: FileText, + rst: FileText, + // Images + png: FileImage, + jpg: FileImage, + jpeg: FileImage, + gif: FileImage, + svg: FileImage, + ico: FileImage, + webp: FileImage, + // Package + lock: Package, + // Python + py: FileCode, + pyc: FileCode, + // Ruby + rb: FileCode, + // Go + go: FileCode, + // Rust + rs: FileCode, + // Java / Kotlin + java: FileCode, + kt: FileCode, + // C / C++ + c: FileCode, + cpp: FileCode, + h: FileCode, + hpp: FileCode, + // Shell + sh: FileCode, + bash: FileCode, + zsh: FileCode, + fish: FileCode, + // SQL + sql: Database, +}; + +function getExtension(filePath: string): string { + const parts = filePath.split("."); + if (parts.length < 2) return ""; + return parts[parts.length - 1].toLowerCase(); +} + +export function getFileIcon(filePath: string): LucideIcon { + const ext = getExtension(filePath); + return EXT_MAP[ext] ?? File; +} + +interface FileIconProps { + filePath: string; + className?: string; +} + +export function FileIcon({ filePath, className }: FileIconProps) { + const Icon = getFileIcon(filePath); + return ; +}