commit cae88b96df2039aeb09f7a42137f167c3d55c8a9
parent 51fbcf993a61848ed616857e47e8b63e8b6e2ab8
Author: Amit Dutta <mail@amit.is-a.dev>
Date: Tue, 25 Aug 2026 14:21:02 +0530
v4.2.1 Security Update, much faster than previous versions. (#4)
Diffstat:
| M | README.md | | | 56 | +++++++++++++++----------------------------------------- |
| M | index.html | | | 574 | +++++++++++++++++++++++++++++++++++++++++++++++-------------------------------- |
| M | index.js | | | 423 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------- |
3 files changed, 737 insertions(+), 316 deletions(-)
diff --git a/README.md b/README.md
@@ -2,17 +2,11 @@
A privacy-focused, self-hosted WhatsApp archiving tool. It captures messages (including deleted ones) via a linked device connection and stores them in your own Firebase Firestore database.
-> [!WARNING]
-> Kindly upgrade to v4.1.7, issues fixed:
-> - **Authentication Loop (Status 428):** Fixed an issue where new installations would get stuck in a continuous reconnection loop due to a deprecated `printQRInTerminal` setting in the Baileys library.
-> - **QR Code Rendering:** Removed the broken terminal QR generation and ensured the QR code is smoothly routed and rendered directly on the Express web interface.
+>[!TIP]
+> Now you can see the server logs in the frontend by going to `Settings` -> `System Diagnostics` -> Toggle `Live server logs`. Also you can visit `https://your-app.onrender.com/logs` after login to see the logs.
-> [!IMPORTANT]
-> **Update Notice**
->
-> I'm trying to build a feature to save media like images, videos, and voice messages — but Firebase's free tier caps storage at 1GB, so it's not viable for that. I'm planning to add this as an optional feature using Hugging Face for storage instead. Messages will continue to be stored in Firebase as before. Frontend and Firebase security are also being upgraded as part of this effort.
->
-> Okay, so, I made the [plan](plan.md).
+> [!WARNING]
+> **Security upgrade in v4.2.1:** versions before v4.2.1 shipped a public Firebase Web SDK config directly in the frontend, which let anyone who viewed the page source read your chat database directly, bypassing login entirely. As of v4.2.1, the frontend never talks to Firestore — it only talks to your Render backend over an authenticated connection (Server-Sent Events, bearer token). If you're on an older version, update and follow the revised Step 1 and Step 4 below, and update your Firestore rules to deny all direct client access.
> [!IMPORTANT]
> Using this logger is completely safe and will not get your WhatsApp account banned. Here is why:
@@ -21,8 +15,7 @@ A privacy-focused, self-hosted WhatsApp archiving tool. It captures messages (in
> * **Standard Linked Device:** The tool connects to WhatsApp using the official Multi-Device WebSocket protocol. To WhatsApp's servers, this connection looks exactly like you logging into standard WhatsApp Web on a secondary browser.
> * **No User Reports:** The number one cause of bans is other users reporting an account. Since this logger works silently in the background and does not interact with anyone, there is zero risk of being reported.
-### Check <a href="https://amit.is-a.dev/logger">guide</a> for detailed installation process.
-> *i forgot to upgrade the docs to 4.1.7, i need some time to do that.*
+### Check <a href="https://docs.amit.is-a.dev/whatsapp-logger/">guide</a> for detailed installation process.
### Important notes:
* It is recommended to download the **web app (PWA)** after the publication of the webpage for better security and native experience.
@@ -60,23 +53,16 @@ A privacy-focused, self-hosted WhatsApp archiving tool. It captures messages (in
* Start in **Production Mode**.
3. **Set Security Rules**:
* Go to the **Rules** tab in Firestore.
- * Replace the rules with the following (allows anyone to read, but only backend with Admin SDK can write):
+ * Replace the rules with the following. As of v4.2.1, the frontend never talks to Firestore directly — only your Render backend does, via the Admin SDK, which bypasses these rules entirely regardless of what they say. So there's no reason to allow any direct client access:
```javascript
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
- // 1. Allow Read: Essential for your HTML page to fetch chats.
- allow read: if true;
-
- // 2. Allow Update: Enables the "Rename Chat" feature from the frontend.
- // This allows updating existing documents (like changing the name)
- // but prevents creating NEW documents or Deleting them.
- allow update: if true;
-
- // 3. Block Create/Delete: Only the Backend (Render) can create new messages
- // or delete them. This prevents random people from injecting fake chats.
- allow create, delete: if false;
+ // Deny all direct client access. The Admin SDK (your Render backend)
+ // bypasses these rules entirely, so this only blocks browsers/apps
+ // that try to read or write Firestore directly with a client SDK.
+ allow read, write: if false;
}
}
}
@@ -85,11 +71,8 @@ A privacy-focused, self-hosted WhatsApp archiving tool. It captures messages (in
* Go to **Project Settings** (Gear icon) -> **Service accounts**.
* Click **Generate new private key**.
* This will download a `.json` file. **Keep this safe.** You will need its content for Render.
-5. **Get Frontend Configuration**:
- * Go to **Project Settings** -> **General**.
- * Scroll down to "Your apps" and click the **Web (</>)** icon.
- * Register app (nickname: "Logger Frontend").
- * Copy the `firebaseConfig` object (API Key, Project ID, etc.). You will need this for `index.html`.
+
+That's everything you need from Firebase — the frontend doesn't need any Firebase configuration at all.
---
@@ -127,26 +110,17 @@ A privacy-focused, self-hosted WhatsApp archiving tool. It captures messages (in
1. Download the `index.html` file from this repository.
2. Open `index.html` in a text editor (Notepad, VS Code, etc.).
-3. Locate the Configuration section (around line 675).
+3. Locate the Configuration section near the top of the `<script>` block.
4. **Fill in the details**:
* `RENDER_BACKEND_URL`: Your Render URL (e.g., `https://your-app.onrender.com` - **No trailing slash**).
- * `firebaseConfig`: The keys you copied in Step 1.5.
**It should look like this before you edit it:**
```javascript
const RENDER_BACKEND_URL = "";
-
- // Firebase Config
- const firebaseConfig = {
- apiKey: "",
- authDomain: "",
- projectId: "",
- storageBucket: "",
- messagingSenderId: "",
- appId: ""
- };
```
+ That's the only setting needed. As of v4.2.1, the frontend authenticates against your Render backend (`/api/verify`) and gets a session token back, used for every chat/message request over Server-Sent Events. Firebase credentials only ever live on the backend, set in Step 2.
+
5. **Deploy the Frontend**:
* You can host this single file anywhere:
* **Firebase Hosting** (Recommended): `firebase init` -> Hosting -> Select `public` directory -> Put `index.html` there -> `firebase deploy`.
diff --git a/index.html b/index.html
@@ -3,14 +3,14 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
- <title>WhatsApp Logger</title>
+ <title>WhatsApp Logger v4.2.1</title>
<!-- PWA Manifest -->
- <link rel="manifest" href="/assets/manifest-wpChat.json">
+ <link rel="manifest" href="manifest.json">
<meta name="theme-color" content="#006C4C">
<!-- SVG Favicon -->
- <link rel="icon" href="/assets/icon.svg">
+ <link rel="icon" href="icon.svg">
<!-- Fonts & Icons -->
<link rel="preconnect" href="https://fonts.googleapis.com">
@@ -29,9 +29,7 @@
}
</script>
- <!-- Firebase SDKs -->
- <script src="https://www.gstatic.com/firebasejs/10.8.0/firebase-app-compat.js"></script>
- <script src="https://www.gstatic.com/firebasejs/10.8.0/firebase-firestore-compat.js"></script>
+ <!-- FIREBASE SDKS REMOVED -->
<style>
:root {
@@ -531,6 +529,11 @@
}
}
+ /* Logs view CSS */
+ .logs-container { max-height: 150px; overflow-y: auto; background: #000; color: #a9dc76; font-family: monospace; font-size: 12px; padding: 12px; border-radius: 8px; margin-top: 8px; border: 1px solid var(--md-sys-color-outline-variant); }
+ .logs-container .error { color: #ff6b6b; }
+ .logs-container div { margin-bottom: 4px; }
+
</style>
</head>
<body>
@@ -648,7 +651,7 @@
<div class="sidebar-header">
<div style="display: flex; align-items: center; gap: 12px; padding: 12px 0;">
<h2 style="font-family: 'Lexend', sans-serif; font-size: 20px; font-weight: 500; color: var(--md-sys-color-on-surface); margin: 0;">Chat Log</h2>
- <span style="font-family: 'Lexend', sans-serif; font-size: 12px; font-weight: 500; color: var(--md-sys-color-on-surface-variant); background-color: var(--md-sys-color-surface-container-high); padding: 2px 8px; border-radius: 100px; display: inline-flex; align-items: center;">v4.1.7</span>
+ <span style="font-family: 'Lexend', sans-serif; font-size: 12px; font-weight: 500; color: var(--md-sys-color-on-surface-variant); background-color: var(--md-sys-color-surface-container-high); padding: 2px 8px; border-radius: 100px; display: inline-flex; align-items: center;">v4.2.1</span>
</div>
<div style="display:flex; gap: 8px;">
<button class="icon-btn ripple-surface" onclick="refreshApp()"><span class="material-symbols-rounded">refresh</span></button>
@@ -773,14 +776,27 @@
</div>
</div>
+ <!-- SYSTEM LOGS SECTION -->
+ <div class="setting-section" id="logsSection">
+ <div class="setting-title">System Diagnostics</div>
+ <div class="setting-row">
+ <div class="setting-label">
+ <h4>Live Server Logs</h4>
+ <p>Real-time events from your connected instance.</p>
+ </div>
+ <label class="switch"><input type="checkbox" id="logsToggle" onchange="toggleLogs()"><span class="slider"></span></label>
+ </div>
+ <div id="logsViewer" class="logs-container hidden"></div>
+ </div>
+
<div class="setting-section">
<div class="setting-title">Help & Support</div>
<div id="faqContainer"></div>
<div style="margin-top:24px; display:flex; gap:12px;">
- <button class="m3-btn filled" style="background:var(--md-sys-color-secondary-container); color:var(--md-sys-color-on-secondary-container); cursor: pointer;" onclick="window.open('https://amit.is-a.dev/logger#faq', '_blank')">View FAQ</button>
+ <button class="m3-btn filled" style="background:var(--md-sys-color-secondary-container); color:var(--md-sys-color-on-secondary-container); cursor: pointer;" onclick="window.open('https://docs.amit.is-a.dev/whatsapp-logger/faqs/', '_blank')">View FAQ</button>
</div>
<div style="margin-top:24px; display:flex; gap:12px;">
- <button class="m3-btn filled" style="background:var(--md-sys-color-secondary-container); color:var(--md-sys-color-on-secondary-container); cursor: pointer;" onclick="window.open('https://amit.is-a.dev/logger', '_blank')">View Docs</button>
+ <button class="m3-btn filled" style="background:var(--md-sys-color-secondary-container); color:var(--md-sys-color-on-secondary-container); cursor: pointer;" onclick="window.open('https://docs.amit.is-a.dev/whatsapp-logger/', '_blank')">View Docs</button>
</div>
</div>
@@ -930,25 +946,22 @@
};
const RENDER_BACKEND_URL = "";
- const firebaseConfig = {
- apiKey: "",
- authDomain: "",
- projectId: "",
- storageBucket: "",
- messagingSenderId: "",
- appId: ""
- };
- firebase.initializeApp(firebaseConfig);
- const db = firebase.firestore();
let activeChatId = null;
+ let activeChatIds = [];
let isCurrentChatGroup = false;
let deferredPrompt;
let currentMessages = [];
+ let rawChatsMap = new Map(); // Client-side store for updates
+ // --- SSE CONNECTION STATE ---
+ let chatEventSource = null;
+ let chatEventRetryCount = 0;
+ const activeMessageSources = new Map(); // Keep track of open message SSEs
+
// --- INDEXED DB CACHING ---
const DB_NAME = 'WPLoggerDB';
- const DB_VERSION = 2; // Incremented for chat list cache
+ const DB_VERSION = 2;
let dbInstance = null;
let isIdbSupported = true;
@@ -978,7 +991,6 @@
}
};
- // Safety timeout: Prevent infinite hangs on mobile browsers
const fallbackTimeout = setTimeout(() => {
console.warn('IDB init timed out');
isIdbSupported = false;
@@ -1002,7 +1014,6 @@
clearTimeout(fallbackTimeout);
dbInstance = e.target.result;
- // Close connection if another tab/process requests an upgrade, prevents blocking
dbInstance.onversionchange = () => {
dbInstance.close();
dbInstance = null;
@@ -1018,7 +1029,6 @@
};
req.onblocked = (e) => {
console.warn('IDB init blocked. Waiting for other connections to close...', e);
- // We rely on fallbackTimeout if it never unblocks
};
} catch (e) {
clearTimeout(fallbackTimeout);
@@ -1143,7 +1153,6 @@
navigator.serviceWorker.register('/assets/sw-wpChat.js').catch(err => {
console.warn('SW Register Fail:', err);
});
- // Reconnect Sync trigger
window.addEventListener('online', () => {
if (navigator.serviceWorker.controller) navigator.serviceWorker.controller.postMessage('sync_on_reconnect');
});
@@ -1197,16 +1206,13 @@
document.getElementById('profileName').value = localStorage.getItem('profileName') || '';
document.getElementById('profilePhone').value = localStorage.getItem('profilePhone') || '';
- // Safely parse BG to prevent querySelector crash
const bg = parseInt(localStorage.getItem('chatBg')) || 1;
changeBg(bg, false);
populateFAQ();
- // Setup Auth & App Routing with smooth transitions
- const authValid = localStorage.getItem('wp_auth_expiry') > Date.now();
+ const authValid = localStorage.getItem('wp_api_token') && localStorage.getItem('wp_auth_expiry') > Date.now();
if (authValid) {
- // If auth is valid, fade out the auth screen completely and show the app
document.getElementById('auth-screen').style.opacity = '0';
setTimeout(() => {
@@ -1228,9 +1234,8 @@
document.getElementById('app-layout').classList.add('visible');
loadChats();
}
- }, 400); // Wait for CSS opacity transition
+ }, 400);
} else {
- // If not valid, hide the spinner and reveal the login form
document.getElementById('authLoader').classList.add('hidden');
document.getElementById('authForm').classList.remove('hidden');
document.getElementById('authForm').classList.add('fade-in');
@@ -1489,9 +1494,12 @@
body: JSON.stringify({ username: document.getElementById('username').value, password: document.getElementById('password').value })
});
const data = await response.json();
- if(data.success) {
+ if(data.success && data.token) {
+ localStorage.setItem('wp_api_token', data.token);
+
if(document.getElementById('rememberMe').checked) localStorage.setItem('wp_auth_expiry', Date.now() + (3 * 24 * 60 * 60 * 1000));
- else localStorage.setItem('wp_auth_expiry', Date.now() + (1 * 60 * 60 * 1000)); // default 1 hr fallback
+ else localStorage.setItem('wp_auth_expiry', Date.now() + (1 * 60 * 60 * 1000));
+
revealApp();
} else throw new Error();
} catch (e) { err.style.opacity = '1'; btn.classList.remove('loading'); btn.disabled = false; }
@@ -1517,7 +1525,7 @@
async function clearAppCacheData() {
if (confirm("Are you absolutely sure you want to clear all cached data? This will clear your offline cache databases and instantly reload the workspace application.")) {
- closeSettingsSidebar(); // Call the new close function
+ closeSettingsSidebar();
document.getElementById('refreshModal').classList.add('active');
document.getElementById('refreshAuthStep').classList.add('hidden');
document.getElementById('refreshProgressStep').classList.remove('hidden');
@@ -1543,11 +1551,11 @@
function saveProfile() { localStorage.setItem('profileName', document.getElementById('profileName').value); localStorage.setItem('profilePhone', document.getElementById('profilePhone').value); }
function populateFAQ() {
const faqs = [
- {q: "Is this secure?", a: "Yes, data lives in your private Firebase. No one else can access it."},
+ {q: "Is this secure?", a: "Yes, data is secured on your own custom backend server and access is guarded by your API Token."},
{q: "Does it track deleted messages?", a: "Yes, once a message is received by the server, it is logged forever, even if the sender deletes it."},
{q: "Does my phone need to be online?", a: "No. Once linked, your Render server handles logging even if your phone is powered off."},
{q: "Is this legal?", a: "The tool uses standard WhatsApp Web protocols to function as a linked device. It is intended for personal archiving of your own conversations."},
- {q: "Does it save photos/videos?", a: "No. To keep the project free (Firebase offers limited storage), the logger only saves text content. Media files would fill up your free tier storage very quickly."},
+ {q: "Does it save photos/videos?", a: "No. To keep the project free, the logger only saves text content. Media files would fill up your free tier storage very quickly."},
{q: "Does it save View Once messages?", a: "No. WhatsApp does not send the content of `View Once` media to linked devices/web clients, so the logger cannot see them."}
];
const container = document.getElementById('faqContainer');
@@ -1568,7 +1576,6 @@
const input = document.getElementById('msgSearchInput');
if (searchContainer.classList.contains('hidden')) {
- // Clear and trigger input event to reset highlights when closed
input.value = '';
input.dispatchEvent(new Event('input'));
} else {
@@ -1583,7 +1590,6 @@
document.querySelectorAll('.msg-bubble').forEach(b => {
const textDiv = b.querySelector('.msg-text');
if(textDiv) {
- // Back up the original HTML on first search so we don't destroy structure
if (!textDiv.hasAttribute('data-orig')) {
textDiv.setAttribute('data-orig', textDiv.innerHTML);
}
@@ -1593,7 +1599,6 @@
if(term && rawText.toLowerCase().includes(term)) {
b.style.display = 'flex';
- // Negative lookahead regex: Highlights term ONLY if it's outside of HTML tags (<...>)
const safeTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(?![^<]*>)(${safeTerm})`, 'gi');
@@ -1601,20 +1606,21 @@
count++;
} else {
b.style.display = term ? 'none' : 'flex';
- textDiv.innerHTML = originalHTML; // Restore clean HTML
+ textDiv.innerHTML = originalHTML;
}
}
});
document.getElementById('searchCount').innerText = term ? `${count} found` : '';
});
+ // --- SSE: CHATS LIST ---
async function loadChats() {
const list = document.getElementById('contactList');
+ const token = localStorage.getItem('wp_api_token');
+ if(!token) return handleLogout();
- // Render initial cache instantly before networking kicks in
const cachedChats = await getCachedChats();
if (cachedChats && cachedChats.length > 0) {
- // Ensure it's correctly sorted descending by activity in cache
cachedChats.sort((a,b) => b.lastActive - a.lastActive);
renderChatList(cachedChats);
} else {
@@ -1622,35 +1628,87 @@
for(let i=0; i<5; i++) list.innerHTML += `<div class="skel-contact fade-in"><div class="skeleton skel-avatar"></div><div class="skel-text-col"><div class="skeleton" style="width:60%; height:16px;"></div><div class="skeleton" style="width:40%; height:12px;"></div></div></div>`;
}
- if (!navigator.onLine) return; // Skip snapshot if offline
+ if (!navigator.onLine) return;
- // Prevent duplicating listeners
- if(window.chatListUnsub) window.chatListUnsub();
+ if (chatEventSource) {
+ chatEventSource.close();
+ chatEventSource = null;
+ }
- // Use real-time listener instead of one-time .get()
- window.chatListUnsub = db.collection('Chats').onSnapshot(async snap => {
- const groups = {};
- const excluded = [];
+ function connectChatsStream() {
+ chatEventSource = new EventSource(`${RENDER_BACKEND_URL}/api/chats/stream?token=${token}`);
- snap.forEach(doc => {
- const d = doc.data();
- if (!d.lastActive || excluded.includes(doc.id)) return;
- const phone = d.phoneNumber || doc.id.split('@')[0];
- if (!groups[phone]) groups[phone] = { main: {id:doc.id, ...d}, ids: [doc.id] };
- else {
- groups[phone].ids.push(doc.id);
- if (d.lastActive > groups[phone].main.lastActive) groups[phone].main = {id:doc.id, ...d};
+ chatEventSource.addEventListener('initial', async (e) => {
+ chatEventRetryCount = 0;
+ try {
+ const rawChats = JSON.parse(e.data);
+ // Normalize subIds to ids to prevent silent crashes
+ const chats = rawChats.map(g => ({ ...g, ids: g.subIds || g.ids }));
+
+ rawChatsMap.clear();
+ chats.forEach(g => {
+ if (g.ids) {
+ g.ids.forEach(id => {
+ rawChatsMap.set(id, { id: id, lastActive: g.lastActive, phoneNumber: g.phoneNumber, customName: g.customName, displayName: g.displayName, preview: g.preview });
+ });
+ }
+ });
+
+ chats.sort((a,b) => (b.lastActive || 0) - (a.lastActive || 0));
+ await saveCachedChats(chats);
+ renderChatList(chats);
+ } catch (err) {
+ console.error("Chat list initial parse error:", err);
}
});
- // Sort descending by time
- const chats = Object.values(groups).map(g => ({...g.main, ids: g.ids})).sort((a,b) => b.lastActive - a.lastActive);
-
- await saveCachedChats(chats);
- renderChatList(chats);
- }, err => {
- console.error("Chat list sync error:", err);
- });
+ chatEventSource.addEventListener('update', async (e) => {
+ try {
+ const changes = JSON.parse(e.data);
+ let needsRebuild = false;
+
+ changes.forEach(change => {
+ if (change.type === 'added' || change.type === 'modified') {
+ rawChatsMap.set(change.doc.id, change.doc);
+ needsRebuild = true;
+ } else if (change.type === 'removed') {
+ rawChatsMap.delete(change.doc.id);
+ needsRebuild = true;
+ }
+ });
+
+ if (needsRebuild) {
+ const groups = {};
+ rawChatsMap.forEach((d, docId) => {
+ const phone = d.phoneNumber || docId.split('@')[0];
+ if (!groups[phone]) {
+ groups[phone] = { main: { id: docId, ...d }, ids: [docId] };
+ } else {
+ groups[phone].ids.push(docId);
+ if ((d.lastActive || 0) > (groups[phone].main.lastActive || 0)) {
+ groups[phone].main = { id: docId, ...d };
+ }
+ }
+ });
+
+ const chats = Object.values(groups).map(g => ({...g.main, ids: g.ids})).sort((a,b) => b.lastActive - a.lastActive);
+ await saveCachedChats(chats);
+ renderChatList(chats);
+ }
+ } catch (err) {
+ console.error("Chat list update parse error:", err);
+ }
+ });
+
+ chatEventSource.onerror = (err) => {
+ chatEventSource.close();
+ chatEventRetryCount++;
+ const backoff = Math.min(1000 * Math.pow(2, chatEventRetryCount), 30000);
+ setTimeout(connectChatsStream, backoff);
+ };
+ }
+
+ connectChatsStream();
}
function renderChatList(chats) {
@@ -1661,24 +1719,19 @@
return;
}
- // Remove empty state or skeleton loading if present
if (list.querySelector('.skel-contact') || list.innerHTML.includes('No chats found')) {
list.innerHTML = '';
}
- // Track active IDs to clean up stale nodes
const currentIds = new Set(chats.map(c => 'chat-' + c.id.replace(/[^a-zA-Z0-9]/g, '-')));
- // Since chats array is sorted, iteratively appending them perfectly preserves/updates the physical DOM order
chats.forEach((c) => {
const safeDomId = 'chat-' + c.id.replace(/[^a-zA-Z0-9]/g, '-');
let el = document.getElementById(safeDomId);
const primaryName = c.customName || c.displayName || c.phoneNumber || c.id;
const isGroup = c.id.includes('@g.us');
- let needsPreviewUpdate = false;
- // Create or Diff DOM Element
if (!el) {
el = document.createElement('div');
el.id = safeDomId;
@@ -1691,48 +1744,48 @@
<span class="chat-time" style="font-size: 12px; color: var(--md-sys-color-on-surface-variant); font-weight: 400; margin-left: 8px; flex-shrink: 0;"></span>
</div>
<div class="contact-sub preview-text" style="display:flex; align-items:center; margin-top:2px; color:var(--md-sys-color-on-surface-variant);">
- <span class="preview-content" style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis; width:100%;">
- <span style="opacity:0.5; font-size: 13px;">Syncing...</span>
- </span>
+ <span class="preview-content" style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis; width:100%;"></span>
</div>
</div>
`;
- needsPreviewUpdate = true;
el.dataset.lastActive = c.lastActive;
} else {
- // Update Name/Avatar if backend changed it
const nameSpan = el.querySelector('.name-text');
if (nameSpan.innerText !== primaryName) {
nameSpan.innerText = primaryName;
el.querySelector('.avatar').innerHTML = isGroup ? '<span class="material-symbols-rounded">group</span>' : escapeHTML(primaryName[0]).toUpperCase();
}
-
- // Check if new messages arrived in background
- const oldActive = parseFloat(el.dataset.lastActive || '0');
- if (c.lastActive > oldActive) {
- needsPreviewUpdate = true;
- el.dataset.lastActive = c.lastActive;
- }
+ el.dataset.lastActive = c.lastActive;
}
- // Re-append moves the element to its correct sorted spot at the bottom
list.appendChild(el);
el.onclick = () => openChat(c.ids, primaryName, el, isGroup, c.phoneNumber || c.id, c.lastActive);
- // Fetch new message text in the background if needed
- if (needsPreviewUpdate) {
- updatePreview(c, el, isGroup);
+ // Update server-side preview directly without extra network reads
+ const contentSpan = el.querySelector('.preview-content');
+ const timeSpan = el.querySelector('.chat-time');
+
+ if (c.lastActive) {
+ const msgDate = new Date(c.lastActive * 1000);
+ const today = new Date();
+ const isToday = msgDate.toDateString() === today.toDateString();
+
+ timeSpan.innerText = isToday
+ ? msgDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+ : msgDate.toLocaleDateString([], { month: 'short', day: 'numeric' });
+
+ contentSpan.innerHTML = escapeHTML(c.preview || 'Media / Call');
+ } else {
+ contentSpan.innerHTML = `<span style="font-style: italic; opacity: 0.7;">No messages</span>`;
}
- // If this is the currently open chat, dynamically update the header's timestamp
if (activeChatId && c.ids.includes(activeChatId)) {
const baseId = c.phoneNumber || c.id;
document.getElementById('headerId').innerText = `${baseId} • ${formatLastActive(c.lastActive)}`;
}
});
- // Cleanup stale DOM nodes
Array.from(list.children).forEach(child => {
if (child.id && child.id.startsWith('chat-') && !currentIds.has(child.id)) {
list.removeChild(child);
@@ -1740,69 +1793,10 @@
});
}
- async function updatePreview(c, el, isGroup) {
- let allMsgs = [];
- for (const id of c.ids) {
- const msgs = await getCachedMessages(id);
- allMsgs = allMsgs.concat(msgs);
- }
-
- allMsgs.sort((a, b) => b.timestamp - a.timestamp);
- let lastMsg = allMsgs.length > 0 ? allMsgs[0] : null;
-
- // Background Firebase Fetch
- if (navigator.onLine && (!lastMsg || c.lastActive > lastMsg.timestamp)) {
- try {
- let latestOnline = null;
- for (const id of c.ids) {
- const snap = await db.collection('Chats').doc(id).collection('Messages')
- .orderBy('timestamp', 'desc').limit(1).get();
-
- if (!snap.empty) {
- const data = snap.docs[0].data();
- data.chatId = id;
- if (!latestOnline || data.timestamp > latestOnline.timestamp) {
- latestOnline = data;
- }
- }
- }
- if (latestOnline && (!lastMsg || latestOnline.timestamp > lastMsg.timestamp)) {
- lastMsg = latestOnline;
- }
- } catch(err) {
- console.warn("Failed fetching realtime preview snippet", err);
- }
- }
-
- const contentSpan = el.querySelector('.preview-content');
- const timeSpan = el.querySelector('.chat-time');
-
- if (lastMsg) {
- const msgDate = new Date(lastMsg.timestamp * 1000);
- const today = new Date();
- const isToday = msgDate.toDateString() === today.toDateString();
-
- timeSpan.innerText = isToday
- ? msgDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
- : msgDate.toLocaleDateString([], { month: 'short', day: 'numeric' });
-
- let prefix = '';
- if (lastMsg.fromMe) {
- prefix = `<span class="material-symbols-rounded" style="font-size: 16px; vertical-align: text-bottom; margin-right: 4px; color: var(--md-sys-color-outline);">done_all</span>`;
- } else if (isGroup && lastMsg.senderName && lastMsg.senderName !== "Unknown") {
- prefix = `<span style="font-weight: 500;">${escapeHTML(lastMsg.senderName)}:</span> `;
- }
-
- const safePreviewText = escapeHTML(lastMsg.text || 'Media');
- contentSpan.innerHTML = `${prefix}${safePreviewText}`;
- } else {
- contentSpan.innerHTML = `<span style="font-style: italic; opacity: 0.7;">No messages</span>`;
- }
- }
-
- // --- UPDATED OPEN CHAT: DELTA SYNC + IDB + CINEMATIC UI ---
+ // --- SSE: MESSAGES SYNC ---
async function openChat(ids, name, el, isGroup, displayId, lastActive) {
activeChatId = ids[0];
+ activeChatIds = ids;
isCurrentChatGroup = isGroup;
document.getElementById('headerName').innerText = name;
document.getElementById('chatHeaderContent').style.opacity = '1';
@@ -1810,6 +1804,9 @@
const timeStr = lastActive ? formatLastActive(lastActive) : "Online";
document.getElementById('headerId').innerText = `${displayId} • ${timeStr}`;
document.getElementById('headerAvatar').innerHTML = isGroup ? `<span class="material-symbols-rounded">group</span>` : escapeHTML(name[0]).toUpperCase();
+
+ const token = localStorage.getItem('wp_api_token');
+ if(!token) return handleLogout();
if (window.matchMedia('(max-width: 768px)').matches) {
window.history.pushState({chatOpen: true}, "");
@@ -1817,17 +1814,18 @@
document.getElementById('chatArea').classList.add('active');
}
- if(window.unsub) window.unsub.forEach(u => u());
- window.unsub = [];
+ // Cleanup old connections
+ activeMessageSources.forEach((source, key) => {
+ source.close();
+ activeMessageSources.delete(key);
+ });
const container = document.getElementById('messagesContainer');
const spinnerWrap = document.getElementById('syncSpinnerWrap');
- // Show cinematic sync UI
spinnerWrap.classList.add('active');
container.classList.add('syncing');
- // 1. Fetch from Cache
let cachedMsgs = [];
for (const id of ids) {
const msgs = await getCachedMessages(id);
@@ -1837,79 +1835,101 @@
const msgsMap = new Map();
cachedMsgs.forEach(m => msgsMap.set(m.id, m));
- // Render Cache Immediately
if (cachedMsgs.length > 0) {
renderMsgs(msgsMap);
- setTimeout(() => scrollToBottom('auto'), 50); // Instantly jump to bottom on load
+ setTimeout(() => scrollToBottom('auto'), 50);
} else {
container.innerHTML = `<div class="skel-msg-group"><div class="skeleton skel-bubble skel-left"></div><div class="skeleton skel-bubble skel-right" style="width:60%"></div><div class="skeleton skel-bubble skel-left" style="width:30%"></div></div>`;
}
- // 2. Delta Sync with Firebase
let lastTs = 0;
- // Prevent cache poisoning by ensuring a baseline cache size before using delta sync.
- // If the cache only has 1 or 2 messages, it forces a full fetch to auto-heal the chat.
if (cachedMsgs.length >= 5) {
lastTs = Math.max(...cachedMsgs.map(m => m.timestamp));
}
- // Artificial delay to let spinner animation glide smoothly if cache was instant
await new Promise(r => setTimeout(r, 400));
let pendingSyncs = ids.length;
ids.forEach(id => {
- let query = db.collection('Chats').doc(id).collection('Messages').orderBy('timestamp');
- if (lastTs > 0) {
- query = query.where('timestamp', '>', lastTs);
- }
+ let retryCount = 0;
+
+ function connectMsgStream() {
+ const source = new EventSource(`${RENDER_BACKEND_URL}/api/messages/stream?chatId=${id}&since=${lastTs}&token=${token}`);
+ activeMessageSources.set(id, source);
- let isFirstSync = true;
- window.unsub.push(query.onSnapshot(async snap => {
- let isNew = false;
- const newToCache = [];
- snap.forEach(doc => {
- const data = doc.data();
- data.chatId = id;
- msgsMap.set(data.id, data);
- newToCache.push(data);
- isNew = true;
- });
+ let isFirstSync = true;
- const c = document.getElementById('messagesContainer');
- const wasAtBottom = (c.scrollHeight - c.scrollTop - c.clientHeight) <= 100;
+ const processPayload = async (dataArray, eventType) => {
+ let isNew = false;
+ const newToCache = [];
+
+ dataArray.forEach(item => {
+ // Unified parser since payload structures vary between initial (raw data) vs update (change docs)
+ const data = eventType === 'initial' ? item : item.doc;
+
+ // Ignore 'removed' events. This is an append-only logger, and ignoring removals prevents
+ // sliding-window bugs where the backend unloads old messages from memory/cache.
+ if (eventType === 'update' && item.type === 'removed') return;
+
+ data.chatId = id;
+ msgsMap.set(data.id, data);
+ newToCache.push(data);
+ isNew = true;
+ });
+
+ const c = document.getElementById('messagesContainer');
+ const wasAtBottom = (c.scrollHeight - c.scrollTop - c.clientHeight) <= 100;
- if (newToCache.length > 0) await saveCachedMessages(newToCache);
- if (isNew || cachedMsgs.length === 0) {
- renderMsgs(msgsMap);
+ if (newToCache.length > 0) await saveCachedMessages(newToCache);
- if (isFirstSync || wasAtBottom) {
- setTimeout(() => scrollToBottom(isFirstSync ? 'auto' : 'smooth'), 50);
- } else if (!isFirstSync && isNew && !wasAtBottom) {
- document.getElementById('newMsgBadge').classList.remove('hidden');
- document.getElementById('scrollFab').classList.add('visible');
+ if (isNew || cachedMsgs.length === 0) {
+ renderMsgs(msgsMap);
+
+ if (isFirstSync || wasAtBottom) {
+ setTimeout(() => scrollToBottom(isFirstSync ? 'auto' : 'smooth'), 50);
+ } else if (!isFirstSync && isNew && !wasAtBottom) {
+ document.getElementById('newMsgBadge').classList.remove('hidden');
+ document.getElementById('scrollFab').classList.add('visible');
+ }
}
- }
-
- if (isFirstSync) {
- isFirstSync = false;
- pendingSyncs--;
- if (pendingSyncs <= 0) {
- spinnerWrap.classList.remove('active');
- container.classList.remove('syncing');
+
+ if (isFirstSync) {
+ isFirstSync = false;
+ pendingSyncs--;
+ if (pendingSyncs <= 0) {
+ spinnerWrap.classList.remove('active');
+ container.classList.remove('syncing');
+ }
}
- }
- }, err => {
- console.error("Sync error:", err);
- if (isFirstSync) {
- isFirstSync = false;
- pendingSyncs--;
- if (pendingSyncs <= 0) {
- spinnerWrap.classList.remove('active');
- container.classList.remove('syncing');
+ };
+
+ source.addEventListener('initial', (e) => {
+ retryCount = 0;
+ try { processPayload(JSON.parse(e.data), 'initial'); } catch(err){ console.error("Message stream initial parse error:", err); }
+ });
+
+ source.addEventListener('update', (e) => {
+ try { processPayload(JSON.parse(e.data), 'update'); } catch(err){ console.error("Message stream update parse error:", err); }
+ });
+
+ source.onerror = (err) => {
+ source.close();
+ activeMessageSources.delete(id);
+
+ // Prevent leaking loading spinners
+ if (isFirstSync) {
+ isFirstSync = false; pendingSyncs--;
+ if (pendingSyncs <= 0) { spinnerWrap.classList.remove('active'); container.classList.remove('syncing'); }
}
- }
- }));
+
+ retryCount++;
+ const backoff = Math.min(1000 * Math.pow(2, retryCount), 30000);
+ setTimeout(() => { if (activeChatId === ids[0]) connectMsgStream(); }, backoff);
+ };
+ }
+
+ connectMsgStream();
});
}
@@ -1950,9 +1970,7 @@
const showSenderName = !m.fromMe && isCurrentChatGroup && m.senderName && m.senderName !== "Unknown" && !isSamePrev;
div.id = `msg-${m.timestamp}`; div.dataset.date = dateStr;
- // Escape all text first so HTML code isn't rendered
const safeText = escapeHTML(m.text || '');
- // Find URLs and wrap them in an anchor tag safely
const urlRegex = /(https?:\/\/[^\s]+)/g;
const formattedText = safeText ? safeText.replace(urlRegex, url => `<a href="${url}" target="_blank" rel="noopener noreferrer" style="color: var(--md-sys-color-primary); text-decoration: underline;">${url}</a>`) : '';
@@ -1961,7 +1979,6 @@
});
if(currentGroup) container.appendChild(currentGroup);
- // Auto-scroll logic is now handled strictly in openChat based on user's scroll position
}
function scrollToBottom(behavior = 'auto') {
@@ -2000,19 +2017,68 @@
} else document.getElementById('notFoundModal').classList.add('active');
}
- function handleLogout() { localStorage.removeItem('wp_auth_expiry'); localStorage.removeItem('wp_app_pin'); localStorage.removeItem('wp_app_bio'); location.reload(); }
- function closeChatMobile() { window.history.back(); }
+ function handleLogout() { localStorage.removeItem('wp_api_token'); localStorage.removeItem('wp_auth_expiry'); localStorage.removeItem('wp_app_pin'); localStorage.removeItem('wp_app_bio'); location.reload(); }
+
+ function closeChatMobile() {
+ window.history.back();
+ // Cleanup source on unmount
+ activeMessageSources.forEach((source, key) => {
+ source.close();
+ activeMessageSources.delete(key);
+ });
+ activeChatId = null;
+ }
+
window.addEventListener('popstate', () => { document.getElementById('chatArea').classList.remove('remove'); document.getElementById('chatArea').classList.remove('active'); });
- function downloadChat() {
- if(!currentMessages.length) return;
- const text = currentMessages.map(m => `[${new Date(m.timestamp*1000).toLocaleString()}] ${m.senderName}: ${m.text}`).join('\n');
- const blob = new Blob([text], {type:'text/plain'});
- const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'chat.txt'; a.click();
+ async function downloadChat() {
+ if(!activeChatId || !activeChatIds || activeChatIds.length === 0) return;
+ const token = localStorage.getItem('wp_api_token');
+ try {
+ const res = await fetch(`${RENDER_BACKEND_URL}/api/export?token=${token}`, { headers: { 'Authorization': `Bearer ${token}` } });
+
+ if (res.ok) {
+ const exportData = await res.json();
+
+ let allMessages = [];
+ activeChatIds.forEach(id => {
+ if (exportData.messages[id]) {
+ allMessages = allMessages.concat(exportData.messages[id]);
+ }
+ });
+
+ if (allMessages.length === 0) return;
+
+ // Sort by time
+ allMessages.sort((a,b) => a.timestamp - b.timestamp);
+ const text = allMessages.map(m => `[${new Date(m.timestamp*1000).toLocaleString()}] ${m.senderName}: ${m.text}`).join('\n');
+
+ const blob = new Blob([text], {type:'text/plain'});
+ const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'chat.txt'; a.click();
+ }
+ } catch (err) {
+ console.error("Export download error:", err);
+ }
}
function showRenameModal() { if(!activeChatId) return; document.getElementById('renameModal').classList.add('active'); document.getElementById('renameInput').value = document.getElementById('headerName').innerText; document.getElementById('chatMenu').classList.remove('show'); }
- async function saveRename() { const name = document.getElementById('renameInput').value; if(name) { await db.collection('Chats').doc(activeChatId).set({customName: name}, {merge:true}); document.getElementById('headerName').innerText = name; closeModal('renameModal'); loadChats(); } }
+
+ async function saveRename() {
+ const name = document.getElementById('renameInput').value;
+ const token = localStorage.getItem('wp_api_token');
+ if(name && token) {
+ await fetch(`${RENDER_BACKEND_URL}/api/rename`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
+ body: JSON.stringify({ id: activeChatId, customName: name })
+ });
+
+ document.getElementById('headerName').innerText = name;
+ closeModal('renameModal');
+ // Will automatically refresh in sidebar via SSE update hook
+ }
+ }
+
function showLogoutModal() { document.getElementById('logoutModal').classList.add('active'); }
document.getElementById('searchInput').addEventListener('input', (e) => {
@@ -2053,6 +2119,9 @@
if (!data.success) throw new Error();
+ // Re-sync Token to be safe
+ if (data.token) localStorage.setItem('wp_api_token', data.token);
+
document.getElementById('refreshAuthStep').classList.add('hidden');
document.getElementById('refreshProgressStep').classList.remove('hidden');
@@ -2079,14 +2148,15 @@
updateRefreshProgress(10, "Clearing local storage...");
const theme = localStorage.getItem('theme');
const bg = localStorage.getItem('chatBg');
+ const tkn = localStorage.getItem('wp_api_token');
localStorage.clear();
if(theme) localStorage.setItem('theme', theme);
if(bg) localStorage.setItem('chatBg', bg);
+ if(tkn) localStorage.setItem('wp_api_token', tkn);
updateRefreshProgress(15, "Wiping database...");
if(dbInstance) { dbInstance.close(); dbInstance = null; }
- // Slight delay helps mobile OS clear file locks before deletion
await new Promise(r => setTimeout(r, 100));
await new Promise(res => {
@@ -2095,13 +2165,11 @@
req.onsuccess = res;
req.onerror = res;
req.onblocked = () => {
- console.warn("Delete DB blocked by another process.");
res();
};
} catch(e) { res(); }
});
- // Slight delay before reopening
await new Promise(r => setTimeout(r, 200));
updateRefreshProgress(25, "Rebuilding database...");
@@ -2109,18 +2177,23 @@
await initIndexedDB();
updateRefreshProgress(30, "Fetching chat list...");
- const snap = await db.collection('Chats').get();
+
+ // HTTP Fetch replacement for hard-sync only
+ const token = localStorage.getItem('wp_api_token');
+ const res = await fetch(`${RENDER_BACKEND_URL}/api/export`, { headers: { 'Authorization': `Bearer ${token}` } });
+ const exportData = await res.json();
+
+ const chatsData = Object.values(exportData.chats);
const groups = {};
- const excluded = ['917278779512@s.whatsapp.net', '201554426618024@lid'];
+ const excluded = [''];
- snap.forEach(doc => {
- const d = doc.data();
- if (!d.lastActive || excluded.includes(doc.id)) return;
- const phone = d.phoneNumber || doc.id.split('@')[0];
- if (!groups[phone]) groups[phone] = { main: {id:doc.id, ...d}, ids: [doc.id] };
+ chatsData.forEach(d => {
+ if (!d.lastActive || excluded.includes(d.id)) return;
+ const phone = d.phoneNumber || d.id.split('@')[0];
+ if (!groups[phone]) groups[phone] = { main: d, ids: [d.id] };
else {
- groups[phone].ids.push(doc.id);
- if (d.lastActive > groups[phone].main.lastActive) groups[phone].main = {id:doc.id, ...d};
+ groups[phone].ids.push(d.id);
+ if (d.lastActive > groups[phone].main.lastActive) groups[phone].main = d;
}
});
@@ -2135,11 +2208,8 @@
updateRefreshProgress(30 + ((done / total) * 60), `Fetching: ${name}...`);
for (const id of chat.ids) {
- const msgSnap = await db.collection('Chats').doc(id).collection('Messages').get();
- const msgs = [];
- msgSnap.forEach(doc => {
- const data = doc.data(); data.chatId = id; msgs.push(data);
- });
+ const msgs = exportData.messages[id] || [];
+ msgs.forEach(m => m.chatId = id);
if (msgs.length > 0) await saveCachedMessages(msgs);
}
done++;
@@ -2154,16 +2224,14 @@
setTimeout(() => window.location.reload(), 2000);
}
}
- // Function to accurately measure total LocalStorage space occupied
+
function calculateLocalStorageUsage() {
let totalBytes = 0;
for (let key in localStorage) {
if (!localStorage.hasOwnProperty(key)) continue;
- // Key length + string content value length multiplied by 2 bytes (UTF-16 characters standard layout size)
totalBytes += ((key.length + localStorage[key].length) * 2);
}
- // Format into human readable metric layout outputs
const sizeInKB = (totalBytes / 1024).toFixed(2);
const displayElement = document.getElementById('localStorageSizeDisplay');
@@ -2175,6 +2243,46 @@
}
}
}
+
+ // --- OPTIONAL: DIAGNOSTICS LOGS ---
+ let logsInterval = null;
+
+ function toggleLogs() {
+ const isChecked = document.getElementById('logsToggle').checked;
+ const viewer = document.getElementById('logsViewer');
+ const token = localStorage.getItem('wp_api_token');
+
+ if (isChecked && token) {
+ viewer.classList.remove('hidden');
+ fetchAndRenderLogs(token);
+ logsInterval = setInterval(() => fetchAndRenderLogs(token), 10000);
+ } else {
+ viewer.classList.add('hidden');
+ if (logsInterval) clearInterval(logsInterval);
+ }
+ }
+
+ async function fetchAndRenderLogs(token) {
+ try {
+ const res = await fetch(`${RENDER_BACKEND_URL}/logs?format=json&token=${token}`);
+ if (res.ok) {
+ const logs = await res.json();
+ const viewer = document.getElementById('logsViewer');
+ viewer.innerHTML = '';
+
+ logs.forEach(l => {
+ const time = new Date(l.timestamp).toLocaleTimeString();
+ const div = document.createElement('div');
+ if (l.level === 'error') div.className = 'error';
+ div.innerText = `[${time}] [${l.level.toUpperCase()}] ${l.message}`;
+ viewer.appendChild(div);
+ });
+
+ viewer.scrollTop = viewer.scrollHeight;
+ }
+ } catch (e) {}
+ }
+
</script>
</body>
</html>
diff --git a/index.js b/index.js
@@ -22,6 +22,38 @@ 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();
+});
+
+// Answer every OPTIONS preflight immediately — before ANY auth middleware sees it
+app.options('*', (req, res) => {
+ res.sendStatus(200);
+});
+
+// --- 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 {
@@ -42,13 +74,89 @@ try {
const db = admin.firestore();
+// --- IN-MEMORY CACHE ---
+const EXCLUDED_JIDS = new Set(['917278779512@s.whatsapp.net', '201554426618024@lid']);
+
+let chatsCache = new Map(); // chatId -> chat data
+let messagesCache = new Map(); // chatId -> Map(msgId -> message data)
+let cacheReady = false;
+let consecutiveCacheFailures = 0;
+
+async function warmCache() {
+ try {
+ console.log("System: Warming cache from Firestore...");
+
+ // 1. Fetch Chats
+ const chatsSnap = await db.collection('Chats').get();
+ chatsSnap.forEach(doc => {
+ if (!EXCLUDED_JIDS.has(doc.id)) {
+ chatsCache.set(doc.id, { id: doc.id, ...doc.data() });
+ }
+ });
+
+ // 2. Fetch all Messages via CollectionGroup
+ const msgsSnap = await db.collectionGroup('Messages').get();
+ msgsSnap.forEach(doc => {
+ const chatId = doc.ref.parent.parent.id;
+ if (!messagesCache.has(chatId)) messagesCache.set(chatId, new Map());
+ messagesCache.get(chatId).set(doc.id, doc.data());
+ });
+
+ cacheReady = true;
+ consecutiveCacheFailures = 0;
+ console.log(`System: Cache warm. ${chatsCache.size} chats, ${msgsSnap.size} messages.`);
+
+ // 3. Start Permanent Listeners
+ startPermanentListeners();
+
+ } catch (err) {
+ consecutiveCacheFailures++;
+ const backoff = Math.min(5000 * Math.pow(2, consecutiveCacheFailures), 300000); // Max 5 min
+ console.error(`System: Cache warm failed (attempt ${consecutiveCacheFailures}). Retrying in ${backoff/1000}s.`, err.message);
+ setTimeout(warmCache, backoff);
+ }
+}
+
+function startPermanentListeners() {
+ // Shared Permanent Listener for Chats
+ db.collection('Chats').onSnapshot(snapshot => {
+ const changes = [];
+ snapshot.docChanges().forEach(change => {
+ if (EXCLUDED_JIDS.has(change.doc.id)) return;
+ const data = { id: change.doc.id, ...change.doc.data() };
+ chatsCache.set(change.doc.id, data);
+ changes.push({ type: change.type, 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){} });
+ }
+ });
+
+ // Shared Permanent Listener for Messages
+ db.collectionGroup('Messages').onSnapshot(snapshot => {
+ snapshot.docChanges().forEach(change => {
+ const chatId = change.doc.ref.parent.parent.id;
+ if (!messagesCache.has(chatId)) messagesCache.set(chatId, new Map());
+
+ const data = change.doc.data();
+ messagesCache.get(chatId).set(change.doc.id, data);
+
+ const chatClients = clients.messages.get(chatId);
+ if (chatClients) {
+ const payload = `event: update\ndata: ${JSON.stringify([{ type: change.type, doc: data }])}\n\n`;
+ chatClients.forEach(res => { try { res.write(payload); } catch(e){} });
+ }
+ });
+ });
+}
+
// --- FIRESTORE AUTH ADAPTER FOR BAILEYS ---
async function useFirestoreAuthState(db, collectionName = 'whatsapp_auth') {
const collection = db.collection(collectionName);
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) {
@@ -56,7 +164,7 @@ async function useFirestoreAuthState(db, collectionName = 'whatsapp_auth') {
}
};
- const readData = async (id) => {
+ const readData = async (id, throwOnError = false) => {
try {
const doc = await collection.doc(id).get();
if (doc.exists) {
@@ -64,6 +172,7 @@ async function useFirestoreAuthState(db, collectionName = 'whatsapp_auth') {
}
} catch (err) {
console.error("System: Error reading auth state:", err.message);
+ if (throwOnError) throw err;
}
return null;
};
@@ -76,8 +185,13 @@ 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();
+ let creds;
+ try {
+ // Pass true to strictly enforce failure up to startWhatsApp for backoff
+ creds = (await readData('creds', true)) || initAuthCreds();
+ } catch (err) {
+ throw err;
+ }
return {
state: {
@@ -115,7 +229,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');
}
};
@@ -126,11 +239,25 @@ let qrCodeData = null;
let sock = null;
let isConnected = false;
+let consecutiveAuthFailures = 0;
+let consecutiveConnectFailures = 0;
+
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');
+ let authResult;
+ try {
+ authResult = await useFirestoreAuthState(db, 'whatsapp_auth');
+ } catch (err) {
+ consecutiveAuthFailures++;
+ const backoff = Math.min(5000 * Math.pow(2, consecutiveAuthFailures), 300000); // cap 5 min
+ console.error(`System: Auth state read failed (attempt ${consecutiveAuthFailures}). Retrying in ${backoff/1000}s.`);
+ setTimeout(startWhatsApp, backoff);
+ return;
+ }
+ consecutiveAuthFailures = 0; // Reset on success
+
+ const { state, saveCreds, clearState } = authResult;
const { version } = await fetchLatestBaileysVersion();
console.log("System: Connecting to WhatsApp servers...");
@@ -139,7 +266,7 @@ async function startWhatsApp() {
version,
logger,
auth: state,
- browser: ["WhatsApp Logger v4.1.7", "Chrome", "4.1.7"],
+ browser: ["WhatsApp Logger v4.2.1", "Chrome", "4.2.1"],
syncFullHistory: true
});
@@ -157,28 +284,28 @@ async function startWhatsApp() {
const statusCode = lastDisconnect?.error?.output?.statusCode;
const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
- console.log(`System: Connection closed (Status: ${statusCode})`);
-
if (shouldReconnect) {
- console.log("System: Reconnecting in 5 seconds...");
- setTimeout(startWhatsApp, 5000);
+ consecutiveConnectFailures++;
+ const backoff = Math.min(5000 * Math.pow(2, consecutiveConnectFailures), 300000); // cap 5 min
+ console.log(`System: Connection closed (Status: ${statusCode}). Reconnecting in ${backoff/1000}s...`);
+ setTimeout(startWhatsApp, backoff);
} else {
console.log("System: Device Logged Out. Wiping session from Firestore.");
await clearState();
qrCodeData = null;
- startWhatsApp(); // Restart to grab a fresh QR code
+ consecutiveConnectFailures = 0;
+ startWhatsApp();
}
} else if (connection === 'open') {
console.log("System: Connection Open and Authenticated. Firebase Auth Sync Active.");
qrCodeData = null;
isConnected = true;
+ consecutiveConnectFailures = 0; // Reset on success
}
});
- // 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 +313,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) {}
}
}
});
@@ -238,7 +360,8 @@ async function startWhatsApp() {
// 1. Ensure Chat Document Exists
await db.collection('Chats').doc(remoteJid).set({
lastActive: timestamp,
- id: remoteJid
+ id: remoteJid,
+ preview: textContent
}, { merge: true });
// 2. Save Message
@@ -255,9 +378,7 @@ async function startWhatsApp() {
id: msg.key.id
}, { merge: true });
- } catch (err) {
- // Silent error handling
- }
+ } catch (err) {}
}
});
}
@@ -277,41 +398,262 @@ 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 = [];
+const clients = {
+ chats: new Set(),
+ messages: new Map()
+};
+
+function enforceConnectionCeiling(req, res, cleanupFunction) {
+ const token = req.query.token || req.headers.authorization?.split(' ')[1];
+ activeConnections.push({ res, token, cleanup: cleanupFunction });
+
+ 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);
+
+
// --- 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 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);
+// --- API: Server Sent Events ---
+app.get('/api/chats/stream', verifyApiToken, async (req, res) => {
+ if (!cacheReady) return res.status(503).json({ error: 'Cache still warming, retry shortly' });
+
+ res.writeHead(200, {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ 'Connection': 'keep-alive',
+ 'X-Accel-Buffering': 'no'
+ });
+ if (res.flushHeaders) res.flushHeaders();
+ if (res.socket) res.socket.setNoDelay(true);
+ res.write('\n');
+
+ const cleanup = () => {
+ clients.chats.delete(res);
+ };
+
+ enforceConnectionCeiling(req, res, cleanup);
+ clients.chats.add(res);
+
+ try {
+ const grouped = {};
+
+ chatsCache.forEach((data, docId) => {
+ const phone = data.phoneNumber || data.id.split('@')[0];
+
+ if (!grouped[phone]) {
+ grouped[phone] = { ...data, ids: [data.id] };
+ } else {
+ grouped[phone].ids.push(data.id);
+ 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);
+ }
+});
+
+app.get('/api/messages/stream', verifyApiToken, async (req, res) => {
+ if (!cacheReady) return res.status(503).json({ error: 'Cache still warming, retry shortly' });
+
+ 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',
+ 'X-Accel-Buffering': 'no'
+ });
+ if (res.flushHeaders) res.flushHeaders();
+ if (res.socket) res.socket.setNoDelay(true);
+ res.write('\n');
+
+ const cleanup = () => {
+ const chatClients = clients.messages.get(chatId);
+ if (chatClients) {
+ chatClients.delete(res);
+ if (chatClients.size === 0) clients.messages.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 initialMessages = [];
+ const chatMsgs = messagesCache.get(chatId);
+
+ if (chatMsgs) {
+ const sinceTs = since ? parseInt(since, 10) : 0;
+ for (const msg of chatMsgs.values()) {
+ if (msg.timestamp > sinceTs) {
+ initialMessages.push(msg);
+ }
+ }
+ initialMessages.sort((a, b) => a.timestamp - b.timestamp);
+ }
+
+ res.write(`event: initial\ndata: ${JSON.stringify(initialMessages)}\n\n`);
+ } catch (e) {
+ console.error("Error sending initial messages:", e);
+ }
+});
+
+// --- API: Standard Actions ---
+app.post('/api/rename', verifyApiToken, async (req, res) => {
+ if (!cacheReady) return res.status(503).json({ error: 'Cache still warming, retry shortly' });
+
+ const { id, customName } = req.body;
+ if (!id || !customName) return res.status(400).json({ error: 'Missing parameters' });
+
+ try {
+ // Fire & Forget to DB
+ await db.collection('Chats').doc(id).set({ customName }, { merge: true });
+
+ // Instant RAM update
+ if (chatsCache.has(id)) {
+ chatsCache.get(id).customName = customName;
+ }
+
+ res.json({ success: true });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.get('/api/export', verifyApiToken, async (req, res) => {
+ if (!cacheReady) return res.status(503).json({ error: 'Cache still warming, retry shortly' });
+
+ try {
+ const exportData = { chats: {}, messages: {} };
+
+ chatsCache.forEach((data, id) => exportData.chats[id] = data);
+ messagesCache.forEach((msgMap, chatId) => {
+ exportData.messages[chatId] = Array.from(msgMap.values());
+ });
+
+ 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>
+ `);
});
-// 3. Login Page
+// --- WEB UI ROUTES ---
app.get('/login', (req, res) => {
res.send(`
<html>
<body style="font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background: #f0f2f5;">
<form action="/login" method="POST" style="background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); width: 300px;">
- <h2 style="margin-top: 0; text-align: center;">WhatsApp Logger</h2>
+ <h2 style="margin-top: 0; text-align: center;">WhatsApp Logger <span style="font-size: 14px; font-weight: normal; color: #666;">v4.2.1</span></h2>
<div style="margin-bottom: 1rem;">
<label style="display: block; margin-bottom: 0.5rem;">Username</label>
<input type="text" name="username" required style="width: 100%; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box;">
@@ -333,7 +675,6 @@ app.get('/login', (req, res) => {
`);
});
-// 4. Login Action
app.post('/login', (req, res) => {
const { username, password, remember } = req.body;
@@ -347,13 +688,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 +704,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>`;
@@ -418,6 +756,7 @@ app.get('/', async (req, res) => {
// --- START SERVER ---
app.listen(PORT, () => {
+ warmCache();
startWhatsApp();
console.log(`Server running on port ${PORT}`);
});