字体加密
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
- 留言墙:接入 Twikoo 后可在页面中留言。
|
- 留言墙:接入 Twikoo 后可在页面中留言。
|
||||||
- 全站搜索:通过 Typesense 搜索并筛选时间线、照片、文章和同学公开资料。
|
- 全站搜索:通过 Typesense 搜索并筛选时间线、照片、文章和同学公开资料。
|
||||||
- PWA:支持添加到主屏幕,并离线访问站点外壳及已浏览的同源内容。
|
- PWA:支持添加到主屏幕,并离线访问站点外壳及已浏览的同源内容。
|
||||||
|
- 字体加密:生产构建会随机混淆页面中的汉字,并生成按需裁剪的 WOFF2 字体,降低静态内容被直接复制或抓取的风险。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
@@ -38,7 +39,9 @@ npm run preview # 预览构建结果
|
|||||||
npm run search:sync # 将公开内容同步到 Typesense
|
npm run search:sync # 将公开内容同步到 Typesense
|
||||||
```
|
```
|
||||||
|
|
||||||
`npm run build` 会在 Astro 构建完成后生成带内容版本号的 Service Worker,并预缓存页面、样式、脚本、应用图标和首页主视觉。照片等大体积资源采用访问后缓存,第三方接口始终走网络。
|
`npm run build` 会在 Astro 构建完成后加密页面字体,再生成带内容版本号的 Service Worker,并预缓存页面、样式、脚本、加密字体、应用图标和首页主视觉。照片等大体积资源采用访问后缓存,第三方接口始终走网络。
|
||||||
|
|
||||||
|
字体加密只作用于 `dist` 中的生产页面,不修改源码,也不会影响本地开发。每次构建默认生成新的随机映射;如需可复现产物,可设置 `FONT_ENCRYPTION_SEED`。`code`、`pre`、表单属性以及脚本动态生成的内容会保留原文,以免影响复制和交互。字体加密只能提高批量采集成本,不能替代访问控制或版权保护。
|
||||||
|
|
||||||
## Typesense 全站搜索
|
## Typesense 全站搜索
|
||||||
|
|
||||||
|
|||||||
Generated
+124
-3
@@ -16,9 +16,12 @@
|
|||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@openfonts/noto-sans-sc_chinese-simplified": "^1.44.9",
|
||||||
"@types/d3-geo": "^3.1.0",
|
"@types/d3-geo": "^3.1.0",
|
||||||
"@types/node": "^26.1.1",
|
"@types/node": "^26.1.1",
|
||||||
"@types/topojson-client": "^3.1.5",
|
"@types/topojson-client": "^3.1.5",
|
||||||
|
"parse5": "^8.0.0",
|
||||||
|
"subset-font": "^2.5.0",
|
||||||
"tsx": "^4.23.1"
|
"tsx": "^4.23.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1657,6 +1660,13 @@
|
|||||||
"@emnapi/runtime": "^1.7.1"
|
"@emnapi/runtime": "^1.7.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@openfonts/noto-sans-sc_chinese-simplified": {
|
||||||
|
"version": "1.44.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@openfonts/noto-sans-sc_chinese-simplified/-/noto-sans-sc_chinese-simplified-1.44.9.tgz",
|
||||||
|
"integrity": "sha512-yNmtcyVuDWU/5KQnRdD+u45QsGXZlNvpwV9+/cIR8z90iHroDscQuXZTtsNy0CfhPq2pNncJVgzhNq62XLGikQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@oslojs/encoding": {
|
"node_modules/@oslojs/encoding": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
|
||||||
@@ -3109,6 +3119,17 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fontverter": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fontverter/-/fontverter-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-DFVX5hvXuhi1Jven1tbpebYTCT9XYnvx6/Z+HFUPb7ZRMCW+pj2clU9VMhoTPgWKPhAs7JJDSk3CW1jNUvKCZQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"wawoff2": "^2.0.0",
|
||||||
|
"woff2sfnt-sfnt2woff": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -3170,6 +3191,13 @@
|
|||||||
"uncrypto": "^0.1.3"
|
"uncrypto": "^0.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/harfbuzzjs": {
|
||||||
|
"version": "0.10.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/harfbuzzjs/-/harfbuzzjs-0.10.3.tgz",
|
||||||
|
"integrity": "sha512-GJnLUrgLMadlMYrBGEXwYEimObbysy3prWT4HyPpFQERvgTU/OZ+ReUlEPOum6w4RBtFXzXiCCmECOr4sz3qwQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/hast-util-from-html": {
|
"node_modules/hast-util-from-html": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz",
|
||||||
@@ -3188,6 +3216,18 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hast-util-from-html/node_modules/parse5": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hast-util-from-parse5": {
|
"node_modules/hast-util-from-parse5": {
|
||||||
"version": "8.0.3",
|
"version": "8.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
|
||||||
@@ -3654,6 +3694,13 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "11.5.2",
|
"version": "11.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||||
@@ -3983,10 +4030,18 @@
|
|||||||
"integrity": "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==",
|
"integrity": "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/parse5": {
|
"node_modules/parse5": {
|
||||||
"version": "7.3.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
|
||||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"entities": "^6.0.0"
|
"entities": "^6.0.0"
|
||||||
@@ -4410,6 +4465,48 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/subset-font": {
|
||||||
|
"version": "2.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/subset-font/-/subset-font-2.5.0.tgz",
|
||||||
|
"integrity": "sha512-Vsa8ngQ/ohhUj0an7on49y9jLZ2rK5U+T1FzPM4/ZQY0xUy5mLis6BfFtPGzecTjFgYXQlvY7FlsJF4t3R/6Ug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"fontverter": "^2.0.0",
|
||||||
|
"harfbuzzjs": "^0.10.3",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"p-limit": "^3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/subset-font/node_modules/p-limit": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"yocto-queue": "^0.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/subset-font/node_modules/yocto-queue": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/svgo": {
|
"node_modules/svgo": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.2.tgz",
|
||||||
@@ -5216,6 +5313,20 @@
|
|||||||
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
|
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/wawoff2": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/wawoff2/-/wawoff2-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-r0CEmvpH63r4T15ebFqeOjGqU4+EgTx4I510NtK35EMciSdcTxCw3Byy3JnBonz7iyIFZ0AbVo0bbFpEVuhCYA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"argparse": "^2.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"woff2_compress.js": "bin/woff2_compress.js",
|
||||||
|
"woff2_decompress.js": "bin/woff2_decompress.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/web-namespaces": {
|
"node_modules/web-namespaces": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
||||||
@@ -5226,6 +5337,16 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/woff2sfnt-sfnt2woff": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/woff2sfnt-sfnt2woff/-/woff2sfnt-sfnt2woff-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-edK4COc1c1EpRfMqCZO1xJOvdUtM5dbVb9iz97rScvnTevqEB3GllnLWCmMVp1MfQBdF1DFg/11I0rSyAdS4qQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^1.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wrap-ansi": {
|
"node_modules/wrap-ansi": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
|
|||||||
+4
-1
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build && node scripts/build-pwa.mjs",
|
"build": "astro build && node scripts/encrypt-font.mjs && node scripts/build-pwa.mjs",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"search:key": "node --env-file-if-exists=.env --env-file-if-exists=.env.local scripts/create-typesense-search-key.mjs",
|
"search:key": "node --env-file-if-exists=.env --env-file-if-exists=.env.local scripts/create-typesense-search-key.mjs",
|
||||||
"search:sync": "node --env-file-if-exists=.env --env-file-if-exists=.env.local --import tsx scripts/sync-typesense.ts"
|
"search:sync": "node --env-file-if-exists=.env --env-file-if-exists=.env.local --import tsx scripts/sync-typesense.ts"
|
||||||
@@ -19,9 +19,12 @@
|
|||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@openfonts/noto-sans-sc_chinese-simplified": "^1.44.9",
|
||||||
"@types/d3-geo": "^3.1.0",
|
"@types/d3-geo": "^3.1.0",
|
||||||
"@types/node": "^26.1.1",
|
"@types/node": "^26.1.1",
|
||||||
"@types/topojson-client": "^3.1.5",
|
"@types/topojson-client": "^3.1.5",
|
||||||
|
"parse5": "^8.0.0",
|
||||||
|
"subset-font": "^2.5.0",
|
||||||
"tsx": "^4.23.1"
|
"tsx": "^4.23.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
const projectRoot = fileURLToPath(new URL("../", import.meta.url));
|
const projectRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||||
const distRoot = path.join(projectRoot, "dist");
|
const distRoot = path.join(projectRoot, "dist");
|
||||||
const serviceWorkerPath = path.join(distRoot, "sw.js");
|
const serviceWorkerPath = path.join(distRoot, "sw.js");
|
||||||
const precacheExtensions = new Set([".html", ".css", ".js", ".webmanifest"]);
|
const precacheExtensions = new Set([".html", ".css", ".js", ".webmanifest", ".woff2"]);
|
||||||
const precacheAssets = new Set([
|
const precacheAssets = new Set([
|
||||||
"assets/campus-hero.png",
|
"assets/campus-hero.png",
|
||||||
"icons/apple-touch-icon.png",
|
"icons/apple-touch-icon.png",
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { parse, serialize } from "parse5";
|
||||||
|
import subsetFont from "subset-font";
|
||||||
|
|
||||||
|
const projectRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||||
|
const distRoot = path.join(projectRoot, "dist");
|
||||||
|
const fontSourcePath = path.join(
|
||||||
|
projectRoot,
|
||||||
|
"node_modules",
|
||||||
|
"@openfonts",
|
||||||
|
"noto-sans-sc_chinese-simplified",
|
||||||
|
"files",
|
||||||
|
"noto-sans-sc-chinese-simplified-400.woff2"
|
||||||
|
);
|
||||||
|
const fontOutputRoot = path.join(distRoot, "assets", "fonts");
|
||||||
|
const cipherFamily = "ClassMemoryCipher";
|
||||||
|
const puaStart = 0xe000;
|
||||||
|
const puaEnd = 0xf8ff;
|
||||||
|
const skippedElements = new Set([
|
||||||
|
"code",
|
||||||
|
"head",
|
||||||
|
"noscript",
|
||||||
|
"pre",
|
||||||
|
"script",
|
||||||
|
"style",
|
||||||
|
"template",
|
||||||
|
"textarea"
|
||||||
|
]);
|
||||||
|
const isHan = (character) => /\p{Script=Han}/u.test(character);
|
||||||
|
|
||||||
|
const listHtmlFiles = async (directory) => {
|
||||||
|
const entries = await readdir(directory, { withFileTypes: true });
|
||||||
|
const files = await Promise.all(
|
||||||
|
entries.map((entry) => {
|
||||||
|
const entryPath = path.join(directory, entry.name);
|
||||||
|
return entry.isDirectory()
|
||||||
|
? listHtmlFiles(entryPath)
|
||||||
|
: entry.isFile() && entry.name.endsWith(".html")
|
||||||
|
? [entryPath]
|
||||||
|
: [];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return files.flat();
|
||||||
|
};
|
||||||
|
|
||||||
|
const visitTextNodes = (node, visitor, isSkipped = false) => {
|
||||||
|
const skipChildren = isSkipped || (node.tagName && skippedElements.has(node.tagName));
|
||||||
|
|
||||||
|
if (node.nodeName === "#text" && !skipChildren) {
|
||||||
|
visitor(node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of node.childNodes ?? []) {
|
||||||
|
visitTextNodes(child, visitor, skipChildren);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const seededRandom = (seed) => {
|
||||||
|
let counter = 0;
|
||||||
|
return () => {
|
||||||
|
const digest = createHash("sha256")
|
||||||
|
.update(seed)
|
||||||
|
.update(String(counter++))
|
||||||
|
.digest();
|
||||||
|
return digest.readUInt32BE(0) / 0x1_0000_0000;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createCipherMap = (characters) => {
|
||||||
|
if (characters.length > puaEnd - puaStart + 1) {
|
||||||
|
throw new Error(`待加密字符 ${characters.length} 个,超过 BMP 私用区容量。`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const puaCodes = Array.from(
|
||||||
|
{ length: puaEnd - puaStart + 1 },
|
||||||
|
(_, index) => puaStart + index
|
||||||
|
);
|
||||||
|
const buildSeed = process.env.FONT_ENCRYPTION_SEED || randomBytes(32).toString("hex");
|
||||||
|
const random = seededRandom(buildSeed);
|
||||||
|
|
||||||
|
for (let index = puaCodes.length - 1; index > 0; index -= 1) {
|
||||||
|
const swapIndex = Math.floor(random() * (index + 1));
|
||||||
|
[puaCodes[index], puaCodes[swapIndex]] = [puaCodes[swapIndex], puaCodes[index]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Map(
|
||||||
|
characters.map((character, index) => [character, String.fromCodePoint(puaCodes[index])])
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readSfntTables = (fontBuffer) => {
|
||||||
|
const tableCount = fontBuffer.readUInt16BE(4);
|
||||||
|
const tables = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < tableCount; index += 1) {
|
||||||
|
const recordOffset = 12 + index * 16;
|
||||||
|
const tag = fontBuffer.toString("ascii", recordOffset, recordOffset + 4);
|
||||||
|
const offset = fontBuffer.readUInt32BE(recordOffset + 8);
|
||||||
|
const length = fontBuffer.readUInt32BE(recordOffset + 12);
|
||||||
|
tables.push({ tag, data: Buffer.from(fontBuffer.subarray(offset, offset + length)) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return tables;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readCmapGlyphMap = (fontBuffer, characters) => {
|
||||||
|
const cmapTable = readSfntTables(fontBuffer).find((table) => table.tag === "cmap");
|
||||||
|
if (!cmapTable) throw new Error("字体缺少 cmap 字符映射表。 ");
|
||||||
|
|
||||||
|
const cmap = cmapTable.data;
|
||||||
|
const recordCount = cmap.readUInt16BE(2);
|
||||||
|
const subtableOffsets = new Set();
|
||||||
|
|
||||||
|
for (let index = 0; index < recordCount; index += 1) {
|
||||||
|
subtableOffsets.add(cmap.readUInt32BE(4 + index * 8 + 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtables = [...subtableOffsets]
|
||||||
|
.map((offset) => ({ offset, format: cmap.readUInt16BE(offset) }))
|
||||||
|
.filter(({ format }) => format === 4 || format === 12)
|
||||||
|
.sort((left, right) => right.format - left.format);
|
||||||
|
|
||||||
|
const readFormat4Glyph = (offset, codePoint) => {
|
||||||
|
if (codePoint > 0xffff) return 0;
|
||||||
|
const segmentCount = cmap.readUInt16BE(offset + 6) / 2;
|
||||||
|
const endCodesOffset = offset + 14;
|
||||||
|
const startCodesOffset = endCodesOffset + segmentCount * 2 + 2;
|
||||||
|
const deltasOffset = startCodesOffset + segmentCount * 2;
|
||||||
|
const rangeOffsetsOffset = deltasOffset + segmentCount * 2;
|
||||||
|
|
||||||
|
for (let index = 0; index < segmentCount; index += 1) {
|
||||||
|
const start = cmap.readUInt16BE(startCodesOffset + index * 2);
|
||||||
|
const end = cmap.readUInt16BE(endCodesOffset + index * 2);
|
||||||
|
if (codePoint < start || codePoint > end) continue;
|
||||||
|
|
||||||
|
const delta = cmap.readInt16BE(deltasOffset + index * 2);
|
||||||
|
const rangeOffsetPosition = rangeOffsetsOffset + index * 2;
|
||||||
|
const rangeOffset = cmap.readUInt16BE(rangeOffsetPosition);
|
||||||
|
if (rangeOffset === 0) return (codePoint + delta) & 0xffff;
|
||||||
|
|
||||||
|
const glyphPosition = rangeOffsetPosition + rangeOffset + (codePoint - start) * 2;
|
||||||
|
if (glyphPosition + 2 > cmap.length) return 0;
|
||||||
|
const glyph = cmap.readUInt16BE(glyphPosition);
|
||||||
|
return glyph === 0 ? 0 : (glyph + delta) & 0xffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readFormat12Glyph = (offset, codePoint) => {
|
||||||
|
const groupCount = cmap.readUInt32BE(offset + 12);
|
||||||
|
let low = 0;
|
||||||
|
let high = groupCount - 1;
|
||||||
|
|
||||||
|
while (low <= high) {
|
||||||
|
const middle = Math.floor((low + high) / 2);
|
||||||
|
const groupOffset = offset + 16 + middle * 12;
|
||||||
|
const start = cmap.readUInt32BE(groupOffset);
|
||||||
|
const end = cmap.readUInt32BE(groupOffset + 4);
|
||||||
|
if (codePoint < start) high = middle - 1;
|
||||||
|
else if (codePoint > end) low = middle + 1;
|
||||||
|
else return cmap.readUInt32BE(groupOffset + 8) + codePoint - start;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const glyphMap = new Map();
|
||||||
|
for (const character of characters) {
|
||||||
|
const codePoint = character.codePointAt(0);
|
||||||
|
for (const subtable of subtables) {
|
||||||
|
const glyph =
|
||||||
|
subtable.format === 12
|
||||||
|
? readFormat12Glyph(subtable.offset, codePoint)
|
||||||
|
: readFormat4Glyph(subtable.offset, codePoint);
|
||||||
|
if (glyph > 0) {
|
||||||
|
glyphMap.set(character, glyph);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return glyphMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCipherCmap = (cipherMap, glyphMap) => {
|
||||||
|
const entries = [...cipherMap.entries()]
|
||||||
|
.map(([original, encrypted]) => ({
|
||||||
|
codePoint: encrypted.codePointAt(0),
|
||||||
|
glyph: glyphMap.get(original)
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.codePoint - right.codePoint);
|
||||||
|
entries.push({ codePoint: 0xffff, glyph: 0 });
|
||||||
|
|
||||||
|
const segmentCount = entries.length;
|
||||||
|
const subtableLength = 16 + segmentCount * 8;
|
||||||
|
const cmap = Buffer.alloc(20 + subtableLength);
|
||||||
|
cmap.writeUInt16BE(0, 0);
|
||||||
|
cmap.writeUInt16BE(2, 2);
|
||||||
|
cmap.writeUInt16BE(0, 4);
|
||||||
|
cmap.writeUInt16BE(3, 6);
|
||||||
|
cmap.writeUInt32BE(20, 8);
|
||||||
|
cmap.writeUInt16BE(3, 12);
|
||||||
|
cmap.writeUInt16BE(1, 14);
|
||||||
|
cmap.writeUInt32BE(20, 16);
|
||||||
|
|
||||||
|
const subtableOffset = 20;
|
||||||
|
const entrySelector = Math.floor(Math.log2(segmentCount));
|
||||||
|
const searchRange = 2 * 2 ** entrySelector;
|
||||||
|
cmap.writeUInt16BE(4, subtableOffset);
|
||||||
|
cmap.writeUInt16BE(subtableLength, subtableOffset + 2);
|
||||||
|
cmap.writeUInt16BE(0, subtableOffset + 4);
|
||||||
|
cmap.writeUInt16BE(segmentCount * 2, subtableOffset + 6);
|
||||||
|
cmap.writeUInt16BE(searchRange, subtableOffset + 8);
|
||||||
|
cmap.writeUInt16BE(entrySelector, subtableOffset + 10);
|
||||||
|
cmap.writeUInt16BE(segmentCount * 2 - searchRange, subtableOffset + 12);
|
||||||
|
|
||||||
|
const endCodesOffset = subtableOffset + 14;
|
||||||
|
const startCodesOffset = endCodesOffset + segmentCount * 2 + 2;
|
||||||
|
const deltasOffset = startCodesOffset + segmentCount * 2;
|
||||||
|
const rangeOffsetsOffset = deltasOffset + segmentCount * 2;
|
||||||
|
entries.forEach(({ codePoint, glyph }, index) => {
|
||||||
|
cmap.writeUInt16BE(codePoint, endCodesOffset + index * 2);
|
||||||
|
cmap.writeUInt16BE(codePoint, startCodesOffset + index * 2);
|
||||||
|
cmap.writeUInt16BE((glyph - codePoint) & 0xffff, deltasOffset + index * 2);
|
||||||
|
cmap.writeUInt16BE(0, rangeOffsetsOffset + index * 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
return cmap;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateChecksum = (buffer) => {
|
||||||
|
const paddedLength = Math.ceil(buffer.length / 4) * 4;
|
||||||
|
const padded = Buffer.alloc(paddedLength);
|
||||||
|
buffer.copy(padded);
|
||||||
|
let checksum = 0;
|
||||||
|
for (let offset = 0; offset < padded.length; offset += 4) {
|
||||||
|
checksum = (checksum + padded.readUInt32BE(offset)) >>> 0;
|
||||||
|
}
|
||||||
|
return checksum;
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceSfntTable = (fontBuffer, tag, replacement) => {
|
||||||
|
const tables = readSfntTables(fontBuffer)
|
||||||
|
.filter((table) => table.tag !== "DSIG")
|
||||||
|
.map((table) => ({ ...table, data: table.tag === tag ? replacement : table.data }));
|
||||||
|
const head = tables.find((table) => table.tag === "head");
|
||||||
|
if (!head) throw new Error("字体缺少 head 表。 ");
|
||||||
|
head.data.writeUInt32BE(0, 8);
|
||||||
|
|
||||||
|
const tableCount = tables.length;
|
||||||
|
const entrySelector = Math.floor(Math.log2(tableCount));
|
||||||
|
const searchRange = 16 * 2 ** entrySelector;
|
||||||
|
const headerLength = 12 + tableCount * 16;
|
||||||
|
let fontLength = headerLength;
|
||||||
|
for (const table of tables) fontLength += Math.ceil(table.data.length / 4) * 4;
|
||||||
|
|
||||||
|
const output = Buffer.alloc(fontLength);
|
||||||
|
fontBuffer.copy(output, 0, 0, 4);
|
||||||
|
output.writeUInt16BE(tableCount, 4);
|
||||||
|
output.writeUInt16BE(searchRange, 6);
|
||||||
|
output.writeUInt16BE(entrySelector, 8);
|
||||||
|
output.writeUInt16BE(tableCount * 16 - searchRange, 10);
|
||||||
|
|
||||||
|
let dataOffset = headerLength;
|
||||||
|
let headOffset = 0;
|
||||||
|
tables.forEach((table, index) => {
|
||||||
|
const recordOffset = 12 + index * 16;
|
||||||
|
output.write(table.tag, recordOffset, 4, "ascii");
|
||||||
|
output.writeUInt32BE(calculateChecksum(table.data), recordOffset + 4);
|
||||||
|
output.writeUInt32BE(dataOffset, recordOffset + 8);
|
||||||
|
output.writeUInt32BE(table.data.length, recordOffset + 12);
|
||||||
|
table.data.copy(output, dataOffset);
|
||||||
|
if (table.tag === "head") headOffset = dataOffset;
|
||||||
|
dataOffset += Math.ceil(table.data.length / 4) * 4;
|
||||||
|
});
|
||||||
|
|
||||||
|
const adjustment = (0xb1b0afba - calculateChecksum(output)) >>> 0;
|
||||||
|
output.writeUInt32BE(adjustment, headOffset + 8);
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
|
||||||
|
const injectFontLoader = (html, fontUrl) => {
|
||||||
|
const loader = [
|
||||||
|
`<link rel="preload" href="${fontUrl}" as="font" type="font/woff2" crossorigin>`,
|
||||||
|
`<style data-font-encryption>@font-face{font-family:"${cipherFamily}";src:url("${fontUrl}") format("woff2");font-style:normal;font-weight:100 900;font-display:block}</style>`
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
if (!html.includes("</head>")) {
|
||||||
|
throw new Error("构建页面缺少 </head>,无法注入加密字体。 ");
|
||||||
|
}
|
||||||
|
|
||||||
|
return html.replace("</head>", `${loader}</head>`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const htmlFiles = await listHtmlFiles(distRoot);
|
||||||
|
if (htmlFiles.length === 0) {
|
||||||
|
throw new Error("dist 中没有 HTML 文件,请先执行 Astro 构建。 ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = await Promise.all(
|
||||||
|
htmlFiles.map(async (filePath) => {
|
||||||
|
const source = await readFile(filePath, "utf8");
|
||||||
|
return { filePath, document: parse(source) };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const requestedCharacters = new Set();
|
||||||
|
for (const page of pages) {
|
||||||
|
visitTextNodes(page.document, (node) => {
|
||||||
|
for (const character of node.value) {
|
||||||
|
if (isHan(character)) requestedCharacters.add(character);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceFontBuffer = await readFile(fontSourcePath);
|
||||||
|
const subsetFontBuffer = await subsetFont(sourceFontBuffer, [...requestedCharacters].join(""), {
|
||||||
|
targetFormat: "sfnt"
|
||||||
|
});
|
||||||
|
const glyphMap = readCmapGlyphMap(subsetFontBuffer, requestedCharacters);
|
||||||
|
const availableCharacters = new Set(glyphMap.keys());
|
||||||
|
const cipherMap = createCipherMap([...availableCharacters].sort());
|
||||||
|
const encryptedSfnt = replaceSfntTable(
|
||||||
|
subsetFontBuffer,
|
||||||
|
"cmap",
|
||||||
|
buildCipherCmap(cipherMap, glyphMap)
|
||||||
|
);
|
||||||
|
const encryptedText = [...cipherMap.values()].join("");
|
||||||
|
const encryptedFont = await subsetFont(encryptedSfnt, encryptedText, {
|
||||||
|
targetFormat: "woff2"
|
||||||
|
});
|
||||||
|
const verificationSfnt = await subsetFont(encryptedFont, encryptedText, {
|
||||||
|
targetFormat: "sfnt"
|
||||||
|
});
|
||||||
|
const verifiedGlyphs = readCmapGlyphMap(verificationSfnt, cipherMap.values());
|
||||||
|
if (verifiedGlyphs.size !== cipherMap.size) {
|
||||||
|
throw new Error(
|
||||||
|
`加密字体校验失败:缺少 ${cipherMap.size - verifiedGlyphs.size} 个映射字形。`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fontHash = createHash("sha256").update(encryptedFont).digest("hex").slice(0, 12);
|
||||||
|
const fontFileName = `class-memory-cipher.${fontHash}.woff2`;
|
||||||
|
const fontUrl = `/assets/fonts/${fontFileName}`;
|
||||||
|
|
||||||
|
await mkdir(fontOutputRoot, { recursive: true });
|
||||||
|
await writeFile(path.join(fontOutputRoot, fontFileName), encryptedFont);
|
||||||
|
|
||||||
|
let replacementCount = 0;
|
||||||
|
for (const page of pages) {
|
||||||
|
visitTextNodes(page.document, (node) => {
|
||||||
|
node.value = [...node.value]
|
||||||
|
.map((character) => {
|
||||||
|
const encrypted = cipherMap.get(character);
|
||||||
|
if (encrypted) replacementCount += 1;
|
||||||
|
return encrypted ?? character;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
});
|
||||||
|
|
||||||
|
const encryptedHtml = injectFontLoader(serialize(page.document), fontUrl);
|
||||||
|
await writeFile(page.filePath, encryptedHtml, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingCharacters = [...requestedCharacters].filter(
|
||||||
|
(character) => !availableCharacters.has(character)
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`[font-encryption] 已处理 ${pages.length} 个页面、${availableCharacters.size} 个汉字、${replacementCount} 处文本,字体 ${Math.ceil(encryptedFont.length / 1024)} KiB。`
|
||||||
|
);
|
||||||
|
if (missingCharacters.length > 0) {
|
||||||
|
console.warn(
|
||||||
|
`[font-encryption] 源字体缺少 ${missingCharacters.length} 个汉字(${missingCharacters.join("、")}),已保留原文。`
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
color: #24170f;
|
color: #24170f;
|
||||||
background: #f7efe3;
|
background: #f7efe3;
|
||||||
font-family:
|
font-family:
|
||||||
|
"ClassMemoryCipher",
|
||||||
"Inter", "PingFang SC", "Microsoft YaHei", "Noto Sans SC", system-ui,
|
"Inter", "PingFang SC", "Microsoft YaHei", "Noto Sans SC", system-ui,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans SC", Arial, sans-serif;
|
font-family: "ClassMemoryCipher", "Microsoft YaHei", "PingFang SC", "Noto Sans SC", Arial, sans-serif;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,7 +531,7 @@ h1 {
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
color: rgba(255, 253, 247, 0.9);
|
color: rgba(255, 253, 247, 0.9);
|
||||||
background: linear-gradient(135deg, rgba(55, 109, 90, 0.94), rgba(69, 111, 148, 0.88));
|
background: linear-gradient(135deg, rgba(55, 109, 90, 0.94), rgba(69, 111, 148, 0.88));
|
||||||
font-family: STKaiti, KaiTi, serif;
|
font-family: "ClassMemoryCipher", STKaiti, KaiTi, serif;
|
||||||
font-size: 48px;
|
font-size: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user