package server import ( "compress/gzip" "context" "fmt" "io" "net/http" "os" "os/exec" "runtime/debug" "strings" "time" "gitcat/internal/ai" "gitcat/internal/gitx" "gitcat/internal/store" ) // gitTransferTimeout 是单次 clone / fetch / push 允许的最长时长。 // // 不能靠 http.Server 的 WriteTimeout 兜底:写超时会把正在上传/下载的 // 大仓库硬生生切断。正确做法是给 git 子进程一个明确的 context——它同时 // 覆盖了"客户端断开"(r.Context 会被取消)和"服务端卡死"两种情况。 const gitTransferTimeout = 30 * time.Minute // maxGitBodySize 限制单个 Git 请求体的解压后体积。 // 恶意(或失控的)客户端可以用 gzip 炸弹把任意大的数据灌进 git 的 stdin。 const maxGitBodySize = 512 << 20 // handleGitAction 处理 Git Smart HTTP 协议请求。 func (s *Server) handleGitAction(w http.ResponseWriter, r *http.Request, name, action string) { if !s.st.Installed() { http.Error(w, "gitcat 尚未完成安装", http.StatusServiceUnavailable) return } repo, err := s.st.RepoByName(name) if err != nil { w.Header().Set("WWW-Authenticate", `Basic realm="gitcat"`) http.Error(w, "项目不存在", http.StatusNotFound) return } dir := s.repoPath(repo.Name) if !gitx.IsBareRepo(dir) { http.Error(w, "仓库数据缺失", http.StatusInternalServerError) return } switch action { case "info/refs": service := r.URL.Query().Get("service") switch service { case "git-upload-pack": s.serveInfoRefs(w, r, dir, "upload-pack") case "git-receive-pack": // 写操作:必须校验身份 + 仓库归属 + 归档状态。 if !s.authorizeGitWrite(w, r, repo) { return } s.serveInfoRefs(w, r, dir, "receive-pack") default: http.Error(w, "不支持的服务类型", http.StatusBadRequest) } case "git-upload-pack": s.serveUploadPack(w, r, dir) case "git-receive-pack": if !s.authorizeGitWrite(w, r, repo) { return } s.serveReceivePack(w, r, repo, dir, userFrom(r.Context())) default: http.Error(w, "未知的 git 请求", http.StatusNotFound) } } // authorizeGitWrite 校验 Git 写操作(push)权限。 // // 这里必须调用 canManageRepo:只校验"已登录"意味着任何一个注册用户都能 // 往别人的仓库推送、强推、删除分支和标签,等于把所有人的代码托管权限 // 交给了最低权限的成员。Git 协议没有 CSRF 保护,权限只能在这里守住。 // // 成功时把用户写入请求上下文,供 serveReceivePack 使用,避免重复做一次 // bcrypt 校验。 func (s *Server) authorizeGitWrite(w http.ResponseWriter, r *http.Request, repo *store.Repo) bool { u := s.basicAuthUser(r) if u == nil { challengeGit(w) return false } if repo.IsArchived { http.Error(w, "项目已归档,暂不接受推送", http.StatusForbidden) return false } if !canManageRepo(u, repo) { http.Error(w, "没有向该项目推送的权限", http.StatusForbidden) return false } ctx := context.WithValue(r.Context(), ctxUser, u) *r = *r.WithContext(ctx) return true } // gitEnv 组装 git 子进程环境,并在请求上下文上挂上传输超时。 func gitEnv(r *http.Request) ([]string, context.CancelFunc) { ctx, cancel := context.WithTimeout(r.Context(), gitTransferTimeout) r = r.WithContext(ctx) env := append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C") if proto := r.Header.Get("Git-Protocol"); proto != "" { env = append(env, "GIT_PROTOCOL="+proto) } return env, cancel } // serveInfoRefs 输出 refs 广告(clone / fetch / push 的握手阶段)。 func (s *Server) serveInfoRefs(w http.ResponseWriter, r *http.Request, dir, service string) { w.Header().Set("Content-Type", "application/x-git-"+service+"-advertisement") w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") w.WriteHeader(http.StatusOK) header := "# service=git-" + service + "\n" fmt.Fprintf(w, "%04x%s0000", len(header)+4, header) env, cancel := gitEnv(r) defer cancel() cmd := exec.CommandContext(r.Context(), "git", service, "--stateless-rpc", "--advertise-refs", dir) cmd.Env = env cmd.Stdout = w cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { fmt.Fprintf(os.Stderr, "git %s --advertise-refs 失败: %v\n", service, err) } } // serveUploadPack 处理 clone / fetch(公开可读)。 func (s *Server) serveUploadPack(w http.ResponseWriter, r *http.Request, dir string) { w.Header().Set("Content-Type", "application/x-git-upload-pack-result") w.Header().Set("Cache-Control", "no-cache") body, closeBody, err := requestBody(w, r) if err != nil { return } defer closeBody() env, cancel := gitEnv(r) defer cancel() cmd := exec.CommandContext(r.Context(), "git", "upload-pack", "--stateless-rpc", dir) cmd.Env = env cmd.Stdin = body cmd.Stdout = w cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { fmt.Fprintf(os.Stderr, "git upload-pack 失败: %v\n", err) } } // serveReceivePack 处理 push,并在完成后记录推送留痕。 func (s *Server) serveReceivePack(w http.ResponseWriter, r *http.Request, repo *store.Repo, dir string, u *store.User) { body, closeBody, err := requestBody(w, r) if err != nil { return } defer closeBody() w.Header().Set("Content-Type", "application/x-git-receive-pack-result") w.Header().Set("Cache-Control", "no-cache") env, cancel := gitEnv(r) defer cancel() mu := s.repoLock(repo.Name) mu.Lock() before, _ := gitx.Refs(dir) cmd := exec.CommandContext(r.Context(), "git", "receive-pack", "--stateless-rpc", dir) cmd.Env = env cmd.Stdin = body cmd.Stdout = w cmd.Stderr = os.Stderr runErr := cmd.Run() if flusher, ok := w.(http.Flusher); ok { flusher.Flush() } after, _ := gitx.Refs(dir) mu.Unlock() if runErr != nil { fmt.Fprintf(os.Stderr, "git receive-pack 失败: %v\n", runErr) } s.recordPush(repo, dir, u, before, after) } type refChange struct { Ref string Old string New string } // recordPush 对比推送前后的 refs,落库留痕并触发 AI 摘要。 func (s *Server) recordPush(repo *store.Repo, dir string, u *store.User, before, after map[string]string) { names := map[string]bool{} for k := range before { names[k] = true } for k := range after { names[k] = true } var changes []refChange for name := range names { oldSHA, newSHA := before[name], after[name] if oldSHA == newSHA { continue } if !strings.HasPrefix(name, "refs/heads/") && !strings.HasPrefix(name, "refs/tags/") { continue } changes = append(changes, refChange{Ref: name, Old: oldSHA, New: newSHA}) } if len(changes) == 0 { return } // 分支优先,其次标签;保证顺序稳定 sortRefChanges(changes) const maxCommits = 30 var details []gitx.PushCommit totalCommits := 0 for _, ch := range changes { if ch.New == "" { continue } commits, err := gitx.CommitsBetween(dir, ch.Old, ch.New, maxCommits) if err != nil { continue } totalCommits += len(commits) for _, c := range commits { if len(details) < maxCommits { details = append(details, c) } } } refLabel := strings.TrimPrefix(changes[0].Ref, "refs/heads/") refLabel = strings.TrimPrefix(refLabel, "refs/tags/") if len(changes) > 1 { refLabel = fmt.Sprintf("%s 等 %d 个引用", refLabel, len(changes)) } var uid *int64 actorName, actorDisplay := "unknown", "未知用户" if u != nil { uid = &u.ID actorName = u.Username actorDisplay = u.Display() } storeDetails := make([]store.PushCommit, 0, len(details)) for _, c := range details { storeDetails = append(storeDetails, store.PushCommit{ SHA: c.SHA, Subject: c.Subject, Author: c.Author, When: c.When, Additions: c.Additions, Deletions: c.Deletions, Files: c.Files, }) } push, err := s.st.CreatePush(repo.ID, uid, actorName, actorDisplay, refLabel, changes[0].Old, changes[0].New, storeDetails) if err != nil { fmt.Fprintf(os.Stderr, "写入推送记录失败: %v\n", err) return } _ = s.st.TouchRepo(repo.ID) // 摘要素材:变更统计 + 提交列表 var diffStat string additions, deletions := 0, 0 for _, c := range details { additions += c.Additions deletions += c.Deletions } primary := changes[0] // 只统计代码分支的变更规模,避免多个引用叠加导致统计失真 if primary.New != "" { diffStat = gitx.DiffStat(dir, primary.Old, primary.New) } // 分支被删除时给一条说明 if primary.New == "" { fallback := fmt.Sprintf("- 删除了分支 `%s`", strings.TrimPrefix(primary.Ref, "refs/heads/")) _ = s.st.SetPushSummary(push.ID, fallback, "fallback") return } go func() { // 这是脱离请求生命周期的独立 goroutine,仓库里没有 recover 中间件, // 一次 panic 就会带走整个进程。宁可丢掉一条更新说明,也不能让服务崩。 defer func() { if rec := recover(); rec != nil { fmt.Fprintf(os.Stderr, "生成更新说明时 panic: %v\n%s\n", rec, debug.Stack()) } }() s.generateWhatsNew(push.ID, repo, primary, refLabel, details, diffStat, additions, deletions) }() } func sortRefChanges(changes []refChange) { // refs/heads 优先于 refs/tags,删除的引用排在最后 weight := func(c refChange) int { w := 0 if strings.HasPrefix(c.Ref, "refs/tags/") { w += 2 } if c.New == "" { w++ } return w } for i := 1; i < len(changes); i++ { for j := i; j > 0 && weight(changes[j]) < weight(changes[j-1]); j-- { changes[j], changes[j-1] = changes[j-1], changes[j] } } } // generateWhatsNew 调用 AI 生成更新说明,失败时降级为提交摘要。 func (s *Server) generateWhatsNew(pushID int64, repo *store.Repo, primary refChange, refLabel string, details []gitx.PushCommit, diffStat string, additions, deletions int) { fallback := fallbackSummary(refLabel, details, additions, deletions) cfg := s.AIConfig() if !repo.AIEnabled { _ = s.st.SetPushSummary(pushID, fallback, "fallback") return } if !cfg.Ready() { _ = s.st.SetPushSummary(pushID, fallback, "fallback") return } lines := make([]ai.CommitLine, 0, len(details)) for _, c := range details { lines = append(lines, ai.CommitLine{SHA: c.SHA, Author: c.Author, Subject: c.Subject}) } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() out, err := ai.GenerateWhatsNew(ctx, cfg, ai.SummaryInput{ RepoName: repo.Name, Ref: refLabel, Commits: lines, DiffStat: diffStat, Additions: additions, Deletions: deletions, }) if err != nil { fmt.Fprintf(os.Stderr, "AI 生成更新说明失败: %v\n", err) _ = s.st.SetPushSummary(pushID, fallback, "failed") return } _ = s.st.SetPushSummary(pushID, out, "done") } // fallbackSummary 在未接入 AI 时给出仍然可读的更新说明。 func fallbackSummary(refLabel string, details []gitx.PushCommit, additions, deletions int) string { var sb strings.Builder if len(details) == 0 { fmt.Fprintf(&sb, "- 更新了 `%s`", refLabel) return sb.String() } fmt.Fprintf(&sb, "- 本次共 %d 个提交,代码变化 +%d / -%d\n", len(details), additions, deletions) for i, c := range details { if i >= 5 { fmt.Fprintf(&sb, "- 其余 %d 个提交见下方提交列表\n", len(details)-i) break } fmt.Fprintf(&sb, "- `%s` %s(%s)\n", c.Short(), c.Subject, c.Author) } return strings.TrimRight(sb.String(), "\n") } // requestBody 取出请求体并按需解压。 // // 解压后的数据会被直接喂给 git 的 stdin,因此必须限量:否则一个几 KB 的 // gzip 炸弹就能把 git 的输入撑到任意大。调用方必须 defer closeBody()。 func requestBody(w http.ResponseWriter, r *http.Request) (io.Reader, func(), error) { noop := func() {} if !strings.Contains(r.Header.Get("Content-Encoding"), "gzip") { return r.Body, noop, nil } gz, err := gzip.NewReader(r.Body) if err != nil { http.Error(w, "无法解析请求体", http.StatusBadRequest) return nil, noop, err } // 多包一层:gzip 之后还要限流,且必须在关闭时同时关掉压缩流。 limited := io.LimitReader(gz, maxGitBodySize) return limited, func() { _ = gz.Close() }, nil }