Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions app/api/docs/history/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { NextRequest, NextResponse } from "next/server";
import type { HistoryItem } from "@/app/types/docs-history";

// 响应缓存 1 小时,GitHub API 每小时限额 5000 次(authenticated)
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;
}

/**
* 规范化前端传入的文档路径为仓库根相对路径(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 rawPath = searchParams.get("path");

const path = rawPath ? normalizeDocsPath(rawPath) : null;
if (!path) {
return NextResponse.json(
{
success: false,
error: "缺少合法的 path 参数(仅允许 app/docs/ 路径)",
},
{ status: 400 },
);
}
Comment on lines +58 to +70
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

path 直接来自 query string,目前只做了“是否为空”的校验就用于拼接 GitHub API 请求。建议至少限制为文档目录内的相对路径(例如只允许以 app/docs/ 或 docsBase 开头,拒绝包含 ..、反斜杠等),避免用服务端的 GitHub token 被动暴露任意仓库文件的 commit 信息。

Copilot uses AI. Check for mistakes.

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`;

Comment on lines +80 to +81
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里把客户端传来的 path 直接传给 commits?path=...,但页面侧传入的是 page.file.path(在 getDocContributorsByPath 里会再统一前缀成 app/docs/...)。如果 path 是 docs 相对路径(不含 app/docs/),GitHub 会查不到文件导致结果为空。建议在服务端统一把 path 规范化成 repo-root 相对路径(复用 lib/github 的 docsBase/normalizeDocsPath),并避免在此处硬编码 owner/repo。

Copilot uses AI. Check for mistakes.
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 },
);
}

// 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 token 无效或过期" },
{ status: 401 },
);
}
Comment on lines +101 to +120
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

将 GitHub 的所有 403 都映射为 429(限流)不准确:403 也可能是 token 权限不足/仓库不可访问等。建议读取响应体 message 或根据 x-ratelimit-remaining/x-ratelimit-reset 判断是否真的触发 rate limit;否则返回更贴近真实原因的 502/401/403 错误。

Copilot uses AI. Check for mistakes.

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,
// author 为 null 时(commit 作者邮箱未关联 GitHub 账号),login 退回展示名
authorLogin: c.author?.login ?? c.commit.author.name,
// commit.author.name 是展示名(可能含中文/空格),拼 github.com/<name>.png 容易 404;
// 仅在有真实 author 时用其 avatar_url,否则返回空串让前端用占位资源
avatarUrl: c.author?.avatar_url ?? "",
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",
},
},
);
}
163 changes: 163 additions & 0 deletions app/components/DocHistoryPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"use client";

import { useEffect, useReducer } from "react";
import Image from "next/image";
import type { HistoryItem } from "@/app/types/docs-history";

// author 缺失时用 1x1 透明占位图,避免 <Image> 收到空 src 报错
const FALLBACK_AVATAR =
"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>";

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();
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 (
<div className="flex items-center gap-3 py-2.5 animate-pulse">
<div className="w-6 h-6 rounded-full bg-neutral-200 dark:bg-neutral-700 shrink-0" />
<div className="flex-1 flex flex-col gap-1">
<div className="h-3 w-2/3 rounded bg-neutral-200 dark:bg-neutral-700" />
<div className="h-2.5 w-1/3 rounded bg-neutral-100 dark:bg-neutral-800" />
</div>
</div>
);
}

export function DocHistoryPanel({ path }: DocHistoryPanelProps) {
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;
fetch(`/api/docs/history?path=${encodeURIComponent(path)}`)
.then((r) => r.json())
.then((json) => {
if (cancelled) return;
if (json.success) {
dispatch({ type: "ok", items: json.data ?? [] });
} else {
dispatch({
type: "error",
message: json.error ?? "无法加载历史",
});
}
})
.catch(() => {
if (!cancelled) {
dispatch({ type: "error", message: "无法加载历史" });
}
});
return () => {
cancelled = true;
};
}, [path]);

return (
<div className="font-serif">
{/* 报纸风格标题 */}
<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">
最近更新
</h2>

{/* 加载中 */}
{state.status === "loading" && (
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
<SkeletonRow />
<SkeletonRow />
<SkeletonRow />
</div>
)}

{/* 错误 */}
{state.status === "error" && (
<p className="text-xs font-mono text-neutral-400 dark:text-neutral-500 py-2">
{state.message}
</p>
)}

{/* 空结果 */}
{state.status === "ok" && state.items.length === 0 && (
<p className="text-xs font-mono text-neutral-400 dark:text-neutral-500 py-2">
暂无更新记录
</p>
)}

{/* 历史列表 */}
{state.status === "ok" && state.items.length > 0 && (
<ol className="divide-y divide-neutral-100 dark:divide-neutral-800">
{state.items.map((item) => (
<li key={item.sha}>
<a
href={item.htmlUrl}
target="_blank"
rel="noopener noreferrer"
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"
>
{/* 头像 */}
<Image
src={item.avatarUrl || FALLBACK_AVATAR}
alt={item.authorLogin}
width={24}
height={24}
className="rounded-full mt-0.5 shrink-0"
unoptimized
/>

<div className="flex-1 min-w-0">
{/* commit message,截断超长内容 */}
<p className="text-sm leading-snug text-neutral-800 dark:text-neutral-200 truncate group-hover:text-[#CC0000] transition-colors">
{item.message}
</p>
{/* 作者 + 时间,monospace 风格 */}
<p className="text-[11px] font-mono text-neutral-400 dark:text-neutral-500 mt-0.5">
{item.authorName}
<span className="mx-1 opacity-40">·</span>
{relativeTime(item.date)}
</p>
</div>
</a>
</li>
))}
</ol>
)}
</div>
);
}
4 changes: 4 additions & 0 deletions app/docs/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -60,6 +61,9 @@ export default async function DocPage({ params }: Param) {
<section className="mt-16">
<GiscusComments docId={docIdFromPage ?? null} />
</section>
<section className="mt-12">
<DocHistoryPanel path={page.file.path} />
</section>
<LicenseNotice className="mt-16" />
</DocsBody>
</DocsPage>
Expand Down
14 changes: 14 additions & 0 deletions app/types/docs-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* 文档历史面板共享类型。
* 抽出到独立模块避免 client 组件从 Route Handler(server 文件)直接 import 类型,
* 防止未来 route 文件引入 node-only 依赖时 client bundle 踩边界问题。
*/
export interface HistoryItem {
sha: string;
authorName: string;
authorLogin: string;
avatarUrl: string;
date: string;
message: string;
htmlUrl: string;
}
Loading