11import { NextRequest , NextResponse } from "next/server" ;
2+ import type { HistoryItem } from "@/app/types/docs-history" ;
23
3- // 响应缓存 1 小时,GitHub API 每小时限额 5000 次
4+ // 响应缓存 1 小时,GitHub API 每小时限额 5000 次(authenticated)
45export const revalidate = 3600 ;
56
67interface GitHubCommit {
@@ -19,23 +20,51 @@ interface GitHubCommit {
1920 html_url : string ;
2021}
2122
22- export interface HistoryItem {
23- sha : string ;
24- authorName : string ;
25- authorLogin : string ;
26- avatarUrl : string ;
27- date : string ;
28- message : string ;
29- htmlUrl : string ;
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 ;
3055}
3156
3257export async function GET ( req : NextRequest ) {
3358 const { searchParams } = new URL ( req . url ) ;
34- const path = searchParams . get ( "path" ) ;
59+ const rawPath = searchParams . get ( "path" ) ;
3560
61+ const path = rawPath ? normalizeDocsPath ( rawPath ) : null ;
3662 if ( ! path ) {
3763 return NextResponse . json (
38- { success : false , error : "缺少 path 参数" } ,
64+ {
65+ success : false ,
66+ error : "缺少合法的 path 参数(仅允许 app/docs/ 路径)" ,
67+ } ,
3968 { status : 400 } ,
4069 ) ;
4170 }
@@ -68,10 +97,25 @@ export async function GET(req: NextRequest) {
6897 ) ;
6998 }
7099
100+ // 403 可能是限流、也可能是 token 权限不足 / 仓库不可访问;用 x-ratelimit-remaining 区分
71101 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 ) {
72116 return NextResponse . json (
73- { success : false , error : "GitHub API 限流,请稍后重试 " } ,
74- { status : 429 } ,
117+ { success : false , error : "GitHub token 无效或过期 " } ,
118+ { status : 401 } ,
75119 ) ;
76120 }
77121
@@ -87,9 +131,11 @@ export async function GET(req: NextRequest) {
87131 const data : HistoryItem [ ] = commits . map ( ( c ) => ( {
88132 sha : c . sha ,
89133 authorName : c . commit . author . name ,
134+ // author 为 null 时(commit 作者邮箱未关联 GitHub 账号),login 退回展示名
90135 authorLogin : c . author ?. login ?? c . commit . author . name ,
91- avatarUrl :
92- c . author ?. avatar_url ?? `https://github.com/${ c . commit . author . name } .png` ,
136+ // commit.author.name 是展示名(可能含中文/空格),拼 github.com/<name>.png 容易 404;
137+ // 仅在有真实 author 时用其 avatar_url,否则返回空串让前端用占位资源
138+ avatarUrl : c . author ?. avatar_url ?? "" ,
93139 date : c . commit . author . date ,
94140 // 只取 commit message 第一行
95141 message : c . commit . message . split ( "\n" ) [ 0 ] ,
0 commit comments