Files
pangolin/web/usercenter/scripts/add-sri.mjs
T
wangjia e2646346a6 feat(web/usercenter): Next.js 用户中心静态导出 + mock/http 双数据层 (tsk_3FIPC8lSnAfJ)
新建 web/usercenter/:Next.js App Router + output:'export' 纯静态导出,
直接复用 design/ui_kits/usercenter React 源码(概览/订阅/兑换/邀请/设置)。

阶段 A(mock,本提交):
- 复刻五大页面,明/暗 × zh/en 四态;colors_and_type.css 原样链入;
  顶栏主题切换 + 语言段控,移动端底部 Tab + 左右滑动切换。
- 设置页新增:偏好(语言/主题) + 设备管理(列表/移除/二次确认+刷新) +
  TOTP 2FA(绑定二维码占位+密钥/验证/解禁),登录二段式 TOTP。
- 数据层 lib/api:ApiClient 抽象 + MockClient/HttpClient 双实现,构建期
  NEXT_PUBLIC_API_MODE 切换;统一错误体 {code,message_zh,message_en} → 双语映射。
- 会话:access token 仅内存,refresh header token + localStorage,静默续期,
  登出失效(取舍:静态导出无服务端 cookie 能力,详见 README)。
- 安全:构建期注入 SRI(sha384);_headers 严格 CSP + 安全基线;
  红线词扫描(铁律13) CI 红线,零命中。

阶段 B(占位待联调):HttpClient 已写好域名池+退避重试+401 续期;
TOTP/登录二段式端点占位待 #1 契约增补;me/redeem/devices 切真实链路依赖 #2/#3/#4。

验证:npm run lint / redline / build 均通过,静态产物 out/ 无服务端依赖。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:23:18 +08:00

99 lines
2.9 KiB
JavaScript

#!/usr/bin/env node
// add-sri.mjs — 构建后为导出 HTML 中的本地 <script>/<link rel=stylesheet> 注入
// Subresource Integrity (sha384) + crossorigin。满足 doc/05 §2「用户中心开启 SRI」。
// 纯静态导出:Next 不内置 SRI,故在 out/ 上后处理。外链(Google Fonts 等)不注入,由 CSP 约束。
import { createHash } from 'node:crypto';
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
import { join, normalize } from 'node:path';
const OUT = 'out';
async function walk(dir) {
const entries = await readdir(dir, { withFileTypes: true });
const files = [];
for (const e of entries) {
const p = join(dir, e.name);
if (e.isDirectory()) files.push(...(await walk(p)));
else if (e.name.endsWith('.html')) files.push(p);
}
return files;
}
const cache = new Map();
async function sriFor(localPath) {
if (cache.has(localPath)) return cache.get(localPath);
try {
const buf = await readFile(localPath);
const hash = 'sha384-' + createHash('sha384').update(buf).digest('base64');
cache.set(localPath, hash);
return hash;
} catch {
cache.set(localPath, null);
return null;
}
}
function isLocal(url) {
return url && !/^https?:\/\//i.test(url) && !url.startsWith('//') && !url.startsWith('data:');
}
async function resolveAsset(url) {
const clean = url.split('?')[0].split('#')[0];
const rel = clean.startsWith('/') ? clean.slice(1) : clean;
const candidate = normalize(join(OUT, rel));
try {
await stat(candidate);
return candidate;
} catch {
return null;
}
}
async function processHtml(file) {
let html = await readFile(file, 'utf8');
let count = 0;
// <script ... src="...">
const scriptRe = /<script\b[^>]*\bsrc=["']([^"']+)["'][^>]*><\/script>/gi;
const linkRe = /<link\b[^>]*\brel=["']stylesheet["'][^>]*>/gi;
const replacements = [];
let m;
while ((m = scriptRe.exec(html))) replacements.push({ tag: m[0], url: m[1] });
while ((m = linkRe.exec(html))) {
const href = /href=["']([^"']+)["']/i.exec(m[0]);
if (href) replacements.push({ tag: m[0], url: href[1] });
}
for (const { tag, url } of replacements) {
if (!isLocal(url) || /integrity=/.test(tag)) continue;
const asset = await resolveAsset(url);
if (!asset) continue;
const hash = await sriFor(asset);
if (!hash) continue;
const newTag = tag.replace(/(\s*\/?>)(\s*(?:<\/script>)?)\s*$/, (mm, close, rest) => ` integrity="${hash}" crossorigin="anonymous"${close}${rest}`);
if (newTag !== tag) {
html = html.replace(tag, newTag);
count++;
}
}
await writeFile(file, html);
return count;
}
async function main() {
let files;
try {
files = await walk(OUT);
} catch {
console.error(`[sri] 找不到 ${OUT}/,请先 next build`);
process.exit(1);
}
let total = 0;
for (const f of files) total += await processHtml(f);
console.log(`[sri] 处理 ${files.length} 个 HTML,注入 integrity ${total}`);
}
main();