/* Minimal static dev server for the component showcase site. Usage (from repo root): node site/dev-server.js Then open: http://127.0.0.1:3311/site/ Serves the repo root so /site, /frameworks and /.design_library are all reachable. */ 'use strict'; const http = require('http'); const fs = require('fs'); const path = require('path'); /* 默认端口 3311;KOLE_PORT 仅供验证脚本起独立实例(避免与正在运行的 3311 抢端口)。 非法值(非整数 / 越界 / 空串)一律回落到 3311,保持既有行为。 */ const PORT = (() => { const raw = process.env.KOLE_PORT; if (raw === undefined || raw === '') return 3311; const n = Number(raw); return Number.isInteger(n) && n > 0 && n < 65536 ? n : 3311; })(); const HOST = '127.0.0.1'; const ROOT = path.resolve(process.cwd()); const TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.txt': 'text/plain; charset=utf-8', '.vue': 'text/plain; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon' }; const SECURITY_HEADERS = { 'Content-Security-Policy': [ "default-src 'self'", "script-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data:", "font-src 'self' data:", "connect-src 'self'", "frame-src 'self'", "object-src 'none'", "base-uri 'self'", "frame-ancestors 'self'", "form-action 'self'" ].join('; '), 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', 'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=()' }; const SENSITIVE_SEGMENT = /^(?:\.git|\.env(?:\..*)?|backup(?:s)?|log(?:s)?)$/i; const SENSITIVE_FILE = /\.(?:bak|backup|log)$/i; function isSensitivePath(relativePath) { if (!relativePath || relativePath === '..' || path.isAbsolute(relativePath)) return true; const segments = relativePath.split(path.sep); return segments.some((segment) => SENSITIVE_SEGMENT.test(segment) || SENSITIVE_FILE.test(segment)); } const PUBLIC_ROOTS = new Set([ 'site', 'frameworks', /* 移动端平台实现目录 + PC×uni-app 端实现目录(与 PC 侧 frameworks/ 物理隔离,见 PLATFORMS.md) */ 'frameworks-mobile', 'frameworks-uniapp-pc', '.design_library', 'tests', 'index.html', 'sitemap.xml', /* 版本清单:站点根在部署前缀下时 URL 是 /versions.json,仓库里对应根目录这一份 */ 'versions.json', ]); const PUBLIC_SPEC_FILES = new Set(Array.from({ length: 10 }, (_, i) => `组件${i + 1}.txt`)); /* 历史版本文档站快照(tools/snapshot-site.mjs 产出):仓库根的 / 目录, 内部结构是 site/ + frameworks/ + .design_library/,URL 形如 /1.4.1/site/… */ const VERSION_DIR = /^\d+\.\d+\.\d+$/; function isPublicPath(relativePath) { if (!relativePath || relativePath === '.' || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) return false; const segments = relativePath.split(path.sep); const top = segments[0]; if (top === 'index.html' || top === 'sitemap.xml' || top === 'versions.json') return segments.length === 1; if (top === 'site' || top === 'frameworks' || top === 'tests') return true; if (top === 'frameworks-mobile' || top === 'frameworks-uniapp-pc') return true; if (top === '.design_library') return true; if (VERSION_DIR.test(top)) return true; // 兼容旧版文档站:仅允许仓库根的 10 份规范原文,其余根文件一律拒绝 return segments.length === 1 && PUBLIC_SPEC_FILES.has(top); } function writeResponse(res, statusCode, headers, body) { res.writeHead(statusCode, { ...SECURITY_HEADERS, ...headers }); res.end(body); } const server = http.createServer((req, res) => { const rawPath = req.url.split('?')[0]; // 原始路径含编码形态的 「.」「/」或 NUL 一律拒绝: // - %2e/%2f(含大小写变体与双重编码)解码后会把 /%2e%2e/x 归一化成合法根路径 // - %00 / %2500 解码后是 NUL,会让 fs.readFile 同步抛 TypeError 打挂进程 if (/%(?:2e|2f|252e|252f|00|2500)/i.test(rawPath)) { writeResponse(res, 403, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Forbidden'); return; } let urlPath; try { urlPath = decodeURIComponent(rawPath).replace(/\\/g, '/'); } catch (e) { writeResponse(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad request'); return; } // 解码后再校验一次 NUL(双保险):无论编码形态如何(含裸 NUL、平台解码差异), // 都不得把 NUL 带进后面的文件路径 if (urlPath.indexOf('\u0000') !== -1) { writeResponse(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad request'); return; } if (urlPath === '/' || urlPath === '') { writeResponse(res, 302, { Location: '/site/' }); return; } // 版本清单只留部署根一份(仓库根 versions.json):快照自带的那份是归档当时的快照, // 之后新归档的版本不会进去,会让快照页误判成单版本(实测:切到旧版后回不到最新版)。 // 因此 //versions.json 也指向根那份,而不是回落到该版壳。 if (urlPath === '/versions.json' || /^\/\d+\.\d+\.\d+\/versions\.json$/.test(urlPath)) { urlPath = '/versions.json'; } else if (urlPath.endsWith('/')) { urlPath += 'index.html'; } // 根路径与常见入口自动跳转到站点,避免裸访问 404 if (urlPath === '/index.html') { writeResponse(res, 302, { Location: '/site/' }); return; } // 历史版本快照的裸入口:// 与 //index.html 都送到该版的站点壳 const verRoot = urlPath.match(/^\/(\d+\.\d+\.\d+)\/(?:index\.html)?$/); if (verRoot) { writeResponse(res, 302, { Location: `/${verRoot[1]}/site/` }); return; } const filePath = path.resolve(ROOT, `.${urlPath}`); const relativePath = path.relative(ROOT, filePath); // 开发服务只开放站点运行所需的顶层目录;仓库元数据、配置、脚本一律拒绝 if (!isPublicPath(relativePath) || isSensitivePath(relativePath)) { writeResponse(res, 403, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Forbidden'); return; } // 文档站用 History API 路由(URL 里没有 #):/site/component/button/h5 这类深层路径 // 磁盘上没有对应文件,回落到 SPA 壳 site/index.html,由 app.js 按 pathname 渲染。 // 与 nginx.conf 的 location /site/ 行为一致;只对无扩展名的路径生效 —— 带扩展名的 // 缺失资源按真 404,避免把一份 HTML 当成 JS/CSS 发出去。 // 历史版本快照(//site/…)走同一条规则,回落到它自己那份壳。 const spaMatch = urlPath.match(/^((?:\/\d+\.\d+\.\d+)?\/site\/)/); const isSpaRoute = !!spaMatch && !/\.[a-z0-9]+$/i.test(urlPath) && !fs.existsSync(filePath); const servePath = isSpaRoute ? path.join(ROOT, spaMatch[1], 'index.html') : filePath; // fs.readFile 会对含 NUL 的路径**同步**抛 ERR_INVALID_ARG_VALUE;异常若逃逸出请求 // 回调会打挂进程(单个畸形请求即可 DoS)。这里兜住同步抛错,转成 400 响应。 try { fs.readFile(servePath, (err, buf) => { if (err) { writeResponse(res, 404, { 'Content-Type': 'text/plain; charset=utf-8' }, '404 Not Found: ' + urlPath); return; } writeResponse(res, 200, { 'Content-Type': TYPES[path.extname(servePath).toLowerCase()] || 'application/octet-stream', /* 版本清单每次构建都会重写,缓存它会让菜单停在旧版本列表上 */ 'Cache-Control': path.basename(servePath) === 'versions.json' ? 'no-store' : 'no-cache' }, buf); }); } catch (e) { writeResponse(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad request'); } }); /* 启动:默认 3311;被占用时自动向上找下一个空闲端口(最多 10 档), 而不是直接 EADDRINUSE 退出 —— 实测踩过:上一个改动前启动的实例还占着 3311, 新实例静默没起来,浏览器里看到的是旧白名单(新目录 403),极易误判成"构建没产出"。 KOLE_PORT 显式指定时不回退(脚本/CI 依赖固定端口,拿不到就该报错退出)。 */ let listenAttempts = 0; /* 只注册一次,端口取 server.address() —— 回退后闭包里的旧端口值是错的(实测先打印了 3311)。 */ server.on('listening', () => { const base = `http://${HOST}:${server.address().port}`; console.log(`Kole UI showcase serving at ${base}/site/`); console.log(''); console.log(' 访问入口:'); console.log(` PC 文档站 ${base}/site/`); console.log(` 移动端文档站 ${base}/site/m/`); console.log(` PC 组件总览 ${base}/site/overview`); console.log(` PC 测试总览 ${base}/tests/index.html`); console.log(` 移动端测试总览 ${base}/tests/mobile/index.html`); console.log(` 回归收集器(PC / 移动端) ${base}/tests/_collect.html · ${base}/tests/mobile/_collect.html`); console.log(''); console.log(' 提示:需要固定端口时用 KOLE_PORT=13511 node site/dev-server.js;'); console.log(` 回归与门禁脚本用 REG_BASE=${base} 指向本实例。`); }); function listen(port) { server.removeAllListeners('error'); server.on('error', (e) => { const explicit = process.env.KOLE_PORT !== undefined && process.env.KOLE_PORT !== ''; if (e.code === 'EADDRINUSE' && !explicit && listenAttempts < 10) { listenAttempts++; console.warn(`端口 ${port} 已被占用,改用 ${port + 1} 启动(KOLE_PORT 可固定端口)`); listen(port + 1); return; } console.error('Server error:', e.message); process.exit(1); }); server.listen(port, HOST); } listen(PORT);