Skip to content

Commit 7311bc3

Browse files
authored
feat(docs): 文档页底部显示最近 5 次更新(GitHub API) (#279)
* feat: 添加文档历史 API 路由,调用 GitHub commits 接口返回最近 5 次更新 * feat: 添加 DocHistoryPanel 组件并挂载至文档页,展示最近更新列表 * chore(docs-history): CR - 路径校验/错误状态/头像兜底/类型解耦 Copilot CR #279: - route: 新增 normalizeDocsPath 做路径校验,只允许 app/docs/ 下相对路径, 拒绝 ..、反斜杠、null 字节,消除 SSRF 风险 - route: 接受 'docs/...' 和 '/docs/...' 形式,统一补成仓库根相对 'app/docs/...' - route: 403 用 x-ratelimit-remaining 区分限流 vs 权限不足,401 单独处理 - route: author 为 null 时 avatarUrl 返回空串而不是拼 github.com/<name>.png 容易 404 - 类型 HistoryItem 抽到 app/types/docs-history.ts,解耦 client 组件与 route handler - DocHistoryPanel: path 变化先清空 items/error 避免 '错误 + 旧列表' 同时显示 - DocHistoryPanel: 空头像用 data URI 占位防 Image 报错 * chore(docs-history): CR - 路由补路径校验与 403 细分 接着前一 commit 补落下的 route.ts 改动(SSRF 防护 + 403 区分限流/权限不足 + 401 单独处理 + 头像兜底) * fix(docs-history): 改用 useReducer 修复 react-hooks/set-state-in-effect lint 错误 PR #279 build 挂在 lint: > 50 | setItems(null); | ^ Avoid calling setState() directly within an effect 把 items / error / loading 合并成 discriminated union + useReducer, effect 里只 dispatch 一次,规避 lint 规则。 副收益:三种状态天然互斥,不会出现'错误提示 + 旧列表'并存的情况 (这正是上个 CR 试图用两次 setState 解决的问题,现在用状态机更干净)。
1 parent a6ed407 commit 7311bc3

4 files changed

Lines changed: 334 additions & 0 deletions

File tree

app/api/docs/history/route.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import type { HistoryItem } from "@/app/types/docs-history";
3+
4+
// 响应缓存 1 小时,GitHub API 每小时限额 5000 次(authenticated)
5+
export const revalidate = 3600;
6+
7+
interface GitHubCommit {
8+
sha: string;
9+
commit: {
10+
author: {
11+
name: string;
12+
date: string;
13+
};
14+
message: string;
15+
};
16+
author: {
17+
login: string;
18+
avatar_url: string;
19+
} | null;
20+
html_url: string;
21+
}
22+
23+
/**
24+
* 规范化前端传入的文档路径为仓库根相对路径(GitHub API 要求)。
25+
*
26+
* 接受的输入形态:
27+
* - `app/docs/ai/...`(仓库根相对)→ 原样返回
28+
* - `docs/ai/...` → 前面补 `app/`
29+
* - `/docs/ai/...`(浏览器 URL 风格)→ 去开头斜杠再补 `app/`
30+
*
31+
* 拒绝:含 `..`、反斜杠、null 字节;最终不落在 `app/docs/` 下的路径一律拒绝,
32+
* 避免用服务端 GITHUB_TOKEN 被动泄露仓库内任意文件的 commit 信息。
33+
*/
34+
function normalizeDocsPath(raw: string): string | null {
35+
if (!raw) return null;
36+
// 路径穿越 / 反斜杠 / null 字节 直接拒
37+
if (raw.includes("..") || raw.includes("\\") || raw.includes("\0")) {
38+
return null;
39+
}
40+
41+
let normalized = raw;
42+
// URL 风格 /docs/... → docs/...
43+
if (normalized.startsWith("/")) {
44+
normalized = normalized.slice(1);
45+
}
46+
// docs/... → app/docs/...
47+
if (normalized.startsWith("docs/")) {
48+
normalized = `app/${normalized}`;
49+
}
50+
// 必须落在 app/docs/ 下才放行
51+
if (!normalized.startsWith("app/docs/")) {
52+
return null;
53+
}
54+
return normalized;
55+
}
56+
57+
export async function GET(req: NextRequest) {
58+
const { searchParams } = new URL(req.url);
59+
const rawPath = searchParams.get("path");
60+
61+
const path = rawPath ? normalizeDocsPath(rawPath) : null;
62+
if (!path) {
63+
return NextResponse.json(
64+
{
65+
success: false,
66+
error: "缺少合法的 path 参数(仅允许 app/docs/ 路径)",
67+
},
68+
{ status: 400 },
69+
);
70+
}
71+
72+
const token = process.env.GITHUB_TOKEN;
73+
if (!token) {
74+
return NextResponse.json(
75+
{ success: false, error: "服务端未配置 GITHUB_TOKEN" },
76+
{ status: 500 },
77+
);
78+
}
79+
80+
const apiUrl = `https://api.github.com/repos/InvolutionHell/involutionhell/commits?path=${encodeURIComponent(path)}&per_page=5`;
81+
82+
let res: Response;
83+
try {
84+
res = await fetch(apiUrl, {
85+
headers: {
86+
Authorization: `Bearer ${token}`,
87+
Accept: "application/vnd.github+json",
88+
"X-GitHub-Api-Version": "2022-11-28",
89+
},
90+
// Next.js fetch 缓存,与 revalidate 配合
91+
next: { revalidate: 3600 },
92+
});
93+
} catch {
94+
return NextResponse.json(
95+
{ success: false, error: "无法连接 GitHub API" },
96+
{ status: 502 },
97+
);
98+
}
99+
100+
// 403 可能是限流、也可能是 token 权限不足 / 仓库不可访问;用 x-ratelimit-remaining 区分
101+
if (res.status === 403) {
102+
const rateRemaining = res.headers.get("x-ratelimit-remaining");
103+
if (rateRemaining === "0") {
104+
return NextResponse.json(
105+
{ success: false, error: "GitHub API 限流,请稍后重试" },
106+
{ status: 429 },
107+
);
108+
}
109+
return NextResponse.json(
110+
{ success: false, error: "GitHub API 403(可能 token 权限不足)" },
111+
{ status: 403 },
112+
);
113+
}
114+
115+
if (res.status === 401) {
116+
return NextResponse.json(
117+
{ success: false, error: "GitHub token 无效或过期" },
118+
{ status: 401 },
119+
);
120+
}
121+
122+
if (!res.ok) {
123+
return NextResponse.json(
124+
{ success: false, error: `GitHub API 返回 ${res.status}` },
125+
{ status: 502 },
126+
);
127+
}
128+
129+
const commits: GitHubCommit[] = await res.json();
130+
131+
const data: HistoryItem[] = commits.map((c) => ({
132+
sha: c.sha,
133+
authorName: c.commit.author.name,
134+
// author 为 null 时(commit 作者邮箱未关联 GitHub 账号),login 退回展示名
135+
authorLogin: c.author?.login ?? c.commit.author.name,
136+
// commit.author.name 是展示名(可能含中文/空格),拼 github.com/<name>.png 容易 404;
137+
// 仅在有真实 author 时用其 avatar_url,否则返回空串让前端用占位资源
138+
avatarUrl: c.author?.avatar_url ?? "",
139+
date: c.commit.author.date,
140+
// 只取 commit message 第一行
141+
message: c.commit.message.split("\n")[0],
142+
htmlUrl: c.html_url,
143+
}));
144+
145+
return NextResponse.json(
146+
{ success: true, data },
147+
{
148+
headers: {
149+
"Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
150+
},
151+
},
152+
);
153+
}

app/components/DocHistoryPanel.tsx

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"use client";
2+
3+
import { useEffect, useReducer } from "react";
4+
import Image from "next/image";
5+
import type { HistoryItem } from "@/app/types/docs-history";
6+
7+
// author 缺失时用 1x1 透明占位图,避免 <Image> 收到空 src 报错
8+
const FALLBACK_AVATAR =
9+
"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><rect width='24' height='24' fill='%23e5e7eb'/></svg>";
10+
11+
interface DocHistoryPanelProps {
12+
path: string;
13+
}
14+
15+
// 将 items / error / loading 合并成一个 discriminated union,
16+
// 避免 effect 里多次同步 setState 触发 react-hooks/set-state-in-effect
17+
// 同时天然保证三种状态互斥(不会同时出现"错误提示 + 旧列表")
18+
type State =
19+
| { status: "loading" }
20+
| { status: "ok"; items: HistoryItem[] }
21+
| { status: "error"; message: string };
22+
23+
type Action =
24+
| { type: "fetch" }
25+
| { type: "ok"; items: HistoryItem[] }
26+
| { type: "error"; message: string };
27+
28+
function reducer(_: State, action: Action): State {
29+
if (action.type === "fetch") return { status: "loading" };
30+
if (action.type === "ok") return { status: "ok", items: action.items };
31+
return { status: "error", message: action.message };
32+
}
33+
34+
// 将 ISO 日期转为相对时间描述(中文)
35+
function relativeTime(dateStr: string): string {
36+
const diff = Date.now() - new Date(dateStr).getTime();
37+
const minutes = Math.floor(diff / 60_000);
38+
if (minutes < 1) return "刚刚";
39+
if (minutes < 60) return `${minutes} 分钟前`;
40+
const hours = Math.floor(minutes / 60);
41+
if (hours < 24) return `${hours} 小时前`;
42+
const days = Math.floor(hours / 24);
43+
if (days < 30) return `${days} 天前`;
44+
const months = Math.floor(days / 30);
45+
if (months < 12) return `${months} 个月前`;
46+
return `${Math.floor(months / 12)} 年前`;
47+
}
48+
49+
// 骨架屏占位行
50+
function SkeletonRow() {
51+
return (
52+
<div className="flex items-center gap-3 py-2.5 animate-pulse">
53+
<div className="w-6 h-6 rounded-full bg-neutral-200 dark:bg-neutral-700 shrink-0" />
54+
<div className="flex-1 flex flex-col gap-1">
55+
<div className="h-3 w-2/3 rounded bg-neutral-200 dark:bg-neutral-700" />
56+
<div className="h-2.5 w-1/3 rounded bg-neutral-100 dark:bg-neutral-800" />
57+
</div>
58+
</div>
59+
);
60+
}
61+
62+
export function DocHistoryPanel({ path }: DocHistoryPanelProps) {
63+
const [state, dispatch] = useReducer(reducer, { status: "loading" });
64+
65+
useEffect(() => {
66+
// 用 dispatch 而不是多次 setState,规避 react-hooks/set-state-in-effect lint;
67+
// path 变化时立刻回到 loading,避免"错误提示 + 旧列表"并存
68+
dispatch({ type: "fetch" });
69+
let cancelled = false;
70+
fetch(`/api/docs/history?path=${encodeURIComponent(path)}`)
71+
.then((r) => r.json())
72+
.then((json) => {
73+
if (cancelled) return;
74+
if (json.success) {
75+
dispatch({ type: "ok", items: json.data ?? [] });
76+
} else {
77+
dispatch({
78+
type: "error",
79+
message: json.error ?? "无法加载历史",
80+
});
81+
}
82+
})
83+
.catch(() => {
84+
if (!cancelled) {
85+
dispatch({ type: "error", message: "无法加载历史" });
86+
}
87+
});
88+
return () => {
89+
cancelled = true;
90+
};
91+
}, [path]);
92+
93+
return (
94+
<div className="font-serif">
95+
{/* 报纸风格标题 */}
96+
<h2 className="text-xs font-mono uppercase tracking-widest text-neutral-400 dark:text-neutral-500 mb-3 border-b border-neutral-200 dark:border-neutral-700 pb-2">
97+
最近更新
98+
</h2>
99+
100+
{/* 加载中 */}
101+
{state.status === "loading" && (
102+
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
103+
<SkeletonRow />
104+
<SkeletonRow />
105+
<SkeletonRow />
106+
</div>
107+
)}
108+
109+
{/* 错误 */}
110+
{state.status === "error" && (
111+
<p className="text-xs font-mono text-neutral-400 dark:text-neutral-500 py-2">
112+
{state.message}
113+
</p>
114+
)}
115+
116+
{/* 空结果 */}
117+
{state.status === "ok" && state.items.length === 0 && (
118+
<p className="text-xs font-mono text-neutral-400 dark:text-neutral-500 py-2">
119+
暂无更新记录
120+
</p>
121+
)}
122+
123+
{/* 历史列表 */}
124+
{state.status === "ok" && state.items.length > 0 && (
125+
<ol className="divide-y divide-neutral-100 dark:divide-neutral-800">
126+
{state.items.map((item) => (
127+
<li key={item.sha}>
128+
<a
129+
href={item.htmlUrl}
130+
target="_blank"
131+
rel="noopener noreferrer"
132+
className="flex items-start gap-3 py-2.5 group hover:bg-neutral-50 dark:hover:bg-neutral-900 rounded transition-colors px-1 -mx-1"
133+
>
134+
{/* 头像 */}
135+
<Image
136+
src={item.avatarUrl || FALLBACK_AVATAR}
137+
alt={item.authorLogin}
138+
width={24}
139+
height={24}
140+
className="rounded-full mt-0.5 shrink-0"
141+
unoptimized
142+
/>
143+
144+
<div className="flex-1 min-w-0">
145+
{/* commit message,截断超长内容 */}
146+
<p className="text-sm leading-snug text-neutral-800 dark:text-neutral-200 truncate group-hover:text-[#CC0000] transition-colors">
147+
{item.message}
148+
</p>
149+
{/* 作者 + 时间,monospace 风格 */}
150+
<p className="text-[11px] font-mono text-neutral-400 dark:text-neutral-500 mt-0.5">
151+
{item.authorName}
152+
<span className="mx-1 opacity-40">·</span>
153+
{relativeTime(item.date)}
154+
</p>
155+
</div>
156+
</a>
157+
</li>
158+
))}
159+
</ol>
160+
)}
161+
</div>
162+
);
163+
}

app/docs/[...slug]/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { Contributors } from "@/app/components/Contributors";
1414
import { DocsAssistant } from "@/app/components/DocsAssistant";
1515
import { LicenseNotice } from "@/app/components/LicenseNotice";
1616
import { PageFeedback } from "@/app/components/PageFeedback";
17+
import { DocHistoryPanel } from "@/app/components/DocHistoryPanel";
1718
// Extract clean text content from MDX - no longer used on client/page side
1819
// content fetching moved to API route for performance
1920

@@ -60,6 +61,9 @@ export default async function DocPage({ params }: Param) {
6061
<section className="mt-16">
6162
<GiscusComments docId={docIdFromPage ?? null} />
6263
</section>
64+
<section className="mt-12">
65+
<DocHistoryPanel path={page.file.path} />
66+
</section>
6367
<LicenseNotice className="mt-16" />
6468
</DocsBody>
6569
</DocsPage>

app/types/docs-history.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* 文档历史面板共享类型。
3+
* 抽出到独立模块避免 client 组件从 Route Handler(server 文件)直接 import 类型,
4+
* 防止未来 route 文件引入 node-only 依赖时 client bundle 踩边界问题。
5+
*/
6+
export interface HistoryItem {
7+
sha: string;
8+
authorName: string;
9+
authorLogin: string;
10+
avatarUrl: string;
11+
date: string;
12+
message: string;
13+
htmlUrl: string;
14+
}

0 commit comments

Comments
 (0)