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