Spaces:
Sleeping
Sleeping
| /** | |
| * ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| * β SECUREVIEW SERVER β VERSION STABLE β | |
| * β WebRTC Surveillance Β· Base de donnΓ©es permanente β | |
| * β Cible: https://tafita1206-admin.hf.space β | |
| * ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| * | |
| * ARCHITECTURE DE ROUTAGE WEBRTC: | |
| * ββββββββββββ offer/ice(βclient) ββββββββββββ | |
| * β ADMIN β ββββββββββββββββββββββΊ β CLIENT β | |
| * β(adminSID)β ββββββββββββββββββββββ β(clientId)β | |
| * ββββββββββββ answer/ice(βadmin) ββββββββββββ | |
| * | |
| * Chaque admin reΓ§oit un adminSessionId unique Γ la connexion. | |
| * Ce SID est transmis au client dans incoming_call/webrtc_offer | |
| * pour que le client sache vers quel admin renvoyer son answer. | |
| */ | |
| ; | |
| const express = require('express'); | |
| const http = require('http'); | |
| const WebSocket = require('ws'); | |
| const path = require('path'); | |
| const cors = require('cors'); | |
| const fs = require('fs'); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // CONFIG | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const PORT = parseInt(process.env.PORT || '7860', 10); | |
| const DB_FILE = path.join(__dirname, 'secureview_db.json'); | |
| const WS_PING_INTERVAL = 20000; // ping ws toutes les 20s pour dΓ©tecter dΓ©connexions | |
| const OFFLINE_TIMEOUT = 90000; // marquer offline après 90s sans heartbeat | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // EXPRESS | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const app = express(); | |
| const server = http.createServer(app); | |
| app.use(cors({ origin: '*', methods: ['GET','POST','PUT','DELETE','OPTIONS'], allowedHeaders: '*' })); | |
| app.use(express.json({ limit: '1mb' })); | |
| app.use(express.static(path.join(__dirname, 'public'))); | |
| // Health check pour HuggingFace | |
| app.get('/', (req, res) => res.json({ status: 'ok', service: 'SecureView', version: '2.0' })); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // BASE DE DONNΓES JSON PERMANENTE | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let db = { clients: {}, history: [] }; | |
| function dbLoad() { | |
| try { | |
| if (fs.existsSync(DB_FILE)) { | |
| const raw = fs.readFileSync(DB_FILE, 'utf8'); | |
| const parsed = JSON.parse(raw); | |
| db.clients = parsed.clients || {}; | |
| db.history = parsed.history || []; | |
| console.log(`[DB] ChargΓ©e: ${Object.keys(db.clients).length} compte(s)`); | |
| } else { | |
| dbSave(); | |
| console.log('[DB] Nouvelle base créée'); | |
| } | |
| } catch(e) { | |
| console.error('[DB] Erreur chargement:', e.message); | |
| db = { clients: {}, history: [] }; | |
| dbSave(); | |
| } | |
| } | |
| let dbSaveTimer = null; | |
| function dbSave() { | |
| // Debounce: ne pas Γ©crire plus d'une fois par seconde | |
| if (dbSaveTimer) return; | |
| dbSaveTimer = setTimeout(() => { | |
| dbSaveTimer = null; | |
| try { fs.writeFileSync(DB_FILE, JSON.stringify(db, null, 2), 'utf8'); } | |
| catch(e) { console.error('[DB] Erreur sauvegarde:', e.message); } | |
| }, 500); | |
| } | |
| function dbAddHistory(clientId, eventType, meta = {}) { | |
| db.history.push({ | |
| id: Date.now() + Math.random(), | |
| client_id: clientId, | |
| event_type: eventType, | |
| timestamp: new Date().toISOString(), | |
| meta, | |
| }); | |
| if (db.history.length > 5000) db.history = db.history.slice(-5000); | |
| dbSave(); | |
| } | |
| dbLoad(); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // GΓNΓRATEUR D'IDs | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function genId(prefix) { | |
| const ts = Date.now().toString(36).toUpperCase(); | |
| const rnd = Math.random().toString(36).substr(2, 6).toUpperCase(); | |
| return `${prefix}_${ts}${rnd}`; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // ΓTAT WEBSOCKET | |
| // ββ connectedClients : Map<clientId, ws> | |
| // ββ adminSessions : Map<adminSID, ws> | |
| // ββ wsRegistry : Map<ws, {type, id}> | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const connectedClients = new Map(); | |
| const adminSessions = new Map(); | |
| const ownerSessions = new Map(); // Map<ownerSID, {ws, clientId}> | |
| const wsRegistry = new Map(); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // WEBSOCKET SERVER | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const wss = new WebSocket.Server({ server, path: '/' }); | |
| wss.on('connection', (ws, req) => { | |
| const ip = (req.headers['x-forwarded-for'] || req.socket.remoteAddress || '?').split(',')[0].trim(); | |
| // ββ Ping/Pong pour dΓ©tecter les connexions mortes ββ | |
| ws._isAlive = true; | |
| ws.on('pong', () => { ws._isAlive = true; }); | |
| ws.on('message', (raw) => { | |
| let msg; | |
| try { msg = JSON.parse(raw.toString()); } | |
| catch(e) { return; } // Ignorer JSON invalide | |
| handleMessage(ws, msg, ip); | |
| }); | |
| ws.on('close', () => handleDisconnect(ws)); | |
| ws.on('error', (e) => { console.error('[WS] Erreur:', e.message); }); | |
| }); | |
| // Ping toutes les 20s pour dΓ©tecter clients fantΓ΄mes | |
| const pingInterval = setInterval(() => { | |
| wss.clients.forEach(ws => { | |
| if (!ws._isAlive) { ws.terminate(); return; } | |
| ws._isAlive = false; | |
| try { ws.ping(); } catch(e) {} | |
| }); | |
| }, WS_PING_INTERVAL); | |
| wss.on('close', () => clearInterval(pingInterval)); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // GESTIONNAIRE DE MESSAGES | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function handleMessage(ws, msg, ip) { | |
| const { type } = msg; | |
| switch(type) { | |
| // ββββββββββββββββββββββββββββββββββββββββ | |
| // ENREGISTREMENT CLIENT | |
| // ββββββββββββββββββββββββββββββββββββββββ | |
| case 'register_client': { | |
| const { clientId, registrationId } = msg; | |
| const client = db.clients[clientId]; | |
| if (!client) { | |
| send(ws, { type: 'error', code: 'NOT_FOUND', message: 'Client ID inconnu. CrΓ©ez un compte via /api/register' }); | |
| return; | |
| } | |
| // VΓ©rification Registration ID si fourni | |
| if (registrationId && client.registration_id !== registrationId) { | |
| send(ws, { type: 'error', code: 'AUTH_FAILED', message: 'Registration ID invalide' }); | |
| return; | |
| } | |
| // Fermer ancienne connexion proprement | |
| const existing = connectedClients.get(clientId); | |
| if (existing && existing !== ws) { | |
| wsRegistry.delete(existing); | |
| try { existing.close(1000, 'New connection'); } catch(e) {} | |
| } | |
| connectedClients.set(clientId, ws); | |
| wsRegistry.set(ws, { type: 'client', id: clientId }); | |
| // Mettre Γ jour statut | |
| client.status = 'online'; | |
| client.last_seen = new Date().toISOString(); | |
| client.last_ip = ip; | |
| dbSave(); | |
| dbAddHistory(clientId, 'connect', { ip }); | |
| send(ws, { | |
| type: 'registration_success', | |
| clientId, | |
| name: client.name, | |
| registrationId: client.registration_id, | |
| }); | |
| notifyAdmins({ | |
| type: 'client_connected', | |
| client: buildClientInfo(clientId), | |
| }); | |
| console.log(`[WS] Client connectΓ©: ${clientId} (${client.name}) depuis ${ip}`); | |
| break; | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββ | |
| // ENREGISTREMENT ADMIN | |
| // ββββββββββββββββββββββββββββββββββββββββ | |
| case 'register_admin': { | |
| const adminSID = genId('ASID'); | |
| adminSessions.set(adminSID, ws); | |
| wsRegistry.set(ws, { type: 'admin', id: adminSID }); | |
| const clientList = buildClientList(); | |
| send(ws, { type: 'admin_registered', adminSID }); | |
| send(ws, { type: 'client_list', clients: clientList }); | |
| console.log(`[WS] Admin connectΓ©: ${adminSID} depuis ${ip}`); | |
| break; | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββ | |
| // ENREGISTREMENT OWNER (propriΓ©taire) | |
| // Le propriΓ©taire se connecte avec son clientId | |
| // Il reΓ§oit le flux WebRTC de son propre appareil | |
| // ββββββββββββββββββββββββββββββββββββββββ | |
| case 'register_owner': { | |
| const { clientId } = msg; | |
| if (!clientId || !db.clients[clientId]) { | |
| send(ws, { type: 'error', code: 'NOT_FOUND', message: 'Client ID inconnu' }); | |
| return; | |
| } | |
| const ownerSID = genId('OSID'); | |
| ownerSessions.set(ownerSID, { ws, clientId }); | |
| wsRegistry.set(ws, { type: 'owner', id: ownerSID, clientId }); | |
| send(ws, { | |
| type: 'owner_registered', | |
| ownerSID, | |
| clientId, | |
| client: buildClientInfo(clientId), | |
| }); | |
| console.log(`[WS] Owner connectΓ©: ${ownerSID} pour client(${clientId}) depuis ${ip}`); | |
| break; | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββ | |
| // CALL FROM OWNER β son propre client APK | |
| // ββββββββββββββββββββββββββββββββββββββββ | |
| case 'owner_call_client': { | |
| const info = wsRegistry.get(ws); | |
| if (info?.type !== 'owner') return; | |
| const { mode } = msg; | |
| const { clientId } = info; | |
| const clientWs = connectedClients.get(clientId); | |
| if (!clientWs || clientWs.readyState !== WebSocket.OPEN) { | |
| send(ws, { type: 'call_error', clientId, message: 'Appareil hors ligne' }); | |
| return; | |
| } | |
| send(clientWs, { type: 'incoming_call', mode: mode || 'manual', adminSID: info.id }); | |
| send(ws, { type: 'call_initiated', clientId }); | |
| console.log(`[CALL] Owner(${info.id}) β Client(${clientId}) mode=${mode}`); | |
| break; | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββ | |
| // WEBRTC OFFER (owner β son client APK) | |
| // ββββββββββββββββββββββββββββββββββββββββ | |
| case 'owner_webrtc_offer': { | |
| const info = wsRegistry.get(ws); | |
| if (info?.type !== 'owner') return; | |
| const { offer } = msg; | |
| const { clientId, id: ownerSID } = info; | |
| const clientWs = connectedClients.get(clientId); | |
| if (!clientWs || clientWs.readyState !== WebSocket.OPEN) { | |
| send(ws, { type: 'rtc_error', message: `Appareil ${clientId} non connectΓ©` }); | |
| return; | |
| } | |
| send(clientWs, { type: 'webrtc_offer', offer, adminSID: ownerSID }); | |
| console.log(`[RTC] Offer: owner(${ownerSID}) β client(${clientId})`); | |
| break; | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββ | |
| // ICE depuis owner β son client APK | |
| // ββββββββββββββββββββββββββββββββββββββββ | |
| case 'owner_webrtc_ice': { | |
| const info = wsRegistry.get(ws); | |
| if (info?.type !== 'owner') return; | |
| const { candidate } = msg; | |
| const { clientId } = info; | |
| const clientWs = connectedClients.get(clientId); | |
| if (clientWs && clientWs.readyState === WebSocket.OPEN) { | |
| send(clientWs, { type: 'webrtc_ice', candidate }); | |
| } | |
| break; | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββ | |
| // HEARTBEAT CLIENT | |
| // ββββββββββββββββββββββββββββββββββββββββ | |
| case 'heartbeat': { | |
| const { clientId } = msg; | |
| if (db.clients[clientId]) { | |
| db.clients[clientId].status = 'online'; | |
| db.clients[clientId].last_seen = new Date().toISOString(); | |
| dbSave(); | |
| } | |
| send(ws, { type: 'heartbeat_ack' }); | |
| break; | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββ | |
| // APPEL CLIENT (admin β client) | |
| // ββββββββββββββββββββββββββββββββββββββββ | |
| case 'call_client': { | |
| const { clientId, mode } = msg; | |
| const info = wsRegistry.get(ws); | |
| if (info?.type !== 'admin') return; | |
| const adminSID = info.id; | |
| const clientWs = connectedClients.get(clientId); | |
| if (!clientWs || clientWs.readyState !== WebSocket.OPEN) { | |
| send(ws, { type: 'call_error', clientId, message: 'Client hors ligne ou introuvable' }); | |
| return; | |
| } | |
| send(clientWs, { type: 'incoming_call', mode: mode || 'manual', adminSID }); | |
| send(ws, { type: 'call_initiated', clientId, adminSID }); | |
| console.log(`[CALL] Admin(${adminSID}) β Client(${clientId}) mode=${mode}`); | |
| break; | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββ | |
| // WEBRTC OFFER (admin β client) | |
| // ββββββββββββββββββββββββββββββββββββββββ | |
| case 'webrtc_offer': { | |
| const { targetClientId, offer, adminSID } = msg; | |
| const clientWs = connectedClients.get(targetClientId); | |
| if (!clientWs || clientWs.readyState !== WebSocket.OPEN) { | |
| send(ws, { type: 'rtc_error', message: `Client ${targetClientId} non connectΓ©` }); | |
| return; | |
| } | |
| send(clientWs, { type: 'webrtc_offer', offer, adminSID }); | |
| console.log(`[RTC] Offer: admin(${adminSID}) β client(${targetClientId})`); | |
| break; | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββ | |
| // WEBRTC ANSWER (client β admin ou owner) | |
| // ββββββββββββββββββββββββββββββββββββββββ | |
| case 'webrtc_answer': { | |
| const { targetAdminSID, answer } = msg; | |
| // Chercher d'abord dans adminSessions | |
| const adminWs = adminSessions.get(targetAdminSID); | |
| if (adminWs && adminWs.readyState === WebSocket.OPEN) { | |
| send(adminWs, { type: 'webrtc_answer', answer }); | |
| console.log(`[RTC] Answer: client β admin(${targetAdminSID})`); | |
| break; | |
| } | |
| // Sinon chercher dans ownerSessions | |
| const ownerEntry = ownerSessions.get(targetAdminSID); | |
| if (ownerEntry && ownerEntry.ws.readyState === WebSocket.OPEN) { | |
| send(ownerEntry.ws, { type: 'webrtc_answer', answer }); | |
| console.log(`[RTC] Answer: client β owner(${targetAdminSID})`); | |
| break; | |
| } | |
| // Fallback: diffuser Γ tous les admins connectΓ©s | |
| console.warn(`[RTC] Dest(${targetAdminSID}) introuvable, broadcast fallback`); | |
| broadcastAdmins({ type: 'webrtc_answer', answer }); | |
| break; | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββ | |
| // ICE CANDIDATE (bidirectionnel) | |
| // direction: 'to_client' | 'to_admin' | |
| // ββββββββββββββββββββββββββββββββββββββββ | |
| case 'webrtc_ice': { | |
| const { direction, targetId, candidate } = msg; | |
| if (direction === 'to_client') { | |
| const dest = connectedClients.get(targetId); | |
| if (dest && dest.readyState === WebSocket.OPEN) { | |
| send(dest, { type: 'webrtc_ice', candidate }); | |
| } | |
| } else if (direction === 'to_admin') { | |
| // Chercher admin d'abord | |
| const dest = adminSessions.get(targetId); | |
| if (dest && dest.readyState === WebSocket.OPEN) { | |
| send(dest, { type: 'webrtc_ice', candidate }); | |
| } else { | |
| // Chercher owner ensuite | |
| const ownerEntry = ownerSessions.get(targetId); | |
| if (ownerEntry && ownerEntry.ws.readyState === WebSocket.OPEN) { | |
| send(ownerEntry.ws, { type: 'webrtc_ice', candidate }); | |
| } else { | |
| broadcastAdmins({ type: 'webrtc_ice', candidate }); | |
| } | |
| } | |
| } | |
| break; | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββ | |
| // FIN SESSION WEBRTC | |
| // ββββββββββββββββββββββββββββββββββββββββ | |
| case 'webrtc_end': { | |
| const { targetId, direction } = msg; | |
| if (direction === 'to_client') { | |
| const dest = connectedClients.get(targetId); | |
| if (dest) send(dest, { type: 'webrtc_end' }); | |
| } else { | |
| const destAdmin = adminSessions.get(targetId); | |
| if (destAdmin) { send(destAdmin, { type: 'webrtc_end' }); return; } | |
| const destOwner = ownerSessions.get(targetId); | |
| if (destOwner) send(destOwner.ws, { type: 'webrtc_end' }); | |
| } | |
| break; | |
| } | |
| // FIN SESSION OWNER β client | |
| case 'owner_webrtc_end': { | |
| const info = wsRegistry.get(ws); | |
| if (info?.type !== 'owner') return; | |
| const clientWs = connectedClients.get(info.clientId); | |
| if (clientWs) send(clientWs, { type: 'webrtc_end' }); | |
| break; | |
| } | |
| default: | |
| console.log('[WS] Message inconnu:', type); | |
| } | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // GESTIONNAIRE DE DΓCONNEXION | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function handleDisconnect(ws) { | |
| const info = wsRegistry.get(ws); | |
| wsRegistry.delete(ws); | |
| if (!info) return; | |
| if (info.type === 'client') { | |
| const { id } = info; | |
| connectedClients.delete(id); | |
| if (db.clients[id]) { | |
| db.clients[id].status = 'offline'; | |
| db.clients[id].last_seen = new Date().toISOString(); | |
| dbSave(); | |
| } | |
| dbAddHistory(id, 'disconnect'); | |
| notifyAdmins({ type: 'client_disconnected', clientId: id }); | |
| console.log(`[WS] Client dΓ©connectΓ©: ${id}`); | |
| } else if (info.type === 'owner') { | |
| ownerSessions.delete(info.id); | |
| console.log(`[WS] Owner dΓ©connectΓ©: ${info.id} (client: ${info.clientId})`); | |
| } else if (info.type === 'admin') { | |
| adminSessions.delete(info.id); | |
| console.log(`[WS] Admin dΓ©connectΓ©: ${info.id}`); | |
| } | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // HELPERS | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function send(ws, data) { | |
| try { | |
| if (ws && ws.readyState === WebSocket.OPEN) | |
| ws.send(JSON.stringify(data)); | |
| } catch(e) { /* ignore */ } | |
| } | |
| function notifyAdmins(data) { | |
| adminSessions.forEach(ws => send(ws, data)); | |
| } | |
| function broadcastAdmins(data) { | |
| adminSessions.forEach(ws => send(ws, data)); | |
| } | |
| function buildClientInfo(id) { | |
| const c = db.clients[id]; | |
| if (!c) return null; | |
| return { ...c, isOnline: connectedClients.has(id) }; | |
| } | |
| function buildClientList() { | |
| return Object.values(db.clients) | |
| .map(c => ({ ...c, isOnline: connectedClients.has(c.client_id) })) | |
| .sort((a, b) => new Date(b.last_seen) - new Date(a.last_seen)); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // NETTOYAGE AUTOMATIQUE β marquer offline | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| setInterval(() => { | |
| const limit = new Date(Date.now() - OFFLINE_TIMEOUT).toISOString(); | |
| let changed = 0; | |
| Object.values(db.clients).forEach(c => { | |
| if (c.status === 'online' && c.last_seen < limit && !connectedClients.has(c.client_id)) { | |
| c.status = 'offline'; | |
| changed++; | |
| } | |
| }); | |
| if (changed > 0) { dbSave(); console.log(`[Cleanup] ${changed} client(s) marquΓ©(s) offline`); } | |
| }, 30000); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // API REST | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // POST /api/register β CrΓ©er un compte permanent | |
| app.post('/api/register', (req, res) => { | |
| const { name, email, phone, custom_id } = req.body || {}; | |
| if (!name || typeof name !== 'string' || !name.trim()) { | |
| return res.status(400).json({ success: false, error: 'Le nom est requis' }); | |
| } | |
| const clientId = custom_id?.trim() || genId('SV'); | |
| if (db.clients[clientId]) { | |
| return res.status(409).json({ | |
| success: false, | |
| error: 'Cet ID existe dΓ©jΓ ', | |
| existing: { client_id: clientId, name: db.clients[clientId].name }, | |
| }); | |
| } | |
| const now = new Date().toISOString(); | |
| const registrationId = genId('REG'); | |
| db.clients[clientId] = { | |
| client_id: clientId, | |
| registration_id: registrationId, | |
| name: name.trim(), | |
| email: (email || '').trim(), | |
| phone: (phone || '').trim(), | |
| status: 'offline', | |
| created_at: now, | |
| last_seen: now, | |
| last_ip: '', | |
| }; | |
| dbSave(); | |
| dbAddHistory(clientId, 'register'); | |
| notifyAdmins({ type: 'new_registration', client: buildClientInfo(clientId) }); | |
| console.log(`[API] Compte créé: ${clientId} (${name.trim()})`); | |
| return res.status(201).json({ | |
| success: true, | |
| data: { | |
| client_id: clientId, | |
| registration_id: registrationId, | |
| name: db.clients[clientId].name, | |
| email: db.clients[clientId].email, | |
| created_at: now, | |
| }, | |
| }); | |
| }); | |
| // GET /api/clients/:id β RΓ©cupΓ©rer un client | |
| app.get('/api/clients/:id', (req, res) => { | |
| const c = db.clients[req.params.id]; | |
| if (!c) return res.status(404).json({ success: false, error: 'Client introuvable' }); | |
| return res.json({ success: true, data: buildClientInfo(req.params.id) }); | |
| }); | |
| // GET /api/clients β Tous les clients | |
| app.get('/api/clients', (req, res) => { | |
| return res.json({ success: true, data: buildClientList() }); | |
| }); | |
| // DELETE /api/clients/:id β Supprimer un compte | |
| app.delete('/api/clients/:id', (req, res) => { | |
| const id = req.params.id; | |
| if (!db.clients[id]) return res.status(404).json({ success: false, error: 'Client introuvable' }); | |
| const { name } = db.clients[id]; | |
| delete db.clients[id]; | |
| dbSave(); | |
| const ws = connectedClients.get(id); | |
| if (ws) { connectedClients.delete(id); wsRegistry.delete(ws); try { ws.close(); } catch(e){} } | |
| notifyAdmins({ type: 'client_deleted', clientId: id }); | |
| console.log(`[API] Compte supprimΓ©: ${id} (${name})`); | |
| return res.json({ success: true, message: `Compte "${name}" supprimΓ©` }); | |
| }); | |
| // POST /api/heartbeat β Heartbeat HTTP | |
| app.post('/api/heartbeat', (req, res) => { | |
| const { clientId } = req.body || {}; | |
| if (!db.clients[clientId]) return res.status(404).json({ success: false, error: 'Client introuvable' }); | |
| db.clients[clientId].status = 'online'; | |
| db.clients[clientId].last_seen = new Date().toISOString(); | |
| dbSave(); | |
| return res.json({ success: true, status: 'online' }); | |
| }); | |
| // GET /api/clients/:id/history | |
| app.get('/api/clients/:id/history', (req, res) => { | |
| const events = db.history | |
| .filter(e => e.client_id === req.params.id) | |
| .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)) | |
| .slice(0, 200); | |
| return res.json({ success: true, data: events }); | |
| }); | |
| // GET /api/stats | |
| app.get('/api/stats', (req, res) => { | |
| const all = Object.values(db.clients); | |
| return res.json({ | |
| success: true, | |
| data: { | |
| totalClients: all.length, | |
| onlineClients: all.filter(c => connectedClients.has(c.client_id)).length, | |
| adminSessions: adminSessions.size, | |
| totalHistory: db.history.length, | |
| totalConnections: db.history.filter(e => e.event_type === 'connect').length, | |
| }, | |
| }); | |
| }); | |
| // POST /api/clients/:id/call β Appel HTTP (utile pour APK sans WS) | |
| app.post('/api/clients/:id/call', (req, res) => { | |
| const id = req.params.id; | |
| const { mode } = req.body || {}; | |
| if (!db.clients[id]) return res.status(404).json({ success: false, error: 'Client introuvable' }); | |
| const cws = connectedClients.get(id); | |
| if (!cws || cws.readyState !== WebSocket.OPEN) | |
| return res.status(503).json({ success: false, error: 'Client hors ligne' }); | |
| const adminSID = adminSessions.keys().next().value || 'ADMIN_BROADCAST'; | |
| send(cws, { type: 'incoming_call', mode: mode || 'manual', adminSID }); | |
| notifyAdmins({ type: 'call_initiated', clientId: id }); | |
| return res.json({ success: true, message: `Appel envoyΓ© Γ ${db.clients[id].name}` }); | |
| }); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // DΓMARRAGE | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| server.listen(PORT, '0.0.0.0', () => { | |
| console.log('\nββββββββββββββββββββββββββββββββββββββββ'); | |
| console.log('β SECUREVIEW SERVER v2.0 β'); | |
| console.log(`β Port : ${PORT} β`); | |
| console.log(`β BDD : secureview_db.json β`); | |
| console.log(`β Comptes : ${String(Object.keys(db.clients).length).padEnd(4)} β`); | |
| console.log('ββββββββββββββββββββββββββββββββββββββββ\n'); | |
| }); | |
| function gracefulShutdown(signal) { | |
| console.log(`\n[Server] ${signal} reΓ§u β arrΓͺt propre...`); | |
| if (dbSaveTimer) { clearTimeout(dbSaveTimer); dbSaveTimer = null; } | |
| try { fs.writeFileSync(DB_FILE, JSON.stringify(db, null, 2), 'utf8'); } | |
| catch(e) { console.error('[DB] Erreur sauvegarde finale:', e.message); } | |
| server.close(() => process.exit(0)); | |
| setTimeout(() => process.exit(1), 5000); | |
| } | |
| process.on('SIGINT', () => gracefulShutdown('SIGINT')); | |
| process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); | |
| process.on('uncaughtException', (e) => console.error('[UNCAUGHT]', e)); | |
| process.on('unhandledRejection', (e) => console.error('[UNHANDLED]', e)); |