89 lines
7.1 KiB
JavaScript
89 lines
7.1 KiB
JavaScript
import http from 'node:http';
|
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
import { readFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
// Expose only built assets and the application API, with a temporary password.
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../apps/web/dist');
|
|
export function createPreviewServer({password=process.env.PREVIEW_PASSWORD || randomBytes(12).toString('base64url')}={}) {
|
|
const sessions = new Map();
|
|
let attempts = [];
|
|
const equal = (a, b) => { const x=Buffer.from(a), y=Buffer.from(b); return x.length===y.length && timingSafeEqual(x,y); };
|
|
const loginPage = (failed=false) => `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Beryl Agent · 测试登录</title><style>body{font:16px system-ui;background:#f5f7fa;color:#203040;display:grid;place-items:center;min-height:95vh;margin:0}main{background:white;padding:36px;border-radius:16px;width:min(340px,80vw);box-shadow:0 8px 40px #1231}input,button{box-sizing:border-box;width:100%;padding:12px;margin-top:16px;border-radius:8px;border:1px solid #ccd4da}button{background:#203040;color:white;cursor:pointer}p{line-height:1.6}small{color:#687684}</style><main><h1>Beryl Agent</h1><p>受密码保护的临时测试入口</p>${failed?'<p role="alert">密码不正确,请重试。</p>':''}<form method="post" action="/__preview/login"><label for="password">访问密码</label><input id="password" name="password" type="password" required autocomplete="current-password" maxlength="128"><button type="submit">登录 Agent</button></form><p><small>仅供受邀测试者使用。登录有效期 8 小时。</small></p></main></html>`;
|
|
const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' };
|
|
return http.createServer(async (req, res) => {
|
|
res.setHeader('Cache-Control','no-store');
|
|
res.setHeader('X-Content-Type-Options','nosniff');
|
|
res.setHeader('Referrer-Policy','no-referrer');
|
|
res.setHeader('X-Frame-Options','DENY');
|
|
const externalLoginNavigation=req.method==='GET' && ['/', '/__preview/login'].includes(req.url) && req.headers['sec-fetch-site']==='cross-site' && req.headers['sec-fetch-dest']==='document';
|
|
if(externalLoginNavigation) {res.writeHead(200,{'Content-Type':'text/html; charset=utf-8'});return res.end(loginPage());}
|
|
if ((req.headers.origin && req.headers.origin !== `https://${req.headers.host}`) || req.headers['sec-fetch-site']==='cross-site' || (!['GET','HEAD'].includes(req.method) && req.headers.origin !== `https://${req.headers.host}`)) {
|
|
res.writeHead(403); return res.end('Origin denied');
|
|
}
|
|
let pathname;
|
|
try { pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname); }
|
|
catch { res.writeHead(400); return res.end(); }
|
|
if(pathname==='/__preview/login' && req.method==='POST') {
|
|
attempts=attempts.filter(t=>Date.now()-t<60000);
|
|
if(attempts.length>=10) {res.writeHead(429,{'Retry-After':'60'});return res.end('Too many attempts; retry in one minute');}
|
|
attempts.push(Date.now());
|
|
if(!req.headers['content-type']?.startsWith('application/x-www-form-urlencoded')) {res.writeHead(415);return res.end();}
|
|
let body='';
|
|
try {for await(const chunk of req) {body+=chunk;if(Buffer.byteLength(body)>2048){res.writeHead(413);res.end();return;}}} catch {res.writeHead(400);return res.end();}
|
|
if(!equal(new URLSearchParams(body).get('password')??'',password)) {res.writeHead(401,{'Content-Type':'text/html; charset=utf-8'});return res.end(loginPage(true));}
|
|
for(const [key,expiry] of sessions) if(expiry<Date.now()) sessions.delete(key);
|
|
if(sessions.size>=100) {res.writeHead(503);return res.end('Session limit reached');}
|
|
const token=randomBytes(32).toString('base64url'); sessions.set(token,Date.now()+8*3600000);
|
|
res.writeHead(303,{'Location':'/','Set-Cookie':`__Host-beryl_preview=${token}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=28800`});return res.end();
|
|
}
|
|
const token=(req.headers.cookie??'').split(';').map(x=>x.trim()).find(x=>x.startsWith('__Host-beryl_preview='))?.slice('__Host-beryl_preview='.length);
|
|
if(!token || (sessions.get(token)??0)<=Date.now()) {
|
|
if((pathname==='/'||pathname==='/__preview/login')&&['GET','HEAD'].includes(req.method)) {res.writeHead(200,{'Content-Type':'text/html; charset=utf-8'});return res.end(req.method==='HEAD'?undefined:loginPage());}
|
|
res.writeHead(401,{'Content-Type':'application/json'});return res.end(JSON.stringify({error:'PREVIEW_LOGIN_REQUIRED',message:'请打开网站首页输入访问密码'}));
|
|
}
|
|
if(pathname==='/__preview/logout' && req.method==='POST') {
|
|
sessions.delete(token);
|
|
res.writeHead(303,{'Location':'/','Set-Cookie':'__Host-beryl_preview=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0'});return res.end();
|
|
}
|
|
if(pathname==='/__preview/login' && req.method==='GET') {
|
|
res.writeHead(200,{'Content-Type':'text/html; charset=utf-8'});return res.end('<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>测试访问管理</title><h1>已登录 Beryl Agent</h1><p><a href="/">进入 Agent</a></p><form method="post" action="/__preview/logout"><button>退出登录</button></form></html>');
|
|
}
|
|
if (pathname.startsWith('/api/') || pathname === '/health') {
|
|
const headers = { ...req.headers, host: 'localhost:3001' };
|
|
delete headers.authorization;
|
|
delete headers.cookie;
|
|
const upstream = http.request({ hostname: '127.0.0.1', port: 3001, path: req.url, method: req.method, headers }, response => {
|
|
const safeHeaders = { ...response.headers, 'cache-control': 'no-store' };
|
|
delete safeHeaders['access-control-allow-origin'];
|
|
res.writeHead(response.statusCode, safeHeaders);
|
|
response.pipe(res);
|
|
});
|
|
upstream.on('error', () => { if (!res.headersSent) res.writeHead(502); res.end('Backend unavailable'); });
|
|
res.on('close', () => upstream.destroy());
|
|
req.pipe(upstream);
|
|
return;
|
|
}
|
|
if (!['GET', 'HEAD'].includes(req.method)) { res.writeHead(405); return res.end(); }
|
|
const relative = pathname === '/' ? 'index.html' : pathname.slice(1);
|
|
const file = path.resolve(root, relative);
|
|
if (!file.startsWith(root + path.sep) || relative.split('/').some(p => p.startsWith('.'))) {
|
|
res.writeHead(404); return res.end();
|
|
}
|
|
try {
|
|
const data = await readFile(file);
|
|
res.writeHead(200, { 'Content-Type': types[path.extname(file)] || 'application/octet-stream', 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'no-store' });
|
|
res.end(req.method === 'HEAD' ? undefined : data);
|
|
} catch { res.writeHead(404); res.end('Not found'); }
|
|
});
|
|
}
|
|
if(process.argv[1] && path.resolve(process.argv[1])===fileURLToPath(import.meta.url)) {
|
|
const password=process.env.PREVIEW_PASSWORD || randomBytes(12).toString('base64url');
|
|
createPreviewServer({password}).listen(4173, '127.0.0.1', () => {
|
|
console.log('Preview listening on 127.0.0.1:4173');
|
|
console.log('Login: password form (no username required)');
|
|
console.log('Password: ' + password);
|
|
});
|
|
}
|