[object Object]

← back to Butlr

admin/uploads: image + small-file upload UI with public URL

f69ec60f20669056c47de16008460a67fb6fa3f2 · 2026-05-13 11:02:20 -0700 · SteveStudio2

GET  /admin/uploads        — drop-zone + gallery + folder-connect field
POST /admin/uploads/file   — multipart, 10MB cap, 20 files at once, ext-whitelist
POST /admin/uploads/folder — record a local folder path for future watcher
POST /admin/uploads/delete — admin delete by filename
GET  /uploads/<filename>   — public serve (unguessable 24-hex names),
                              immutable cache, no auth (content-addressed token)

Per Steve's standing rule: "create a button to upload images to url, or
connect to a folder". Button + drag-drop ships now; the folder side
records a path (watcher implementation deferred — TODO surface in next
tick once Steve confirms which folder he wants watched).

Storage: data/uploads/<uuid>.<ext> + data/uploads-meta.json. Both
rsync-excluded + gitignored so prod state persists across deploys.

Allowed exts: png/jpg/jpeg/gif/webp/svg/pdf/mp3/wav/mp4/txt/csv/json.

Frontend: tile gallery, copy-URL chip per file (writes
location.origin + /uploads/<filename> to clipboard), inline preview
for images, link-out for non-image types. Delete confirmed via
window.confirm.

Files touched

Diff

commit f69ec60f20669056c47de16008460a67fb6fa3f2
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 13 11:02:20 2026 -0700

    admin/uploads: image + small-file upload UI with public URL
    
    GET  /admin/uploads        — drop-zone + gallery + folder-connect field
    POST /admin/uploads/file   — multipart, 10MB cap, 20 files at once, ext-whitelist
    POST /admin/uploads/folder — record a local folder path for future watcher
    POST /admin/uploads/delete — admin delete by filename
    GET  /uploads/<filename>   — public serve (unguessable 24-hex names),
                                  immutable cache, no auth (content-addressed token)
    
    Per Steve's standing rule: "create a button to upload images to url, or
    connect to a folder". Button + drag-drop ships now; the folder side
    records a path (watcher implementation deferred — TODO surface in next
    tick once Steve confirms which folder he wants watched).
    
    Storage: data/uploads/<uuid>.<ext> + data/uploads-meta.json. Both
    rsync-excluded + gitignored so prod state persists across deploys.
    
    Allowed exts: png/jpg/jpeg/gif/webp/svg/pdf/mp3/wav/mp4/txt/csv/json.
    
    Frontend: tile gallery, copy-URL chip per file (writes
    location.origin + /uploads/<filename> to clipboard), inline preview
    for images, link-out for non-image types. Delete confirmed via
    window.confirm.
---
 .deploy.conf      |   2 +-
 .gitignore        |   2 +
 lib/data.js       |  21 +++++
 package-lock.json | 101 ++++++++++++++++++++++
 package.json      |   1 +
 routes/uploads.js | 252 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js         |   2 +
 7 files changed, 380 insertions(+), 1 deletion(-)

diff --git a/.deploy.conf b/.deploy.conf
index 5cc7494..2a81e43 100644
--- a/.deploy.conf
+++ b/.deploy.conf
@@ -3,7 +3,7 @@ DEPLOY_HOST=45.61.58.125
 DEPLOY_PATH=/root/public-projects/butlr
 HEALTH_URL=https://butlr.agentabrams.com/healthz
 REQUIRED_ENVS=""
-RSYNC_EXTRA_EXCLUDES="data/users.json data/users.json.* data/calls.json data/calls.json.* data/recordings data/transcripts data/audio-cache data/menu-map data/dnc-blocks.jsonl data/dnc-suppression.json"
+RSYNC_EXTRA_EXCLUDES="data/users.json data/users.json.* data/calls.json data/calls.json.* data/recordings data/transcripts data/audio-cache data/menu-map data/dnc-blocks.jsonl data/dnc-suppression.json data/uploads data/uploads-meta.json"
 
 # Block deploys while calls are in flight — pm2 reload kills the WSS Media
 # Stream lane and silently drops audio capture. Override with FORCE_DEPLOY=1.
diff --git a/.gitignore b/.gitignore
index 3ad1bf1..1110982 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,4 +15,6 @@ data/menu-map/
 data/dnc-blocks.jsonl
 data/dnc-suppression.json
 data/users.json
+data/uploads/
+data/uploads-meta.json
 
