Files
Sravan 3250530596 feat(db): complete async call-site conversion — Phase 3 done, validated on SQLite
The full sync→async conversion is complete and green on the SQLite backend. Every
DB call across the app now awaits the async adapter, so the identical code runs on
Postgres at cutover.

Converted (this commit finishes Phase 3):
- session.js: currentUser/apiKeyFromReq async → 63 route awaits + WS + static.
- routes.js: all ~250 R.* awaited; DTO helpers (namesFor, avatarsFor, buildMsgDTO,
  buildPollDTO, reactionsForMessage, postSystemMessage, pushGroupUpdate,
  issueRefreshToken, provisionFromBizgaze) made async; every `.map(x=>buildDTO(x))`
  restructured to `await Promise.all(...map(async...))` preserving order; `.filter`
  predicates that hit the DB moved to an `asyncFilter` helper; chained
  `R.x.y(...).length/.map/.filter` wrapped as `(await R.x.y(...)).method`; stream
  upload handlers (recording/transcript/attachment) made async.
- calls.js / signaling.js: all call/meeting fns async; leaveMeeting AWAITS
  persistCallHistory + finalizeTranscript BEFORE endCallByRoom (ordering matters —
  fire-and-forget would race the map teardown); WS handle()/cleanup() async with
  .catch guards.
- static.js: authAttachment(Raw) async (the .some carrier check became a loop),
  handleGet async; server.js dispatch catches handler rejections → 500 not a hang.
- media.js backfill, push.js, reminders.js, webhooks.js await their repo calls.

Validation on DB_BACKEND=sqlite: db-smoke 22/22; legacy e2e 80 checks pass with zero
FAILs (throws only at a PRE-EXISTING WS lobby-drift assertion, unrelated). Every
server file `node --check` clean.

Still on the branch — master untouched. Next: Phase 5 (pg backend + ~7 dialect
queries + data migration + Docker Postgres + cutover), then merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 22:06:27 +05:30

280 lines
16 KiB
JavaScript

