← 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 => ({'&':'&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/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;