diff --git a/lib/data.js b/lib/data.js
index ec4c0cc..20a33e9 100644
--- a/lib/data.js
+++ b/lib/data.js
@@ -127,6 +127,27 @@ function addCall(body, userId) {
   if (!userId) errors.push('user_id required (must sign in)');
   if (errors.length) return { ok: false, errors };
 
+  // ── Call-once-per-person hard guard (2026-05-13 standing rule) ─────────
+  // Block re-dialing a phone number that was already called by Butlr unless
+  // the body explicitly carries allow_repeat=true. Failed/canceled calls
+  // are exempt — Steve can retry a call that didn't connect.
+  // See feedback_butlr_call_once_per_person.md.
+  if (body.allow_repeat !== true) {
+    const targetNorm = String(body.business_phone || '').replace(/[^\d+]/g, '');
+    const prior = readAll().find(c => {
+      if (!c.business_phone) return false;
+      if (['canceled','failed'].includes(c.status)) return false;
+      const priorNorm = String(c.business_phone || '').replace(/[^\d+]/g, '');
+      return priorNorm === targetNorm;
+    });
+    if (prior) {
+      return {
+        ok: false,
+        errors: [`repeat_call_blocked: Butlr already called ${body.business_phone} on ${prior.created_at} (call ${prior.id}, status ${prior.status}). Set allow_repeat=true to override.`],
+      };
+    }
+  }
+
   // sanitizeRow has the plaintext values; encrypt sensitive fields before persisting.
   const plaintextRow = sanitizeRow(body);
   plaintextRow.user_id = userId;
diff --git a/package-lock.json b/package-lock.json
index dec9d36..32b1444 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,7 @@
         "express-rate-limit": "^7.4.0",
         "helmet": "^7.1.0",
         "morgan": "^1.10.0",
+        "multer": "^2.1.1",
         "ws": "^8.20.1"
       },
       "engines": {
@@ -34,6 +35,12 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/append-field": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
+      "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
+      "license": "MIT"
+    },
     "node_modules/array-flatten": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -112,6 +119,23 @@
         "balanced-match": "^1.0.0"
       }
     },
+    "node_modules/buffer-from": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+      "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+      "license": "MIT"
+    },
+    "node_modules/busboy": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+      "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+      "dependencies": {
+        "streamsearch": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=10.16.0"
+      }
+    },
     "node_modules/bytes": {
       "version": "3.1.2",
       "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -150,6 +174,21 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/concat-stream": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
+      "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
+      "engines": [
+        "node >= 6.0"
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "buffer-from": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^3.0.2",
+        "typedarray": "^0.0.6"
+      }
+    },
     "node_modules/content-disposition": {
       "version": "0.5.4",
       "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -691,6 +730,25 @@
       "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
       "license": "MIT"
     },
