fix: persist chat uploads on /data volume (broken images) + prevent duplicate chat sockets

- Broken images: UPLOADS_DIR/REC_DIR/TRANS_DIR were server/<dir> INSIDE the image,
  so every deploy.sh rebuild wiped uploaded files — old images 404'd ('broken
  image') though their DB rows survived. Make them env-overridable and point prod
  at /data/uploads|recordings|transcripts (persistent volume), matching DB/downloads.
  NOTE: files already lost to prior rebuilds can't be recovered; new uploads persist.
- Duplicate notifications: harden connectChatWs — close/detach any prior socket
  before opening a new one and keep a single pending reconnect timer, so a flaky
  reconnect can't leave two live sockets delivering every event/notification twice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 13:09:24 +05:30
parent 7d284213d1
commit 1128f9811a
3 changed files with 19 additions and 5 deletions
+5
View File
@@ -14,6 +14,11 @@ services:
# Desktop installers + auto-update feed live on the persistent volume so uploaded
# builds survive image rebuilds (a plain image path would be wiped on every deploy).
- DOWNLOADS_DIR=/data/downloads
# Chat uploads / recordings / transcripts on the persistent volume too, so they survive image
# rebuilds (otherwise old shared images 404 as "broken image" after every deploy).
- UPLOADS_DIR=/data/uploads
- REC_DIR=/data/recordings
- TRANS_DIR=/data/transcripts
# Secrets (TURN credentials, SSO_SECRET, BIZGAZE_WEBHOOK_URL, etc.) live in
# a .env file next to this compose file. It is gitignored — never committed.
# See .env.example for the expected keys.
+6 -3
View File
@@ -3,9 +3,12 @@ const fs = require('fs');
const path = require('path');
const PUBLIC_DIR = path.join(__dirname, 'public');
const REC_DIR = path.join(__dirname, 'recordings');
const TRANS_DIR = path.join(__dirname, 'transcripts');
const UPLOADS_DIR = path.join(__dirname, 'uploads');
// Uploaded chat files, recordings and transcripts MUST live on the persistent volume (like the DB
// and downloads). With the old in-image path, every deploy.sh rebuild wiped them — old images/files
// then 404 ("broken image") while their DB rows survive. Overridable so prod points them at /data.
const REC_DIR = process.env.REC_DIR || path.join(__dirname, 'recordings');
const TRANS_DIR = process.env.TRANS_DIR || path.join(__dirname, 'transcripts');
const UPLOADS_DIR = process.env.UPLOADS_DIR || path.join(__dirname, 'uploads');
// Desktop installers + auto-update feed (latest.yml). Override with DOWNLOADS_DIR to point at a
// mounted volume in production; IT drops the electron-builder dist/ output here.
const DOWNLOADS_DIR = process.env.DOWNLOADS_DIR || path.join(__dirname, 'downloads');
+8 -2
View File
@@ -788,7 +788,7 @@
<body>
<script src="/icons.js?v=4"></script>
<script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script>
<script>window.__BUILD='2026-07-07-batch43';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-07-batch44';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
// Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load
// (emojis stay as plain Unicode). (#5)
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
@@ -2252,12 +2252,18 @@ async function refreshOpenThread(){
});
if(changed){ THREAD.sort((a,b)=>(a.created_at||0)-(b.created_at||0)); THREAD_CACHE.set(kind+':'+id, THREAD.slice()); renderThread(); }
}
let _chatReconnectT=null;
function connectChatWs(){
try{
// Defensive: drop any prior socket first so a flaky reconnect can't leave TWO live sockets —
// which would deliver every event (and notification) twice. Detach its handlers before closing
// so its onclose doesn't schedule yet another reconnect.
if(chatWs){ try{ chatWs.onopen=chatWs.onmessage=chatWs.onclose=chatWs.onerror=null; chatWs.close(); }catch(_){} chatWs=null; }
if(_chatReconnectT){ clearTimeout(_chatReconnectT); _chatReconnectT=null; }
chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
chatWs.onopen=()=>{ try{ chatWs.send(JSON.stringify({type:'chat-hello'})); }catch(_){} if(_chatConnectedOnce) resyncChat(); _chatConnectedOnce=true; };
chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-edited') onChatEdited(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='presence') onPresence(d); else if(d.type==='chat-typing') onTyping(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); };
chatWs.onclose=()=>{ setTimeout(connectChatWs, 3000); }; // auto-reconnect
chatWs.onclose=()=>{ if(_chatReconnectT) clearTimeout(_chatReconnectT); _chatReconnectT=setTimeout(connectChatWs, 3000); }; // auto-reconnect (single pending timer)
}catch(_){}
}