4df8dc6f87
新建 web/website/(Astro 零 SSR): - 组件迁移:交互块保留 .jsx 经 @astrojs/react(Header/AnnouncementBar/SignupForm/PricingPlans),纯展示块转 .astro 去运行时 JS;像素以原型为基准。 - 令牌同源:build-tokens.mjs 从 design/colors_and_type.css 生成 tokens.gen.css,仅剔除第三方 Google Fonts @import,数值不改。 - 字体自托管:@fontsource(Sora/Manrope/Noto Sans SC/JetBrains Mono),无第三方 CDN。 - 图标:Lucide 构建期内联 SVG(替代 unpkg CDN),零运行时、零 CDN。 - i18n:/(zh)与 /en/(en)双路由单显,语言切换组件;文案沿用 ui_kits 脱敏文案。 - 安全:public/_headers 严格 CSP(全 self + 自托管资源 + 内联片段 sha256,无 unsafe-inline)+ HSTS;无支付表单。 - CI:.gitea/workflows/website.yml 构建(红线扫描+lint+CSP 哈希)→ 同时发布 Cloudflare Pages 主站与镜像。 - 灾备:README 写明干净环境 npm ci && npm run build 可直接部署到任意静态托管;dist 指纹确定性,主站镜像一致。 测试:npm run lint(0 error)/ npm test(build+CSP 哈希注入+红线扫描 0 命中)/ 两次干净构建 dist 指纹一致。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
79 lines
3.0 KiB
JavaScript
79 lines
3.0 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* csp-hashes.mjs — 构建后为「严格 CSP」补齐内联片段的 sha256 哈希(postbuild)。
|
||
*
|
||
* Astro 的 island 水合会注入少量**内联** <script>(idle/load/visible 引导 + 自定义元素定义)
|
||
* 与一段内联 <style>(astro-island{display:contents})。在 script-src/style-src 'self' 的
|
||
* 严格 CSP 下,内联片段必须以 'sha256-...' 显式放行 —— 既不放开 'unsafe-inline',又能跑。
|
||
*
|
||
* 这些内联片段由 Astro 运行时生成、跨页面完全一致、随版本确定,故哈希稳定可复现。
|
||
* 本脚本扫描 dist 下所有 .html,去重收集内联片段,算哈希,写回 dist/_headers 的 CSP。
|
||
* 同时校验:dist/_headers 里 CSP 不得包含 'unsafe-inline'(红线)。
|
||
*/
|
||
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
||
import { createHash } from 'node:crypto';
|
||
import { join, resolve } from 'node:path';
|
||
|
||
const DIST = resolve(process.argv[2] ?? 'dist');
|
||
const HEADERS = join(DIST, '_headers');
|
||
|
||
if (!existsSync(HEADERS)) {
|
||
console.error(`[csp] 找不到 ${HEADERS}(public/_headers 应被构建复制到 dist)`);
|
||
process.exit(2);
|
||
}
|
||
|
||
function listHtml(dir) {
|
||
const out = [];
|
||
for (const name of readdirSync(dir)) {
|
||
const p = join(dir, name);
|
||
const st = statSync(p);
|
||
if (st.isDirectory()) out.push(...listHtml(p));
|
||
else if (name.endsWith('.html')) out.push(p);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
const sha = (s) => `'sha256-${createHash('sha256').update(s, 'utf8').digest('base64')}'`;
|
||
|
||
const scripts = new Set();
|
||
const styles = new Set();
|
||
|
||
// 抓取无 src 的内联 <script> 与内联 <style> 的「原始内容」(CSP 哈希基于原始字节)。
|
||
const reScript = /<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi;
|
||
const reStyle = /<style[^>]*>([\s\S]*?)<\/style>/gi;
|
||
|
||
for (const file of listHtml(DIST)) {
|
||
const html = readFileSync(file, 'utf8');
|
||
for (const m of html.matchAll(reScript)) if (m[1].length) scripts.add(m[1]);
|
||
for (const m of html.matchAll(reStyle)) if (m[1].length) styles.add(m[1]);
|
||
}
|
||
|
||
const scriptHashes = [...scripts].map(sha);
|
||
const styleHashes = [...styles].map(sha);
|
||
|
||
let headers = readFileSync(HEADERS, 'utf8');
|
||
|
||
// 在 `script-src 'self'` / `style-src 'self'` 之后追加哈希(幂等:若已含则跳过)。
|
||
function inject(directive, hashes) {
|
||
if (!hashes.length) return;
|
||
const re = new RegExp(`(${directive} 'self')([^;]*)`);
|
||
headers = headers.replace(re, (_full, head, rest) => {
|
||
const have = new Set(rest.trim().split(/\s+/).filter(Boolean));
|
||
const add = hashes.filter((h) => !have.has(h));
|
||
return `${head}${rest}${add.length ? ' ' + add.join(' ') : ''}`;
|
||
});
|
||
}
|
||
|
||
inject('script-src', scriptHashes);
|
||
inject('style-src', styleHashes);
|
||
|
||
if (/unsafe-inline/.test(headers)) {
|
||
console.error("[csp] 致命:CSP 含 'unsafe-inline',违反严格策略。");
|
||
process.exit(1);
|
||
}
|
||
|
||
writeFileSync(HEADERS, headers, 'utf8');
|
||
console.log(
|
||
`[csp] 已写入 ${HEADERS}:script ${scriptHashes.length} 个内联哈希,style ${styleHashes.length} 个内联哈希。`
|
||
);
|