方案 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
272 lines
11 KiB
TypeScript
272 lines
11 KiB
TypeScript
// 站点文案单例访问层(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();
|
||
}
|
||
}
|