← 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
M .deploy.confM .gitignoreM lib/data.jsM package-lock.jsonM package.jsonA routes/uploads.jsM server.js
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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 →