server_photo_s35js/ws-server.js
2026-07-18 13:51:20 +02:00

410 lines
11 KiB
JavaScript

// ws-server.js
require("dotenv").config();
const WebSocket = require("ws");
const jwt = require("jsonwebtoken");
const { randomUUID } = require("crypto");
const dbWs = require("./db/dbWs");
const PORT = process.env.WS_PORT || 4002;
const HOST = process.env.WS_HOST || "0.0.0.0";
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
console.error("❌ ERRORE: JWT_SECRET non definito nel .env");
process.exit(1);
}
const wss = new WebSocket.Server({ port: PORT, host: HOST });
console.log(`🚀 WebSocket server attivo su ws://${HOST}:${PORT}`);
// =====================================================
// PULIZIA SESSIONI E PENDING AL RIAVVIO SERVER
// =====================================================
// Nota:
// questa pulizia è lasciata ATTIVA perché la usi per i test.
// La mancanza di sessione NON viene interpretata come full sync,
// ma solo come need_recovery=true.
(async () => {
try {
console.log("🧹 [WS CLEANUP] Pulizia sessioni e pending al riavvio server...");
await dbWs.deleteAllSessions();
await dbWs.deleteAllPendingEvents();
console.log("🧹 [WS CLEANUP] Completata: tutte le sessioni e pending rimossi");
} catch (err) {
console.error("❌ [WS CLEANUP ERROR]:", err);
}
})();
// Mappa session_id -> Map(device_id -> ws)
const wsBySession = new Map();
// =====================================================
// COSTANTI
// =====================================================
const WS_DORMANT_MS = 2 * 60 * 1000; // 2 minuti
// =====================================================
// HELPER: invio con log
// =====================================================
function sendToClient(ws, msg) {
const json = JSON.stringify(msg);
console.log(`📤 [WS SEND] → user=${ws.user}: ${json}`);
ws.send(json);
}
// =====================================================
// HELPER: decide recovery/full sync
// =====================================================
async function decideAuthRecovery({
session_id,
user,
device_id,
}) {
const sessionRow = await dbWs.getSession(session_id, device_id);
let needFull = false;
let needRecovery = false;
let reason = "none";
if (!sessionRow) {
// Se cancelli le sessioni al riavvio per test, è normale non trovarla.
// Questo NON deve significare full bootstrap.
// Significa solo: fai recovery/delta.
needRecovery = true;
reason = "session not found after cleanup";
} else {
const lastAck = sessionRow.last_ack || 0;
const dormancy = lastAck > 0 ? Date.now() - lastAck : 0;
if (sessionRow.need_full_sync) {
// Manteniamo compatibilità con la vecchia colonna,
// ma semanticamente la trattiamo come "need recovery".
needRecovery = true;
reason = "db recovery flag";
} else if (lastAck > 0 && dormancy >= WS_DORMANT_MS) {
// Dormant non è full sync.
// È recovery progressiva.
needRecovery = true;
reason = "dormant";
} else {
needRecovery = false;
reason = "recent or clean auth";
}
}
// Per compatibilità con lo schema attuale:
// salviamo needRecovery nella colonna need_full_sync,
// ma nel protocollo WS mandiamo need_recovery separato.
await dbWs.setSessionNeedFullSync(session_id, device_id, needRecovery);
console.log("🧪 [WS AUTH DECISION]", {
user,
session_id,
device_id,
needFull,
needRecovery,
reason,
last_ack: sessionRow?.last_ack,
last_sync: sessionRow?.last_sync,
db_need_full_sync: sessionRow?.need_full_sync,
});
return {
needFull,
needRecovery,
reason,
};
}
// =====================================================
// CONNESSIONE CLIENT
// =====================================================
wss.on("connection", (ws) => {
console.log("🔌 [WS CONNECT] Nuovo client");
ws.authenticated = false;
ws.user = null;
ws.session_id = null;
ws.device_id = null;
ws.request_id = null;
ws.on("message", async (msg) => {
console.log(`📩 [WS RAW] msg=${msg.toString()}`);
let data;
try {
data = JSON.parse(msg.toString());
} catch (err) {
console.error("❌ Errore parsing JSON:", err);
return;
}
// =====================================================
// AUTENTICAZIONE
// =====================================================
if (data.type === "auth") {
try {
const payload = jwt.verify(data.token, JWT_SECRET);
const user = payload.name;
const session_id = data.session_id || randomUUID();
// IMPORTANTE:
// Non generare randomUUID() a ogni connessione se il client
// non manda device_id, altrimenti il server vede sempre
// un device nuovo.
//
// Per test singolo dispositivo va bene default-device.
// In futuro meglio far mandare un device_id persistente dal client.
const device_id = data.device_id || "default-device";
const request_id = data.request_id || null;
console.log("🧪 AUTH DEBUG:", {
data_device_id: data.device_id,
ws_device_id: device_id,
session_id,
user,
});
ws.user = user;
ws.session_id = session_id;
ws.device_id = device_id;
ws.request_id = request_id;
ws.authenticated = true;
if (!wsBySession.has(session_id)) {
wsBySession.set(session_id, new Map());
}
wsBySession.get(session_id).set(device_id, ws);
console.log(
`🔐 [WS AUTH OK] user=${user} session=${session_id} device=${device_id}`
);
await dbWs.upsertSession(session_id, user, device_id);
const decision = await decideAuthRecovery({
session_id,
user,
device_id,
});
sendToClient(ws, {
type: "auth_ok",
user,
session_id,
// Full bootstrap vero.
// Per dormancy/session cleanup deve restare false.
need_full_sync: decision.needFull,
// Flag corretto per delta/progressive recovery.
need_recovery: decision.needRecovery,
reason: decision.reason,
});
} catch (err) {
console.error("❌ [WS AUTH ERROR]:", err);
sendToClient(ws, {
type: "auth_error",
error: err.message,
});
ws.close();
}
return;
}
// =====================================================
// BLOCCO NON AUTENTICATO
// =====================================================
if (!ws.authenticated) {
sendToClient(ws, {
type: "error",
message: "Not authenticated",
});
return;
}
// =====================================================
// ACK EVENTO
// =====================================================
if (data.type === "ack" && data.event_id) {
console.log(`🟢 [WS ACK] user=${ws.user} event=${data.event_id}`);
await dbWs.updateSessionAck(ws.session_id, ws.device_id);
await dbWs.deletePendingEvent({
user_id: ws.user,
session_id: ws.session_id,
device_id: ws.device_id,
event_id: data.event_id,
});
return;
}
// =====================================================
// CLIENT RECOVERED
// =====================================================
if (data.type === "client_recovered") {
console.log(`🔄 [WS CLIENT RECOVERED] user=${ws.user}`);
const session = await dbWs.getSession(ws.session_id, ws.device_id);
if (!session) {
console.log("⚠️ [WS CLIENT RECOVERED] session missing -> need_recovery");
sendToClient(ws, {
type: "need_recovery",
reason: "session missing",
});
return;
}
const lastAck = session.last_ack || 0;
const dormancy = lastAck > 0 ? Date.now() - lastAck : 0;
if (dormancy >= WS_DORMANT_MS || session.need_full_sync) {
sendToClient(ws, {
type: "need_recovery",
reason: dormancy >= WS_DORMANT_MS ? "dormant" : "db recovery flag",
});
return;
}
const pending = await dbWs.getPendingEvents(
ws.user,
ws.session_id,
ws.device_id
);
for (const ev of pending) {
ws.send(ev.payload);
}
return;
}
// =====================================================
// PONG
// =====================================================
if (data.type === "pong") {
await dbWs.updateSessionAck(ws.session_id, ws.device_id);
return;
}
// =====================================================
// RECOVERY DONE
// =====================================================
if (data.type === "recovery_done") {
console.log(
`✅ [WS RECOVERY DONE] user=${ws.user} session=${ws.session_id} device=${ws.device_id}`
);
await dbWs.clearNeedFullSync(ws.session_id, ws.device_id);
await dbWs.updateSessionAck(ws.session_id, ws.device_id);
return;
}
// =====================================================
// ALTRI MESSAGGI
// =====================================================
console.log(`📨 [WS MESSAGE] user=${ws.user} data=${JSON.stringify(data)}`);
});
ws.on("close", () => {
console.log(`❌ [WS DISCONNECT] user=${ws.user} session=${ws.session_id}`);
if (ws.session_id && ws.device_id) {
const devices = wsBySession.get(ws.session_id);
if (devices) {
devices.delete(ws.device_id);
if (devices.size === 0) {
wsBySession.delete(ws.session_id);
}
}
}
});
});
// =====================================================
// BROADCAST AFFIDABILE
// =====================================================
wss.broadcastToUserReliable = async function (user, payload) {
console.log(`📣 [WS BROADCAST] user=${user}`);
const sessions = await dbWs.getSessionsByUser(user);
if (!sessions || sessions.length === 0) return;
const event_id = randomUUID();
const now = Date.now();
// 1. Salva pending per TUTTI i device registrati nel DB,
// anche se offline.
for (const s of sessions) {
const deviceRows = await dbWs.getDevicesForSession(s.session_id);
for (const device_id of deviceRows) {
await dbWs.insertPendingEvent({
user_id: user,
session_id: s.session_id,
device_id,
event_id,
payload: JSON.stringify(payload),
created_at: now,
});
}
}
// 2. Invia WS ai device attivi.
for (const s of sessions) {
const devices = wsBySession.get(s.session_id);
if (!devices) continue;
for (const [device_id, ws] of devices) {
if (ws.readyState === WebSocket.OPEN && ws.authenticated) {
ws.send(
JSON.stringify({
...payload,
event_id,
})
);
}
}
}
};
wss.broadcastToUser = wss.broadcastToUserReliable;
module.exports = wss;