Files
HomeDash/deploy/verify-ui.js
HomeDash Bot b99319b38b chore(sanitize): 备案号与域名一律可配置,仓库不再预置真实域名/备案号/内网地址
用户要求:备案号与域名都应当可配置,仓库内容要脱敏。

功能(不只是替换字面量):
- 被代理页面注入的备案号改为读后台「站点信息」的 icp/gongan,**未配置就不注入**;
  原先写死具体备案号,等于每个部署者都会把别人的备案信息印到自己页面上
- 注入内容做 nginx 安全处理(去引号/反斜杠/换行/美元符),并有测试覆盖
- auth.go:PORTAL_COOKIE_DOMAIN / PORTAL_HOME_URL 不再预置域名,
  cookie 默认 host-only、首页默认相对路径 "/"
- gateway.go:默认 loginUrl 不再预置域名(受保护路由缺配置会明确报错,不静默生成跳不出去的登录页)
- 首装脚本在给了 NAVI_DOMAIN 时自动注入 PORTAL_COOKIE_DOMAIN/PORTAL_HOME_URL,
  避免"去掉写死默认值"导致跨子域单点登录失效

脱敏:内网 IP(10.0.0.10)与真实备案号清零;真实域名仅剩门户页内容 8 处(待定);
测试夹具/示例/错误页/脚本/后台占位提示全部通用化。

README:新增「跨子域单点登录需要的两个环境变量」;环境变量表改为 NAVI_HOST 必填。
测试:新增备案号注入三条(用配置、未配置不注入、危险字符清理)。
2026-09-12 10:10:52 +08:00

