/** * ╔══════════════════════════════════════════════════════════╗ * ║ 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. */ 'use strict'; 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 // └─ adminSessions : Map // ┌─ wsRegistry : Map // ───────────────────────────────────────────────── const connectedClients = new Map(); const adminSessions = new Map(); const ownerSessions = new Map(); // Map 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));