← 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) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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}`);
});