feat: Windows download link + self-served update feed + install tracking
Downloads/updates: - config: DOWNLOADS_DIR (override to a mounted volume in prod). - static.js: serves /downloads/* (installer, latest.yml, .blockmap) with range support for resumable + differential auto-updates; /download/windows redirects to the current .exe (stable link). Landing page gets a "Download the Windows desktop app" button (hidden in-app). Install tracking (who installed the app): - db app_installs + repos.appInstalls (upsert by install_id, fills in the user on sign-in). - POST /api/v1/telemetry/install (records install + user once authenticated); GET /api/v1/admin/installs (admin: list installs with user/version/os/last-seen). - desktop main.js: stable per-install id in userData, exposed via preload (bizConnectNative.installId/version/os); home.html reportInstall() posts it after login. - e2e: +2 checks (telemetry recorded, admin sees it). 119/119. build batch20. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,7 @@ mobile/ios/
|
|||||||
server/recordings/
|
server/recordings/
|
||||||
server/transcripts/
|
server/transcripts/
|
||||||
server/uploads/
|
server/uploads/
|
||||||
|
server/downloads/
|
||||||
|
|
||||||
# OS files
|
# OS files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -10,6 +10,25 @@
|
|||||||
// Server origin is configurable so the same build works against prod or a dev server.
|
// Server origin is configurable so the same build works against prod or a dev server.
|
||||||
const { app, BrowserWindow, session, desktopCapturer, shell, Menu, ipcMain, nativeImage } = require('electron');
|
const { app, BrowserWindow, session, desktopCapturer, shell, Menu, ipcMain, nativeImage } = require('electron');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// A stable per-install id (persisted in userData), so the server can count installs and
|
||||||
|
// associate them with the user who signs in. Created once, then reused across launches.
|
||||||
|
function getInstallId() {
|
||||||
|
try {
|
||||||
|
const p = path.join(app.getPath('userData'), 'install-id');
|
||||||
|
if (fs.existsSync(p)) { const v = fs.readFileSync(p, 'utf8').trim(); if (v) return v; }
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
fs.writeFileSync(p, id);
|
||||||
|
return id;
|
||||||
|
} catch (_) { return 'unknown'; }
|
||||||
|
}
|
||||||
|
// The renderer (web app) reads this synchronously to report telemetry after login.
|
||||||
|
ipcMain.on('get-install-info', (e) => {
|
||||||
|
e.returnValue = { installId: getInstallId(), appVersion: app.getVersion(), os: process.platform + ' ' + os.release() };
|
||||||
|
});
|
||||||
// Auto-update: only NATIVE shell changes (this .exe) need this — all web/UI changes arrive
|
// Auto-update: only NATIVE shell changes (this .exe) need this — all web/UI changes arrive
|
||||||
// live from the server. Checks the self-hosted feed (publish config in package.json →
|
// live from the server. Checks the self-hosted feed (publish config in package.json →
|
||||||
// https://remote.bizgaze.com/downloads/latest.yml), downloads in the background, and installs
|
// https://remote.bizgaze.com/downloads/latest.yml), downloads in the background, and installs
|
||||||
|
|||||||
+7
-1
@@ -3,10 +3,16 @@
|
|||||||
// or prefer native push). No Node APIs are exposed to page JS.
|
// or prefer native push). No Node APIs are exposed to page JS.
|
||||||
const { contextBridge, ipcRenderer } = require('electron');
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
// Pull the stable install id + version/os from main (synchronous, one-time at preload).
|
||||||
|
let info = { installId: '', appVersion: '', os: '' };
|
||||||
|
try { info = ipcRenderer.sendSync('get-install-info') || info; } catch (_) {}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('__NATIVE__', 'desktop');
|
contextBridge.exposeInMainWorld('__NATIVE__', 'desktop');
|
||||||
contextBridge.exposeInMainWorld('bizConnectNative', Object.freeze({
|
contextBridge.exposeInMainWorld('bizConnectNative', Object.freeze({
|
||||||
platform: 'desktop',
|
platform: 'desktop',
|
||||||
version: process.env.npm_package_version || '0.1.0',
|
version: info.appVersion || '0.1.0',
|
||||||
|
installId: info.installId || '',
|
||||||
|
os: info.os || '',
|
||||||
// Bring the app window to the foreground (e.g. when a notification is clicked) — the web
|
// Bring the app window to the foreground (e.g. when a notification is clicked) — the web
|
||||||
// Notification's window.focus() can't raise an Electron window; the main process must.
|
// Notification's window.focus() can't raise an Electron window; the main process must.
|
||||||
focusApp: () => ipcRenderer.send('focus-window'),
|
focusApp: () => ipcRenderer.send('focus-window'),
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ const PUBLIC_DIR = path.join(__dirname, 'public');
|
|||||||
const REC_DIR = path.join(__dirname, 'recordings');
|
const REC_DIR = path.join(__dirname, 'recordings');
|
||||||
const TRANS_DIR = path.join(__dirname, 'transcripts');
|
const TRANS_DIR = path.join(__dirname, 'transcripts');
|
||||||
const UPLOADS_DIR = path.join(__dirname, 'uploads');
|
const 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');
|
||||||
try { fs.mkdirSync(REC_DIR, { recursive: true }); } catch (e) {}
|
try { fs.mkdirSync(REC_DIR, { recursive: true }); } catch (e) {}
|
||||||
try { fs.mkdirSync(TRANS_DIR, { recursive: true }); } catch (e) {}
|
try { fs.mkdirSync(TRANS_DIR, { recursive: true }); } catch (e) {}
|
||||||
try { fs.mkdirSync(UPLOADS_DIR, { recursive: true }); } catch (e) {}
|
try { fs.mkdirSync(UPLOADS_DIR, { recursive: true }); } catch (e) {}
|
||||||
|
try { fs.mkdirSync(DOWNLOADS_DIR, { recursive: true }); } catch (e) {}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
PORT: process.env.PORT || 8090,
|
PORT: process.env.PORT || 8090,
|
||||||
@@ -17,6 +21,7 @@ module.exports = {
|
|||||||
REC_DIR,
|
REC_DIR,
|
||||||
TRANS_DIR,
|
TRANS_DIR,
|
||||||
UPLOADS_DIR,
|
UPLOADS_DIR,
|
||||||
|
DOWNLOADS_DIR,
|
||||||
SESSION_TTL: 1000 * 60 * 60 * 24, // 24h access-token / cookie lifetime
|
SESSION_TTL: 1000 * 60 * 60 * 24, // 24h access-token / cookie lifetime
|
||||||
REFRESH_TTL: 1000 * 60 * 60 * 24 * 90, // 90d refresh-token lifetime (native clients)
|
REFRESH_TTL: 1000 * 60 * 60 * 24 * 90, // 90d refresh-token lifetime (native clients)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -321,6 +321,25 @@ CREATE TABLE IF NOT EXISTS device_tokens (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_devtok_user ON device_tokens(user_id);
|
CREATE INDEX IF NOT EXISTS idx_devtok_user ON device_tokens(user_id);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// App installs (desktop/mobile clients): one row per install, associated with the user once
|
||||||
|
// they sign in. Lets admins see who installed the app, which version, and when it was last used.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS app_installs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
install_id TEXT NOT NULL UNIQUE,
|
||||||
|
user_id TEXT,
|
||||||
|
user_email TEXT,
|
||||||
|
tenant_id TEXT,
|
||||||
|
platform TEXT,
|
||||||
|
app_version TEXT,
|
||||||
|
os TEXT,
|
||||||
|
first_seen INTEGER NOT NULL,
|
||||||
|
last_seen INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_installs_tenant ON app_installs(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_installs_user ON app_installs(user_id);
|
||||||
|
`);
|
||||||
|
|
||||||
// Favourite conversations (per user). target = 'dm:<userId>' or 'group:<groupId>'.
|
// Favourite conversations (per user). target = 'dm:<userId>' or 'group:<groupId>'.
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS favorites (
|
CREATE TABLE IF NOT EXISTS favorites (
|
||||||
|
|||||||
@@ -731,7 +731,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="/icons.js?v=4"></script>
|
<script src="/icons.js?v=4"></script>
|
||||||
<script>window.__BUILD='2026-07-01-batch19';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);</script>
|
<script>window.__BUILD='2026-07-01-batch20';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);</script>
|
||||||
<div class="loading" id="loading">Loading…</div>
|
<div class="loading" id="loading">Loading…</div>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
@@ -1831,6 +1831,13 @@ function urlB64ToUint8(base64){ const pad='='.repeat((4-base64.length%4)%4); con
|
|||||||
// FCM/APNs (see server/push.js). No bundler needed — plugins live on Capacitor.Plugins.
|
// FCM/APNs (see server/push.js). No bundler needed — plugins live on Capacitor.Plugins.
|
||||||
function capPlugin(name){ const C=window.Capacitor; return (C && C.isNativePlatform && C.isNativePlatform() && C.Plugins && C.Plugins[name]) ? C.Plugins[name] : null; }
|
function capPlugin(name){ const C=window.Capacitor; return (C && C.isNativePlatform && C.isNativePlatform() && C.Plugins && C.Plugins[name]) ? C.Plugins[name] : null; }
|
||||||
function nativePlatform(){ const C=window.Capacitor; if(C && C.getPlatform){ const p=C.getPlatform(); if(p==='ios'||p==='android') return p; } if(window.__NATIVE__==='desktop') return 'desktop'; return ''; }
|
function nativePlatform(){ const C=window.Capacitor; if(C && C.getPlatform){ const p=C.getPlatform(); if(p==='ios'||p==='android') return p; } if(window.__NATIVE__==='desktop') return 'desktop'; return ''; }
|
||||||
|
// Record this install against the signed-in user (desktop shell exposes a stable installId).
|
||||||
|
// Fires post-login; the request carries the session so admins can see who installed the app.
|
||||||
|
function reportInstall(){
|
||||||
|
try{ const n=window.bizConnectNative; if(!n||!n.installId) return;
|
||||||
|
postJSON('/api/v1/telemetry/install',{ installId:n.installId, platform:n.platform||'desktop', appVersion:n.version||'', os:n.os||'' }).catch(()=>{});
|
||||||
|
}catch(_){}
|
||||||
|
}
|
||||||
let _nativeTok=null;
|
let _nativeTok=null;
|
||||||
async function setupNativePush(){
|
async function setupNativePush(){
|
||||||
const PN=capPlugin('PushNotifications'); const plat=nativePlatform();
|
const PN=capPlugin('PushNotifications'); const plat=nativePlatform();
|
||||||
@@ -2756,6 +2763,7 @@ async function doRegister(){
|
|||||||
loadNotifs(); wireBell(); wireProfile();
|
loadNotifs(); wireBell(); wireProfile();
|
||||||
placeHdrRight(); window.addEventListener('resize', placeHdrRight); // mobile: bell+profile live in the chat-list header (no top bar)
|
placeHdrRight(); window.addEventListener('resize', placeHdrRight); // mobile: bell+profile live in the chat-list header (no top bar)
|
||||||
connectChatWs();
|
connectChatWs();
|
||||||
|
reportInstall(); // desktop/mobile shell: record this install against the signed-in user
|
||||||
setupPush(); // register the notification service worker + subscribe to Web Push (if granted)
|
setupPush(); // register the notification service worker + subscribe to Web Push (if granted)
|
||||||
{ const cl=document.getElementById('chatlist'); if(cl) enablePullRefresh(cl, loadSidebar); } // pull-to-refresh the chat list
|
{ const cl=document.getElementById('chatlist'); if(cl) enablePullRefresh(cl, loadSidebar); } // pull-to-refresh the chat list
|
||||||
setTimeout(maybeNotifPrompt, 1500); // gentle "enable notifications" prompt if still undecided (key for iOS PWA)
|
setTimeout(maybeNotifPrompt, 1500); // gentle "enable notifications" prompt if still undecided (key for iOS PWA)
|
||||||
|
|||||||
@@ -67,6 +67,7 @@
|
|||||||
<!-- Team member path BELOW: log in to the full app. Stub SSO -> /home for now. -->
|
<!-- Team member path BELOW: log in to the full app. Stub SSO -> /home for now. -->
|
||||||
<div class="divider" style="margin-top:1.6rem">BizGaze team member?</div>
|
<div class="divider" style="margin-top:1.6rem">BizGaze team member?</div>
|
||||||
<a class="ssobtn" id="ssoBtn" href="/home"><span class="bmark">B</span> Log in with BizGaze</a>
|
<a class="ssobtn" id="ssoBtn" href="/home"><span class="bmark">B</span> Log in with BizGaze</a>
|
||||||
|
<a class="dl-win" id="dlWin" href="/download/windows" style="display:inline-flex;align-items:center;gap:.5rem;margin-top:1rem;color:var(--blue);font-size:.88rem;text-decoration:none;font-weight:600"><span data-ic="download" data-sz="16"></span> Download the Windows desktop app</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<footer>© BizGaze · Remote Support</footer>
|
<footer>© BizGaze · Remote Support</footer>
|
||||||
@@ -76,6 +77,8 @@ function profileHTML(name){return '<div class="profile"><button class="pbtn" id=
|
|||||||
function wireProfile(){const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');if(!btn)return;btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};document.addEventListener('click',()=>menu.classList.remove('open'));const lo=document.getElementById('plogout');if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};}
|
function wireProfile(){const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');if(!btn)return;btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};document.addEventListener('click',()=>menu.classList.remove('open'));const lo=document.getElementById('plogout');if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};}
|
||||||
function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});}
|
function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});}
|
||||||
makeBrandClickable();
|
makeBrandClickable();
|
||||||
|
// No "download" link when we're already inside the desktop app.
|
||||||
|
if(window.__NATIVE__){var _dl=document.getElementById('dlWin');if(_dl)_dl.style.display='none';}
|
||||||
// Already signed in -> skip this landing entirely and go straight to the app (no redundant
|
// Already signed in -> skip this landing entirely and go straight to the app (no redundant
|
||||||
// "Open Biz Connect" page). This landing only shows when logged out.
|
// "Open Biz Connect" page). This landing only shows when logged out.
|
||||||
(async function(){try{const r=await fetch('/api/me');if(r.ok){ location.replace('/home'); return; }}catch(_){}})();
|
(async function(){try{const r=await fetch('/api/me');if(r.ok){ location.replace('/home'); return; }}catch(_){}})();
|
||||||
|
|||||||
+17
-1
@@ -299,4 +299,20 @@ const deviceTokens = {
|
|||||||
removeByToken: (token) => db.prepare('DELETE FROM device_tokens WHERE token=?').run(token),
|
removeByToken: (token) => db.prepare('DELETE FROM device_tokens WHERE token=?').run(token),
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { teams, users, authSessions, machines, audit, sessionsLog, refreshTokens, apiKeys, webhooks, messages, reactions, attachments, conversations, scheduledMeetings, recordings, polls, pollVotes, pushSubs, favorites, deviceTokens };
|
const appInstalls = {
|
||||||
|
// Upsert by install_id: each launch refreshes version/os/last_seen and fills in the user once
|
||||||
|
// they sign in (COALESCE keeps a known user if a later anonymous ping arrives).
|
||||||
|
record: ({ id, installId, userId, userEmail, tenantId, platform, appVersion, os }) =>
|
||||||
|
db.prepare(`INSERT INTO app_installs (id,install_id,user_id,user_email,tenant_id,platform,app_version,os,first_seen,last_seen)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(install_id) DO UPDATE SET
|
||||||
|
user_id=COALESCE(excluded.user_id, app_installs.user_id),
|
||||||
|
user_email=COALESCE(excluded.user_email, app_installs.user_email),
|
||||||
|
tenant_id=COALESCE(excluded.tenant_id, app_installs.tenant_id),
|
||||||
|
platform=excluded.platform, app_version=excluded.app_version, os=excluded.os,
|
||||||
|
last_seen=excluded.last_seen`)
|
||||||
|
.run(id, installId, userId || null, userEmail || null, tenantId || null, platform || null, appVersion || null, os || null, now(), now()),
|
||||||
|
listForTenant: (tenantId) => db.prepare('SELECT * FROM app_installs WHERE tenant_id=? ORDER BY last_seen DESC').all(tenantId),
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { teams, users, authSessions, machines, audit, sessionsLog, refreshTokens, apiKeys, webhooks, messages, reactions, attachments, conversations, scheduledMeetings, recordings, polls, pollVotes, pushSubs, favorites, deviceTokens, appInstalls };
|
||||||
|
|||||||
@@ -316,6 +316,24 @@ route('POST', '/api/devices/remove', async (req, res) => {
|
|||||||
json(res, 200, { ok: true });
|
json(res, 200, { ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- App install telemetry: records each install and, once the user signs in, who's using it. ---
|
||||||
|
route('POST', '/api/telemetry/install', async (req, res) => {
|
||||||
|
const { installId, platform, appVersion, os } = await readBody(req);
|
||||||
|
if (!installId || typeof installId !== 'string') return json(res, 400, { error: 'installId required' });
|
||||||
|
const u = currentUser(req); // may be null on a pre-login launch — still counted
|
||||||
|
try {
|
||||||
|
R.appInstalls.record({ id: A.id(), installId: installId.slice(0, 64), userId: u && u.id, userEmail: u && u.email, tenantId: u && u.team_id, platform: (platform || '').slice(0, 20), appVersion: (appVersion || '').slice(0, 20), os: (os || '').slice(0, 60) });
|
||||||
|
} catch (_) {}
|
||||||
|
json(res, 200, { ok: true });
|
||||||
|
});
|
||||||
|
// Admin: who installed the app (this tenant).
|
||||||
|
route('GET', '/api/admin/installs', async (req, res) => {
|
||||||
|
const u = currentUser(req);
|
||||||
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||||
|
if (u.role !== 'admin') return json(res, 403, { error: 'admin only' });
|
||||||
|
json(res, 200, R.appInstalls.listForTenant(u.team_id));
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- BizGaze SSO: agent arrives already logged in ----------
|
// ---------- BizGaze SSO: agent arrives already logged in ----------
|
||||||
route('GET', '/sso', async (req, res) => {
|
route('GET', '/sso', async (req, res) => {
|
||||||
if (!process.env.SSO_SECRET) { res.writeHead(503); return res.end('SSO not configured'); }
|
if (!process.env.SSO_SECRET) { res.writeHead(503); return res.end('SSO not configured'); }
|
||||||
|
|||||||
+39
-1
@@ -5,7 +5,7 @@ const path = require('path');
|
|||||||
const R = require('./repos');
|
const R = require('./repos');
|
||||||
const { json } = require('./lib');
|
const { json } = require('./lib');
|
||||||
const { currentUser } = require('./session');
|
const { currentUser } = require('./session');
|
||||||
const { PUBLIC_DIR, REC_DIR, TRANS_DIR, UPLOADS_DIR } = require('./config');
|
const { PUBLIC_DIR, REC_DIR, TRANS_DIR, UPLOADS_DIR, DOWNLOADS_DIR } = require('./config');
|
||||||
|
|
||||||
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' };
|
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' };
|
||||||
|
|
||||||
@@ -47,6 +47,44 @@ function serveStatic(req, res) {
|
|||||||
// GET fallback: authenticated transcript/recording downloads, else static files.
|
// GET fallback: authenticated transcript/recording downloads, else static files.
|
||||||
function handleGet(req, res) {
|
function handleGet(req, res) {
|
||||||
const pathOnly = req.url.split('?')[0];
|
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/')) {
|
if (pathOnly.startsWith('/transcripts/')) {
|
||||||
const u = currentUser(req);
|
const u = currentUser(req);
|
||||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||||
|
|||||||
@@ -105,6 +105,12 @@ function nextMsg(ws, type, timeout = 3000) {
|
|||||||
const devRm = await call('/api/v1/devices/remove', { token: 'fcm-tok-123' }, cookie);
|
const devRm = await call('/api/v1/devices/remove', { token: 'fcm-tok-123' }, cookie);
|
||||||
check('device token removed', devRm.status === 200);
|
check('device token removed', devRm.status === 200);
|
||||||
|
|
||||||
|
// 3b''. App install telemetry (track who installed) + admin listing
|
||||||
|
const tel = await call('/api/v1/telemetry/install', { installId: 'inst-e2e-1', platform: 'desktop', appVersion: '0.1.1', os: 'win32' }, cookie);
|
||||||
|
check('install telemetry recorded', tel.status === 200 && tel.data.ok === true);
|
||||||
|
const insts = await get('/api/v1/admin/installs', cookie);
|
||||||
|
check('admin sees the install tied to the user', insts.status === 200 && Array.isArray(insts.data) && insts.data.some((i) => i.install_id === 'inst-e2e-1' && i.app_version === '0.1.1' && i.user_email));
|
||||||
|
|
||||||
// 3c. API keys (machine-to-machine integration), scoped + revocable
|
// 3c. API keys (machine-to-machine integration), scoped + revocable
|
||||||
const mkKey = await call('/api/v1/keys', { name: 'ci', scopes: ['report:read'] }, cookie);
|
const mkKey = await call('/api/v1/keys', { name: 'ci', scopes: ['report:read'] }, cookie);
|
||||||
check('admin creates API key (bzc_ prefix)', mkKey.status === 200 && /^bzc_/.test(mkKey.data.key || ''));
|
check('admin creates API key (bzc_ prefix)', mkKey.status === 200 && /^bzc_/.test(mkKey.data.key || ''));
|
||||||
|
|||||||
Reference in New Issue
Block a user