← back to Dave Share

server.js

186 lines

#!/usr/bin/env node
/**
 * dave-share — a read-only "drop folder" web server meant to be exposed to Dave
 * (a peer on a *separate* Tailscale tailnet) via `tailscale serve` + node-sharing.
 *
 * Security posture (deliberate):
 *   - GET/HEAD only. No writes, no exec, no eval, nothing dynamic but a dir index.
 *   - Hard-locked to ROOT (./shared). Path-traversal is resolved + bounds-checked.
 *   - Because `tailscale serve` proxies from 127.0.0.1, every request looks local —
 *     so this server intentionally has NOTHING that trusts localhost. The only thing
 *     reachable is the contents of ./shared, which Steve curates by hand.
 *   - Tailscale injects identity headers (Tailscale-User-Login) for shared-in peers;
 *     we surface them so you can see who's connected. We do NOT gate on them — the
 *     node-share itself is the access control (only Dave's node can reach this URL).
 */
'use strict';

const http = require('http');
const fs = require('fs');
const path = require('path');
const url = require('url');

const PORT = process.env.DAVE_SHARE_PORT ? Number(process.env.DAVE_SHARE_PORT) : 9930;
const HOST = '127.0.0.1'; // only ever bound to loopback; Tailscale serve fronts it
const ROOT = path.join(__dirname, 'shared');

const MIME = {
  '.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8',
  '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
  '.json': 'application/json; charset=utf-8', '.txt': 'text/plain; charset=utf-8',
  '.md': 'text/plain; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml',
  '.webp': 'image/webp', '.pdf': 'application/pdf', '.mp4': 'video/mp4',
  '.mov': 'video/quicktime', '.zip': 'application/zip', '.csv': 'text/csv; charset=utf-8',
  '.ico': 'image/x-icon',
};

const esc = (s) => String(s).replace(/[&<>"']/g, (c) =>
  ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));

function humanSize(n) {
  if (n < 1024) return n + ' B';
  const u = ['KB', 'MB', 'GB', 'TB'];
  let i = -1;
  do { n /= 1024; i++; } while (n >= 1024 && i < u.length - 1);
  return n.toFixed(1) + ' ' + u[i];
}

function who(req) {
  // Headers injected by `tailscale serve` for identified (incl. shared-in) peers.
  const login = req.headers['tailscale-user-login'];
  const name = req.headers['tailscale-user-name'];
  if (login) return esc(name ? `${name} (${login})` : login);
  return null;
}

function page(title, body, viewer) {
  const whoBadge = viewer
    ? `<span class="who">🔑 ${viewer}</span>`
    : `<span class="who who--anon">on the tailnet</span>`;
  return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(title)}</title>
