[object Object]

← 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

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 →