仰望星辰工作室

gitcat

gitcat/ internal/server/githttp.go 11.9 KB · 391 行 原始文件
1package server
2
3import (
4 "compress/gzip"
5 "context"
6 "fmt"
7 "io"
8 "net/http"
9 "os"
10 "os/exec"
11 "runtime/debug"
12 "strings"
13 "time"
14
15 "gitcat/internal/ai"
16 "gitcat/internal/gitx"
17 "gitcat/internal/store"
18)
19
20// gitTransferTimeout 是单次 clone / fetch / push 允许的最长时长。
21//
22// 不能靠 http.Server 的 WriteTimeout 兜底:写超时会把正在上传/下载的
23// 大仓库硬生生切断。正确做法是给 git 子进程一个明确的 context——它同时
24// 覆盖了"客户端断开"(r.Context 会被取消)和"服务端卡死"两种情况。
25const gitTransferTimeout = 30 * time.Minute
26
27// maxGitBodySize 限制单个 Git 请求体的解压后体积。
28// 恶意(或失控的)客户端可以用 gzip 炸弹把任意大的数据灌进 git 的 stdin。
29const maxGitBodySize = 512 << 20
30
31// handleGitAction 处理 Git Smart HTTP 协议请求。
32func (s *Server) handleGitAction(w http.ResponseWriter, r *http.Request, name, action string) {
33 if !s.st.Installed() {
34 http.Error(w, "gitcat 尚未完成安装", http.StatusServiceUnavailable)
35 return
36 }
37 repo, err := s.st.RepoByName(name)
38 if err != nil {
39 w.Header().Set("WWW-Authenticate", `Basic realm="gitcat"`)
40 http.Error(w, "项目不存在", http.StatusNotFound)
41 return
42 }
43 dir := s.repoPath(repo.Name)
44 if !gitx.IsBareRepo(dir) {
45 http.Error(w, "仓库数据缺失", http.StatusInternalServerError)
46 return
47 }
48
49 switch action {
50 case "info/refs":
51 service := r.URL.Query().Get("service")
52 switch service {
53 case "git-upload-pack":
54 s.serveInfoRefs(w, r, dir, "upload-pack")
55 case "git-receive-pack":
56 // 写操作:必须校验身份 + 仓库归属 + 归档状态。
57 if !s.authorizeGitWrite(w, r, repo) {
58 return
59 }
60 s.serveInfoRefs(w, r, dir, "receive-pack")
61 default:
62 http.Error(w, "不支持的服务类型", http.StatusBadRequest)
63 }
64 case "git-upload-pack":
65 s.serveUploadPack(w, r, dir)
66 case "git-receive-pack":
67 if !s.authorizeGitWrite(w, r, repo) {
68 return
69 }
70 s.serveReceivePack(w, r, repo, dir, userFrom(r.Context()))
71 default:
72 http.Error(w, "未知的 git 请求", http.StatusNotFound)
73 }
74}
75
76// authorizeGitWrite 校验 Git 写操作(push)权限。
77//
78// 这里必须调用 canManageRepo:只校验"已登录"意味着任何一个注册用户都能
79// 往别人的仓库推送、强推、删除分支和标签,等于把所有人的代码托管权限
80// 交给了最低权限的成员。Git 协议没有 CSRF 保护,权限只能在这里守住。
81//
82// 成功时把用户写入请求上下文,供 serveReceivePack 使用,避免重复做一次
83// bcrypt 校验。
84func (s *Server) authorizeGitWrite(w http.ResponseWriter, r *http.Request, repo *store.Repo) bool {
85 u := s.basicAuthUser(r)
86 if u == nil {
87 challengeGit(w)
88 return false
89 }
90 if repo.IsArchived {
91 http.Error(w, "项目已归档,暂不接受推送", http.StatusForbidden)
92 return false
93 }
94 if !canManageRepo(u, repo) {
95 http.Error(w, "没有向该项目推送的权限", http.StatusForbidden)
96 return false
97 }
98 ctx := context.WithValue(r.Context(), ctxUser, u)
99 *r = *r.WithContext(ctx)
100 return true
101}
102
103// gitEnv 组装 git 子进程环境,并在请求上下文上挂上传输超时。
104func gitEnv(r *http.Request) ([]string, context.CancelFunc) {
105 ctx, cancel := context.WithTimeout(r.Context(), gitTransferTimeout)
106 r = r.WithContext(ctx)
107 env := append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C")
108 if proto := r.Header.Get("Git-Protocol"); proto != "" {
109 env = append(env, "GIT_PROTOCOL="+proto)
110 }
111 return env, cancel
112}
113
114// serveInfoRefs 输出 refs 广告(clone / fetch / push 的握手阶段)。
115func (s *Server) serveInfoRefs(w http.ResponseWriter, r *http.Request, dir, service string) {
116 w.Header().Set("Content-Type", "application/x-git-"+service+"-advertisement")
117 w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
118 w.WriteHeader(http.StatusOK)
119
120 header := "# service=git-" + service + "\n"
121 fmt.Fprintf(w, "%04x%s0000", len(header)+4, header)
122
123 env, cancel := gitEnv(r)
124 defer cancel()
125 cmd := exec.CommandContext(r.Context(), "git", service, "--stateless-rpc", "--advertise-refs", dir)
126 cmd.Env = env
127 cmd.Stdout = w
128 cmd.Stderr = os.Stderr
129 if err := cmd.Run(); err != nil {
130 fmt.Fprintf(os.Stderr, "git %s --advertise-refs 失败: %v\n", service, err)
131 }
132}
133
134// serveUploadPack 处理 clone / fetch(公开可读)。
135func (s *Server) serveUploadPack(w http.ResponseWriter, r *http.Request, dir string) {
136 w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
137 w.Header().Set("Cache-Control", "no-cache")
138 body, closeBody, err := requestBody(w, r)
139 if err != nil {
140 return
141 }
142 defer closeBody()
143 env, cancel := gitEnv(r)
144 defer cancel()
145 cmd := exec.CommandContext(r.Context(), "git", "upload-pack", "--stateless-rpc", dir)
146 cmd.Env = env
147 cmd.Stdin = body
148 cmd.Stdout = w
149 cmd.Stderr = os.Stderr
150 if err := cmd.Run(); err != nil {
151 fmt.Fprintf(os.Stderr, "git upload-pack 失败: %v\n", err)
152 }
153}
154
155// serveReceivePack 处理 push,并在完成后记录推送留痕。
156func (s *Server) serveReceivePack(w http.ResponseWriter, r *http.Request, repo *store.Repo, dir string, u *store.User) {
157 body, closeBody, err := requestBody(w, r)
158 if err != nil {
159 return
160 }
161 defer closeBody()
162 w.Header().Set("Content-Type", "application/x-git-receive-pack-result")
163 w.Header().Set("Cache-Control", "no-cache")
164
165 env, cancel := gitEnv(r)
166 defer cancel()
167
168 mu := s.repoLock(repo.Name)
169 mu.Lock()
170 before, _ := gitx.Refs(dir)
171
172 cmd := exec.CommandContext(r.Context(), "git", "receive-pack", "--stateless-rpc", dir)
173 cmd.Env = env
174 cmd.Stdin = body
175 cmd.Stdout = w
176 cmd.Stderr = os.Stderr
177 runErr := cmd.Run()
178 if flusher, ok := w.(http.Flusher); ok {
179 flusher.Flush()
180 }
181 after, _ := gitx.Refs(dir)
182 mu.Unlock()
183
184 if runErr != nil {
185 fmt.Fprintf(os.Stderr, "git receive-pack 失败: %v\n", runErr)
186 }
187 s.recordPush(repo, dir, u, before, after)
188}
189
190type refChange struct {
191 Ref string
192 Old string
193 New string
194}
195
196// recordPush 对比推送前后的 refs,落库留痕并触发 AI 摘要。
197func (s *Server) recordPush(repo *store.Repo, dir string, u *store.User, before, after map[string]string) {
198 names := map[string]bool{}
199 for k := range before {
200 names[k] = true
201 }
202 for k := range after {
203 names[k] = true
204 }
205 var changes []refChange
206 for name := range names {
207 oldSHA, newSHA := before[name], after[name]
208 if oldSHA == newSHA {
209 continue
210 }
211 if !strings.HasPrefix(name, "refs/heads/") && !strings.HasPrefix(name, "refs/tags/") {
212 continue
213 }
214 changes = append(changes, refChange{Ref: name, Old: oldSHA, New: newSHA})
215 }
216 if len(changes) == 0 {
217 return
218 }
219 // 分支优先,其次标签;保证顺序稳定
220 sortRefChanges(changes)
221
222 const maxCommits = 30
223 var details []gitx.PushCommit
224 totalCommits := 0
225 for _, ch := range changes {
226 if ch.New == "" {
227 continue
228 }
229 commits, err := gitx.CommitsBetween(dir, ch.Old, ch.New, maxCommits)
230 if err != nil {
231 continue
232 }
233 totalCommits += len(commits)
234 for _, c := range commits {
235 if len(details) < maxCommits {
236 details = append(details, c)
237 }
238 }
239 }
240
241 refLabel := strings.TrimPrefix(changes[0].Ref, "refs/heads/")
242 refLabel = strings.TrimPrefix(refLabel, "refs/tags/")
243 if len(changes) > 1 {
244 refLabel = fmt.Sprintf("%s 等 %d 个引用", refLabel, len(changes))
245 }
246
247 var uid *int64
248 actorName, actorDisplay := "unknown", "未知用户"
249 if u != nil {
250 uid = &u.ID
251 actorName = u.Username
252 actorDisplay = u.Display()
253 }
254 storeDetails := make([]store.PushCommit, 0, len(details))
255 for _, c := range details {
256 storeDetails = append(storeDetails, store.PushCommit{
257 SHA: c.SHA, Subject: c.Subject, Author: c.Author, When: c.When,
258 Additions: c.Additions, Deletions: c.Deletions, Files: c.Files,
259 })
260 }
261 push, err := s.st.CreatePush(repo.ID, uid, actorName, actorDisplay, refLabel, changes[0].Old, changes[0].New, storeDetails)
262 if err != nil {
263 fmt.Fprintf(os.Stderr, "写入推送记录失败: %v\n", err)
264 return
265 }
266 _ = s.st.TouchRepo(repo.ID)
267
268 // 摘要素材:变更统计 + 提交列表
269 var diffStat string
270 additions, deletions := 0, 0
271 for _, c := range details {
272 additions += c.Additions
273 deletions += c.Deletions
274 }
275 primary := changes[0]
276 // 只统计代码分支的变更规模,避免多个引用叠加导致统计失真
277 if primary.New != "" {
278 diffStat = gitx.DiffStat(dir, primary.Old, primary.New)
279 }
280 // 分支被删除时给一条说明
281 if primary.New == "" {
282 fallback := fmt.Sprintf("- 删除了分支 `%s`", strings.TrimPrefix(primary.Ref, "refs/heads/"))
283 _ = s.st.SetPushSummary(push.ID, fallback, "fallback")
284 return
285 }
286
287 go func() {
288 // 这是脱离请求生命周期的独立 goroutine,仓库里没有 recover 中间件,
289 // 一次 panic 就会带走整个进程。宁可丢掉一条更新说明,也不能让服务崩。
290 defer func() {
291 if rec := recover(); rec != nil {
292 fmt.Fprintf(os.Stderr, "生成更新说明时 panic: %v\n%s\n", rec, debug.Stack())
293 }
294 }()
295 s.generateWhatsNew(push.ID, repo, primary, refLabel, details, diffStat, additions, deletions)
296 }()
297}
298
299func sortRefChanges(changes []refChange) {
300 // refs/heads 优先于 refs/tags,删除的引用排在最后
301 weight := func(c refChange) int {
302 w := 0
303 if strings.HasPrefix(c.Ref, "refs/tags/") {
304 w += 2
305 }
306 if c.New == "" {
307 w++
308 }
309 return w
310 }
311 for i := 1; i < len(changes); i++ {
312 for j := i; j > 0 && weight(changes[j]) < weight(changes[j-1]); j-- {
313 changes[j], changes[j-1] = changes[j-1], changes[j]
314 }
315 }
316}
317
318// generateWhatsNew 调用 AI 生成更新说明,失败时降级为提交摘要。
319func (s *Server) generateWhatsNew(pushID int64, repo *store.Repo, primary refChange, refLabel string, details []gitx.PushCommit, diffStat string, additions, deletions int) {
320 fallback := fallbackSummary(refLabel, details, additions, deletions)
321
322 cfg := s.AIConfig()
323 if !repo.AIEnabled {
324 _ = s.st.SetPushSummary(pushID, fallback, "fallback")
325 return
326 }
327 if !cfg.Ready() {
328 _ = s.st.SetPushSummary(pushID, fallback, "fallback")
329 return
330 }
331
332 lines := make([]ai.CommitLine, 0, len(details))
333 for _, c := range details {
334 lines = append(lines, ai.CommitLine{SHA: c.SHA, Author: c.Author, Subject: c.Subject})
335 }
336 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
337 defer cancel()
338 out, err := ai.GenerateWhatsNew(ctx, cfg, ai.SummaryInput{
339 RepoName: repo.Name,
340 Ref: refLabel,
341 Commits: lines,
342 DiffStat: diffStat,
343 Additions: additions,
344 Deletions: deletions,
345 })
346 if err != nil {
347 fmt.Fprintf(os.Stderr, "AI 生成更新说明失败: %v\n", err)
348 _ = s.st.SetPushSummary(pushID, fallback, "failed")
349 return
350 }
351 _ = s.st.SetPushSummary(pushID, out, "done")
352}
353
354// fallbackSummary 在未接入 AI 时给出仍然可读的更新说明。
355func fallbackSummary(refLabel string, details []gitx.PushCommit, additions, deletions int) string {
356 var sb strings.Builder
357 if len(details) == 0 {
358 fmt.Fprintf(&sb, "- 更新了 `%s`", refLabel)
359 return sb.String()
360 }
361 fmt.Fprintf(&sb, "- 本次共 %d 个提交,代码变化 +%d / -%d\n", len(details), additions, deletions)
362 for i, c := range details {
363 if i >= 5 {
364 fmt.Fprintf(&sb, "- 其余 %d 个提交见下方提交列表\n", len(details)-i)
365 break
366 }
367 fmt.Fprintf(&sb, "- `%s` %s(%s)\n", c.Short(), c.Subject, c.Author)
368 }
369 return strings.TrimRight(sb.String(), "\n")
370}
371
372// requestBody 取出请求体并按需解压。
373//
374// 解压后的数据会被直接喂给 git 的 stdin,因此必须限量:否则一个几 KB 的
375// gzip 炸弹就能把 git 的输入撑到任意大。调用方必须 defer closeBody()。
376func requestBody(w http.ResponseWriter, r *http.Request) (io.Reader, func(), error) {
377 noop := func() {}
378 if !strings.Contains(r.Header.Get("Content-Encoding"), "gzip") {
379 return r.Body, noop, nil
380 }
381 gz, err := gzip.NewReader(r.Body)
382 if err != nil {
383 http.Error(w, "无法解析请求体", http.StatusBadRequest)
384 return nil, noop, err
385 }
386 // 多包一层:gzip 之后还要限流,且必须在关闭时同时关掉压缩流。
387 limited := io.LimitReader(gz, maxGitBodySize)
388 return limited, func() {
389 _ = gz.Close()
390 }, nil
391}