<style>
  :root{--ink:#1a1a1a;--muted:#6b6b6b;--line:#e6e3dd;--bg:#faf8f4;--accent:#7a5c3e}
  *{box-sizing:border-box}
  body{margin:0;font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--bg)}
  header{padding:28px 32px 18px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:8px}
  h1{margin:0;font-size:22px;letter-spacing:.02em;font-weight:600}
  .sub{color:var(--muted);font-size:13px;margin-top:4px}
  .who{font-size:12px;color:var(--accent);border:1px solid var(--line);border-radius:999px;padding:4px 12px;background:#fff}
  .who--anon{color:var(--muted)}
  main{padding:22px 32px 60px;max-width:880px}
  ul.list{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:#fff}
  ul.list li{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:12px 18px;border-bottom:1px solid var(--line)}
  ul.list li:last-child{border-bottom:none}
  ul.list a{text-decoration:none;color:var(--ink);font-weight:500}
  ul.list a:hover{color:var(--accent)}
  .meta{color:var(--muted);font-size:12px;white-space:nowrap}
  .empty{color:var(--muted);padding:40px;text-align:center;border:1px dashed var(--line);border-radius:10px;background:#fff}
  .ico{display:inline-block;width:22px}
  footer{color:var(--muted);font-size:12px;padding:0 32px 40px;max-width:880px}
  code{background:#fff;border:1px solid var(--line);border-radius:5px;padding:1px 6px;font-size:13px}
  a.crumb{color:var(--accent);text-decoration:none}
</style></head><body>
<header>
  <div><h1>${esc(title)}</h1><div class="sub">Shared privately over Tailscale · read-only</div></div>
  ${whoBadge}
</header>
<main>${body}</main>
<footer>This page lives only inside Steve's tailnet and the nodes shared into it. Drop files into the
<code>shared/</code> folder to publish them here.</footer>
</body></html>`;
}

function iconFor(name, isDir) {
  if (isDir) return '📁';
  const e = path.extname(name).toLowerCase();
  if (['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp'].includes(e)) return '🖼️';
  if (['.mp4', '.mov'].includes(e)) return '🎬';
  if (e === '.pdf') return '📄';
  if (['.html', '.htm'].includes(e)) return '🌐';
  if (e === '.zip') return '🗜️';
  return '📃';
}

function listDir(req, res, fsPath, urlPath) {
  let entries;
  try { entries = fs.readdirSync(fsPath, { withFileTypes: true }); }
  catch { res.writeHead(500); return res.end('cannot read directory'); }

  entries = entries
    .filter((e) => !e.name.startsWith('.'))
    .sort((a, b) => (b.isDirectory() - a.isDirectory()) || a.name.localeCompare(b.name));

  const crumbs = urlPath.split('/').filter(Boolean);
  let trail = '<a class="crumb" href="/">shared</a>';
  let acc = '';
  for (const c of crumbs) { acc += '/' + c; trail += ` / <a class="crumb" href="${esc(acc)}/">${esc(c)}</a>`; }

  let body = `<p class="sub" style="margin-top:0">${trail}</p>`;
  if (!entries.length) {
    body += `<div class="empty">Nothing shared yet.<br>Steve: drop files or <code>.html</code> pages into
      <code>~/Projects/dave-share/shared/</code> and they appear here.</div>`;
  } else {
    body += '<ul class="list">';
    for (const e of entries) {
      const href = encodeURIComponent(e.name) + (e.isDirectory() ? '/' : '');
      let meta = '';
      try {
        const st = fs.statSync(path.join(fsPath, e.name));
        meta = e.isDirectory() ? 'folder' : humanSize(st.size);
      } catch {}
      body += `<li><span><span class="ico">${iconFor(e.name, e.isDirectory())}</span>
        <a href="${esc(href)}">${esc(e.name)}</a></span><span class="meta">${esc(meta)}</span></li>`;
    }
    body += '</ul>';
  }
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
  res.end(page('Shared with you', body, who(req)));
}

const server = http.createServer((req, res) => {
  if (req.method !== 'GET' && req.method !== 'HEAD') {
    res.writeHead(405, { Allow: 'GET, HEAD' });
    return res.end('read-only');
  }
  let pathname;
  try { pathname = decodeURIComponent(url.parse(req.url).pathname || '/'); }
  catch { res.writeHead(400); return res.end('bad request'); }

  // Resolve and bounds-check against ROOT (block path traversal).
  const resolved = path.resolve(ROOT, '.' + pathname);
  if (resolved !== ROOT && !resolved.startsWith(ROOT + path.sep)) {
    res.writeHead(403); return res.end('forbidden');
  }

  let st;
  try { st = fs.statSync(resolved); }
  catch { res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
    return res.end(page('Not found', '<div class="empty">That file isn\'t here.</div>', who(req))); }

  if (st.isDirectory()) {
    const indexFile = path.join(resolved, 'index.html');
    if (fs.existsSync(indexFile)) { st = fs.statSync(indexFile); return sendFile(req, res, indexFile, st); }
    return listDir(req, res, resolved, pathname);
  }
  return sendFile(req, res, resolved, st);
});

function sendFile(req, res, file, st) {
  const type = MIME[path.extname(file).toLowerCase()] || 'application/octet-stream';
  res.writeHead(200, {
    'Content-Type': type,
    'Content-Length': st.size,
    'Cache-Control': 'no-cache',
    'X-Content-Type-Options': 'nosniff',
  });
  if (req.method === 'HEAD') return res.end();
  fs.createReadStream(file).pipe(res);
}

server.listen(PORT, HOST, () => {
  console.log(`[dave-share] read-only on http://${HOST}:${PORT}  root=${ROOT}`);
});