fix: 按 Halo 应用市场审核意见整改外部资源与隐私(v1.2.5 修订)

- 默认零第三方自动请求:移除 21 个模板的 preconnect/dns-prefetch、PrismJS+autoloader、qrcode-generator 及内置一言/IP 定位 API
- 一言与代码高亮配色改为自托管;欢迎弹窗自定义 IP 接口改为 opt-in;微信分享改为复制链接;LightGallery 按 GPLv3 使用
- README 新增「外部资源与隐私」「验证与回归测试」章节,修正无效链接并补充字体 OFL 来源;同步 docs 与修正虚构的目录树
- friends 等 12 处 #strings.substring 加空值保护;logo 由两份 1.5MB 压缩去重为单份 146KB
- 新增 pnpm audit:external 与打包前置合规审计;制品补齐 34 个 docs 文档与预览图
This commit is contained in:
nxxy335 committed 2026-11-11 14:37:09 +08:00
1 parent c0ad7331d4
commit dda86294b0
36 files changed
+830 -236

No files matched your search

+167
View File
@@ -0,0 +1,167 @@
/**
* 外部资源与隐私审计(发版前自动检查,Node 内置模块,无第三方依赖)
*
* 对应 Halo 应用市场审核指南:
* - 1.4 远程执行与动态加载:禁止远程加载核心代码 / 动态拉取并执行组件
* - 4.4.4 性能与外部资源:不得预连接外部 CDN、加载远程脚本样式、打包无关大资源
* - 4.6 / 2.2:不得出现占位内容、无效链接
*
* 检查项:
* 1. 模板中不得有指向第三方的 <link rel=preconnect|dns-prefetch|preload>
* 2. 模板中不得有 src/href 直接指向 http(s) 的 script/link/img/iframe/source/video/audio
* 3. 模板与静态资源中不得硬编码 fetch("https://...") / import("https://...") / url(https://...)
* 4. 不得出现历史上被审核点名的第三方服务域名,或 autoloader 动态加载痕迹
* 5. README 中的相对链接必须在安装包清单中真实存在
*
* 说明:<a href="https://..."> 这类需要访客主动点击的分享 / 备案 / 应用市场外链是允许的,
* 不属于「自动外部请求」,但已在 README「外部资源与隐私」章节逐项披露。
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
/** 曾经造成未披露外部请求的服务域名,出现即视为回归 */
const BLOCKED_HOSTS = [
"cdn.jsdelivr.net",
"unpkg.com",
"cdnjs.cloudflare.com",
"fonts.googleapis.com",
"fonts.gstatic.com",
"v1.hitokoto.cn",
"myip.ipip.net",
"r.inews.qq.com",
];
const REMOTE_LOAD_RE =
/<(?:script|link|img|iframe|source|video|audio)\b[^>]*\b(?:src|href)\s*=\s*["']https?:\/\/[^"']+["']/gi;
const PRECONNECT_RE =
/<link\b[^>]*\brel\s*=\s*["'](?:preconnect|dns-prefetch|preload)["'][^>]*\bhref\s*=\s*["']https?:\/\/[^"']+["']/gi;
const REMOTE_FETCH_RE = /\b(?:fetch|importScripts)\s*\(\s*["']https?:\/\/[^"']+["']/gi;
const REMOTE_IMPORT_RE = /\bimport\s*\(\s*["']https?:\/\/[^"']+["']/gi;
const REMOTE_CSS_URL_RE = /url\(\s*["']?https?:\/\/[^)"']+/gi;
const AUTOLOADER_RE = /\b(?:prism-autoloader|languages_path|Prism\.plugins\.autoloader)\b/gi;
function listFiles(dir, exts, out = []) {
if (!fs.existsSync(dir)) return out;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
/* 字体目录只有本地 woff2 与版权注释,不参与审计 */
if (entry.name === "fonts") continue;
listFiles(abs, exts, out);
} else if (exts.some((ext) => entry.name.endsWith(ext))) {
out.push(abs);
}
}
return out;
}
export function auditTemplates(templatesDir = path.join(root, "templates")) {
const problems = [];
const files = listFiles(templatesDir, [".html", ".js", ".css"]);
const htmlFiles = files.filter((f) => f.endsWith(".html"));
/* 对外报告的模板数只算 templates/ 根目录下的 Halo 页面模板;
templates/error/ 下的错误页 HTML 同样参与审计,但不计入模板数。 */
const htmlCount = htmlFiles.filter((f) => path.dirname(f) === templatesDir).length;
if (htmlFiles.length === 0) {
problems.push("未找到任何 templates/*.html,构建产物不完整");
return { fileCount: 0, htmlCount: 0, problems };
}
for (const file of files) {
const rel = path.relative(root, file).replace(/\\/g, "/");
const text = fs.readFileSync(file, "utf8");
const isHtml = file.endsWith(".html");
const rules = isHtml
? [
["远程预连接 / 预加载", PRECONNECT_RE],
["远程脚本 / 样式 / 媒体", REMOTE_LOAD_RE],
["硬编码外部 fetch", REMOTE_FETCH_RE],
["远程动态 import", REMOTE_IMPORT_RE],
["autoloader 动态加载", AUTOLOADER_RE],
]
: [
["硬编码外部 fetch", REMOTE_FETCH_RE],
["远程动态 import", REMOTE_IMPORT_RE],
["CSS 远程 url()", REMOTE_CSS_URL_RE],
];
for (const [label, re] of rules) {
re.lastIndex = 0;
const hit = re.exec(text);
if (hit) {
problems.push(`${rel}: ${label} → ${hit[0].slice(0, 110)}`);
}
}
for (const host of BLOCKED_HOSTS) {
if (isHtml && text.includes(host)) {
problems.push(`${rel}: 出现已移除的第三方服务域名 ${host}`);
}
}
}
return { fileCount: files.length, htmlCount, problems };
}
export function auditReadmeLinks(entryNames, readmePath = path.join(root, "README.md")) {
const problems = [];
if (!fs.existsSync(readmePath)) return ["README.md 不存在"];
const readme = fs.readFileSync(readmePath, "utf8");
const linkRe = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
let match;
let checked = 0;
while ((match = linkRe.exec(readme)) !== null) {
const href = match[1];
if (/^(?:https?:|mailto:|#)/.test(href)) continue;
let target = href.split("#")[0];
if (!target) continue;
try {
target = decodeURIComponent(target);
} catch (e) {
/* 保留原值 */
}
target = target.replace(/^\.\//, "");
checked++;
if (!entryNames.has(target)) {
problems.push(`README.md 相对链接在安装包中不存在:${href}(期望 ${target})`);
}
}
return { problems, checked };
}
function report(label, problems) {
if (problems.length === 0) {
console.log(`[audit] PASS ${label}`);
return 0;
}
for (const p of problems) console.error(`[audit] FAIL ${label}: ${p}`);
return problems.length;
}
const isCli = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isCli) {
const templates = auditTemplates();
let failures = 0;
failures += report(
`模板外部资源(${templates.htmlCount} 个 HTML / ${templates.fileCount} 个文件)`,
templates.problems
);
/* 独立运行时按当前磁盘上的打包清单口径检查 README 链接 */
const distZip = path.join(root, "dist");
const zip = fs.existsSync(distZip)
? fs.readdirSync(distZip).filter((f) => f.endsWith(".zip") && f.includes("warm-island"))
: [];
if (zip.length) {
/* 简单口径:zip 存在时仅提示需通过 pnpm package 做完整链接校验 */
console.log(`[audit] 提示:README 链接校验在 pnpm package 内执行(发现 ${zip.length} 个安装包)`);
} else {
console.log("[audit] 提示:README 链接校验在 pnpm package 内执行(当前无安装包)");
}
console.log(`[audit] 模板审计共 ${failures} 项问题`);
process.exit(failures ? 1 : 0);
}
+48 -3
View File
@@ -10,6 +10,7 @@ import fs from "node:fs";
import path from "node:path";
import zlib from "node:zlib";
import { fileURLToPath } from "node:url";
import { auditTemplates, auditReadmeLinks } from "./audit-external.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -131,11 +132,17 @@ const INCLUDE_ROOT = [
"settings.yaml",
"LICENSE",
"README.md",
/* 上架说明引用的预览图必须真实存在于制品中(审核指南 4.6 / 2.2) */
"WarmIsland Preview.webp",
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
];
const INCLUDE_DIRS = ["templates"];
const EXCLUDE_DIRS = new Set([".git", "node_modules", "src", "dist", "compat", ".astro"]);
/* README 引用的 docs/*.md 也要随包分发,避免制品内出现死链 */
const INCLUDE_DOCS = ["docs"];
const EXCLUDE_DIRS = new Set([
".git", "node_modules", "src", "dist", "compat", ".astro", ".vitepress",
]);
/* ---------------- 构建产物前置校验 ----------------
* templates/ 被 .gitignore 忽略,克隆仓库或清理工作区后默认不存在。
@@ -173,14 +180,16 @@ for (const name of INCLUDE_ROOT) {
}
}
function walk(dir, prefix) {
function walk(dir, prefix, opts = {}) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const abs = path.join(dir, entry.name);
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
if (EXCLUDE_DIRS.has(entry.name)) continue;
walk(abs, rel);
walk(abs, rel, opts);
} else {
/* docs 只收 Markdown 源文件,不收 VitePress 构建产物 / 依赖 README */
if (opts.mdOnly && !entry.name.endsWith(".md")) continue;
entries.push({ name: rel, data: fs.readFileSync(abs), date: fs.statSync(abs).mtime });
}
}
@@ -190,6 +199,10 @@ for (const dir of INCLUDE_DIRS) {
const full = path.join(root, dir);
if (fs.existsSync(full)) walk(full, dir);
}
for (const dir of INCLUDE_DOCS) {
const full = path.join(root, dir);
if (fs.existsSync(full)) walk(full, dir, { mdOnly: true });
}
if (!entries.some((entry) => entry.name === "templates/index.html")) {
fail("打包清单中缺少 templates/index.html");
@@ -201,6 +214,38 @@ if (pkgVersion !== version) {
fail(`版本号不一致:package.json=${pkgVersion},theme.yaml=${version}`);
}
/* ---------------- 上架合规前置校验 ----------------
* 1. 主题不得出现未披露的第三方自动请求 / 远程动态加载(审核指南 1.4 / 4.4.4)
* 2. README 的相对链接必须在制品内真实存在(审核指南 4.6 / 2.2)
* 3. logo 只保留一份压缩版,避免打包两份 1.5MB 大图
*/
const templateAudit = auditTemplates(templatesDir);
for (const problem of templateAudit.problems) {
console.error(`[package] ${problem}`);
}
if (templateAudit.problems.length) {
fail("外部资源审计未通过:模板仍存在第三方远程加载 / 预连接");
}
const linkAudit = auditReadmeLinks(new Set(entries.map((entry) => entry.name)));
for (const problem of linkAudit.problems) {
console.error(`[package] ${problem}`);
}
if (linkAudit.problems.length) {
fail("README 存在指向制品中不存在文件的相对链接");
}
if (fs.existsSync(path.join(templatesDir, "logo.png"))) {
fail("templates/logo.png 仍存在:应只保留压缩后的 templates/assets/logo.png");
}
const logoFile = path.join(templatesDir, "assets", "logo.png");
if (fs.existsSync(logoFile) && fs.statSync(logoFile).size > 300 * 1024) {
fail("templates/assets/logo.png 超过 300KB,请先压缩再打包");
}
console.log(
`[package] 合规审计通过(${templateAudit.htmlCount} 个模板、${linkAudit.checked} 个相对链接、logo 单份压缩)`
);
const outFile = path.join(root, "dist", `warm-island-${version}.zip`);
buildZip(entries, outFile);
+7 -5
View File
@@ -1,6 +1,6 @@
/**
* 构建后置处理:
* 1. 复制 logo.png 到 assets(保留历史行为)
* 1. 校验主题 logo 存在且只保留一份(public/assets/logo.png → templates/assets/logo.png)
* 2. 合并 compat/assets 下的历史版本静态资源,避免站点/代理缓存中的旧 HTML
* 仍然引用上一版本的哈希文件名时出现 500(FileNotFoundException)
* 3. 校验所有 templates/*.html 引用的静态资源真实存在,缺失即构建失败
@@ -16,11 +16,13 @@ const templatesDir = path.join(root, "templates");
fs.mkdirSync(assetsDir, { recursive: true });
// 1. logo
const logoSrc = path.join(templatesDir, "logo.png");
if (fs.existsSync(logoSrc)) {
fs.copyFileSync(logoSrc, path.join(assetsDir, "logo.png"));
// 1. logo:旧实现把 templates/logo.png 再复制一份到 assets,安装包里会有两份 1.5MB 大图。
// 现在源文件统一放在 public/assets/logo.png(256x256 压缩版),这里只做存在性校验。
if (!fs.existsSync(path.join(assetsDir, "logo.png"))) {
console.error("[postbuild] 未找到 templates/assets/logo.png(public/assets/logo.png 应随构建复制)");
process.exit(1);
}
console.log("[postbuild] logo.png 已去重,仅保留 templates/assets/logo.png");
// 2. 历史资源合并(已存在的同名文件以当前构建为准,不覆盖)
if (fs.existsSync(compatDir)) {