Files
blog/src/lib/site-content.ts
T
wait4xandGLM-5.3 b0a3881725 fix(security): 页面文案 markdown 管线加标签/属性/协议净化(P0)
方案 10 §7.1 实锤漏洞:renderMarkdown() 用无参 createMarkdownProcessor,
默认 allowDangerousHtml + 无条件 rehype-raw,页面文案(about intro、开源页
叙述、README)的 raw HTML 直通 set:html——script/style/iframe/on* 全不剥。

- 新增 src/lib/rehype-content-sanitize.ts:标签白名单(不含 script/style/
  iframe/object/form/link/meta/svg 等,危险标签整棵丢弃、未知标签解包保内容)
  + 属性白名单(a[href,title,rel]、img[src,alt,width,height,loading]、class/id)
  + 协议白名单(http/https/mailto + 站内相对 + 小图 data:)
- site-content.ts 插件链与文章管线同序:rehypeRaw → 内容净化 → class 白名单
  → 链接卡片(副作用为正:页面文案里的独立链接也会成 OG 卡片)
- 修两处自身实现坑(实测驱动):hast 的 class 属性名是 className(按 class
  过滤会把合法排版类一起剃掉);属性白名单需统一小写比较(驼峰 key 比不到)

验证: 注入载荷实测(<script>/<iframe>/onclick/style="color:red"/
      href="javascript:")全部剥离、合法 class="ink-2" 与文本保留;
      构建 48 页 exit 0;E2E 37/37

Spec: none (安全修复,方案 P0)

Co-authored-by: GLM-5.3
2026-11-02 16:57:52 +08:00

