Regression / regression (push) Canceled after 0s
## 品牌标识(本次会话) 起因:品牌此前没有任何图形标识 —— 唯一 favicon 是内联 data-URI 里的字母「A」, 那是 v2.0.0「Aurora Admin → Kole UI」改名漏掉的一处(PC 顶栏也是「A」, 移动端站已是「K」;移动端文档站则完全没有 favicon)。 - 几何:24 网格三个互不接触的笔画(竖 + 两斜),圆头描边; 描边 2.25 → 16px 标签页尺寸下正好 1.5px = 规范原文「描边1.5px」 - 取色分两套(刻意):favicon 硬编码品牌蓝/白(渲染在浏览器标签栏,不继承 kole-dark); 顶栏标记走 currentColor(实测暗色下自动转 rgb(20,22,28)) - 新增 theme-color 双条(light #FFFFFF / dark #1C1F26,取 --kole-color-card-bg) - 修 site/app.js hero 标语 KOLE ADMIN → KOLE UI(改名变形残留) - 移动端 7 个模板补 favicon(此前计数 0) 验收:门禁 9 条全 OK(site-routing/site-routes/mobile-docs/mobile-site/isolation/ theme/nav/i18n/icons);PC 回归 1464/1464 · 移动端 807/807,各连跑 8 次一致; 两端 favicon 405 字节逐字节一致;PC 站控制台错误 1→0。 ## 并行会话成果(本次一并入库) - 图标系统:2576 图标(TDesign/Element Plus,MIT)+ 11 端注入 + 5 个构建门禁工具 + IconPreview 预览页 + ICON-SPEC.md 冻结规格 - 移动端平台:47 组件 × 6 端 + 文档站 53 页 + 隔离门禁 - PC 组件:103 个大后台组件 / 组件11 批次 - uni-app:PC 端试点 + 移动端端实现 + 真实编译验证 ## 工程 - .gitignore 补 .scratch/ 与 .zcode-preexisting-*.txt(会话中间产物,实测 9.1MB,不入库) - CHANGELOG 补品牌标识条目 - ROADMAP 登 S8-P4(品牌标识任务包 + og:image/apple-touch-icon 未做部分)
218 lines
9.8 KiB
JavaScript
218 lines
9.8 KiB
JavaScript
/* 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 是 <prefix>/versions.json,仓库里对应根目录这一份 */
|
||
'versions.json',
|
||
]);
|
||
const PUBLIC_SPEC_FILES = new Set(Array.from({ length: 10 }, (_, i) => `组件${i + 1}.txt`));
|
||
/* 历史版本文档站快照(tools/snapshot-site.mjs 产出):仓库根的 <x.y.z>/ 目录,
|
||
内部结构是 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):快照自带的那份是归档当时的快照,
|
||
// 之后新归档的版本不会进去,会让快照页误判成单版本(实测:切到旧版后回不到最新版)。
|
||
// 因此 /<x.y.z>/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;
|
||
}
|
||
// 历史版本快照的裸入口:/<x.y.z>/ 与 /<x.y.z>/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 发出去。
|
||
// 历史版本快照(/<x.y.z>/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);
|