logger

Self-hosted WhatsApp logger us...
Log | Files | Refs | Activity

commit 0c7f5253e48bc6bd37c1982ac7cd397c4e740d6d
parent 9feee673a4f9cf4c0646133419d016d7d3c777f5
Author: Amit Dutta <mail@amit.is-a.dev>
Date:   Tue, 25 Aug 2026 07:26:37 +0530

Implement CORS, logging, and SSE enhancements

Added CORS support and in-memory logging functionality. Updated API token verification and improved SSE connection management.
Diffstat:
Mindex.js | 346+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
1 file changed, 314 insertions(+), 32 deletions(-)

diff --git a/index.js b/index.js @@ -22,6 +22,33 @@ const app = express(); app.use(express.urlencoded({ extended: true })); app.use(express.json()); +app.use((req, res, next) => { + res.header("Access-Control-Allow-Origin", "*"); + res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization"); + next(); +}); + +// --- IN-MEMORY LOGGING BUFFER --- +const MAX_LOGS = 500; +const logBuffer = []; + +const originalLog = console.log; +const originalError = console.error; + +function teeLog(level, originalFn, ...args) { + const message = args.map(arg => + typeof arg === 'object' ? JSON.stringify(arg) : String(arg) + ).join(' '); + + logBuffer.push({ timestamp: Date.now(), level, message }); + if (logBuffer.length > MAX_LOGS) logBuffer.shift(); + + originalFn.apply(console, args); +} + +console.log = (...args) => teeLog('log', originalLog, ...args); +console.error = (...args) => teeLog('error', originalError, ...args); + // --- FIREBASE SETUP --- let serviceAccount; try { @@ -48,7 +75,6 @@ async function useFirestoreAuthState(db, collectionName = 'whatsapp_auth') { const writeData = async (data, id) => { try { - // BufferJSON converts buffers & uint8 arrays into storable base64 strings const str = JSON.stringify(data, BufferJSON.replacer); await collection.doc(id).set({ data: str }); } catch (err) { @@ -76,7 +102,6 @@ async function useFirestoreAuthState(db, collectionName = 'whatsapp_auth') { } }; - // Load credentials from Firestore or generate new ones (for initial QR scan) const creds = (await readData('creds')) || initAuthCreds(); return { @@ -115,7 +140,6 @@ async function useFirestoreAuthState(db, collectionName = 'whatsapp_auth') { return writeData(creds, 'creds'); }, clearState: async () => { - // We only need to delete the primary creds to force a new QR scan await removeData('creds'); } }; @@ -129,7 +153,6 @@ let isConnected = false; async function startWhatsApp() { const logger = pino({ level: 'silent' }); - // Use our custom Firestore Auth adapter instead of useMultiFileAuthState const { state, saveCreds, clearState } = await useFirestoreAuthState(db, 'whatsapp_auth'); const { version } = await fetchLatestBaileysVersion(); @@ -166,7 +189,7 @@ async function startWhatsApp() { console.log("System: Device Logged Out. Wiping session from Firestore."); await clearState(); qrCodeData = null; - startWhatsApp(); // Restart to grab a fresh QR code + startWhatsApp(); } } else if (connection === 'open') { console.log("System: Connection Open and Authenticated. Firebase Auth Sync Active."); @@ -175,10 +198,8 @@ async function startWhatsApp() { } }); - // Write updated credentials back to Firestore whenever keys change sock.ev.on('creds.update', saveCreds); - // --- FEATURE: REAL NUMBER SYNC --- sock.ev.on('contacts.upsert', async (contacts) => { for (const contact of contacts) { let updateData = {}; @@ -186,25 +207,20 @@ async function startWhatsApp() { if (displayName) updateData.displayName = displayName; - // Extract standard phone number from standard JID if (contact.id && contact.id.endsWith('@s.whatsapp.net')) { updateData.phoneNumber = contact.id.split('@')[0]; } - // Sync using LID or ID const primaryId = contact.lid || contact.id; if (primaryId && Object.keys(updateData).length > 0) { try { await db.collection('Chats').doc(primaryId).set(updateData, { merge: true }); - // Keep the fallback JID document synced as well if we routed via LID if (contact.lid && contact.id !== contact.lid) { await db.collection('Chats').doc(contact.id).set(updateData, { merge: true }); } - } catch (err) { - // Silent fail to keep logs clean - } + } catch (err) {} } } }); @@ -235,10 +251,11 @@ async function startWhatsApp() { const isFromMe = msg.key.fromMe || false; const senderName = isFromMe ? "Me" : (msg.pushName || "Unknown"); - // 1. Ensure Chat Document Exists + // 1. Ensure Chat Document Exists (Added preview for server-side grouping) await db.collection('Chats').doc(remoteJid).set({ lastActive: timestamp, - id: remoteJid + id: remoteJid, + preview: textContent // Makes server-side SSE preview processing extremely lightweight }, { merge: true }); // 2. Save Message @@ -255,9 +272,7 @@ async function startWhatsApp() { id: msg.key.id }, { merge: true }); - } catch (err) { - // Silent error handling - } + } catch (err) {} } }); } @@ -277,35 +292,306 @@ function parseCookies(request) { return list; } +const verifyLogsAccess = (req, res, next) => { + let token = req.query.token; + if (req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) { + token = req.headers.authorization.split(' ')[1]; + } + const cookies = parseCookies(req); + + if (token === SESSION_SECRET || cookies.auth_session === SESSION_SECRET) { + return next(); + } + res.status(401).send('Unauthorized'); +}; + +// --- SSE CONNECTION MANAGER --- +const MAX_CONNECTIONS_PER_TOKEN = 15; +const activeConnections = []; // Tracks all SSE connections for limits/heartbeats +const clients = { + chats: new Set(), + messages: new Map() // chatId -> Set of response objects +}; + +function enforceConnectionCeiling(req, res, cleanupFunction) { + const token = req.query.token || req.headers.authorization?.split(' ')[1]; + activeConnections.push({ res, token, cleanup: cleanupFunction }); + + // Enforce cap per token to prevent free instance exhaustion + const userConns = activeConnections.filter(c => c.token === token); + if (userConns.length > MAX_CONNECTIONS_PER_TOKEN) { + const oldestIdx = activeConnections.findIndex(c => c.token === token); + if (oldestIdx > -1) { + const oldest = activeConnections[oldestIdx]; + oldest.cleanup(); + oldest.res.end(); + activeConnections.splice(oldestIdx, 1); + } + } + + res.on('close', () => { + const idx = activeConnections.findIndex(c => c.res === res); + if (idx > -1) activeConnections.splice(idx, 1); + cleanupFunction(); + }); +} + +// Global heartbeat to keep Render connections alive +setInterval(() => { + activeConnections.forEach(({ res }) => { + try { res.write(': ping\n\n'); } catch (e) {} + }); +}, 25000); + +// --- SHARED FIRESTORE LISTENERS --- +const EXCLUDED_JIDS = new Set(['917278779512@s.whatsapp.net', '201554426618024@lid']); + +let chatsUnsubscribe = null; +const messageListeners = new Map(); // chatId -> unsubscribe function + +function startChatsListener() { + if (chatsUnsubscribe) return; + let isFirstRun = true; + chatsUnsubscribe = db.collection('Chats').onSnapshot(snapshot => { + if (isFirstRun) { + isFirstRun = false; + return; // Clients get initial state from .get(), only push deltas here + } + const changes = []; + snapshot.docChanges().forEach(change => { + if (!EXCLUDED_JIDS.has(change.doc.id)) { + changes.push({ type: change.type, doc: { id: change.doc.id, ...change.doc.data() } }); + } + }); + if (changes.length > 0) { + const payload = `event: update\ndata: ${JSON.stringify(changes)}\n\n`; + clients.chats.forEach(res => { try { res.write(payload); } catch(e){} }); + } + }); +} + +function startMessagesListener(chatId) { + if (messageListeners.has(chatId)) return; + let isFirstRun = true; + const unsub = db.collection('Chats').doc(chatId).collection('Messages') + .orderBy('timestamp', 'asc') + .onSnapshot(snapshot => { + if (isFirstRun) { + isFirstRun = false; + return; + } + const changes = []; + snapshot.docChanges().forEach(change => { + changes.push({ type: change.type, doc: { id: change.doc.id, ...change.doc.data() } }); + }); + if (changes.length > 0) { + const payload = `event: update\ndata: ${JSON.stringify(changes)}\n\n`; + const chatClients = clients.messages.get(chatId); + if (chatClients) { + chatClients.forEach(res => { try { res.write(payload); } catch(e){} }); + } + } + }); + messageListeners.set(chatId, unsub); +} + // --- EXPRESS ROUTES --- -// 1. Ping (UptimeRobot) app.get('/ping', (req, res) => { res.status(200).send('Pong'); }); -// 2. API: Verify Credentials -app.post('/api/verify', (req, res) => { - res.header("Access-Control-Allow-Origin", "*"); - res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); +// Auth Middleware for APIs +const verifyApiToken = (req, res, next) => { + const authHeader = req.headers.authorization; + let token = req.query.token; + + if (authHeader && authHeader.startsWith('Bearer ')) { + token = authHeader.split(' ')[1]; + } + + if (token === SESSION_SECRET) return next(); + res.status(401).json({ error: 'Unauthorized' }); +}; +app.post('/api/verify', (req, res) => { const { username, password } = req.body; if (username === AUTH_USER && password === AUTH_PASS) { - return res.json({ success: true }); + // Return token directly for the frontend to use in headers/EventSource + return res.json({ success: true, token: SESSION_SECRET }); } else { return res.status(401).json({ success: false }); } }); -// CORS Pre-flight app.options('/api/verify', (req, res) => { - res.header("Access-Control-Allow-Origin", "*"); - res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); res.sendStatus(200); }); -// 3. Login Page +// --- API: Server Sent Events --- +app.get('/api/chats/stream', verifyApiToken, async (req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + }); + res.write('\n'); // Flush headers + + const cleanup = () => { + clients.chats.delete(res); + if (clients.chats.size === 0 && chatsUnsubscribe) { + chatsUnsubscribe(); + chatsUnsubscribe = null; + } + }; + + enforceConnectionCeiling(req, res, cleanup); + clients.chats.add(res); + + // Initial load & Grouping server-side + try { + const snapshot = await db.collection('Chats').get(); + const grouped = {}; + + snapshot.docs.forEach(doc => { + if (EXCLUDED_JIDS.has(doc.id)) return; + + const data = { id: doc.id, ...doc.data() }; + const phone = data.phoneNumber || data.id.split('@')[0]; + + if (!grouped[phone]) { + grouped[phone] = { ...data, subIds: [data.id] }; + } else { + grouped[phone].subIds.push(data.id); + // Keep the most recent data + if ((data.lastActive || 0) > (grouped[phone].lastActive || 0)) { + grouped[phone].lastActive = data.lastActive; + grouped[phone].preview = data.preview || grouped[phone].preview; + } + if (data.customName) grouped[phone].customName = data.customName; + } + }); + + res.write(`event: initial\ndata: ${JSON.stringify(Object.values(grouped))}\n\n`); + } catch (e) { + console.error("Error sending initial chats:", e); + } + + startChatsListener(); +}); + +app.get('/api/messages/stream', verifyApiToken, async (req, res) => { + const { chatId, since } = req.query; + if (!chatId) return res.status(400).send('Missing chatId'); + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + }); + res.write('\n'); + + const cleanup = () => { + const chatClients = clients.messages.get(chatId); + if (chatClients) { + chatClients.delete(res); + if (chatClients.size === 0 && messageListeners.has(chatId)) { + // Garbage collect listener if no one is watching this chat anymore + messageListeners.get(chatId)(); + messageListeners.delete(chatId); + } + } + }; + + enforceConnectionCeiling(req, res, cleanup); + + if (!clients.messages.has(chatId)) { + clients.messages.set(chatId, new Set()); + } + clients.messages.get(chatId).add(res); + + try { + let query = db.collection('Chats').doc(chatId).collection('Messages').orderBy('timestamp', 'asc'); + if (since) { + query = query.where('timestamp', '>', parseInt(since, 10)); + } + + const snapshot = await query.get(); + const initialMessages = snapshot.docs.map(doc => doc.data()); + res.write(`event: initial\ndata: ${JSON.stringify(initialMessages)}\n\n`); + } catch (e) { + console.error("Error sending initial messages:", e); + } + + startMessagesListener(chatId); +}); + +// --- API: Standard Actions --- +app.post('/api/rename', verifyApiToken, async (req, res) => { + const { id, customName } = req.body; + if (!id || !customName) return res.status(400).json({ error: 'Missing parameters' }); + + try { + await db.collection('Chats').doc(id).set({ customName }, { merge: true }); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.get('/api/export', verifyApiToken, async (req, res) => { + try { + const exportData = { chats: {}, messages: {} }; + const chatsSnap = await db.collection('Chats').get(); + + for (const doc of chatsSnap.docs) { + exportData.chats[doc.id] = doc.data(); + const msgs = await db.collection('Chats').doc(doc.id).collection('Messages').get(); + exportData.messages[doc.id] = msgs.docs.map(m => m.data()); + } + + res.json(exportData); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// --- SYSTEM LOGS ROUTE --- +app.get('/logs', verifyLogsAccess, (req, res) => { + const wantsJson = req.query.format === 'json' || (req.headers.accept && req.headers.accept.includes('application/json')); + + if (wantsJson) { + return res.json(logBuffer); + } + + const logLines = logBuffer.map(l => { + const time = new Date(l.timestamp).toLocaleTimeString(); + const color = l.level === 'error' ? '#ff6b6b' : '#a9dc76'; + return `<div style="color: ${color}">[${time}] [${l.level.toUpperCase()}] ${l.message}</div>`; + }).join(''); + + res.send(` + <html> + <head> + <meta http-equiv="refresh" content="5"> + <title>System Logs</title> + <style> + body { font-family: monospace; background: #1e1e1e; color: #d4d4d4; padding: 20px; } + .log-container { background: #000; padding: 15px; border-radius: 5px; overflow-x: auto; max-width: 100%; white-space: pre-wrap; font-size: 14px; line-height: 1.5; } + </style> + </head> + <body onload="window.scrollTo(0,document.body.scrollHeight);"> + <h2 style="color: #fff; margin-top: 0;">System Logs</h2> + <div class="log-container"> + ${logLines.length > 0 ? logLines : '<div>No logs yet...</div>'} + </div> + </body> + </html> + `); +}); + +// --- WEB UI ROUTES --- app.get('/login', (req, res) => { res.send(` <html> @@ -333,7 +619,6 @@ app.get('/login', (req, res) => { `); }); -// 4. Login Action app.post('/login', (req, res) => { const { username, password, remember } = req.body; @@ -347,13 +632,11 @@ app.post('/login', (req, res) => { res.status(401).send('Invalid credentials. <a href="/login">Try again</a>'); }); -// 5. Logout app.get('/logout', (req, res) => { res.setHeader('Set-Cookie', 'auth_session=; Max-Age=0; Path=/;'); res.redirect('/login'); }); -// --- MIDDLEWARE --- const checkAuth = (req, res, next) => { if (!AUTH_USER || !AUTH_PASS) return next(); const cookies = parseCookies(req); @@ -365,7 +648,6 @@ const checkAuth = (req, res, next) => { app.use(checkAuth); -// 6. Main Route app.get('/', async (req, res) => { const logoutBtn = `<a href="/logout" style="position: absolute; top: 10px; right: 10px; padding: 8px 16px; background: #ff4444; color: white; text-decoration: none; border-radius: 4px; font-size: 14px;">Logout</a>`;
© notamitgamer • Site Built: 2026-09-05 01:53:16 UTC • git-mirror commit: c170d72 [view raw info]
Originally created with stagit • modified by notamitgamer
Forked from github.com/notamitgamer/git-mirror