272 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 站点文案单例访问层(cms 车道 A):后台改 content/site/*.yaml → 页面读此处
// 兜底纪律:文件缺 → 用默认文案;字段缺/空串 → 用默认文案(构建绝不因文案炸)
// 注意:整文件被删会先在 file() loader 层报错(误删级),此处守的是字段级缺失/空值
import { getEntry } from 'astro:content';
import type { ImageMetadata } from 'astro';
import type { Locale } from './locale';
export interface Bi {
zh: string;
en: string;
}
/** 与仓库现状逐字一致的默认文案:yaml 全空时产物不变(零回归可视验证锚点) */
export const SITE_DEFAULTS = {
homeTagline: {
zh: '写作者 · 读者 · 快门按压者,拥抱开源。',
en: 'Writer · Reader · Shutter-presser — embracing open source.',
},
aboutIntro: {
zh: '写作者 · 读者 · 快门按压者,拥抱开源。这里是我三条轨道的公开工作台:读过的书留下评注,写过的代码进项目墙,按下的快门收入图库。',
en: 'Writer · Reader · Shutter-presser, embracing open source. This is the public workbench of my three tracks — books that left notes, code that reaches the project wall, and shutters I pressed.',
},
opensource: {
blurb: { zh: '本站本身即开源:', en: 'This site is itself open source: ' },
blurbAfter: {
zh: '—— Astro SSG、双写作流、构建发布全链皆在仓中。以下是我在自托管 gitea 与 GitHub 上的全部公开仓库,以及最近一年的贡献活动。',
en: '— the Astro SSG, dual-writing workflow, and the full build & deploy pipeline live in the repo. Below are all my public repositories on self-hosted gitea and GitHub, plus contributions from the past year.',
},
repoUrl: 'https://git.wait4x.online/wait4x/blog',
repoLabel: 'git.wait4x.online/wait4x/blog',
pinned: ['blog'] as string[],
},
} as const;
/** 字段级回落:yaml 值非空才采信(空白串视为空) */
const pick = (value: string | undefined, fallback: string): string =>
value && value.trim() ? value : fallback;
const pickBi = (value: { zh?: string; en?: string } | undefined, fallback: Bi, locale: Locale): string =>
pick(value?.[locale], fallback[locale]);
/** 主页 tagline(zh/en) */
export async function homeTagline(locale: Locale): Promise<string> {
const entry = await getEntry('siteHome', 'home');
return pickBi(entry?.data.tagline, SITE_DEFAULTS.homeTagline, locale);
}
/** 关于页身份陈述段(zh/en) */
export async function aboutIntro(locale: Locale): Promise<string> {
const entry = await getEntry('siteAbout', 'about');
return pickBi(entry?.data.intro, SITE_DEFAULTS.aboutIntro, locale);
}
export interface OpenSourceCopy {
blurb: string;
blurbAfter: string;
repoUrl: string;
repoLabel: string;
/** 置顶仓短名(顺序即顺序);yaml 空数组/缺省 → 默认 ['blog'] */
pinned: string[];
}
/** 开源页叙述 + 置顶名单 */
export async function openSourceCopy(locale: Locale): Promise<OpenSourceCopy> {
const entry = await getEntry('siteOpenSource', 'opensource');
const d = SITE_DEFAULTS.opensource;
const pinned = entry?.data.pinned?.filter((n) => n.trim()) ?? [];
return {
blurb: pickBi(entry?.data.blurb, d.blurb, locale),
blurbAfter: pickBi(entry?.data.blurb_after, d.blurbAfter, locale),
repoUrl: pick(entry?.data.repo_url, d.repoUrl),
repoLabel: pick(entry?.data.repo_label, d.repoLabel),
pinned: pinned.length ? pinned : [...d.pinned],
};
}
// ---- 车道「后台能力扩展」:社交链接 / 头像 / 站点图标 / markdown 正文 ----
export interface SocialLink {
label: string;
url: string;
/** 内置图标键:github / rss / email / bilibili / x;未知或空 → 纯文字按钮 */
icon?: string;
}
export const DEFAULT_SOCIALS: SocialLink[] = [
{ label: 'GitHub', url: 'https://github.com/wait4xx', icon: 'github' },
{ label: 'RSS', url: '/rss.xml', icon: 'rss' },
{ label: '邮件', url: 'mailto:admin@wait4x.online', icon: 'email' },
];
/** 社交链接(顺序即展示顺序);yaml 空/缺省 → 默认三条(与历史硬编码一致) */
export async function homeSocials(): Promise<SocialLink[]> {
const entry = await getEntry('siteHome', 'home');
const list = entry?.data.socials?.filter((s) => s.label?.trim() && s.url?.trim()) ?? [];
return list.length ? list : DEFAULT_SOCIALS;
}
/** 头像(Astro 图片管线的 ImageMetadata);未配置 → null,调用方回退渐变占位 */
export async function homeAvatar(): Promise<ImageMetadata | null> {
const entry = await getEntry('siteHome', 'home');
return entry?.data.avatar ?? null;
}
/** 站点图标路径:yaml 值非空即采信,否则 /favicon.svg;值不带前导 / 时自动补 */
export async function siteFavicon(): Promise<{ href: string; type: string }> {
const entry = await getEntry('siteHome', 'home');
const raw = pick(entry?.data.favicon, '/favicon.svg');
const href = raw.startsWith('/') || raw.startsWith('http') ? raw : `/${raw}`;
const type = href.endsWith('.svg')
? 'image/svg+xml'
: href.endsWith('.ico')
? 'image/x-icon'
: href.endsWith('.png')
? 'image/png'
: '';
return { href, type };
}
/** markdown 字符串 → HTML(进程内单例处理器,构建期复用) */
let mdProcessor: Promise<import('@astrojs/markdown-remark').MarkdownProcessor> | null = null;
export async function renderMarkdown(md: string): Promise<string> {
const { createMarkdownProcessor } = await import('@astrojs/markdown-remark');
// 与 astro.config.mjs 的文章管线同序:raw → 内容净化(标签/属性/协议)→
// class 白名单 → 链接卡片。此前这里 createMarkdownProcessor() 无参,页面
// 文案的 raw HTML 直通 set:html(方案 10 §7.1 实锤漏洞:script/style/on* 不剥)。
mdProcessor ??= (async () => {
const [{ default: rehypeRaw }, { default: rehypeContentSanitize }, { default: rehypeClassWhitelist }, { default: rehypeLinkCards }] =
await Promise.all([
import('rehype-raw'),
import('./rehype-content-sanitize'),
import('./rehype-class-whitelist'),
import('./rehype-link-cards'),
]);
return createMarkdownProcessor({
rehypePlugins: [rehypeRaw, rehypeContentSanitize, rehypeClassWhitelist, rehypeLinkCards],
});
})();
const { code } = await (await mdProcessor).render(md);
return code;
}
/** 关于页正文(markdown,未渲染);空 → 默认文案 */
export async function aboutIntroMarkdown(locale: Locale): Promise<string> {
const entry = await getEntry('siteAbout', 'about');
return pickBi(entry?.data.intro, SITE_DEFAULTS.aboutIntro, locale);
}
// ---- 车道「首页+开源页后台扩展」:首页四项可配 + 项目墙可见性 + README 开篇 ----
export interface HomeSections {
tracks: boolean;
photos: boolean;
heatmap: boolean;
projects: boolean;
}
export interface HomeConfig {
/** 副标题 markdown 原文;空串 = 不渲染 */
subtitle: string;
/** 最新影像张数:0 = 隐藏区块 */
latestPhotos: number;
/** 每轨文章条数 */
perTrackLimit: number;
sections: HomeSections;
}
const clampInt = (value: number | undefined, min: number, max: number, fallback: number): number => {
if (typeof value !== 'number' || Number.isNaN(value)) return fallback;
return Math.min(max, Math.max(min, Math.trunc(value)));
};
/** 首页可配项(字段级回落:零配置 = 现有形态;越界值夹取而非报错) */
export async function homeConfig(locale: Locale): Promise<HomeConfig> {
const entry = await getEntry('siteHome', 'home');
const d = entry?.data;
return {
subtitle: pickBi(d?.subtitle, { zh: '', en: '' }, locale),
latestPhotos: clampInt(d?.latestPhotos, 0, 6, 4),
perTrackLimit: clampInt(d?.perTrackLimit, 4, 8, 6),
sections: {
tracks: d?.sections?.tracks ?? true,
photos: d?.sections?.photos ?? true,
heatmap: d?.sections?.heatmap ?? true,
projects: d?.sections?.projects ?? true,
},
};
}
export type ProjectMode = 'all' | 'only' | 'exclude';
export type ProjectSort = 'pinned-first' | 'stars' | 'updated';
export type ProjectSource = 'both' | 'github' | 'gitea';
export interface OpenSourceOptions {
mode: ProjectMode;
include: string[];
exclude: string[];
sort: ProjectSort;
perSource: ProjectSource;
introFrom: 'off' | 'github-readme';
}
/** 开源页项目墙选项(缺省 = 现状:all / pinned-first / 双源) */
export async function openSourceOptions(): Promise<OpenSourceOptions> {
const entry = await getEntry('siteOpenSource', 'opensource');
const d = entry?.data;
return {
mode: d?.mode ?? 'all',
include: (d?.include ?? []).filter((n) => n.trim()),
exclude: (d?.exclude ?? []).filter((n) => n.trim()),
sort: d?.sort ?? 'pinned-first',
perSource: d?.perSource ?? 'both',
introFrom: d?.intro_from ?? 'off',
};
}
/** GitHub profile README 缓存:取不到网就吃缓存,都无则调用方回落 yaml blurb */
const README_CACHE = 'content/.github-readme.md';
const PROFILE_REPO = 'wait4xx/wait4xx';
/**
* 构建期取 profile 仓 README(Accept: raw),成功后回写 content/.github-readme.md(入仓缓存)
* @returns README markdown;网络/权限/仓无 README 时回落缓存,仍无 → null
*/
export async function githubReadmeMarkdown(): Promise<string | null> {
const { readFileSync, writeFileSync, existsSync } = await import('node:fs');
const { join } = await import('node:path');
const cachePath = join(process.cwd(), README_CACHE);
const readCache = (): string | null => {
try {
return existsSync(cachePath) ? readFileSync(cachePath, 'utf8') : null;
} catch {
return null;
}
};
let token: string | null = null;
try {
token = readFileSync(join(process.env.HOME ?? '', 'docker_apps/blog/github-token.txt'), 'utf8').trim() || null;
} catch {
token = null;
}
if (!token) {
console.warn('[opensource] github-readme:token 缺位,用缓存/回落 yaml blurb');
return readCache();
}
try {
const res = await fetch(`https://api.github.com/repos/${PROFILE_REPO}/readme`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github.raw', 'User-Agent': 'wait4x-blog-build' },
});
if (!res.ok) {
console.warn(`[opensource] github-readme:${PROFILE_REPO} 取 README 失败 HTTP ${res.status},用缓存/回落`);
return readCache();
}
const md = (await res.text()).trim();
if (!md) {
console.warn('[opensource] github-readme:README 为空,用缓存/回落');
return readCache();
}
const cached = readCache();
if (cached !== md) {
writeFileSync(cachePath, `${md}\n`, 'utf8');
console.log(`[opensource] github-readme:已刷新缓存 ${README_CACHE}(${md.length} 字符)`);
}
return md;
} catch (err) {
console.warn(`[opensource] github-readme:请求异常(${(err as Error).message}),用缓存/回落`);
return readCache();
}
}