diff --git a/web/Dockerfile b/web/Dockerfile index 0ee9bc8..59d430c 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -25,10 +25,12 @@ RUN adduser --system --uid 1001 nextjs COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/server.ts ./server.ts USER nextjs EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" -CMD ["node", "server.js"] +# Use custom server instead of default next start +CMD ["node", "server.ts"] diff --git a/web/package-lock.json b/web/package-lock.json index a5818f6..fb792f5 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -16,6 +16,7 @@ "pg": "^8.20.0", "react": "19.2.4", "react-dom": "19.2.4", + "ws": "^8.20.0", "zod": "^4.3.6" }, "devDependencies": { @@ -23,6 +24,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/ws": "^8.18.1", "drizzle-kit": "^0.31.10", "eslint": "^9", "eslint-config-next": "16.2.2", @@ -2543,6 +2545,16 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.58.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", @@ -8465,6 +8477,27 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/web/package.json b/web/package.json index 2823426..97c9b76 100644 --- a/web/package.json +++ b/web/package.json @@ -17,6 +17,7 @@ "pg": "^8.20.0", "react": "19.2.4", "react-dom": "19.2.4", + "ws": "^8.20.0", "zod": "^4.3.6" }, "devDependencies": { @@ -24,6 +25,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/ws": "^8.18.1", "drizzle-kit": "^0.31.10", "eslint": "^9", "eslint-config-next": "16.2.2", diff --git a/web/server.ts b/web/server.ts new file mode 100644 index 0000000..e8498aa --- /dev/null +++ b/web/server.ts @@ -0,0 +1,24 @@ +import { createServer } from 'http'; +import next from 'next'; +import { setupWebSocket } from './src/lib/ws'; + +const dev = process.env.NODE_ENV !== 'production'; +const hostname = process.env.HOSTNAME || '0.0.0.0'; +const port = parseInt(process.env.PORT || '3000', 10); + +const app = next({ dev, hostname, port }); +const handle = app.getRequestHandler(); + +app.prepare().then(() => { + const server = createServer((req, res) => { + // Don't log query params on /ws route (ticket security) + handle(req, res); + }); + + setupWebSocket(server); + + server.listen(port, hostname, () => { + console.log(`> Ready on http://${hostname}:${port}`); + console.log(`> WebSocket server on ws://${hostname}:${port}/ws`); + }); +}); diff --git a/web/src/app/api/health/route.ts b/web/src/app/api/health/route.ts index c9b9c51..ed259c5 100644 --- a/web/src/app/api/health/route.ts +++ b/web/src/app/api/health/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { db } from '@/db/client'; import { sql } from 'drizzle-orm'; +import { getActiveConnections } from '@/lib/ws'; export async function GET() { const start = Date.now(); @@ -16,6 +17,9 @@ export async function GET() { status: 'connected', latencyMs: dbLatency, }, + ws: { + activeConnections: getActiveConnections(), + }, }); } catch (error) { return NextResponse.json({ @@ -25,6 +29,9 @@ export async function GET() { status: 'disconnected', error: error instanceof Error ? error.message : 'Unknown error', }, + ws: { + activeConnections: getActiveConnections(), + }, }, { status: 503 }); } } diff --git a/web/src/app/api/ws-ticket/route.ts b/web/src/app/api/ws-ticket/route.ts index 3f21c1d..659bffb 100644 --- a/web/src/app/api/ws-ticket/route.ts +++ b/web/src/app/api/ws-ticket/route.ts @@ -1,35 +1,20 @@ import { NextResponse } from 'next/server'; import { randomUUID } from 'crypto'; import { requireAuth } from '@/lib/api'; - -// In-memory ticket store (TTL 30s, single use) -const ticketStore = new Map(); +import { getTicketStore } from '@/lib/ws'; const TTL_30S = 30 * 1000; -// Cleanup expired tickets function cleanupTickets() { + const store = getTicketStore(); const now = Date.now(); - for (const [key, entry] of ticketStore) { + for (const [key, entry] of store) { if (entry.expiresAt < now) { - ticketStore.delete(key); + store.delete(key); } } } -/** - * Validate and consume a WS ticket. Returns userId if valid, null otherwise. - */ -export function consumeTicket(ticket: string): string | null { - const entry = ticketStore.get(ticket); - if (!entry || entry.expiresAt < Date.now()) { - ticketStore.delete(ticket); - return null; - } - ticketStore.delete(ticket); // Single use - return entry.userId; -} - export async function POST() { const auth = await requireAuth(); if (auth.error) return auth.error; @@ -37,7 +22,7 @@ export async function POST() { cleanupTickets(); const ticket = randomUUID(); - ticketStore.set(ticket, { + getTicketStore().set(ticket, { userId: auth.userId, expiresAt: Date.now() + TTL_30S, }); diff --git a/web/src/lib/ws.ts b/web/src/lib/ws.ts new file mode 100644 index 0000000..2384ba5 --- /dev/null +++ b/web/src/lib/ws.ts @@ -0,0 +1,147 @@ +import { WebSocketServer, WebSocket } from 'ws'; +import type { IncomingMessage } from 'http'; +import type { Server } from 'http'; + +export type WsMessage = + | { type: 'sync'; entity: 'list' | 'task' | 'tag'; action: 'create' | 'update' | 'delete'; id: string } + | { type: 'auth_expired' }; + +interface AuthenticatedSocket extends WebSocket { + userId: string; + lastValidated: number; + isAlive: boolean; +} + +// Ticket store (shared with /api/ws-ticket route) +// In production this would be imported from a shared module +const ticketStore = globalThis as unknown as { + __wsTickets?: Map; +}; + +export function getTicketStore() { + if (!ticketStore.__wsTickets) { + ticketStore.__wsTickets = new Map(); + } + return ticketStore.__wsTickets; +} + +const ALLOWED_ORIGINS = [ + 'https://liste.lacompagniemaximus.com', + 'http://localhost:3000', +]; + +const REVALIDATION_INTERVAL = 15 * 60 * 1000; // 15 minutes +const HEARTBEAT_INTERVAL = 30 * 1000; // 30 seconds + +const clients = new Set(); + +export function setupWebSocket(server: Server) { + const wss = new WebSocketServer({ noServer: true }); + + server.on('upgrade', (request: IncomingMessage, socket, head) => { + const url = new URL(request.url || '', `http://${request.headers.host}`); + + if (url.pathname !== '/ws') { + socket.destroy(); + return; + } + + // Validate Origin + const origin = request.headers.origin; + if (origin && !ALLOWED_ORIGINS.includes(origin)) { + socket.destroy(); + return; + } + + // Validate ticket + const ticket = url.searchParams.get('ticket'); + if (!ticket) { + socket.destroy(); + return; + } + + const store = getTicketStore(); + const entry = store.get(ticket); + if (!entry || entry.expiresAt < Date.now()) { + store.delete(ticket); + socket.destroy(); + return; + } + + // Invalidate ticket (single use) + const userId = entry.userId; + store.delete(ticket); + + wss.handleUpgrade(request, socket, head, (ws) => { + const authWs = ws as AuthenticatedSocket; + authWs.userId = userId; + authWs.lastValidated = Date.now(); + authWs.isAlive = true; + wss.emit('connection', authWs); + }); + }); + + wss.on('connection', (ws: AuthenticatedSocket) => { + clients.add(ws); + + ws.on('pong', () => { + ws.isAlive = true; + }); + + ws.on('close', () => { + clients.delete(ws); + }); + + ws.on('error', () => { + clients.delete(ws); + }); + }); + + // Heartbeat + session revalidation + const interval = setInterval(() => { + const now = Date.now(); + + for (const ws of clients) { + // Check session expiry + if (now - ws.lastValidated > REVALIDATION_INTERVAL) { + const msg: WsMessage = { type: 'auth_expired' }; + ws.send(JSON.stringify(msg)); + ws.terminate(); + clients.delete(ws); + continue; + } + + // Heartbeat + if (!ws.isAlive) { + ws.terminate(); + clients.delete(ws); + continue; + } + + ws.isAlive = false; + ws.ping(); + } + }, HEARTBEAT_INTERVAL); + + wss.on('close', () => { + clearInterval(interval); + }); + + return wss; +} + +/** + * Broadcast a sync notification to all connected clients of a specific user. + */ +export function broadcastToUser(userId: string, message: WsMessage) { + const payload = JSON.stringify(message); + for (const ws of clients) { + if (ws.userId === userId && ws.readyState === WebSocket.OPEN) { + ws.send(payload); + } + } +} + +export function getActiveConnections(): number { + return clients.size; +}