+    "node_modules/multer": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
+      "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
+      "license": "MIT",
+      "dependencies": {
+        "append-field": "^1.0.0",
+        "busboy": "^1.6.0",
+        "concat-stream": "^2.0.0",
+        "type-is": "^1.6.18"
+      },
+      "engines": {
+        "node": ">= 10.16.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
     "node_modules/negotiator": {
       "version": "0.6.3",
       "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -806,6 +864,20 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/readable-stream": {
+      "version": "3.6.2",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+      "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+      "license": "MIT",
+      "dependencies": {
+        "inherits": "^2.0.3",
+        "string_decoder": "^1.1.1",
+        "util-deprecate": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
     "node_modules/safe-buffer": {
       "version": "5.2.1",
       "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -964,6 +1036,23 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/streamsearch": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+      "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/string_decoder": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+      "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.2.0"
+      }
+    },
     "node_modules/toidentifier": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -986,6 +1075,12 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/typedarray": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+      "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+      "license": "MIT"
+    },
     "node_modules/unpipe": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -995,6 +1090,12 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "license": "MIT"
+    },
     "node_modules/utils-merge": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
diff --git a/package.json b/package.json
index 93eca89..af05d1b 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
     "express-rate-limit": "^7.4.0",
     "helmet": "^7.1.0",
     "morgan": "^1.10.0",
+    "multer": "^2.1.1",
     "ws": "^8.20.1"
   },
   "engines": {
diff --git a/routes/uploads.js b/routes/uploads.js
new file mode 100644
index 0000000..33c0d2f
--- /dev/null
+++ b/routes/uploads.js
@@ -0,0 +1,252 @@
+// /admin/uploads — image (and small file) upload UI.
+//
+//   GET  /admin/uploads               — page: button + gallery + connect-folder UI
+//   POST /admin/uploads/file          — multipart upload, returns { ok, url, filename }
+//   GET  /uploads/<filename>          — public serve (unguessable filenames)
+//   POST /admin/uploads/folder        — record a folder path to watch
+//   POST /admin/uploads/delete        — admin delete by filename
+//
+// Storage:
+//   data/uploads/<uuid>.<ext>         — file bytes
+//   data/uploads-meta.json            — { files: [{filename, original_name, size, type, uploaded_at}], folder?: <path> }
+//
+// Security:
+//   - upload + admin operations require role=admin (via routes/admin gate)
+//   - public /uploads/<filename> uses unguessable filenames so listing the
+//     filename is the access token. Adequate for non-sensitive images.
+//   - mime sniffing: we trust the extension (multer + ext whitelist).
+//   - size limit: 10 MB per file.
+
+const express = require('express');
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const multer = require('multer');
+const { requireAdmin } = require('../lib/admin-gate');
+
+const router = express.Router();
+
+const UPLOADS_DIR = path.join(__dirname, '..', 'data', 'uploads');
+const META_PATH = path.join(__dirname, '..', 'data', 'uploads-meta.json');
+fs.mkdirSync(UPLOADS_DIR, { recursive: true });
+
+const ALLOWED_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.pdf', '.mp3', '.wav', '.mp4', '.txt', '.csv', '.json']);
+const MAX_SIZE = 10 * 1024 * 1024;  // 10 MB
+
+const storage = multer.diskStorage({
+  destination: (req, file, cb) => cb(null, UPLOADS_DIR),
+  filename: (req, file, cb) => {
+    const ext = (path.extname(file.originalname || '') || '').toLowerCase();
+    const safeExt = ALLOWED_EXT.has(ext) ? ext : '.bin';
+    const id = crypto.randomBytes(12).toString('hex');
+    cb(null, id + safeExt);
+  },
+});
+const upload = multer({
+  storage,
+  limits: { fileSize: MAX_SIZE, files: 20 },
+  fileFilter: (req, file, cb) => {
+    const ext = (path.extname(file.originalname || '') || '').toLowerCase();
+    if (!ALLOWED_EXT.has(ext)) return cb(new Error('extension not allowed: ' + ext));
+    cb(null, true);
+  },
+});
+
+function readMeta() {
+  try { return JSON.parse(fs.readFileSync(META_PATH, 'utf8')); } catch { return { files: [] }; }
+}
+function writeMeta(meta) {
+  const tmp = META_PATH + '.tmp';
+  fs.writeFileSync(tmp, JSON.stringify(meta, null, 2));
+  fs.renameSync(tmp, META_PATH);
+}
+
+function escapeHtml(s) {
+  return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]);
+}
+
+router.get('/admin/uploads', requireAdmin, (req, res) => {
+  const meta = readMeta();
+  const files = (meta.files || []).slice().reverse();  // newest first
+  const folder = meta.folder || '';
+
+  const gallery = files.length === 0
+    ? '<p class="muted">No uploads yet.</p>'
+    : `<div class="gallery">${files.map(f => {
+        const url = `/uploads/${f.filename}`;
+        const isImg = /\.(png|jpe?g|gif|webp|svg)$/i.test(f.filename);
+        return `<div class="card">
+          ${isImg
+            ? `<a href="${url}" target="_blank"><img src="${url}" alt="${escapeHtml(f.original_name || f.filename)}"></a>`
+            : `<div class="non-img"><a href="${url}" target="_blank">${escapeHtml(f.original_name || f.filename)}</a></div>`}
+          <div class="meta">
+            <div class="filename" title="${escapeHtml(f.original_name || '')}">${escapeHtml(f.original_name || f.filename)}</div>
+            <div class="bytes">${(f.size / 1024).toFixed(1)} KB · ${escapeHtml(String(f.uploaded_at || '').slice(0, 19))}</div>
+            <div class="actions">
+              <input class="url-field" readonly value="${escapeHtml(url)}" onclick="this.select();">
+              <button type="button" class="copy-btn" data-url="${escapeHtml(url)}">Copy</button>
+              <form method="POST" action="/admin/uploads/delete" style="display:inline" onsubmit="return confirm('Delete this file?')">
+                <input type="hidden" name="filename" value="${escapeHtml(f.filename)}">
+                <button type="submit" class="danger">×</button>
+              </form>
+            </div>
+          </div>
+        </div>`;
+      }).join('')}</div>`;
+
+  res.type('html').send(`<!doctype html><html><head>
+    <meta charset="utf-8">
+    <title>Uploads — Butlr admin</title>
+    <link rel="stylesheet" href="/css/site.css">
+    <style>
+      .card { padding: 14px 16px; border: 1px solid #e5e7eb; border-radius: 8px; margin-bottom: 16px; background: #fff; }
+      .muted { color: #6b7280; font-size: 13px; }
+      .gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
+      .gallery .card { padding: 8px; }
+      .gallery img { width: 100%; aspect-ratio: 4/3; object-fit: cover; border-radius: 6px; background: #f3f4f6; }
+      .gallery .non-img { aspect-ratio: 4/3; display:flex; align-items:center; justify-content:center; background: #f3f4f6; border-radius: 6px; padding: 12px; word-break: break-all; }
+      .gallery .meta { padding-top: 8px; font-size: 11px; }
+      .gallery .filename { font-weight: 600; color: #111827; word-break: break-all; }
+      .gallery .bytes { color: #6b7280; margin-top: 2px; }
+      .gallery .actions { display:flex; gap: 4px; margin-top: 6px; align-items:center; }
+      .gallery .url-field { flex: 1; padding: 4px 6px; font-size: 10px; border: 1px solid #d1d5db; border-radius: 4px; min-width: 0; }
+      .gallery .copy-btn, .gallery .danger { padding: 4px 8px; font-size: 11px; cursor: pointer; }
+      .gallery .danger { background: #fef2f2; color: #b91c1c; border: 1px solid #fecaca; border-radius: 4px; }
+      .drop-zone { border: 2px dashed #cbd5e1; border-radius: 10px; padding: 32px; text-align: center; cursor: pointer; transition: all .15s; }
+      .drop-zone:hover, .drop-zone.dragover { border-color: var(--accent,#1a56db); background: #eff6ff; }
+      .drop-zone p { margin: 4px 0; color: #6b7280; }
+      .drop-zone strong { color: #111827; }
+      .field { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
+      .field input[type=text] { flex: 1; padding: 6px 10px; border: 1px solid #d1d5db; border-radius: 6px; }
+    </style>
+  </head><body>
+    <main style="max-width: 960px; margin: 0 auto; padding: 24px;">
+      <h1>Uploads</h1>
+      <p class="muted">Drop images or files here. Get back a public URL you can paste into a call goal or share.</p>
+
+      <div class="card">
+        <form id="upload-form" enctype="multipart/form-data" method="POST" action="/admin/uploads/file">
+          <label for="file-input" class="drop-zone" id="drop-zone">
+            <p><strong>Click to choose</strong> or drag files here</p>
+            <p>PNG · JPG · GIF · WEBP · SVG · PDF · MP3 · WAV · MP4 · TXT · CSV · JSON (max 10 MB each, 20 at once)</p>
+          </label>
+          <input id="file-input" type="file" name="files" multiple accept="image/*,application/pdf,audio/*,video/*,text/*,application/json" style="display:none">
+          <p id="upload-status" class="muted" style="text-align:center;margin-top:8px;min-height:18px"></p>
+        </form>
+      </div>
+
+      <div class="card">
+        <strong>Connect to a folder</strong>
+        <p class="muted" style="margin: 4px 0 8px">Record a local folder path. A future watcher tick will import new files from this directory automatically.</p>
+        <form method="POST" action="/admin/uploads/folder" class="field">
+          <input type="text" name="folder" placeholder="/Users/stevestudio2/Pictures/butlr-inbox" value="${escapeHtml(folder)}">
+          <button type="submit">Save</button>
+        </form>
+        ${folder ? `<p class="muted" style="margin-top:6px">Currently watching: <code>${escapeHtml(folder)}</code></p>` : ''}
+      </div>
+
+      <h2 style="font-size:18px; margin-top:28px;">Gallery (${files.length})</h2>
+      ${gallery}
+
+      <p class="muted" style="margin-top:24px;"><a href="/admin">← Admin dashboard</a></p>
+    </main>
+
+    <script>
+      const dz = document.getElementById('drop-zone');
+      const input = document.getElementById('file-input');
+      const status = document.getElementById('upload-status');
+      const form = document.getElementById('upload-form');
+
+      async function uploadFiles(files) {
+        if (!files || !files.length) return;
+        status.textContent = 'Uploading ' + files.length + ' file' + (files.length === 1 ? '' : 's') + '…';
+        const fd = new FormData();
+        for (const f of files) fd.append('files', f);
+        try {
+          const r = await fetch('/admin/uploads/file', { method: 'POST', body: fd });
+          const j = await r.json();
+          if (j.ok) {
+            status.textContent = '✓ uploaded ' + j.files.length + ' — reloading…';
+            setTimeout(() => location.reload(), 500);
+          } else {
+            status.textContent = '✗ ' + (j.error || 'failed');
+          }
+        } catch (e) {
+          status.textContent = '✗ ' + e.message;
+        }
+      }
+
+      input.addEventListener('change', () => uploadFiles(input.files));
+      dz.addEventListener('dragover', (e) => { e.preventDefault(); dz.classList.add('dragover'); });
+      dz.addEventListener('dragleave', () => dz.classList.remove('dragover'));
+      dz.addEventListener('drop', (e) => {
+        e.preventDefault(); dz.classList.remove('dragover');
+        uploadFiles(e.dataTransfer.files);
+      });
+
+      document.querySelectorAll('.copy-btn').forEach(b => {
+        b.addEventListener('click', async () => {
+          const u = b.dataset.url;
+          try {
+            await navigator.clipboard.writeText(window.location.origin + u);
+            const t = b.textContent; b.textContent = '✓';
+            setTimeout(() => { b.textContent = t; }, 1200);
+          } catch (e) {}
+        });
+      });
+    </script>
+  </body></html>`);
+});
+
+// Multipart upload — multer parses the body, files land on disk via storage above.
+router.post('/admin/uploads/file', requireAdmin, upload.array('files', 20), (req, res) => {
+  const files = (req.files || []).map(f => ({
+    filename: f.filename,
+    original_name: f.originalname,
+    size: f.size,
+    type: f.mimetype,
+    uploaded_at: new Date().toISOString(),
+  }));
+  const meta = readMeta();
+  meta.files = (meta.files || []).concat(files);
+  writeMeta(meta);
+  res.json({ ok: true, files: files.map(f => ({ url: '/uploads/' + f.filename, ...f })) });
+});
+
+// Multer error handler — catches "file too large" / "wrong extension" cleanly.
+router.use('/admin/uploads/file', (err, req, res, next) => {
+  if (err) return res.status(400).json({ ok: false, error: err.message });
+  next();
+});
+
+router.post('/admin/uploads/folder', requireAdmin, express.urlencoded({ extended: true }), (req, res) => {
+  const folder = String((req.body && req.body.folder) || '').trim();
+  const meta = readMeta();
+  if (folder) meta.folder = folder; else delete meta.folder;
+  writeMeta(meta);
+  res.redirect('/admin/uploads');
+});
+
+router.post('/admin/uploads/delete', requireAdmin, express.urlencoded({ extended: true }), (req, res) => {
+  const filename = String((req.body && req.body.filename) || '').trim();
+  if (!/^[a-f0-9]{24}\.[a-z0-9]+$/i.test(filename)) return res.redirect('/admin/uploads');
+  const fp = path.join(UPLOADS_DIR, filename);
+  try { fs.unlinkSync(fp); } catch {}
+  const meta = readMeta();
+  meta.files = (meta.files || []).filter(f => f.filename !== filename);
+  writeMeta(meta);
+  res.redirect('/admin/uploads');
+});
+
+// Public file serving — no auth, but filenames are 24-hex-char unguessable.
+router.get('/uploads/:filename', (req, res) => {
+  const filename = req.params.filename;
+  if (!/^[a-f0-9]{24}\.[a-z0-9]+$/i.test(filename)) return res.status(404).end();
+  const fp = path.join(UPLOADS_DIR, filename);
+  if (!fs.existsSync(fp)) return res.status(404).end();
+  // Cache aggressively — the filename is content-addressed (random-uuid).
+  res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
+  res.sendFile(fp);
+});
+
+module.exports = router;
diff --git a/server.js b/server.js
index 5e7e8dd..295cb36 100644
--- a/server.js
+++ b/server.js
@@ -13,6 +13,7 @@ const agentLogRoutes = require('./routes/agent-log');
 const twilioWebhooks = require('./routes/twilio-webhooks');
 const vapiWebhooks = require('./routes/vapi-webhooks');
 const adminRoutes = require('./routes/admin');
+const uploadsRoutes = require('./routes/uploads');
 const { attachListenBridge } = require('./lib/listen-bridge');
 const { requireOwner, softAuth, mountLogin } = require('./lib/owner-auth');
 const users = require('./lib/users');
@@ -140,6 +141,7 @@ app.use(softAuth);
 app.use('/', listenRoutes);
 app.use('/', agentLogRoutes);
 app.use('/', adminRoutes);
+app.use('/', uploadsRoutes);
 app.use('/', publicRoutes);
 
 app.use((req, res) => {

← eb1df5e cost-tracker: log Vapi voice_min + Twilio voice_min + SMS to  ·  back to Butlr  ·  uploads: folder-sync — one-shot scan + "Sync now" button 81ccd55 →