Admin / server.js
Tafita1206's picture
Update server.js
0cb68f2 verified
/**
* ╔══════════════════════════════════════════════════════════╗
* β•‘ 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<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));