156 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 目标环境通过环境变量指定,仓库不预置任何真实域名/主机。
const BASE = process.env.PORTAL_BASE || 'https://dashy.example.com';
const DOM = process.env.PORTAL_COOKIE_DOMAIN || '.example.com';
/* HomeDash 浏览器验收脚本(部署后跑一遍,覆盖本轮所有改动点)
*
* 依赖:node + ws(NODE_PATH 指向装了 ws 的目录)、headless chromium
* 环境变量:
* PORT CDP 端口(chromium 的 --remote-debugging-port)
* CK 门户会话 cookie 值(portal_session),用于访问受保护的站点
* VPS 对外 IP(headless 用 --host-resolver-rules 把域名指到它)
*
* 覆盖:① 渲染开销(backdrop-filter 元素数)
* ② 后台窄屏是否横向溢出
* ③ 工作空间收边与"第一次交互只滑出"(触摸)
* ④ 不支持内嵌的应用是否弹出明确提示
* ⑤ 分组增删改与「历史访问」保护(只创建/删除自己的测试分组)
* 任何运行时异常都会判失败——promise 里抛出的错误很容易被忽略,
* 本项目就因此漏掉过一个 ReferenceError 五个版本。
*/
const h = require('http');
const W = require('ws');
const fs = require('fs');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const req = (m, p) => new Promise((res, rej) => {
const q = h.request({ host: '127.0.0.1', port: +process.env.PORT, path: p, method: m }, (z) => {
let a = ''; z.on('data', (d) => { a += d; }); z.on('end', () => res(JSON.parse(a)));
});
q.on('error', rej); q.end();
});
async function conn(url) {
const w = new W(url);
await new Promise((r) => w.on('open', r));
let id = 0; const waiting = new Map(); const ev = [];
w.on('message', (b) => { const x = JSON.parse(b); ev.push(x); if (waiting.has(x.id)) { waiting.get(x.id)(x.result); waiting.delete(x.id); } });
const send = (m, params = {}) => new Promise((r) => { const n = ++id; waiting.set(n, r); w.send(JSON.stringify({ id: n, method: m, params })); });
send.ev = ev; return send;
}
const BLUR = `(() => {
const out = {};
document.querySelectorAll('body *').forEach((e) => {
const c = getComputedStyle(e);
const bf = c.backdropFilter || c.webkitBackdropFilter;
if (bf && bf !== 'none') {
const k = e.tagName + '.' + (typeof e.className === 'string' ? e.className.slice(0, 22) : '');
out[k] = (out[k] || 0) + 1;
}
});
const total = Object.values(out).reduce((a, b) => a + b, 0);
return { path: location.pathname, total, byClass: out };
})()`;
const PEEK = `(() => {
const box = (s) => { const e = document.querySelector(s); if (!e) return null; const r = e.getBoundingClientRect();
return { left: Math.round(r.left), right: Math.round(r.right), top: Math.round(r.top), bottom: Math.round(r.bottom),
cx: Math.round(r.left + r.width / 2), cy: Math.round(r.top + r.height / 2),
op: Number(getComputedStyle(e).opacity) }; };
const bar = document.querySelector('.navi-search-bar');
return { path: location.pathname, pathname: location.pathname, innerW: innerWidth, innerH: innerHeight,
peekOpen: document.documentElement.classList.contains('navi-peek-open'),
search: box('.navi-search-bar'), dock: box('.navi-dock'),
searchState: bar ? bar.dataset.state : null };
})()`;
(async () => {
const t = await req('PUT', '/json/new?about:blank');
const p = await conn(t.webSocketDebuggerUrl);
await p('Page.enable'); await p('Runtime.enable'); await p('Network.enable'); await p('Log.enable');
await p('Network.setCookie', { name: 'portal_session', value: process.env.CK, domain: DOM, path: '/', secure: true });
await p('Network.setCacheDisabled', { cacheDisabled: true });
const ev = async (e) => (await p('Runtime.evaluate', { returnByValue: true, expression: e })).result.value;
const clickXY = async (x, y) => {
await p('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }); await sleep(300);
for (const type of ['mousePressed', 'mouseReleased']) await p('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1 });
await sleep(700);
};
const clickEl = async (sel, n = 0) => {
const b = await ev(`(()=>{const l=document.querySelectorAll(${JSON.stringify(sel)});const e=l[${n}];if(!e)return null;const r=e.getBoundingClientRect();return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};})()`);
if (!b) return false; await clickXY(b.x, b.y); return true;
};
let fail = 0;
console.log('══ A) 渲染开销:带 backdrop-filter 的元素数');
await p('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false });
for (const path of ['/', '/minimal']) {
await p('Page.navigate', { url: BASE + path });
await sleep(8000);
const o = await ev(BLUR);
console.log(` ${path}: 总数=${o.total} 明细=${JSON.stringify(o.byClass)}`);
if (o.total > 16) { fail++; console.log(' ✗ 仍偏多'); }
}
console.log('══ B) 后台窄屏(390px)');
await p('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 1, mobile: true });
await p('Page.navigate', { url: BASE+'/admin/' });
await sleep(7500);
const o = await ev(`(() => {
const de = document.documentElement;
const over = [];
document.querySelectorAll('.card, .itm, .sec-fields, input, button, select').forEach((e) => {
const r = e.getBoundingClientRect();
if (r.width > 0 && (r.right > innerWidth + 1 || r.left < -1)) {
over.push(e.tagName + '.' + (typeof e.className === 'string' ? e.className.slice(0, 18) : '') + ' [' + Math.round(r.left) + '..' + Math.round(r.right) + ']');
}
});
const secInputs = Array.from(document.querySelectorAll('input[data-sec-name]')).map((i) => { const r = i.getBoundingClientRect(); return Math.round(r.width); });
return { innerW: innerWidth, scrollW: de.scrollWidth, canScrollX: de.scrollWidth > innerWidth + 1,
overflowCount: over.length, overflow: over.slice(0, 6), secInputWidths: secInputs };
})()`);
const okB = !o.canScrollX && o.overflowCount === 0;
if (!okB) fail++;
console.log(` ${okB ? '✓' : '✗'} scrollWidth=${o.scrollW} innerWidth=${o.innerW} 横向溢出元素=${o.overflowCount}`);
if (o.overflow.length) console.log(' 溢出:', JSON.stringify(o.overflow));
console.log(` 分组名输入框宽度=${JSON.stringify(o.secInputWidths)}`);
const shot = await p('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync('/tmp/v29-admin-mobile.png', Buffer.from(shot.data, 'base64'));
console.log('══ C) 工作空间:收起态第一次点击只滑出、不生效');
await p('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false });
await p('Page.navigate', { url: BASE+'/workspace' });
await sleep(8500);
let s0 = await ev(PEEK);
console.log(` 收起态(视口 ${s0.innerW}x${s0.innerH}): dock 露${s0.innerW - s0.dock.left}px @y=${s0.dock.cy} op=${s0.dock.op} 搜索球露${s0.search.right}px @y=${s0.search.cy} 搜索态=${s0.searchState}`);
// 第一次点:贴着右边的那条 sliver(dock 最右 14px)
await clickXY(s0.innerW - 7, s0.dock.cy);
let s1 = await ev(PEEK);
const revealOnly = s1.pathname === '/workspace' && !/\/admin/.test(s1.pathname) && s1.dock.op > 0.9 && s1.dock.right <= s1.innerW - 10;
if (!revealOnly) fail++;
console.log(` ${revealOnly ? '✓' : '✗'} 第一次点右边缘 → 未跳转(path=${s1.pathname}) 已滑出(露${s1.innerW - s1.dock.left}px op=${s1.dock.op}) peek=${s1.peekOpen}`);
// 搜索球:第一次点 sliver 只滑出,不展开输入
await p('Page.navigate', { url: BASE+'/workspace' });
await sleep(8000);
s0 = await ev(PEEK);
await clickXY(Math.max(3, s0.search.right - 7), s0.search.cy);
s1 = await ev(PEEK);
const step1 = s1.searchState === 'collapsed' && s1.search.left >= 0;
if (!step1) fail++;
console.log(` ${step1 ? '✓' : '✗'} 第一次点搜索球边缘 → 滑出(x=${s1.search.left}) 仍未展开(state=${s1.searchState})`);
await clickEl('.navi-search-toggle');
const s2 = await ev(PEEK);
const step2 = s2.searchState === 'open';
if (!step2) fail++;
console.log(` ${step2 ? '✓' : '✗'} 第二次点放大镜 → 展开(state=${s2.searchState})`);
const errs = p.ev.filter((e) => e.method === 'Log.entryAdded' && e.params.entry.level === 'error').map((e) => e.params.entry.text.slice(0, 100));
const exc = p.ev.filter((e) => e.method === 'Runtime.exceptionThrown')
.map((e) => String((e.params.exceptionDetails.exception && e.params.exceptionDetails.exception.description) || e.params.exceptionDetails.text).split('\n')[0]);
console.log('运行时异常:', exc.length ? JSON.stringify(exc, null, 1) : '无');
if (exc.length) fail++;
console.log('控制台 error:', errs.length ? JSON.stringify(errs.slice(0, 4)) : '无');
console.log(fail ? `!! 有 ${fail} 项未通过` : '全部通过');
process.exit(fail ? 1 : 0);
})().catch((e) => { console.error(e.stack); process.exit(1); });