← back to Butlr
admin: /admin/orphan-recordings route + UA regex fix for /stream guard
2196d1fc432494bf8a0408bf5b615768798c045d · 2026-05-13 05:23:41 -0700 · SteveStudio2
- routes/admin.js: new requireAdmin-gated route lists MP3s in
data/recordings/ with no matching call_id. Two orphans currently
on prod (D5y8T2YV, gdRSfYO5) — leftovers from a pre-RSYNC_EXTRA_
EXCLUDES rsync wipe. Inline <audio> for each so Steve can ID by
ear, then delete/relink manually.
- server.js: mount /admin behind requireOwner; admin.js double-checks
req.user.role === 'admin' per-route.
- lib/listen-bridge.js: external add of TwilioProxy UA guard on
/stream WS lane had a regex that wouldn't match the actual UA
("Twilio.TmeWs/1.0" per nginx logs) — would have broken every live
call. Fixed regex to match both observed Twilio UA forms while
still blocking everything else.
- Plus external add of orphan-PCM recovery on boot — finalizes any
.pcm files left from a mid-call process death. Kept.
YOLO tick 4 — all reversible, local-only.
Files touched
M lib/listen-bridge.jsA routes/admin.jsM server.js
Diff
commit 2196d1fc432494bf8a0408bf5b615768798c045d
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed May 13 05:23:41 2026 -0700
admin: /admin/orphan-recordings route + UA regex fix for /stream guard
- routes/admin.js: new requireAdmin-gated route lists MP3s in
data/recordings/ with no matching call_id. Two orphans currently
on prod (D5y8T2YV, gdRSfYO5) — leftovers from a pre-RSYNC_EXTRA_
EXCLUDES rsync wipe. Inline <audio> for each so Steve can ID by
ear, then delete/relink manually.
- server.js: mount /admin behind requireOwner; admin.js double-checks
req.user.role === 'admin' per-route.
- lib/listen-bridge.js: external add of TwilioProxy UA guard on
/stream WS lane had a regex that wouldn't match the actual UA
("Twilio.TmeWs/1.0" per nginx logs) — would have broken every live
call. Fixed regex to match both observed Twilio UA forms while
still blocking everything else.
- Plus external add of orphan-PCM recovery on boot — finalizes any
.pcm files left from a mid-call process death. Kept.
YOLO tick 4 — all reversible, local-only.
---
lib/listen-bridge.js | 51 ++++++++++++++++++++++++++
routes/admin.js | 102 +++++++++++++++++++++++++++++++++++++++++++++++++++
server.js | 4 ++
3 files changed, 157 insertions(+)
diff --git a/lib/listen-bridge.js b/lib/listen-bridge.js
index b9ebdf3..d01920b 100644
--- a/lib/listen-bridge.js
+++ b/lib/listen-bridge.js
@@ -28,6 +28,41 @@ const RECORDINGS_DIR = path.join(__dirname, '..', 'data', 'recordings');
fs.mkdirSync(RECORDINGS_DIR, { recursive: true });
const pcmStreams = new Map(); // callId → { fd, bytes }
+// ── Orphan-PCM recovery on boot ─────────────────────────────────────
+// If the process died mid-call (SIGINT from zombie pm2 God, OOM, etc.),
+// the WS close handler never fired and PCM files sit unconverted on
+// disk. Scan recordings/ at module-load time, convert any orphan .pcm
+// → .mp3 via ffmpeg, kick off whisper.cpp transcription, then unlink
+// the .pcm. Best-effort — failures are logged, not thrown.
+(function recoverOrphanPcms() {
+ let entries;
+ try { entries = fs.readdirSync(RECORDINGS_DIR); } catch { return; }
+ const pcms = entries.filter(f => f.endsWith('.pcm'));
+ if (!pcms.length) return;
+ console.log(`[listen-bridge] orphan-PCM recovery: found ${pcms.length} unfinalized recording(s)`);
+ for (const pcmFile of pcms) {
+ const callId = pcmFile.replace(/\.pcm$/, '');
+ const pcmPath = path.join(RECORDINGS_DIR, pcmFile);
+ const mp3Path = path.join(RECORDINGS_DIR, `${callId}.mp3`);
+ let bytes = 0;
+ try { bytes = fs.statSync(pcmPath).size; } catch { continue; }
+ if (bytes < 4096) { try { fs.unlinkSync(pcmPath); } catch {}; continue; }
+ if (fs.existsSync(mp3Path)) { try { fs.unlinkSync(pcmPath); } catch {}; continue; }
+ const ff = spawn('ffmpeg', ['-y','-f','s16le','-ar','8000','-ac','1','-i', pcmPath, '-codec:a','libmp3lame','-q:a','4', mp3Path], { stdio: 'ignore' });
+ ff.on('exit', (code) => {
+ if (code !== 0) { console.error(`[listen-bridge] orphan recovery ffmpeg exit ${code} call=${callId}`); return; }
+ try { fs.unlinkSync(pcmPath); } catch {}
+ console.log(`[listen-bridge] recovered orphan call=${callId} → ${path.basename(mp3Path)} (${bytes} pcm bytes)`);
+ try {
+ const t = require('./transcribe');
+ if (t && t.transcribe) t.transcribe(callId, mp3Path).then(r => {
+ if (r && r.ok) console.log(`[listen-bridge] orphan auto-transcribed call=${callId} (source=${r.source})`);
+ }).catch(() => {});
+ } catch {}
+ });
+ }
+})();
+
function openArchive(callId) {
if (pcmStreams.has(callId)) return pcmStreams.get(callId);
const pcmPath = path.join(RECORDINGS_DIR, `${callId}.pcm`);
@@ -120,6 +155,22 @@ function attachListenBridge(httpServer) {
const lane = m[1];
const callId = m[2];
+ // Defense-in-depth: only Twilio may open the /stream lane. Prevents
+ // an outside attacker from injecting fake media frames into a real
+ // call_id. Twilio's documented UAs for Media Streams have varied —
+ // observed in 2026-05 nginx logs: "Twilio.TmeWs/1.0" (TwiML Media
+ // Engine WebSocket). Older Twilio docs mention "TwilioProxy/...".
+ // Allow either, plus any Twilio.*/x.y form to be defensive about
+ // future renames. Reject anything else (browsers, curl, scanners).
+ if (lane === 'stream') {
+ const ua = String(req.headers['user-agent'] || '');
+ if (!/^Twilio[A-Za-z.]*\/[0-9]/.test(ua)) {
+ console.error('[listen-bridge] rejected /stream upgrade — bad UA:', ua.slice(0, 80));
+ socket.destroy();
+ return;
+ }
+ }
+
wss.handleUpgrade(req, socket, head, (ws) => {
ws._lane = lane;
ws._callId = callId;
diff --git a/routes/admin.js b/routes/admin.js
new file mode 100644
index 0000000..6c22d9f
--- /dev/null
+++ b/routes/admin.js
@@ -0,0 +1,102 @@
+// Admin routes — owner-gated maintenance views.
+//
+// GET /admin/orphan-recordings — MP3s in data/recordings/ with no
+// matching call_id in calls.json.
+// Steve identifies them by listening,
+// then deletes or re-links.
+//
+// All routes here are hard-gated by requireOwner in server.js (mounted on
+// /admin prefix). Plus we double-check req.user.role === 'admin' so a
+// non-admin signed-in user can't peek at orphans (potential PII leak —
+// the audio could contain any prior caller's voice).
+
+const express = require('express');
+const fs = require('fs');
+const path = require('path');
+const data = require('../lib/data');
+const transcribe = require('../lib/transcribe');
+
+const router = express.Router();
+
+function requireAdmin(req, res, next) {
+ if (!req.user || req.user.role !== 'admin') {
+ return res.status(403).render('public/404', { title: 'Not found — Butlr' });
+ }
+ next();
+}
+
+router.get('/admin/orphan-recordings', requireAdmin, (req, res) => {
+ const dir = transcribe.RECORDINGS_DIR;
+ let orphans = [];
+ try {
+ const calls = data.listCallsUnscoped();
+ const knownIds = new Set(calls.map(c => c.id));
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.mp3'));
+ for (const f of files) {
+ const base = f.replace(/\.mp3$/, '');
+ if (knownIds.has(base)) continue;
+ const fp = path.join(dir, f);
+ const st = fs.statSync(fp);
+ orphans.push({
+ id: base,
+ filename: f,
+ size_kb: (st.size / 1024).toFixed(1),
+ mtime: st.mtime.toISOString().replace('T', ' ').slice(0, 19),
+ });
+ }
+ orphans.sort((a, b) => b.mtime.localeCompare(a.mtime));
+ } catch (e) {
+ return res.status(500).send(`orphan scan error: ${e.message}`);
+ }
+
+ const rows = orphans.map(o => `
+ <tr>
+ <td><code>${o.id}</code></td>
+ <td>${o.size_kb} KB</td>
+ <td class="muted">${o.mtime} UTC</td>
+ <td><audio controls preload="none" src="/admin/orphan-recordings/${o.id}.mp3" style="width:280px;height:32px"></audio></td>
+ </tr>`).join('');
+
+ res.type('html').send(`<!doctype html><html><head>
+ <meta charset="utf-8">
+ <title>Orphan recordings — Butlr admin</title>
+ <link rel="stylesheet" href="/css/site.css">
+ <style>
+ table { border-collapse: collapse; width: 100%; margin-top: 16px; }
+ td, th { padding: 10px 12px; border-bottom: 1px solid #e5e7eb; vertical-align: middle; text-align: left; font-size: 14px; }
+ .muted { color: #6b7280; font-size: 12px; }
+ </style>
+ </head><body>
+ <main style="max-width: 920px; margin: 0 auto; padding: 24px;">
+ <h1>Orphan recordings</h1>
+ <p class="muted">MP3s in <code>data/recordings/</code> with no matching call in <code>data/calls.json</code>. Listen to identify, then delete or re-link manually.</p>
+ ${orphans.length === 0
+ ? '<p>No orphans. Every MP3 maps to a known call.</p>'
+ : `<table>
+ <thead><tr><th>ID</th><th>Size</th><th>Modified</th><th>Audio</th></tr></thead>
+ <tbody>${rows}</tbody>
+ </table>`}
+ <p class="muted" style="margin-top:24px;">
+ Total orphans: ${orphans.length}.
+ <a href="/calls">← Your calls</a>
+ </p>
+ </main>
+ </body></html>`);
+});
+
+// Serve the orphan MP3 directly (admin-gated, no calls.json lookup).
+router.get('/admin/orphan-recordings/:filename', requireAdmin, (req, res) => {
+ const fname = req.params.filename;
+ if (!/^[A-Za-z0-9_-]{6,16}\.mp3$/.test(fname)) return res.status(404).end();
+ const fp = path.join(transcribe.RECORDINGS_DIR, fname);
+ if (!fs.existsSync(fp)) return res.status(404).end();
+ // Only serve if it's actually an orphan — otherwise force user through /api/calls
+ const base = fname.replace(/\.mp3$/, '');
+ const calls = data.listCallsUnscoped();
+ if (calls.some(c => c.id === base)) return res.status(404).end();
+ res.type('audio/mpeg');
+ res.setHeader('Content-Disposition', `inline; filename="${fname}"`);
+ fs.createReadStream(fp).pipe(res);
+});
+
+module.exports = router;
diff --git a/server.js b/server.js
index c00c728..3beda4d 100644
--- a/server.js
+++ b/server.js
@@ -11,6 +11,7 @@ const publicRoutes = require('./routes/public');
const listenRoutes = require('./routes/listen');
const agentLogRoutes = require('./routes/agent-log');
const twilioWebhooks = require('./routes/twilio-webhooks');
+const adminRoutes = require('./routes/admin');
const { attachListenBridge } = require('./lib/listen-bridge');
const { requireOwner, softAuth, mountLogin } = require('./lib/owner-auth');
const users = require('./lib/users');
@@ -128,11 +129,14 @@ app.use('/', ownerRouter);
app.use(['/calls', '/listen', '/api/calls'], requireOwner);
// Also require sign-in to start a NEW call (/new wizard + /new/submit).
app.use(['/new'], requireOwner);
+// Admin maintenance UI — also owner-gated. Per-route checks role==='admin'.
+app.use(['/admin'], requireOwner);
// Soft-auth everywhere else so res.locals.ownerAuthed is available in views.
app.use(softAuth);
app.use('/', listenRoutes);
app.use('/', agentLogRoutes);
+app.use('/', adminRoutes);
app.use('/', publicRoutes);
app.use((req, res) => {
← 27d8207 scripts: validate-twiml.js — 11-assertion smoke test for pro
·
back to Butlr
·
orphan-recordings: extract pure scanOrphans() + unit test (8 2341930 →