← back to Claude Webdev Accelerator

scripts/repo-viewer.js

55 lines

// Zero-dep read-only repo viewer: directory listing + file view for a ROOT dir.
// Safe: rejects path traversal and any segment starting with '.' (blocks .git, .env, .DS_Store).
// Serves .html as html; text/code as styled <pre>; other types as octet-stream download.
// Usage: PORT=3906 ROOT=/path/to/repo node repo-viewer.js   (run plain; front with gate-proxy)
const http = require('http'), fs = require('fs'), path = require('path');
const PORT = parseInt(process.env.PORT || '3906', 10);
const ROOT = path.resolve(process.env.ROOT || process.cwd());

const TEXT = new Set(['.md', '.txt', '.js', '.mjs', '.cjs', '.json', '.sh', '.html', '.css', '.yml', '.yaml', '.gitignore', '.conf']);
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const page = (title, body) => `<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>${esc(title)}</title>
<style>body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;color:#1a1a1a;background:#fafafa}
header{padding:12px 20px;border-bottom:1px solid #e6e6e6;background:#fff;position:sticky;top:0}
a{color:#0645ad;text-decoration:none}a:hover{text-decoration:underline}
.wrap{padding:16px 20px;max-width:1000px}ul{list-style:none;padding:0;margin:0}
li{padding:4px 0;border-bottom:1px solid #f0f0f0}.d{font-weight:600}
pre{background:#fff;border:1px solid #e6e6e6;border-radius:8px;padding:16px;overflow:auto;font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}
.crumb{color:#888;font-size:12px}</style>
<header><b>claude-webdev-accelerator</b> <span class=crumb>${esc(title)}</span></header><div class=wrap>${body}</div>`;

function safe(reqPath) {
  const rel = decodeURIComponent(reqPath.replace(/^\/+/, '')).replace(/\/+$/, '');
  if (rel.split('/').some(seg => seg.startsWith('.') || seg === '')) return rel === '' ? ROOT : null;
  const abs = path.resolve(ROOT, rel);
  if (abs !== ROOT && !abs.startsWith(ROOT + path.sep)) return null;
  return abs;
}

http.createServer((req, res) => {
  const u = new URL(req.url, 'http://x');
  const abs = safe(u.pathname);
  if (abs === null) { res.writeHead(403); return res.end('forbidden'); }
  let st; try { st = fs.statSync(abs); } catch { res.writeHead(404); return res.end('not found'); }
  const rel = path.relative(ROOT, abs) || '/';
  if (st.isDirectory()) {
    const up = rel === '/' ? '' : `<li><a href="/${esc(path.dirname(rel) === '.' ? '' : path.dirname(rel))}">../</a></li>`;
    const items = fs.readdirSync(abs).filter(n => !n.startsWith('.')).sort((a, b) => {
      const ad = fs.statSync(path.join(abs, a)).isDirectory(), bd = fs.statSync(path.join(abs, b)).isDirectory();
      return ad === bd ? a.localeCompare(b) : (ad ? -1 : 1);
    }).map(n => {
      const d = fs.statSync(path.join(abs, n)).isDirectory();
      const href = '/' + (rel === '/' ? '' : rel + '/') + encodeURIComponent(n);
      return `<li${d ? ' class=d' : ''}><a href="${href}">${esc(n)}${d ? '/' : ''}</a></li>`;
    }).join('');
    res.writeHead(200, { 'Content-Type': 'text/html' });
    return res.end(page(rel, `<ul>${up}${items}</ul>`));
  }
  const ext = path.extname(abs).toLowerCase() || path.basename(abs).toLowerCase();
  const buf = fs.readFileSync(abs);
  if (ext === '.html') { res.writeHead(200, { 'Content-Type': 'text/html' }); return res.end(buf); }
  if (TEXT.has(ext)) { res.writeHead(200, { 'Content-Type': 'text/html' }); return res.end(page(rel, `<pre>${esc(buf.toString('utf8'))}</pre>`)); }
  res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Disposition': `attachment; filename="${path.basename(abs)}"` });
  res.end(buf);
}).listen(PORT, '127.0.0.1', function () { console.log(`[repo-viewer] :${PORT} serving ${ROOT}`); });