transcript: fix duplicate copy on multi-device + iOS download hijacking the app
- Duplicate ("Transcript shows two times"): finalizeTranscript had a race — for the
SAME user on two devices (#12 multi-device), both devices leaving at once each
passed the subscriber membership check across an await before either removed the
sub, so both wrote a private transcript. Now the subscriber is CLAIMED
SYNCHRONOUSLY (subs.delete filter) before any await, so only the first writer wins.
Verified with a concurrency simulation (2 concurrent leaves -> 1 write).
- iOS download: the /mrec transcript link had no `download` attribute, so WKWebView
NAVIGATED to the file and loaded it inline with no way back (had to force-quit the
app). Added download + data-mime so browsers download it and the existing native
click-interceptor catches it: it now saves to the Files folder and opens in native
Quick Look (view + its own share/save — into Files or Word) instead of hijacking
the WebView. recDTO now exposes the recording mime.
Web/server only — no native build needed; live on next app launch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+8
-4
@@ -32,21 +32,25 @@ async function meetingContext(room) {
|
|||||||
async function finalizeTranscript(room, onlyUserId) {
|
async function finalizeTranscript(room, onlyUserId) {
|
||||||
const subs = transcriptSubs.get(room); if (!subs || !subs.size) { if (!onlyUserId) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } return; }
|
const subs = transcriptSubs.get(room); if (!subs || !subs.size) { if (!onlyUserId) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } return; }
|
||||||
const buf = transcriptBuffers.get(room) || [];
|
const buf = transcriptBuffers.get(room) || [];
|
||||||
const ids = onlyUserId ? (subs.has(onlyUserId) ? [onlyUserId] : []) : [...subs];
|
// CLAIM the subscriber(s) SYNCHRONOUSLY (before any await). Two concurrent finalize calls for the SAME uid —
|
||||||
|
// e.g. the same user's two devices both leaving at once (#12 multi-device) — would otherwise both pass the
|
||||||
|
// membership check during the awaits below and write the transcript TWICE (the "transcript shows two times"
|
||||||
|
// bug). subs.delete() returns true only for the first caller, so the loser claims nothing and writes nothing.
|
||||||
|
const candidates = onlyUserId ? (subs.has(onlyUserId) ? [onlyUserId] : []) : [...subs];
|
||||||
|
const ids = candidates.filter((uid) => subs.delete(uid));
|
||||||
if (ids.length && buf.length) {
|
if (ids.length && buf.length) {
|
||||||
const ctx = await meetingContext(room);
|
const ctx = await meetingContext(room);
|
||||||
const lines = buf.map((s) => { const ts = new Date(s.t); const hh = String(ts.getHours()).padStart(2, '0'), mm = String(ts.getMinutes()).padStart(2, '0'); return '[' + hh + ':' + mm + '] ' + s.speaker + ': ' + s.text; });
|
const lines = buf.map((s) => { const ts = new Date(s.t); const hh = String(ts.getHours()).padStart(2, '0'), mm = String(ts.getMinutes()).padStart(2, '0'); return '[' + hh + ':' + mm + '] ' + s.speaker + ': ' + s.text; });
|
||||||
const body = ctx.title + ' — transcript\n' + new Date(buf[0].t).toLocaleString() + '\n\n' + lines.join('\n') + '\n';
|
const body = ctx.title + ' — transcript\n' + new Date(buf[0].t).toLocaleString() + '\n\n' + lines.join('\n') + '\n';
|
||||||
for (const uid of ids) {
|
for (const uid of ids) {
|
||||||
let user = null; try { user = await R.users.byId(uid); } catch (_) {}
|
let user = null; try { user = await R.users.byId(uid); } catch (_) {}
|
||||||
if (!user) { subs.delete(uid); continue; }
|
if (!user) continue;
|
||||||
const id = A.id(); const file = 'm_' + id + '.txt';
|
const id = A.id(); const file = 'm_' + id + '.txt';
|
||||||
try { fs.writeFileSync(path.join(TRANS_DIR, file), body); } catch (e) { continue; }
|
try { fs.writeFileSync(path.join(TRANS_DIR, file), body); } catch (e) { continue; }
|
||||||
// groupId null → private to its creator (see canSeeRec / /mrec auth).
|
// groupId null → private to its creator (see canSeeRec / /mrec auth).
|
||||||
await R.recordings.create({ id, teamId: user.team_id, room, groupId: null, meetingId: ctx.meetingId, title: ctx.title, kind: 'transcript', file, mime: 'text/plain', size: null, durationMs: null, createdBy: uid, createdByName: user.name || user.email });
|
await R.recordings.create({ id, teamId: user.team_id, room, groupId: null, meetingId: ctx.meetingId, title: ctx.title, kind: 'transcript', file, mime: 'text/plain', size: null, durationMs: null, createdBy: uid, createdByName: user.name || user.email });
|
||||||
subs.delete(uid);
|
|
||||||
}
|
}
|
||||||
} else { ids.forEach((uid) => subs.delete(uid)); }
|
}
|
||||||
if (!subs.size) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } // last subscriber done
|
if (!subs.size) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } // last subscriber done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3229,7 +3229,13 @@ async function nativeSaveFile(url, name, meta){
|
|||||||
e.preventDefault(); e.stopPropagation(); // never let the WebView navigate to the file
|
e.preventDefault(); e.stopPropagation(); // never let the WebView navigate to the file
|
||||||
var P=C.Plugins;
|
var P=C.Plugins;
|
||||||
// Real inline save into the app's Files folder (id comes from /files/<id>; mime refines the subfolder).
|
// Real inline save into the app's Files folder (id comes from /files/<id>; mime refines the subfolder).
|
||||||
if(P&&P.Filesystem){ nativeSaveFile(href, a.getAttribute('download')||'', { mime:a.getAttribute('data-mime')||'' }); return; }
|
if(P&&P.Filesystem){
|
||||||
|
var isRec=/\/mrec\//.test(href); var dn=a.getAttribute('download')||'';
|
||||||
|
// recordings/transcripts: after saving, OPEN in native Quick Look (view + its own share/save — e.g. into
|
||||||
|
// Files or Word) instead of the WebView loading the file inline with no way back.
|
||||||
|
nativeSaveFile(href, dn, { mime:a.getAttribute('data-mime')||'' }).then(function(){ if(isRec){ var rid=String(href).split('?')[0].split('/').pop(); var rec=bzLibGet(rid); if(rec) bzOpenFile(rec, dn); } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
var isImg = /\.(png|jpe?g|gif|webp|heic|heif|bmp|svg)(\?|#|$)/i.test(href) || !!a.closest('.lightbox');
|
var isImg = /\.(png|jpe?g|gif|webp|heic|heif|bmp|svg)(\?|#|$)/i.test(href) || !!a.closest('.lightbox');
|
||||||
toast(isImg ? 'Press and hold the image, then tap "Save to Photos".' : 'Saving files needs the latest app update.');
|
toast(isImg ? 'Press and hold the image, then tap "Save to Photos".' : 'Saving files needs the latest app update.');
|
||||||
}, true); // capture phase so it wins over the lightbox / bubble handlers
|
}, true); // capture phase so it wins over the lightbox / bubble handlers
|
||||||
@@ -5077,7 +5083,7 @@ async function loadScheduledMeetings(){
|
|||||||
+'<div class="si-meta">'+meta.join(' · ')+'</div>'
|
+'<div class="si-meta">'+meta.join(' · ')+'</div>'
|
||||||
+(m.description?'<div class="si-desc">'+pEsc(m.description)+'</div>':'')
|
+(m.description?'<div class="si-desc">'+pEsc(m.description)+'</div>':'')
|
||||||
+(m.invited&&m.invited.length?'<div class="si-invited" title="'+pEsc(m.invited.join(', '))+'">'+ic('users',12)+' '+pEsc(m.invited.slice(0,3).join(', '))+(m.invited.length>3?(' +'+(m.invited.length-3)):'')+'</div>':'')
|
+(m.invited&&m.invited.length?'<div class="si-invited" title="'+pEsc(m.invited.join(', '))+'">'+ic('users',12)+' '+pEsc(m.invited.slice(0,3).join(', '))+(m.invited.length>3?(' +'+(m.invited.length-3)):'')+'</div>':'')
|
||||||
+(m.recordings&&m.recordings.length?'<div class="si-recs">'+m.recordings.map(r=>'<a class="rec-dl '+(r.kind==='video'?'vid':'txt')+'" href="'+pEsc(r.url)+'" title="Download '+(r.kind==='video'?'recording':'transcript')+'">'+ic('download',14)+'<span>'+(r.kind==='video'?'Recording':'Transcript')+'</span>'+(r.kind==='video'&&r.durationMs?'<span class="rd-dur">'+fmtElapsed(r.durationMs)+'</span>':'')+'</a>').join('')+'</div>':'')+'</div>'
|
+(m.recordings&&m.recordings.length?'<div class="si-recs">'+m.recordings.map(r=>{ var isVid=r.kind==='video'; var _dn=String(m.title||'Meeting').replace(/[\/\\:*?"<>|]+/g,'_')+(isVid?' recording':' transcript')+(isVid?(/mp4/.test(r.mime||'')?'.mp4':'.webm'):'.txt'); return '<a class="rec-dl '+(isVid?'vid':'txt')+'" href="'+pEsc(r.url)+'" download="'+pEsc(_dn)+'" data-mime="'+pEsc(r.mime||(isVid?'video/webm':'text/plain'))+'" title="Download '+(isVid?'recording':'transcript')+'">'+ic('download',14)+'<span>'+(isVid?'Recording':'Transcript')+'</span>'+(isVid&&r.durationMs?'<span class="rd-dur">'+fmtElapsed(r.durationMs)+'</span>':'')+'</a>'; }).join('')+'</div>':'')+'</div>'
|
||||||
+'<div class="si-actions">'
|
+'<div class="si-actions">'
|
||||||
+((m.status!=='past'&&!cancelled)?'<button class="iconbtn copylink" data-link="'+pEsc(m.link||'')+'" title="Copy invite link" aria-label="Copy invite link">'+ic('link',14)+'</button>':'')
|
+((m.status!=='past'&&!cancelled)?'<button class="iconbtn copylink" data-link="'+pEsc(m.link||'')+'" title="Copy invite link" aria-label="Copy invite link">'+ic('link',14)+'</button>':'')
|
||||||
+((m.status!=='past'&&!cancelled&&canStart)?'<button class="btn sm join" data-code="'+pEsc(m.roomCode)+'">'+(m.status==='running'?'Join':'Start')+'</button>':'')
|
+((m.status!=='past'&&!cancelled&&canStart)?'<button class="btn sm join" data-code="'+pEsc(m.roomCode)+'">'+(m.status==='running'?'Join':'Start')+'</button>':'')
|
||||||
|
|||||||
+1
-1
@@ -1320,7 +1320,7 @@ route('GET', '/api/meetings', async (req, res) => {
|
|||||||
// Attach recordings/transcripts. A recording is visible to its creator, group members, or people
|
// Attach recordings/transcripts. A recording is visible to its creator, group members, or people
|
||||||
// who can see the scheduled meeting it belongs to. Recordings not tied to a listed meeting become
|
// who can see the scheduled meeting it belongs to. Recordings not tied to a listed meeting become
|
||||||
// their own "Past meeting" entry (group calls show the group name).
|
// their own "Past meeting" entry (group calls show the group name).
|
||||||
const recDTO = (r) => ({ id: r.id, kind: r.kind, url: '/mrec/' + r.id, createdAt: r.created_at, durationMs: r.duration_ms, size: r.size, by: r.created_by_name });
|
const recDTO = (r) => ({ id: r.id, kind: r.kind, url: '/mrec/' + r.id, mime: r.mime || '', createdAt: r.created_at, durationMs: r.duration_ms, size: r.size, by: r.created_by_name });
|
||||||
const canSeeRec = async (r) => {
|
const canSeeRec = async (r) => {
|
||||||
if (r.kind === 'transcript') return r.created_by === u.id; // transcripts are private to their owner
|
if (r.kind === 'transcript') return r.created_by === u.id; // transcripts are private to their owner
|
||||||
if (r.created_by === u.id) return true;
|
if (r.created_by === u.id) return true;
|
||||||
|
|||||||
Reference in New Issue
Block a user