// Static file serving + authenticated recording/transcript downloads.
// handleGet() is the fallback for any GET that didn't match an API route.
const fs = require('fs');
const path = require('path');
const R = require('./repos');
const { json } = require('./lib');
const { currentUser } = require('./session');
const { PUBLIC_DIR, REC_DIR, TRANS_DIR, UPLOADS_DIR, DOWNLOADS_DIR } = require('./config');
const media = require('./media');
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.json': 'application/json', '.webmanifest': 'application/manifest+json' };
// Authorize an attachment id: the uploader, a member of the group using it as an avatar, or a participant of
// ANY message carrying it (the "any" covers forwarded attachments, which reuse the same id). Returns the row.
async function authAttachmentRaw(id, u) {
const a = await R.attachments.byId(id);
if (!a || a.team_id !== u.team_id) return null;
const avatarGroup = await R.conversations.byAvatar(id);
const carriers = await R.messages.allByAttachment(id);
let ok = a.uploader_id === u.id || (avatarGroup && await R.conversations.isMember(avatarGroup.id, u.id));
if (!ok) {
// A .some() predicate can't await, so walk the carriers explicitly.
for (const msg of carriers) {
const carried = msg.conversation_id
? await R.conversations.isMember(msg.conversation_id, u.id)
: (msg.sender_id === u.id || msg.recipient_id === u.id);
if (carried) { ok = true; break; }
}
}
return ok ? a : null;
}
// Video playback fires MANY /files Range requests, and authAttachmentRaw scans messages by attachment_id
// each time — that per-chunk scan is what made playback stutter ("buffers and plays…"). Cache the resolved
// decision per user+attachment for 60s so range requests after the first are ~free. Bounded to keep memory flat.
const _attAuth = new Map();
async function authAttachment(id, u) {
const key = u.id + ':' + id, now = Date.now();
const hit = _attAuth.get(key);
if (hit && hit.exp > now) return hit.a;
const a = await authAttachmentRaw(id, u);
if (_attAuth.size > 4000) _attAuth.clear();
_attAuth.set(key, { a, exp: now + 60000 });
return a;
}
// Serve a file with HTTP Range support (206 Partial Content) — required for reliable iOS video
// streaming and for seeking anywhere other than the start.
function sendRanged(req, res, fp, headers) {
return fs.stat(fp, (err, st) => {
if (err) return json(res, 404, { error: 'not found' });
const total = st.size;
const base = Object.assign({ 'Accept-Ranges': 'bytes' }, headers);
const mm = req.headers.range && /^bytes=(\d*)-(\d*)$/.exec(req.headers.range);
if (mm) {
let start = mm[1] ? parseInt(mm[1], 10) : 0;
let end = mm[2] ? parseInt(mm[2], 10) : total - 1;
if (isNaN(start)) start = 0;
if (isNaN(end) || end >= total) end = total - 1;
if (start > end || start >= total) { res.writeHead(416, { 'Content-Range': 'bytes */' + total }); return res.end(); }
res.writeHead(206, Object.assign({}, base, { 'Content-Range': 'bytes ' + start + '-' + end + '/' + total, 'Content-Length': (end - start + 1) }));
const rs = fs.createReadStream(fp, { start, end });
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
return rs.pipe(res);
}
res.writeHead(200, Object.assign({}, base, { 'Content-Length': total }));
const rs = fs.createReadStream(fp);
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
rs.pipe(res);
});
}
function serveStatic(req, res) {
let p = req.url.split('?')[0];
if (p === '/') p = '/index.html';
if (p === '/home') p = '/home.html';
// Console was replaced by Dashboard; keep the old path working.
if (p === '/console' || p === '/dashboard') p = '/dashboard.html';
if (p === '/share') p = '/share.html';
if (p === '/connect') p = '/connect.html';
const fp = path.join(PUBLIC_DIR, path.normalize(p));
if (!fp.startsWith(PUBLIC_DIR)) return json(res, 403, { error: 'forbidden' });
// ETag + revalidation: the browser keeps the file cached and we answer repeat loads with a
// tiny 304 (no re-download) when nothing changed — fast reloads, but always fresh on edits.
fs.stat(fp, (serr, st) => {
if (serr || !st.isFile()) return json(res, 404, { error: 'not found' });
const ext = path.extname(fp);
const ct = MIME[ext] || 'application/octet-stream';
// HTML entry pages are NEVER cached (no-store) so a deploy reaches every browser on the
// next load — no hard-refresh needed. Versioned assets (e.g. icons.js?v=) still revalidate
// cheaply via ETag/304.
const isHtml = ext === '.html';
const etag = '"' + st.size.toString(16) + '-' + Math.round(st.mtimeMs).toString(16) + '"';
if (!isHtml && req.headers['if-none-match'] === etag) {
res.writeHead(304, { ETag: etag, 'Cache-Control': 'no-cache' });
return res.end();
}
fs.readFile(fp, (err, data) => {
if (err) return json(res, 404, { error: 'not found' });
const headers = { 'Content-Type': ct, 'Content-Length': st.size, 'Cache-Control': isHtml ? 'no-store, must-revalidate' : 'no-cache' };
if (!isHtml) headers.ETag = etag;
res.writeHead(200, headers);
res.end(data);
});
});
}
// GET fallback: authenticated transcript/recording downloads, else static files.
async function handleGet(req, res) {
const pathOnly = req.url.split('?')[0];
// Stable "latest Windows installer" link (used by the site's Download button). Reads the
// electron-updater manifest and redirects to the current versioned .exe.
if (pathOnly === '/download/windows') {
try {
const yml = fs.readFileSync(path.join(DOWNLOADS_DIR, 'latest.yml'), 'utf8');
const m = yml.match(/^path:\s*(.+)$/m);
const file = m ? m[1].trim().replace(/^['"]|['"]$/g, '') : null;
if (file) { res.writeHead(302, { Location: '/downloads/' + encodeURIComponent(file), 'Cache-Control': 'no-cache' }); return res.end(); }
} catch (_) {}
return json(res, 404, { error: 'No Windows build is available yet.' });
}
// Public download feed: installers (.exe), the update manifest (latest.yml) and .blockmap.
// Range support so large installers resume and electron-updater can do differential updates.
if (pathOnly.startsWith('/downloads/')) {
const name = path.basename(decodeURIComponent(pathOnly));
if (!name || name.startsWith('.')) return json(res, 404, { error: 'not found' });
const fp = path.join(DOWNLOADS_DIR, name);
if (!fp.startsWith(DOWNLOADS_DIR)) return json(res, 403, { error: 'forbidden' });
return fs.stat(fp, (err, st) => {
if (err || !st.isFile()) return json(res, 404, { error: 'not found' });
const ext = path.extname(name).toLowerCase();
const ct = ext === '.yml' ? 'text/yaml; charset=utf-8' : ext === '.dmg' ? 'application/x-apple-diskimage' : 'application/octet-stream';
const headers = { 'Content-Type': ct, 'Accept-Ranges': 'bytes', 'Cache-Control': ext === '.yml' ? 'no-cache' : 'public, max-age=86400' };
if (ext === '.exe' || ext === '.dmg' || ext === '.appimage') headers['Content-Disposition'] = 'attachment; filename="' + name + '"';
const range = req.headers.range;
if (range && /^bytes=\d*-\d*$/.test(range)) {
const [s, e] = range.replace('bytes=', '').split('-');
const start = s ? parseInt(s, 10) : 0; const end = e ? parseInt(e, 10) : st.size - 1;
if (start >= st.size || end >= st.size || start > end) { res.writeHead(416, { 'Content-Range': 'bytes */' + st.size }); return res.end(); }
headers['Content-Range'] = 'bytes ' + start + '-' + end + '/' + st.size; headers['Content-Length'] = end - start + 1;
res.writeHead(206, headers);
const rs = fs.createReadStream(fp, { start, end }); rs.on('error', () => { try { res.destroy(); } catch (_) {} }); return rs.pipe(res);
}
headers['Content-Length'] = st.size;
res.writeHead(200, headers);
const rs = fs.createReadStream(fp); rs.on('error', () => { try { res.destroy(); } catch (_) {} }); rs.pipe(res);
});
}
if (pathOnly.startsWith('/transcripts/')) {
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const name = path.basename(decodeURIComponent(pathOnly));
const sid = name.replace(/\.txt$/i, '');
const row = await R.sessionsLog.byIdInTenant(sid, u.team_id);
if (!row || !row.transcript) return json(res, 404, { error: 'not found' });
const fp = path.join(TRANS_DIR, row.transcript);
if (!fp.startsWith(TRANS_DIR)) return json(res, 403, { error: 'forbidden' });
return fs.stat(fp, (err, st) => {
if (err) return json(res, 404, { error: 'not found' });
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': st.size, 'Content-Disposition': 'attachment; filename="transcript-' + sid + '.txt"', 'Cache-Control': 'no-store' });
const rs = fs.createReadStream(fp);
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
rs.pipe(res);
});
}
if (pathOnly.startsWith('/recordings/')) {
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const name = path.basename(decodeURIComponent(pathOnly));
const sid = name.replace(/\.(webm|mp4)$/i, '');
const row = await R.sessionsLog.byIdInTenant(sid, u.team_id);
if (!row || !row.recording) return json(res, 404, { error: 'not found' });
const fp = path.join(REC_DIR, row.recording);
if (!fp.startsWith(REC_DIR)) return json(res, 403, { error: 'forbidden' });
const ext = path.extname(row.recording).toLowerCase() === '.mp4' ? 'mp4' : 'webm';
const ctype = ext === 'mp4' ? 'video/mp4' : 'video/webm';
return fs.stat(fp, (err, st) => {
if (err) return json(res, 404, { error: 'not found' });
res.writeHead(200, { 'Content-Type': ctype, 'Content-Length': st.size, 'Content-Disposition': 'attachment; filename="session-' + sid + '.' + ext + '"', 'Cache-Control': 'no-store', 'Accept-Ranges': 'bytes' });
const rs = fs.createReadStream(fp);
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
rs.pipe(res);
});
}
// Meeting recordings & transcripts (/mrec/<id>). Visible to the creator, group members, or those
// who can see the scheduled meeting it belongs to.
if (pathOnly.startsWith('/mrec/')) {
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const id = path.basename(decodeURIComponent(pathOnly));
const r = await R.recordings.byId(id);
if (!r || r.team_id !== u.team_id || !r.file) return json(res, 404, { error: 'not found' });
let allowed = r.created_by === u.id;
if (r.kind === 'transcript') allowed = r.created_by === u.id; // transcripts are private to their owner
else {
if (!allowed && r.group_id) allowed = await R.conversations.isMember(r.group_id, u.id);
if (!allowed && r.meeting_id) { const s = await R.scheduledMeetings.byId(r.meeting_id); if (s) allowed = s.created_by === u.id || (s.participants && s.participants.includes('"' + u.id + '"')); }
}
if (!allowed) return json(res, 403, { error: 'forbidden' });
const isVideo = r.kind === 'video';
const dir = isVideo ? REC_DIR : TRANS_DIR;
const fp = path.join(dir, r.file);
if (!fp.startsWith(dir)) return json(res, 403, { error: 'forbidden' });
const ext = isVideo ? 'webm' : 'txt';
const fname = String(r.title || 'meeting').replace(/[^a-z0-9 _-]/gi, '').trim().slice(0, 40) || 'meeting';
return fs.stat(fp, (err, st) => {
if (err) return json(res, 404, { error: 'not found' });
res.writeHead(200, { 'Content-Type': r.mime || (isVideo ? 'video/webm' : 'text/plain; charset=utf-8'), 'Content-Length': st.size, 'Content-Disposition': 'attachment; filename="' + fname + '-' + (isVideo ? 'recording' : 'transcript') + '.' + ext + '"', 'Cache-Control': 'no-store', 'Accept-Ranges': 'bytes' });
const rs = fs.createReadStream(fp);
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
rs.pipe(res);
});
}
// Video POSTER thumbnail — first frame extracted with ffmpeg, cached next to the file. Cosmetic: if ffmpeg
// is missing or fails we 404 and the <video> just falls back to its own (black) poster.
if (pathOnly.startsWith('/thumbs/')) {
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const id = path.basename(decodeURIComponent(pathOnly));
const a = await authAttachment(id, u);
if (!a) return json(res, 404, { error: 'not found' });
if (!/^video\//.test(a.mime || '')) return json(res, 404, { error: 'not a video' });
const src = path.join(UPLOADS_DIR, id);
const thumb = path.join(UPLOADS_DIR, id + '.thumb.jpg');
if (!src.startsWith(UPLOADS_DIR)) return json(res, 403, { error: 'forbidden' });
const send = () => fs.stat(thumb, (e, st) => {
if (e) return json(res, 404, { error: 'no thumbnail' });
res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Content-Length': st.size, 'Cache-Control': 'private, max-age=604800' });
const rs = fs.createReadStream(thumb); rs.on('error', () => { try { res.destroy(); } catch (e2) {} }); rs.pipe(res);
});
if (fs.existsSync(thumb)) return send();
// Write to a temp then rename — media.js may pre-generate the same poster at upload, and two writers
// to the same path could otherwise serve a half-written JPEG.
const tmp = thumb + '.req.part';
return require('child_process').execFile('ffmpeg', ['-y', '-ss', '0.5', '-i', src, '-frames:v', '1', '-vf', 'scale=480:-2', '-q:v', '4', tmp], { timeout: 15000 }, (err) => {
if (err) { try { fs.unlinkSync(tmp); } catch (_) {} return json(res, 404, { error: 'thumbnail unavailable' }); }
try { if (!fs.existsSync(thumb)) fs.renameSync(tmp, thumb); else fs.unlinkSync(tmp); } catch (_) {}
send();
});
}
// PLAYBACK source for in-chat videos. Prefers the capped/faststart rendition built by media.js; falls
// back to the original bytes while that is still transcoding, so a video is never unplayable. The
// download button keeps pointing at /files, which always serves the untouched original.
if (pathOnly.startsWith('/stream/')) {
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const id = path.basename(decodeURIComponent(pathOnly));
const a = await authAttachment(id, u);
if (!a) return json(res, 404, { error: 'not found' });
media.ensureWebRendition(id, a.mime); // idempotent — also backfills pre-existing uploads
const ready = media.hasWebRendition(id);
const fp = ready ? media.webPath(id) : path.join(UPLOADS_DIR, id);
if (!fp.startsWith(UPLOADS_DIR)) return json(res, 403, { error: 'forbidden' });
return sendRanged(req, res, fp, {
'Content-Type': ready ? 'video/mp4' : (a.mime || 'application/octet-stream'),
'Content-Disposition': 'inline',
// Don't let a browser cache the heavy original past the point the rendition lands.
'Cache-Control': ready ? 'private, max-age=86400' : 'private, max-age=60',
});
}
if (pathOnly.startsWith('/files/')) {
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const id = path.basename(decodeURIComponent(pathOnly));
const a = await authAttachment(id, u);
if (!a) return json(res, 404, { error: 'not found' });
const fp = path.join(UPLOADS_DIR, id);
if (!fp.startsWith(UPLOADS_DIR)) return json(res, 403, { error: 'forbidden' });
const inline = /^(image|video|audio)\//.test(a.mime || ''); // media opens/plays inline; other files download
const safeName = String(a.name || 'file').replace(/[\r\n"]/g, '');
return sendRanged(req, res, fp, {
'Content-Type': a.mime || 'application/octet-stream',
'Content-Disposition': (inline ? 'inline' : 'attachment') + '; filename="' + safeName + '"',
'Cache-Control': 'private, max-age=86400',
});
}
return serveStatic(req, res);
}
module.exports = { handleGet, serveStatic };