From eb4edfe026c35ec9b83a5718fcca8971e0f81678 Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Tue, 14 Apr 2026 18:50:13 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=8E=86=E5=8F=B2=20API=20=E8=B7=AF=E7=94=B1=EF=BC=8C?= =?UTF-8?q?=E8=B0=83=E7=94=A8=20GitHub=20commits=20=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E6=9C=80=E8=BF=91=205=20=E6=AC=A1=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/docs/history/route.ts | 107 ++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 app/api/docs/history/route.ts diff --git a/app/api/docs/history/route.ts b/app/api/docs/history/route.ts new file mode 100644 index 00000000..978efe37 --- /dev/null +++ b/app/api/docs/history/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from "next/server"; + +// 响应缓存 1 小时,GitHub API 每小时限额 5000 次 +export const revalidate = 3600; + +interface GitHubCommit { + sha: string; + commit: { + author: { + name: string; + date: string; + }; + message: string; + }; + author: { + login: string; + avatar_url: string; + } | null; + html_url: string; +} + +export interface HistoryItem { + sha: string; + authorName: string; + authorLogin: string; + avatarUrl: string; + date: string; + message: string; + htmlUrl: string; +} + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const path = searchParams.get("path"); + + if (!path) { + return NextResponse.json( + { success: false, error: "缺少 path 参数" }, + { status: 400 }, + ); + } + + const token = process.env.GITHUB_TOKEN; + if (!token) { + return NextResponse.json( + { success: false, error: "服务端未配置 GITHUB_TOKEN" }, + { status: 500 }, + ); + } + + const apiUrl = `https://api.github.com/repos/InvolutionHell/involutionhell/commits?path=${encodeURIComponent(path)}&per_page=5`; + + let res: Response; + try { + res = await fetch(apiUrl, { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + // Next.js fetch 缓存,与 revalidate 配合 + next: { revalidate: 3600 }, + }); + } catch { + return NextResponse.json( + { success: false, error: "无法连接 GitHub API" }, + { status: 502 }, + ); + } + + if (res.status === 403) { + return NextResponse.json( + { success: false, error: "GitHub API 限流,请稍后重试" }, + { status: 429 }, + ); + } + + if (!res.ok) { + return NextResponse.json( + { success: false, error: `GitHub API 返回 ${res.status}` }, + { status: 502 }, + ); + } + + const commits: GitHubCommit[] = await res.json(); + + const data: HistoryItem[] = commits.map((c) => ({ + sha: c.sha, + authorName: c.commit.author.name, + authorLogin: c.author?.login ?? c.commit.author.name, + avatarUrl: + c.author?.avatar_url ?? `https://github.com/${c.commit.author.name}.png`, + date: c.commit.author.date, + // 只取 commit message 第一行 + message: c.commit.message.split("\n")[0], + htmlUrl: c.html_url, + })); + + return NextResponse.json( + { success: true, data }, + { + headers: { + "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400", + }, + }, + ); +} From 890b3a78c7f7f679f7bb27244e596a4ac94ece36 Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Tue, 14 Apr 2026 18:50:22 +0000 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20DocHistoryPane?= =?UTF-8?q?l=20=E7=BB=84=E4=BB=B6=E5=B9=B6=E6=8C=82=E8=BD=BD=E8=87=B3?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E9=A1=B5=EF=BC=8C=E5=B1=95=E7=A4=BA=E6=9C=80?= =?UTF-8?q?=E8=BF=91=E6=9B=B4=E6=96=B0=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/components/DocHistoryPanel.tsx | 133 +++++++++++++++++++++++++++++ app/docs/[...slug]/page.tsx | 4 + 2 files changed, 137 insertions(+) create mode 100644 app/components/DocHistoryPanel.tsx diff --git a/app/components/DocHistoryPanel.tsx b/app/components/DocHistoryPanel.tsx new file mode 100644 index 00000000..3224b331 --- /dev/null +++ b/app/components/DocHistoryPanel.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Image from "next/image"; +import type { HistoryItem } from "@/app/api/docs/history/route"; + +interface DocHistoryPanelProps { + path: string; +} + +// 将 ISO 日期转为相对时间描述(中文) +function relativeTime(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const minutes = Math.floor(diff / 60_000); + if (minutes < 1) return "刚刚"; + if (minutes < 60) return `${minutes} 分钟前`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} 小时前`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days} 天前`; + const months = Math.floor(days / 30); + if (months < 12) return `${months} 个月前`; + return `${Math.floor(months / 12)} 年前`; +} + +// 骨架屏占位行 +function SkeletonRow() { + return ( +
+
+
+
+
+
+
+ ); +} + +export function DocHistoryPanel({ path }: DocHistoryPanelProps) { + const [items, setItems] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + fetch(`/api/docs/history?path=${encodeURIComponent(path)}`) + .then((r) => r.json()) + .then((json) => { + if (cancelled) return; + if (json.success) { + setItems(json.data); + } else { + setError(json.error ?? "无法加载历史"); + } + }) + .catch(() => { + if (!cancelled) setError("无法加载历史"); + }); + return () => { + cancelled = true; + }; + }, [path]); + + return ( +
+ {/* 报纸风格标题 */} +

+ 最近更新 +

+ + {/* 加载中 */} + {items === null && error === null && ( +
+ + + +
+ )} + + {/* 错误 */} + {error !== null && ( +

+ {error} +

+ )} + + {/* 空结果 */} + {items !== null && items.length === 0 && ( +

+ 暂无更新记录 +

+ )} + + {/* 历史列表 */} + {items !== null && items.length > 0 && ( +
    + {items.map((item) => ( +
  1. + + {/* 头像 */} + {item.authorLogin} + +
    + {/* commit message,截断超长内容 */} +

    + {item.message} +

    + {/* 作者 + 时间,monospace 风格 */} +

    + {item.authorName} + · + {relativeTime(item.date)} +

    +
    +
    +
  2. + ))} +
+ )} +
+ ); +} diff --git a/app/docs/[...slug]/page.tsx b/app/docs/[...slug]/page.tsx index 64c62e29..f79a3e4c 100644 --- a/app/docs/[...slug]/page.tsx +++ b/app/docs/[...slug]/page.tsx @@ -14,6 +14,7 @@ import { Contributors } from "@/app/components/Contributors"; import { DocsAssistant } from "@/app/components/DocsAssistant"; import { LicenseNotice } from "@/app/components/LicenseNotice"; import { PageFeedback } from "@/app/components/PageFeedback"; +import { DocHistoryPanel } from "@/app/components/DocHistoryPanel"; // Extract clean text content from MDX - no longer used on client/page side // content fetching moved to API route for performance @@ -60,6 +61,9 @@ export default async function DocPage({ params }: Param) {
+
+ +
From 7f7b4cf0ea6d079b582945d67108ac87f280245d Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Tue, 14 Apr 2026 19:01:39 +0000 Subject: [PATCH 3/5] =?UTF-8?q?chore(docs-history):=20CR=20-=20=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E6=A0=A1=E9=AA=8C/=E9=94=99=E8=AF=AF=E7=8A=B6?= =?UTF-8?q?=E6=80=81/=E5=A4=B4=E5=83=8F=E5=85=9C=E5=BA=95/=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E8=A7=A3=E8=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/.png 容易 404 - 类型 HistoryItem 抽到 app/types/docs-history.ts,解耦 client 组件与 route handler - DocHistoryPanel: path 变化先清空 items/error 避免 '错误 + 旧列表' 同时显示 - DocHistoryPanel: 空头像用 data URI 占位防 Image 报错 --- app/components/DocHistoryPanel.tsx | 18 +++++++++++++++--- app/types/docs-history.ts | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 app/types/docs-history.ts diff --git a/app/components/DocHistoryPanel.tsx b/app/components/DocHistoryPanel.tsx index 3224b331..f14452f8 100644 --- a/app/components/DocHistoryPanel.tsx +++ b/app/components/DocHistoryPanel.tsx @@ -2,7 +2,11 @@ import { useEffect, useState } from "react"; import Image from "next/image"; -import type { HistoryItem } from "@/app/api/docs/history/route"; +import type { HistoryItem } from "@/app/types/docs-history"; + +// author 缺失时用 1x1 透明占位图,避免 收到空 src 报错 +const FALLBACK_AVATAR = + "data:image/svg+xml;utf8,"; interface DocHistoryPanelProps { path: string; @@ -42,18 +46,26 @@ export function DocHistoryPanel({ path }: DocHistoryPanelProps) { useEffect(() => { let cancelled = false; + // path 变化触发重新 fetch 时先清空旧状态,避免"错误提示 + 旧列表"同时显示 + setItems(null); + setError(null); fetch(`/api/docs/history?path=${encodeURIComponent(path)}`) .then((r) => r.json()) .then((json) => { if (cancelled) return; if (json.success) { setItems(json.data); + setError(null); } else { + setItems(null); setError(json.error ?? "无法加载历史"); } }) .catch(() => { - if (!cancelled) setError("无法加载历史"); + if (!cancelled) { + setItems(null); + setError("无法加载历史"); + } }); return () => { cancelled = true; @@ -103,7 +115,7 @@ export function DocHistoryPanel({ path }: DocHistoryPanelProps) { > {/* 头像 */} {item.authorLogin} Date: Tue, 14 Apr 2026 19:02:25 +0000 Subject: [PATCH 4/5] =?UTF-8?q?chore(docs-history):=20CR=20-=20=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E8=A1=A5=E8=B7=AF=E5=BE=84=E6=A0=A1=E9=AA=8C=E4=B8=8E?= =?UTF-8?q?=20403=20=E7=BB=86=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接着前一 commit 补落下的 route.ts 改动(SSRF 防护 + 403 区分限流/权限不足 + 401 单独处理 + 头像兜底) --- app/api/docs/history/route.ts | 76 ++++++++++++++++++++++++++++------- 1 file changed, 61 insertions(+), 15 deletions(-) diff --git a/app/api/docs/history/route.ts b/app/api/docs/history/route.ts index 978efe37..08ad6e30 100644 --- a/app/api/docs/history/route.ts +++ b/app/api/docs/history/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; +import type { HistoryItem } from "@/app/types/docs-history"; -// 响应缓存 1 小时,GitHub API 每小时限额 5000 次 +// 响应缓存 1 小时,GitHub API 每小时限额 5000 次(authenticated) export const revalidate = 3600; interface GitHubCommit { @@ -19,23 +20,51 @@ interface GitHubCommit { html_url: string; } -export interface HistoryItem { - sha: string; - authorName: string; - authorLogin: string; - avatarUrl: string; - date: string; - message: string; - htmlUrl: string; +/** + * 规范化前端传入的文档路径为仓库根相对路径(GitHub API 要求)。 + * + * 接受的输入形态: + * - `app/docs/ai/...`(仓库根相对)→ 原样返回 + * - `docs/ai/...` → 前面补 `app/` + * - `/docs/ai/...`(浏览器 URL 风格)→ 去开头斜杠再补 `app/` + * + * 拒绝:含 `..`、反斜杠、null 字节;最终不落在 `app/docs/` 下的路径一律拒绝, + * 避免用服务端 GITHUB_TOKEN 被动泄露仓库内任意文件的 commit 信息。 + */ +function normalizeDocsPath(raw: string): string | null { + if (!raw) return null; + // 路径穿越 / 反斜杠 / null 字节 直接拒 + if (raw.includes("..") || raw.includes("\\") || raw.includes("\0")) { + return null; + } + + let normalized = raw; + // URL 风格 /docs/... → docs/... + if (normalized.startsWith("/")) { + normalized = normalized.slice(1); + } + // docs/... → app/docs/... + if (normalized.startsWith("docs/")) { + normalized = `app/${normalized}`; + } + // 必须落在 app/docs/ 下才放行 + if (!normalized.startsWith("app/docs/")) { + return null; + } + return normalized; } export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url); - const path = searchParams.get("path"); + const rawPath = searchParams.get("path"); + const path = rawPath ? normalizeDocsPath(rawPath) : null; if (!path) { return NextResponse.json( - { success: false, error: "缺少 path 参数" }, + { + success: false, + error: "缺少合法的 path 参数(仅允许 app/docs/ 路径)", + }, { status: 400 }, ); } @@ -68,10 +97,25 @@ export async function GET(req: NextRequest) { ); } + // 403 可能是限流、也可能是 token 权限不足 / 仓库不可访问;用 x-ratelimit-remaining 区分 if (res.status === 403) { + const rateRemaining = res.headers.get("x-ratelimit-remaining"); + if (rateRemaining === "0") { + return NextResponse.json( + { success: false, error: "GitHub API 限流,请稍后重试" }, + { status: 429 }, + ); + } + return NextResponse.json( + { success: false, error: "GitHub API 403(可能 token 权限不足)" }, + { status: 403 }, + ); + } + + if (res.status === 401) { return NextResponse.json( - { success: false, error: "GitHub API 限流,请稍后重试" }, - { status: 429 }, + { success: false, error: "GitHub token 无效或过期" }, + { status: 401 }, ); } @@ -87,9 +131,11 @@ export async function GET(req: NextRequest) { const data: HistoryItem[] = commits.map((c) => ({ sha: c.sha, authorName: c.commit.author.name, + // author 为 null 时(commit 作者邮箱未关联 GitHub 账号),login 退回展示名 authorLogin: c.author?.login ?? c.commit.author.name, - avatarUrl: - c.author?.avatar_url ?? `https://github.com/${c.commit.author.name}.png`, + // commit.author.name 是展示名(可能含中文/空格),拼 github.com/.png 容易 404; + // 仅在有真实 author 时用其 avatar_url,否则返回空串让前端用占位资源 + avatarUrl: c.author?.avatar_url ?? "", date: c.commit.author.date, // 只取 commit message 第一行 message: c.commit.message.split("\n")[0], From 206964cfc1aa72eb851b37b9f44b3cf08266e23c Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Tue, 14 Apr 2026 19:19:41 +0000 Subject: [PATCH 5/5] =?UTF-8?q?fix(docs-history):=20=E6=94=B9=E7=94=A8=20u?= =?UTF-8?q?seReducer=20=E4=BF=AE=E5=A4=8D=20react-hooks/set-state-in-effec?= =?UTF-8?q?t=20lint=20=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 解决的问题,现在用状态机更干净)。 --- app/components/DocHistoryPanel.tsx | 54 ++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/app/components/DocHistoryPanel.tsx b/app/components/DocHistoryPanel.tsx index f14452f8..b1a3298a 100644 --- a/app/components/DocHistoryPanel.tsx +++ b/app/components/DocHistoryPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useReducer } from "react"; import Image from "next/image"; import type { HistoryItem } from "@/app/types/docs-history"; @@ -12,6 +12,25 @@ interface DocHistoryPanelProps { path: string; } +// 将 items / error / loading 合并成一个 discriminated union, +// 避免 effect 里多次同步 setState 触发 react-hooks/set-state-in-effect +// 同时天然保证三种状态互斥(不会同时出现"错误提示 + 旧列表") +type State = + | { status: "loading" } + | { status: "ok"; items: HistoryItem[] } + | { status: "error"; message: string }; + +type Action = + | { type: "fetch" } + | { type: "ok"; items: HistoryItem[] } + | { type: "error"; message: string }; + +function reducer(_: State, action: Action): State { + if (action.type === "fetch") return { status: "loading" }; + if (action.type === "ok") return { status: "ok", items: action.items }; + return { status: "error", message: action.message }; +} + // 将 ISO 日期转为相对时间描述(中文) function relativeTime(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); @@ -41,30 +60,29 @@ function SkeletonRow() { } export function DocHistoryPanel({ path }: DocHistoryPanelProps) { - const [items, setItems] = useState(null); - const [error, setError] = useState(null); + const [state, dispatch] = useReducer(reducer, { status: "loading" }); useEffect(() => { + // 用 dispatch 而不是多次 setState,规避 react-hooks/set-state-in-effect lint; + // path 变化时立刻回到 loading,避免"错误提示 + 旧列表"并存 + dispatch({ type: "fetch" }); let cancelled = false; - // path 变化触发重新 fetch 时先清空旧状态,避免"错误提示 + 旧列表"同时显示 - setItems(null); - setError(null); fetch(`/api/docs/history?path=${encodeURIComponent(path)}`) .then((r) => r.json()) .then((json) => { if (cancelled) return; if (json.success) { - setItems(json.data); - setError(null); + dispatch({ type: "ok", items: json.data ?? [] }); } else { - setItems(null); - setError(json.error ?? "无法加载历史"); + dispatch({ + type: "error", + message: json.error ?? "无法加载历史", + }); } }) .catch(() => { if (!cancelled) { - setItems(null); - setError("无法加载历史"); + dispatch({ type: "error", message: "无法加载历史" }); } }); return () => { @@ -80,7 +98,7 @@ export function DocHistoryPanel({ path }: DocHistoryPanelProps) { {/* 加载中 */} - {items === null && error === null && ( + {state.status === "loading" && (
@@ -89,23 +107,23 @@ export function DocHistoryPanel({ path }: DocHistoryPanelProps) { )} {/* 错误 */} - {error !== null && ( + {state.status === "error" && (

- {error} + {state.message}

)} {/* 空结果 */} - {items !== null && items.length === 0 && ( + {state.status === "ok" && state.items.length === 0 && (

暂无更新记录

)} {/* 历史列表 */} - {items !== null && items.length > 0 && ( + {state.status === "ok" && state.items.length > 0 && (
    - {items.map((item) => ( + {state.items.map((item) => (