feat(v1.2.6): 合并审核整改、确认完全原创并完善可选 API 披露

版本升至 1.2.6(1.2.5 审核被拒未发布,整改内容并入本版);README 新增原创声明(4.2.1/4.2.2 不适用);Hero 描述来源默认 none,选一言自动填入官方接口、清空回退本地句子库;文档列出两个 IP 定位候选接口与访客 IP 披露文案;含上一轮 4.4.2 内容渲染安全与 5.2 字体许可证修复。
This commit is contained in:
nxxy335 committed 2026-11-11 16:09:31 +08:00
1 parent dda86294b0
commit c92429d139
18 files changed
+485 -63

No files matched your search

+67 -1
View File
@@ -4,6 +4,8 @@
* 对应 Halo 应用市场审核指南:
* - 1.4 远程执行与动态加载:禁止远程加载核心代码 / 动态拉取并执行组件
* - 4.4.4 性能与外部资源:不得预连接外部 CDN、加载远程脚本样式、打包无关大资源
* - 4.4.2 内容渲染安全:不得把站点内容用 th:utext 拼进 <script> 原始文本
* - 5.2 / 2.2:第三方字体必须随包携带许可证全文;上架说明数字必须与配置定义一致
* - 4.6 / 2.2:不得出现占位内容、无效链接
*
* 检查项:
@@ -12,6 +14,8 @@
* 3. 模板与静态资源中不得硬编码 fetch("https://...") / import("https://...") / url(https://...)
* 4. 不得出现历史上被审核点名的第三方服务域名,或 autoloader 动态加载痕迹
* 5. README 中的相对链接必须在安装包清单中真实存在
* 6. 任何 <script> 开标签不得带 th:utext;字体许可证全文必须在制品清单中;
* README 的设置分组 / 配置项数量必须与 settings.yaml 一致
*
* 说明:<a href="https://..."> 这类需要访客主动点击的分享 / 备案 / 应用市场外链是允许的,
* 不属于「自动外部请求」,但已在 README「外部资源与隐私」章节逐项披露。
@@ -134,6 +138,63 @@ export function auditReadmeLinks(entryNames, readmePath = path.join(root, "READM
return { problems, checked };
}
/**
* 合规审计:审核指南 4.4.2(内容渲染安全)、5.2(许可证传递)、2.2(上架信息准确)
*
* @param {Set<string>|null} entryNames 打包清单;传入时额外校验许可证确实进了制品
*/
export function auditCompliance(entryNames = null) {
const problems = [];
const templatesDir = path.join(root, "templates");
const oflRel = "templates/assets/fonts/LXGWWenKai/OFL.txt";
// 1. <script> 是原始文本元素,th:utext 不转义且可被脚本闭合标签截断(4.4.2)
const scriptTagRe = /<script\b[^>]*>/gi;
for (const file of listFiles(templatesDir, [".html"])) {
const rel = path.relative(root, file).replace(/\\/g, "/");
const text = fs.readFileSync(file, "utf8");
scriptTagRe.lastIndex = 0;
let hit;
while ((hit = scriptTagRe.exec(text)) !== null) {
if (/\bth:utext\b/i.test(hit[0])) {
problems.push(`${rel}: <script> 开标签使用了 th:utext(4.4.2 内容注入风险)`);
}
}
}
// 2. SIL OFL 1.1 要求随字体软件附带版权声明与许可证全文(5.2)
const oflPath = path.join(root, oflRel);
if (entryNames && !entryNames.has(oflRel)) {
problems.push(`安装包缺少 ${oflRel}(5.2 字体许可证传递)`);
}
if (!fs.existsSync(oflPath)) {
problems.push(`构建产物缺少 ${oflRel}(5.2 字体许可证传递)`);
} else {
const ofl = fs.readFileSync(oflPath, "utf8");
if (!/SIL OPEN FONT LICENSE Version 1\.1/.test(ofl) || !/Copyright 2021-2026 LXGW/.test(ofl)) {
problems.push(`${oflRel} 内容不完整:需包含上游版权声明与 OFL 1.1 全文`);
}
}
// 3. 上架说明里的配置数量必须与 settings.yaml 一致(2.2 / 4.6)
const settingsPath = path.join(root, "settings.yaml");
const readmePath = path.join(root, "README.md");
let groupCount = 0;
let fieldCount = 0;
if (fs.existsSync(settingsPath) && fs.existsSync(readmePath)) {
const settings = fs.readFileSync(settingsPath, "utf8");
const readme = fs.readFileSync(readmePath, "utf8");
groupCount = (settings.match(/^\s*-\s+group:/gm) || []).length;
fieldCount = (settings.match(/^\s*-\s+\$formkit:/gm) || []).length;
const expected = `${groupCount} 个设置分组、${fieldCount} 个配置项`;
if (!readme.includes(expected)) {
problems.push(`README 配置数量与 settings.yaml 不一致,应为「${expected}」(2.2)`);
}
}
return { problems, groupCount, fieldCount };
}
function report(label, problems) {
if (problems.length === 0) {
console.log(`[audit] PASS ${label}`);
@@ -146,11 +207,16 @@ function report(label, problems) {
const isCli = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isCli) {
const templates = auditTemplates();
const compliance = auditCompliance();
let failures = 0;
failures += report(
`模板外部资源(${templates.htmlCount} 个 HTML / ${templates.fileCount} 个文件)`,
templates.problems
);
failures += report(
`合规审计 4.4.2 / 5.2 / 2.2(${compliance.groupCount} 组 / ${compliance.fieldCount} 配置项)`,
compliance.problems
);
/* 独立运行时按当前磁盘上的打包清单口径检查 README 链接 */
const distZip = path.join(root, "dist");
const zip = fs.existsSync(distZip)
@@ -162,6 +228,6 @@ if (isCli) {
} else {
console.log("[audit] 提示:README 链接校验在 pnpm package 内执行(当前无安装包)");
}
console.log(`[audit] 模板审计共 ${failures} 项问题`);
console.log(`[audit] 审计共 ${failures} 项问题`);
process.exit(failures ? 1 : 0);
}
+13 -2
View File
@@ -10,7 +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";
import { auditTemplates, auditReadmeLinks, auditCompliance } from "./audit-external.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -235,6 +235,16 @@ if (linkAudit.problems.length) {
fail("README 存在指向制品中不存在文件的相对链接");
}
/* 4.4.2 / 5.2 / 2.2:内容渲染安全、字体许可证传递、上架说明数字一致性 */
const entryNameSet = new Set(entries.map((entry) => entry.name));
const complianceAudit = auditCompliance(entryNameSet);
for (const problem of complianceAudit.problems) {
console.error(`[package] ${problem}`);
}
if (complianceAudit.problems.length) {
fail("合规审计未通过(4.4.2 内容渲染安全 / 5.2 许可证传递 / 2.2 上架信息)");
}
if (fs.existsSync(path.join(templatesDir, "logo.png"))) {
fail("templates/logo.png 仍存在:应只保留压缩后的 templates/assets/logo.png");
}
@@ -243,7 +253,8 @@ if (fs.existsSync(logoFile) && fs.statSync(logoFile).size > 300 * 1024) {
fail("templates/assets/logo.png 超过 300KB,请先压缩再打包");
}
console.log(
`[package] 合规审计通过(${templateAudit.htmlCount} 个模板、${linkAudit.checked} 个相对链接、logo 单份压缩)`
`[package] 合规审计通过(${templateAudit.htmlCount} 个模板、${linkAudit.checked} 个相对链接、` +
`${complianceAudit.groupCount} 组 / ${complianceAudit.fieldCount} 配置项、logo 单份压缩、OFL 全文随包)`
);
const outFile = path.join(root, "dist", `warm-island-${version}.zip`);
+11
View File
@@ -24,6 +24,17 @@ if (!fs.existsSync(path.join(assetsDir, "logo.png"))) {
}
console.log("[postbuild] logo.png 已去重,仅保留 templates/assets/logo.png");
// 1b. 第三方字体许可证全文必须随构建产物分发(审核指南 5.2:许可证传递 / 版权声明保留)
const oflFile = path.join(assetsDir, "fonts", "LXGWWenKai", "OFL.txt");
if (!fs.existsSync(oflFile)) {
console.error(
"[postbuild] 未找到 templates/assets/fonts/LXGWWenKai/OFL.txt" +
"(public/assets/fonts/LXGWWenKai/OFL.txt 应随构建复制)"
);
process.exit(1);
}
console.log("[postbuild] LXGW WenKai 的 OFL 1.1 许可证全文已随包");
// 2. 历史资源合并(已存在的同名文件以当前构建为准,不覆盖)
if (fs.existsSync(compatDir)) {
let copied = 0;
+120
View File
@@ -0,0 +1,120 @@
/**
* 结构化数据 / 内容渲染安全冒烟测试(审核指南 4.4.2)
*
* 从构建产物 templates/post.html 提取 JSON-LD 生成脚本,用桩 DOM 执行,验证:
* - 输出的 JSON-LD 合法、字段完整、可选字段缺省时不产生非法 JSON
* - 标题里注入的脚本闭合标签只作为普通字符串保留,不会变成可执行内容
* - 写入方式为 textContent,且模板中的 JSON-LD 标签初始为空
*
* 仅用 Node 内置模块,无浏览器 / 第三方依赖;需先执行 pnpm build。
*/
import fs from "node:fs";
import path from "node:path";
import vm from "node:vm";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const postHtmlPath = path.join(root, "templates", "post.html");
if (!fs.existsSync(postHtmlPath)) {
console.error("[jsonld-smoke] 未找到 templates/post.html,请先执行 pnpm build");
process.exit(1);
}
const html = fs.readFileSync(postHtmlPath, "utf8");
const blocks = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)].map((m) => m[1]);
const code = blocks.find((block) => block.includes("wi-article-jsonld"));
if (!code) {
console.error("[jsonld-smoke] 未找到 JSON-LD 生成脚本");
process.exit(1);
}
let pass = 0;
let fail = 0;
function check(name, fn) {
try {
fn();
console.log(` ✔ ${name}`);
pass++;
} catch (error) {
console.error(` ✘ ${name} -> ${error.message}`);
fail++;
}
}
function render(values) {
const target = { textContent: "" };
const document = {
querySelector: (selector) =>
values[selector] == null ? null : { getAttribute: () => values[selector] },
getElementById: (id) => (id === "wi-article-jsonld" ? target : null),
title: "fallback title",
};
let executed = false;
vm.runInNewContext(
code,
{ document, JSON, console, eval: () => (executed = true) },
{ timeout: 1000 }
);
return { raw: target.textContent, executed };
}
const base = {
'meta[property="og:title"]': "普通标题",
'meta[property="og:url"]': "https://example.com/archives/a",
'meta[property="article:published_time"]': "2026-11-11",
'meta[name="author"]': "暖心向阳335",
'meta[property="og:site_name"]': "WarmIsland",
'meta[property="og:image"]': "https://example.com/cover.png",
'meta[property="og:description"]': '含"引号"与\n换行的摘要',
};
check("正常文章生成合法 JSON-LD", () => {
const data = JSON.parse(render(base).raw);
if (data["@type"] !== "Article" || data.headline !== "普通标题") {
throw new Error("标题 / 类型不符");
}
if (data.author.name !== "暖心向阳335") throw new Error("作者不符");
if (!data.description.includes("\n")) throw new Error("换行丢失");
if (data.image !== base['meta[property="og:image"]']) throw new Error("封面丢失");
});
check("模板里的 JSON-LD 标签初始为空(动态内容不经 HTML 解析)", () => {
const match = html.match(/<script\b[^>]*application\/ld\+json[^>]*>([\s\S]*?)<\/script>/i);
if (!match) throw new Error("未找到 JSON-LD 标签");
if (match[1].trim() !== "") throw new Error("标签内仍有静态 / 拼接内容,可能被解析为 HTML");
});
check("脚本只用 textContent 写入,不含 innerHTML / document.write / eval", () => {
if (!code.includes(".textContent = JSON.stringify")) throw new Error("未使用 textContent 安全赋值");
if (/innerHTML|document\.write|\beval\s*\(/.test(code)) throw new Error("存在不安全写入方式");
});
check("标题含脚本闭合标签时只作为 JSON 字符串保留,不产生动态执行", () => {
const injected = "</" + "script><img src=x onerror=alert(1)>";
const result = render({ ...base, 'meta[property="og:title"]': injected });
const data = JSON.parse(result.raw);
if (data.headline !== injected || typeof data.headline !== "string") {
throw new Error("标题被篡改或不是纯字符串");
}
if (result.executed) throw new Error("发生动态执行");
});
check("缺失可选字段时不产生 undefined / 非法 JSON", () => {
const sparse = { ...base };
delete sparse['meta[property="og:image"]'];
delete sparse['meta[property="og:description"]'];
const data = JSON.parse(render(sparse).raw);
if ("image" in data || "description" in data) throw new Error("空字段未剔除");
});
check("全部 meta 缺失时仍输出合法 JSON", () => {
const data = JSON.parse(render({}).raw);
if (data["@context"] !== "https://schema.org" || data.author.name !== "") {
throw new Error("兜底失败");
}
});
console.log(`[jsonld-smoke] 内容渲染安全 ${pass} 项通过 / ${fail} 项失败`);
process.exit(fail ? 1 : 0);