Compare commits
2 commits
46ead345b4
...
2f2a48f644
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f2a48f644 | |||
|
|
6d2e7449f3 |
7 changed files with 221 additions and 21 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
33
web/package-lock.json
generated
33
web/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
24
web/server.ts
Normal file
24
web/server.ts
Normal file
|
|
@ -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`);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, { userId: string; expiresAt: number }>();
|
||||
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,
|
||||
});
|
||||
|
|
|
|||
147
web/src/lib/ws.ts
Normal file
147
web/src/lib/ws.ts
Normal file
|
|
@ -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<string, { userId: string; expiresAt: number }>;
|
||||
};
|
||||
|
||||
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<AuthenticatedSocket>();
|
||||
|
||||
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;
|
||||
}
|
||||
Loading…
Reference in a new issue