2026-06-12 00:40:07 +05:30
|
|
|
// 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');
|
2026-06-23 16:15:29 +05:30
|
|
|
const { PUBLIC_DIR, REC_DIR, TRANS_DIR, UPLOADS_DIR } = require('./config');
|
2026-06-12 00:40:07 +05:30
|
|
|
|
|
|
|
|
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' };
|
|
|
|
|
|
|
|
|
|
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' });
|
2026-06-23 16:15:29 +05:30
|
|
|
// 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' });
|
2026-06-24 16:30:17 +05:30
|
|
|
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';
|
2026-06-23 16:15:29 +05:30
|
|
|
const etag = '"' + st.size.toString(16) + '-' + Math.round(st.mtimeMs).toString(16) + '"';
|
2026-06-24 16:30:17 +05:30
|
|
|
if (!isHtml && req.headers['if-none-match'] === etag) {
|
2026-06-23 16:15:29 +05:30
|
|
|
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' });
|
2026-06-24 16:30:17 +05:30
|
|
|
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);
|
2026-06-23 16:15:29 +05:30
|
|
|
res.end(data);
|
|
|
|
|
});
|
2026-06-12 00:40:07 +05:30
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GET fallback: authenticated transcript/recording downloads, else static files.
|
|
|
|
|
function handleGet(req, res) {
|
|
|
|
|
const pathOnly = req.url.split('?')[0];
|
|
|
|
|
if (pathOnly.startsWith('/transcripts/')) {
|
|
|
|
|
const u = currentUser(req);
|
|
|
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
|
|
|
const name = path.basename(decodeURIComponent(pathOnly));
|
|
|
|
|
const sid = name.replace(/\.txt$/i, '');
|
|
|
|
|
const row = 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 = 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 = 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);
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-06-23 16:15:29 +05:30
|
|
|
// 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 = currentUser(req);
|
|
|
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
|
|
|
const id = path.basename(decodeURIComponent(pathOnly));
|
|
|
|
|
const r = 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 = R.conversations.isMember(r.group_id, u.id);
|
|
|
|
|
if (!allowed && r.meeting_id) { const s = 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);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (pathOnly.startsWith('/files/')) {
|
|
|
|
|
const u = currentUser(req);
|
|
|
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
|
|
|
const id = path.basename(decodeURIComponent(pathOnly));
|
|
|
|
|
const a = R.attachments.byId(id);
|
|
|
|
|
if (!a || a.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
|
|
|
|
|
// Authorize: the uploader, a participant of the message carrying this attachment,
|
|
|
|
|
// or a member of the group that uses this attachment as its image.
|
|
|
|
|
const msg = R.messages.byAttachment(id);
|
|
|
|
|
const avatarGroup = R.conversations.byAvatar(id);
|
|
|
|
|
const allowed = a.uploader_id === u.id
|
|
|
|
|
|| (avatarGroup && R.conversations.isMember(avatarGroup.id, u.id))
|
|
|
|
|
|| (msg && (
|
|
|
|
|
msg.conversation_id ? R.conversations.isMember(msg.conversation_id, u.id)
|
|
|
|
|
: (msg.sender_id === u.id || msg.recipient_id === u.id)));
|
|
|
|
|
if (!allowed) return json(res, 403, { error: 'forbidden' });
|
|
|
|
|
const fp = path.join(UPLOADS_DIR, id);
|
|
|
|
|
if (!fp.startsWith(UPLOADS_DIR)) return json(res, 403, { error: 'forbidden' });
|
|
|
|
|
const isImage = /^image\//.test(a.mime || '');
|
|
|
|
|
const safeName = String(a.name || 'file').replace(/[\r\n"]/g, '');
|
|
|
|
|
return fs.stat(fp, (err, st) => {
|
|
|
|
|
if (err) return json(res, 404, { error: 'not found' });
|
|
|
|
|
res.writeHead(200, { 'Content-Type': a.mime || 'application/octet-stream', 'Content-Length': st.size, 'Content-Disposition': (isImage ? 'inline' : 'attachment') + '; filename="' + safeName + '"', 'Cache-Control': 'private, max-age=86400' });
|
|
|
|
|
const rs = fs.createReadStream(fp);
|
|
|
|
|
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
|
|
|
|
|
rs.pipe(res);
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-06-12 00:40:07 +05:30
|
|
|
return serveStatic(req, res);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = { handleGet, serveStatic };
|