← back to Butlr
routes/uploads.js
332 lines
// /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/macstudio3/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> · <button type="button" id="sync-now-btn" style="padding:4px 12px;font-size:12px;cursor:pointer">Sync now</button> <span id="sync-status" class="muted" style="font-size:11px;margin-left:6px"></span></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);
});
// Folder sync — one-shot scan
const syncBtn = document.getElementById('sync-now-btn');
const syncStat = document.getElementById('sync-status');
if (syncBtn) {
syncBtn.addEventListener('click', async () => {
syncBtn.disabled = true;
syncStat.textContent = 'scanning…';
try {
const r = await fetch('/admin/uploads/folder/sync', { method: 'POST' });
const j = await r.json();
if (j.ok) {
syncStat.textContent = '✓ imported ' + j.imported + ' · skipped ' + j.skipped + (j.errors && j.errors.length ? ' · ' + j.errors.length + ' errors' : '');
if (j.imported > 0) setTimeout(() => location.reload(), 900);
} else {
syncStat.textContent = '✗ ' + (j.error || 'failed');
}
} catch (e) { syncStat.textContent = '✗ ' + e.message; }
finally { syncBtn.disabled = false; }
});
}
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');
});
// One-shot folder sync — scans meta.folder, imports any file not already
// in the gallery. Hash-by-(size+mtime) to avoid double-import. Returns
// {ok, imported, skipped, errors}.
router.post('/admin/uploads/folder/sync', requireAdmin, (req, res) => {
const meta = readMeta();
const folder = meta.folder;
if (!folder) return res.status(400).json({ ok: false, error: 'no folder configured' });
if (!fs.existsSync(folder)) return res.status(400).json({ ok: false, error: 'folder does not exist: ' + folder });
// Build a Set of (size:mtime) keys for files already in meta — cheap dedupe.
const existingKeys = new Set((meta.files || [])
.filter(f => f.source_path)
.map(f => `${f.source_size}:${f.source_mtime}`));
let imported = 0, skipped = 0;
const errors = [];
const newEntries = [];
let entries;
try { entries = fs.readdirSync(folder); }
catch (e) { return res.status(500).json({ ok: false, error: 'readdir failed: ' + e.message }); }
for (const name of entries) {
if (name.startsWith('.')) continue; // skip hidden
const srcPath = path.join(folder, name);
let st;
try { st = fs.statSync(srcPath); } catch { continue; }
if (!st.isFile()) continue;
if (st.size > MAX_SIZE) { errors.push(`${name}: too large (${(st.size/1024/1024).toFixed(1)}MB > 10MB)`); continue; }
const ext = (path.extname(name) || '').toLowerCase();
if (!ALLOWED_EXT.has(ext)) { skipped++; continue; }
const key = `${st.size}:${st.mtime.getTime()}`;
if (existingKeys.has(key)) { skipped++; continue; }
try {
const newName = crypto.randomBytes(12).toString('hex') + ext;
fs.copyFileSync(srcPath, path.join(UPLOADS_DIR, newName));
newEntries.push({
filename: newName,
original_name: name,
size: st.size,
type: 'application/octet-stream', // we don't sniff; client may guess by ext
uploaded_at: new Date().toISOString(),
source_path: srcPath,
source_size: st.size,
source_mtime: st.mtime.getTime(),
});
imported++;
} catch (e) {
errors.push(`${name}: ${e.message}`);
}
}
if (newEntries.length) {
meta.files = (meta.files || []).concat(newEntries);
writeMeta(meta);
}
res.json({ ok: true, imported, skipped, errors, folder });
});
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;