gitcat
1package server
2
3import (
4 "errors"
5 "fmt"
6 "html/template"
7 "mime"
8 "net/http"
9 "net/url"
10 "os"
11 "path"
12 "strconv"
13 "strings"
14
15 "gitcat/internal/gitx"
16 "gitcat/internal/store"
17)
18
19const commitsPerPage = 30
20
21// 页面预览的限制。
22const (
23 // maxBlobPreview 是允许读进内存做页面预览的文件体积上限。
24 maxBlobPreview = 2 << 20
25 // maxPreviewLines 是预览时最多渲染的行数。
26 maxPreviewLines = 5000
27)
28
29// loadRepo 读取 URL 中的仓库并校验磁盘数据。
30func (s *Server) loadRepo(w http.ResponseWriter, r *http.Request) (*store.Repo, string, bool) {
31 name := r.PathValue("repo")
32 repo, err := s.st.RepoByName(name)
33 if err != nil {
34 s.renderError(w, r, http.StatusNotFound, "项目 "+name+" 不存在")
35 return nil, "", false
36 }
37 dir := s.repoPath(repo.Name)
38 if !gitx.IsBareRepo(dir) {
39 s.renderError(w, r, http.StatusInternalServerError, "仓库数据缺失,请联系管理员")
40 return nil, "", false
41 }
42 return repo, dir, true
43}
44
45// resolveRefAndPath 解决分支名带斜杠时的歧义:优先匹配最长的 ref。
46func resolveRefAndPath(dir, ref string, rest []string) (string, string) {
47 if !gitx.ValidRef(ref) {
48 return "", strings.Join(rest, "/")
49 }
50 candidates := []string{ref}
51 for _, seg := range rest {
52 candidates = append(candidates, candidates[len(candidates)-1]+"/"+seg)
53 }
54 for i := len(candidates) - 1; i >= 0; i-- {
55 cand := candidates[i]
56 if !gitx.ValidRef(cand) {
57 continue
58 }
59 for _, full := range []string{"refs/heads/" + cand, "refs/tags/" + cand, cand} {
60 if _, err := gitx.Resolve(dir, full); err == nil {
61 return cand, strings.Join(rest[i:], "/")
62 }
63 }
64 }
65 return ref, strings.Join(rest, "/")
66}
67
68func splitPath(p string) []string {
69 p = strings.Trim(p, "/")
70 if p == "" {
71 return nil
72 }
73 return strings.Split(p, "/")
74}
75
76// refFromPath 取出并校验 URL 中的 ref / sha。
77//
78// 这些值会直接成为 git 命令行的参数:以 "-" 开头会被 git 当成选项,
79// 含空格或 ".." 的值会被拆开或指向别的对象。统一在入口拦掉。
80func (s *Server) refFromPath(w http.ResponseWriter, r *http.Request, key string) (string, bool) {
81 v := r.PathValue(key)
82 if !gitx.ValidRef(v) {
83 s.renderError(w, r, http.StatusBadRequest, "非法的引用名")
84 return "", false
85 }
86 return v, true
87}
88
89// pickRef 选择展示用的 ref:优先指定值,其次默认分支,最后任意分支。
90func (s *Server) pickRef(dir string, repo *store.Repo, want string) string {
91 if want != "" && gitx.ValidRef(want) {
92 if _, err := gitx.Resolve(dir, want); err == nil {
93 return want
94 }
95 }
96 if repo.DefaultBranch != "" {
97 if _, err := gitx.Resolve(dir, repo.DefaultBranch); err == nil {
98 return repo.DefaultBranch
99 }
100 }
101 if b := gitx.CurrentBranch(dir); b != "" {
102 if _, err := gitx.Resolve(dir, b); err == nil {
103 return b
104 }
105 }
106 branches, err := gitx.Branches(dir)
107 if err == nil && len(branches) > 0 {
108 return branches[0].Name
109 }
110 return repo.DefaultBranch
111}
112
113func cloneURL(r *http.Request, name string) string {
114 scheme := "http"
115 if r.TLS != nil {
116 scheme = "https"
117 }
118 if proto := r.Header.Get("X-Forwarded-Proto"); proto == "https" || proto == "http" {
119 scheme = proto
120 }
121 return fmt.Sprintf("%s://%s/%s.git", scheme, r.Host, name)
122}
123
124// repoCommon 填充项目页面通用的数据(侧边栏等)。
125func (s *Server) repoCommon(r *http.Request, repo *store.Repo, dir string, ref string) map[string]any {
126 u := userFrom(r.Context())
127 data := map[string]any{}
128 data["Repo"] = repo
129 data["Ref"] = ref
130 data["CanManage"] = canManageRepo(u, repo)
131 data["CloneURL"] = cloneURL(r, repo.Name)
132 data["CloneHTTP"] = cloneURL(r, repo.Name)
133
134 branches, _ := gitx.Branches(dir)
135 tags, _ := gitx.Tags(dir)
136 data["Branches"] = branches
137 data["Tags"] = tags
138 data["TagCount"] = len(tags)
139 data["BranchCount"] = len(branches)
140
141 pushes, _ := s.st.PushesByRepo(repo.ID, 6)
142 data["RecentPushes"] = pushes
143 data["LatestPush"], _ = s.st.LatestPush(repo.ID)
144
145 data["IsEmpty"] = !gitx.HasCommits(dir)
146
147 // 统计信息在这里统一填充:repo_home 模板的“关于”卡片在空仓库与目录页
148 // 也要渲染这些字段,缺任意一项都会让模板执行中断。
149 data["CommitCount"] = 0
150 data["FileCount"] = 0
151 data["SizeText"] = humanSize(gitx.RepoSize(dir))
152 if data["IsEmpty"] == false {
153 data["CommitCount"] = gitx.CountCommits(dir, ref)
154 data["FileCount"] = gitx.FileCount(dir, ref)
155 }
156 data["Languages"] = gitx.Languages(dir, ref, 6)
157 data["Contributors"] = gitx.Contributors(dir, ref, 10)
158 if commits, err := gitx.Log(dir, ref, 5, 0); err == nil {
159 data["LatestCommits"] = commits
160 }
161
162 if c, ok := gitx.LastCommitForPath(dir, ref, ""); ok {
163 data["LastCommit"] = c
164 }
165 return data
166}
167
168func (s *Server) handleRepoHome(w http.ResponseWriter, r *http.Request) {
169 repo, dir, ok := s.loadRepo(w, r)
170 if !ok {
171 return
172 }
173 p := s.page(r, repo.Name)
174 p.Active = "repo"
175 ref := s.pickRef(dir, repo, r.URL.Query().Get("ref"))
176
177 data := s.repoCommon(r, repo, dir, ref)
178 p.Data = data
179
180 if data["IsEmpty"] == true {
181 s.render(w, r, "repo_home", p)
182 return
183 }
184
185 entries, err := gitx.Tree(dir, ref, "")
186 if err != nil {
187 s.renderError(w, r, http.StatusInternalServerError, "读取文件列表失败:"+err.Error())
188 return
189 }
190 lastCommits := gitx.LastCommits(dir, ref, "", 80)
191 type dirEntry struct {
192 gitx.TreeEntry
193 LastCommit gitx.Commit
194 HasCommit bool
195 }
196 var list []dirEntry
197 for _, e := range entries {
198 de := dirEntry{TreeEntry: e}
199 if c, ok := lastCommitFor(lastCommits, e); ok {
200 de.LastCommit = c
201 de.HasCommit = true
202 }
203 list = append(list, de)
204 }
205 p.Data["Entries"] = list
206 p.Data["Path"] = ""
207 p.Data["Breadcrumbs"] = nil
208
209 if readmeName, content, found := gitx.Readme(dir, ref); found {
210 isMarkdown := strings.HasSuffix(strings.ToLower(readmeName), ".md") || strings.HasSuffix(strings.ToLower(readmeName), ".markdown")
211 p.Data["ReadmeName"] = readmeName
212 p.Data["ReadmeIsMarkdown"] = isMarkdown
213 if isMarkdown {
214 // 必须标记为 template.HTML,否则会被模板再次转义
215 p.Data["ReadmeHTML"] = template.HTML(renderMarkdown(string(content)))
216 } else {
217 p.Data["ReadmeText"] = string(content)
218 }
219 }
220
221 s.render(w, r, "repo_home", p)
222}
223
224// lastCommitFor 查找列表条目对应的最近一次提交。
225// 目录本身在 git 里没有提交记录,回退到该目录下最新的一条提交,
226// 这样文件夹那一行也能显示是谁改的。
227func lastCommitFor(lastCommits map[string]gitx.Commit, e gitx.TreeEntry) (gitx.Commit, bool) {
228 if c, ok := lastCommits[e.Path]; ok {
229 return c, true
230 }
231 if !e.IsDir() {
232 return gitx.Commit{}, false
233 }
234 prefix := strings.TrimSuffix(e.Path, "/") + "/"
235 best := gitx.Commit{}
236 found := false
237 for p, c := range lastCommits {
238 if !strings.HasPrefix(p, prefix) {
239 continue
240 }
241 if !found || c.When.After(best.When) {
242 best, found = c, true
243 }
244 }
245 return best, found
246}
247
248func (s *Server) handleRepoTree(w http.ResponseWriter, r *http.Request) {
249 repo, dir, ok := s.loadRepo(w, r)
250 if !ok {
251 return
252 }
253 want, ok := s.refFromPath(w, r, "ref")
254 if !ok {
255 return
256 }
257 ref, sub := resolveRefAndPath(dir, want, splitPath(r.PathValue("path")))
258 if ref == "" {
259 s.renderError(w, r, http.StatusBadRequest, "非法的引用名")
260 return
261 }
262 if _, err := gitx.Resolve(dir, ref); err != nil {
263 s.renderError(w, r, http.StatusNotFound, "找不到引用 "+ref)
264 return
265 }
266 if sub != "" && !gitx.TreeExists(dir, ref, sub) {
267 s.renderError(w, r, http.StatusNotFound, "路径 "+sub+" 不存在")
268 return
269 }
270 entries, err := gitx.Tree(dir, ref, sub)
271 if err != nil {
272 s.renderError(w, r, http.StatusInternalServerError, "读取目录失败:"+err.Error())
273 return
274 }
275 lastCommits := gitx.LastCommits(dir, ref, sub, 80)
276 type dirEntry struct {
277 gitx.TreeEntry
278 LastCommit gitx.Commit
279 HasCommit bool
280 }
281 var list []dirEntry
282 for _, e := range entries {
283 de := dirEntry{TreeEntry: e}
284 if c, ok := lastCommitFor(lastCommits, e); ok {
285 de.LastCommit = c
286 de.HasCommit = true
287 }
288 list = append(list, de)
289 }
290 p := s.page(r, repo.Name+" · "+sub)
291 p.Active = "repo"
292 p.Data = s.repoCommon(r, repo, dir, ref)
293 p.Data["Entries"] = list
294 p.Data["Path"] = sub
295 p.Data["Breadcrumbs"] = breadcrumbs(sub)
296 if sub != "" {
297 if c, ok := gitx.LastCommitForPath(dir, ref, sub); ok {
298 p.Data["LastCommit"] = c
299 }
300 }
301 s.render(w, r, "repo_home", p)
302}
303
304type crumb struct {
305 Name string
306 Path string
307}
308
309func breadcrumbs(sub string) []crumb {
310 var out []crumb
311 parts := splitPath(sub)
312 for i, part := range parts {
313 out = append(out, crumb{Name: part, Path: strings.Join(parts[:i+1], "/")})
314 }
315 return out
316}
317
318func (s *Server) handleRepoBlob(w http.ResponseWriter, r *http.Request) {
319 repo, dir, ok := s.loadRepo(w, r)
320 if !ok {
321 return
322 }
323 want, ok := s.refFromPath(w, r, "ref")
324 if !ok {
325 return
326 }
327 ref, file := resolveRefAndPath(dir, want, splitPath(r.PathValue("path")))
328 if ref == "" {
329 s.renderError(w, r, http.StatusBadRequest, "非法的引用名")
330 return
331 }
332 if file == "" {
333 http.Redirect(w, r, "/"+repo.Name, http.StatusFound)
334 return
335 }
336 if !gitx.TreeExists(dir, ref, file) {
337 s.renderError(w, r, http.StatusNotFound, "文件 "+file+" 不存在")
338 return
339 }
340
341 // 先拿体积再决定要不要读进内存。以前是无条件 Blob() 读完整个文件,
342 // 才在后面用 size 判断"是否过大"——一个几百 MB 的文件就能把进程撑爆。
343 size := gitx.BlobSize(dir, ref, file)
344 tooLarge := size > maxBlobPreview
345
346 var content []byte
347 if !tooLarge {
348 var err error
349 content, err = gitx.BlobLimited(dir, ref, file, maxBlobPreview)
350 if errors.Is(err, gitx.ErrTooLarge) {
351 tooLarge, size = true, maxBlobPreview+1
352 } else if err != nil {
353 s.renderError(w, r, http.StatusInternalServerError, "读取文件失败:"+err.Error())
354 return
355 }
356 }
357 isBinary := len(content) > 0 && gitx.IsBinary(content)
358 if len(content) == 0 && !tooLarge {
359 // 空文件
360 isBinary = false
361 }
362
363 p := s.page(r, repo.Name+" · "+path.Base(file))
364 p.Active = "repo"
365 p.Data = s.repoCommon(r, repo, dir, ref)
366 p.Data["Path"] = file
367 p.Data["FileName"] = path.Base(file)
368 p.Data["Breadcrumbs"] = breadcrumbs(file)
369 p.Data["BlobSize"] = humanSize(size)
370 p.Data["IsBinary"] = isBinary
371 p.Data["TooLarge"] = tooLarge
372 ext := strings.ToLower(path.Ext(file))
373 p.Data["IsImage"] = inlineSafeExt[ext]
374 if !isBinary && !tooLarge {
375 text := string(content)
376 lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
377 if len(lines) > maxPreviewLines {
378 lines = lines[:maxPreviewLines]
379 p.Data["TruncatedLines"] = true
380 }
381 p.Data["Lines"] = lines
382 }
383 // 行数用流式统计,超大文件不必读进内存也能显示。
384 if n, err := gitx.LineCount(dir, ref, file); err == nil {
385 p.Data["LineCount"] = n
386 } else if len(content) > 0 {
387 p.Data["LineCount"] = strings.Count(string(content), "\n") + 1
388 }
389 lastCommits := gitx.LastCommits(dir, ref, file, 1)
390 if c, ok := lastCommits[file]; ok {
391 p.Data["LastCommit"] = c
392 }
393 s.render(w, r, "repo_blob", p)
394}
395
396// inlineSafeExt 是允许在浏览器里直接内联渲染的扩展名白名单。
397//
398// 仓库里的文件属于不可信内容:HTML / SVG / XML 天生带脚本能力,一旦以内联
399// 方式从 gitcat 自己的源返回,提交者放一个 evil.html 就等于对所有访客执行
400// 任意脚本(存储型 XSS)。所以白名单只留位图,其余一律降级为
401// text/plain 或 attachment。
402var inlineSafeExt = map[string]bool{
403 ".png": true, ".jpg": true, ".jpeg": true, ".gif": true,
404 ".webp": true, ".bmp": true, ".ico": true,
405}
406
407// forceDownloadExt 是浏览器会当作「活动内容」处理的扩展名。
408//
409// 它们多半是文本文件,会落进下面的 text/plain 内联分支。虽然 text/plain
410// 本身不会执行脚本,但既然代价只是少一次「点开看源码」,就没必要赌浏览器
411// 的嗅探行为——一律强制下载。
412var forceDownloadExt = map[string]bool{
413 ".html": true, ".htm": true, ".xhtml": true, ".shtml": true,
414 ".js": true, ".mjs": true, ".cjs": true, ".jsx": true, ".ts": true,
415 ".xml": true, ".xsl": true, ".xslt": true, ".svg": true, ".svgz": true,
416 ".swf": true, ".hta": true, ".vtt": true, ".wasm": true,
417}
418
419// maxInlineSize 是 raw 原样查看的大小上限,超过则提示下载。
420const maxInlineSize = 8 << 20
421
422// rawSecurityHeaders 施加在 raw 响应上。
423//
424// sandbox 会把响应放进一个不透明来源(unique origin):即便将来某天
425// Content-Type 判断出了偏差,脚本也执行不了,更碰不到 gitcat 的 Cookie。
426func rawSecurityHeaders(w http.ResponseWriter) {
427 w.Header().Set("X-Content-Type-Options", "nosniff")
428 w.Header().Set("Content-Security-Policy", "sandbox")
429 w.Header().Set("Cache-Control", "no-cache")
430}
431
432func (s *Server) handleRepoRaw(w http.ResponseWriter, r *http.Request) {
433 _, dir, ok := s.loadRepo(w, r)
434 if !ok {
435 return
436 }
437 want, ok := s.refFromPath(w, r, "ref")
438 if !ok {
439 return
440 }
441 ref, file := resolveRefAndPath(dir, want, splitPath(r.PathValue("path")))
442 if ref == "" || file == "" {
443 http.NotFound(w, r)
444 return
445 }
446 if !gitx.TreeExists(dir, ref, file) {
447 http.NotFound(w, r)
448 return
449 }
450
451 ext := strings.ToLower(path.Ext(file))
452 size := gitx.BlobSize(dir, ref, file)
453 ctype := ""
454 disposition := "attachment"
455
456 switch {
457 case inlineSafeExt[ext]:
458 ctype = mime.TypeByExtension(ext)
459 disposition = "inline"
460 case forceDownloadExt[ext]:
461 ctype = "application/octet-stream"
462 case size <= maxInlineSize && !looksBinaryName(ext):
463 // 其余文本统一按 text/plain 内联:浏览器不会执行其中的任何标记,
464 // 同时保留「点开直接看源码」的体验。
465 ctype = "text/plain; charset=utf-8"
466 disposition = "inline"
467 default:
468 ctype = "application/octet-stream"
469 }
470 if ctype == "" {
471 ctype = "application/octet-stream"
472 }
473
474 rawSecurityHeaders(w)
475 w.Header().Set("Content-Type", ctype)
476 w.Header().Set("Content-Disposition", disposition+"; filename*=UTF-8''"+url.PathEscape(path.Base(file)))
477 if size > 0 {
478 w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
479 }
480 // 流式输出:不再把整个文件读进内存,GB 级文件也能正常下载。
481 if err := gitx.BlobReader(dir, ref, file, w); err != nil {
482 // 响应头已发出,无法再改状态码;只记录并中断传输。
483 fmt.Fprintf(os.Stderr, "输出原始文件 %s:%s 失败: %v\n", ref, file, err)
484 }
485}
486
487// looksBinaryName 按扩展名粗判二进制,用于在流式响应里决定内联还是下载。
488func looksBinaryName(ext string) bool {
489 switch ext {
490 case ".zip", ".gz", ".tar", ".bz2", ".xz", ".7z", ".rar", ".jar", ".war",
491 ".exe", ".dll", ".so", ".dylib", ".bin", ".dat", ".db", ".sqlite",
492 ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
493 ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".svg",
494 ".mp3", ".mp4", ".avi", ".mov", ".mkv", ".webm", ".ogg", ".wav",
495 ".ttf", ".otf", ".woff", ".woff2", ".eot":
496 return true
497 }
498 return false
499}
500
501func (s *Server) handleRepoCommits(w http.ResponseWriter, r *http.Request) {
502 repo, dir, ok := s.loadRepo(w, r)
503 if !ok {
504 return
505 }
506 want, ok := s.refFromPath(w, r, "ref")
507 if !ok {
508 return
509 }
510 ref := s.pickRef(dir, repo, want)
511 // 空仓库还没有任何提交,HEAD 指向的分支尚不存在。仓库主页照样会给出
512 // “提交历史”入口,所以这里渲染空状态,而不是把它变成 404。
513 empty := !gitx.HasCommits(dir)
514 if !empty {
515 if _, err := gitx.Resolve(dir, ref); err != nil {
516 s.renderError(w, r, http.StatusNotFound, "找不到引用 "+ref)
517 return
518 }
519 }
520 pageNo, _ := strconv.Atoi(r.URL.Query().Get("page"))
521 if pageNo < 1 {
522 pageNo = 1
523 }
524 var commits []gitx.Commit
525 total := 0
526 if !empty {
527 var err error
528 commits, err = gitx.Log(dir, ref, commitsPerPage, (pageNo-1)*commitsPerPage)
529 if err != nil {
530 s.renderError(w, r, http.StatusInternalServerError, "读取提交历史失败:"+err.Error())
531 return
532 }
533 total = gitx.CountCommits(dir, ref)
534 }
535 p := s.page(r, repo.Name+" · 提交历史")
536 p.Active = "repo"
537 p.Data = s.repoCommon(r, repo, dir, ref)
538 p.Data["Commits"] = commits
539 p.Data["Page"] = pageNo
540 p.Data["Total"] = total
541 p.Data["HasPrev"] = pageNo > 1
542 p.Data["HasNext"] = pageNo*commitsPerPage < total
543 p.Data["PrevPage"] = pageNo - 1
544 p.Data["NextPage"] = pageNo + 1
545 s.render(w, r, "repo_commits", p)
546}
547
548func (s *Server) handleRepoCommit(w http.ResponseWriter, r *http.Request) {
549 repo, dir, ok := s.loadRepo(w, r)
550 if !ok {
551 return
552 }
553 sha, ok := s.refFromPath(w, r, "sha")
554 if !ok {
555 return
556 }
557 detail, err := gitx.Show(dir, sha)
558 if err != nil {
559 s.renderError(w, r, http.StatusNotFound, "找不到提交 "+sha)
560 return
561 }
562 p := s.page(r, repo.Name+" · "+detail.Short())
563 p.Active = "repo"
564 p.Data = s.repoCommon(r, repo, dir, s.pickRef(dir, repo, ""))
565 p.Data["Commit"] = detail
566 if len(detail.Parents) > 0 {
567 p.Data["Parent"] = detail.Parents[0]
568 }
569 additions, deletions := 0, 0
570 for _, f := range detail.Files {
571 additions += f.Additions
572 deletions += f.Deletions
573 }
574 p.Data["Additions"] = additions
575 p.Data["Deletions"] = deletions
576 p.Data["FileChanged"] = len(detail.Files)
577 s.render(w, r, "repo_commit", p)
578}
579
580func (s *Server) handleRepoBranches(w http.ResponseWriter, r *http.Request) {
581 repo, dir, ok := s.loadRepo(w, r)
582 if !ok {
583 return
584 }
585 ref := s.pickRef(dir, repo, "")
586 p := s.page(r, repo.Name+" · 分支与标签")
587 p.Active = "repo"
588 p.Data = s.repoCommon(r, repo, dir, ref)
589 s.render(w, r, "repo_branches", p)
590}
591
592func (s *Server) handleRepoActivity(w http.ResponseWriter, r *http.Request) {
593 repo, dir, ok := s.loadRepo(w, r)
594 if !ok {
595 return
596 }
597 ref := s.pickRef(dir, repo, "")
598 pushes, _ := s.st.PushesByRepo(repo.ID, 60)
599 p := s.page(r, repo.Name+" · 动态")
600 p.Active = "repo"
601 p.Data = s.repoCommon(r, repo, dir, ref)
602 p.Data["Pushes"] = pushes
603 p.Data["PushActors"] = s.pushActors(pushes)
604 s.render(w, r, "repo_activity", p)
605}
606
607func (s *Server) handleRepoSettingsPage(w http.ResponseWriter, r *http.Request) {
608 repo, dir, ok := s.loadRepo(w, r)
609 if !ok {
610 return
611 }
612 u := userFrom(r.Context())
613 if !canManageRepo(u, repo) {
614 s.renderError(w, r, http.StatusForbidden, "只有项目创建者或管理员可以修改设置")
615 return
616 }
617 p := s.page(r, repo.Name+" · 设置")
618 p.Active = "repo"
619 p.Data = s.repoCommon(r, repo, dir, s.pickRef(dir, repo, ""))
620 s.render(w, r, "repo_settings", p)
621}
622
623func (s *Server) handleRepoSettingsSave(w http.ResponseWriter, r *http.Request) {
624 repo, dir, ok := s.loadRepo(w, r)
625 if !ok {
626 return
627 }
628 u := userFrom(r.Context())
629 if !canManageRepo(u, repo) {
630 s.renderError(w, r, http.StatusForbidden, "只有项目创建者或管理员可以修改设置")
631 return
632 }
633 if !s.checkCSRF(r) {
634 s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
635 return
636 }
637 desc := strings.TrimSpace(r.FormValue("description"))
638 if len(desc) > 300 {
639 s.renderError(w, r, http.StatusBadRequest, "项目描述不能超过 300 个字符")
640 return
641 }
642 if raw := strings.TrimSpace(r.FormValue("new_name")); raw != "" {
643 newName, err := validateNewRepoName(raw)
644 if err != nil {
645 s.renderError(w, r, http.StatusBadRequest, err.Error())
646 return
647 }
648 if newName != repo.Name {
649 if err := s.renameRepo(repo, newName); err != nil {
650 s.renderError(w, r, http.StatusBadRequest, "重命名失败:"+err.Error())
651 return
652 }
653 // 目录已经搬走,后续对默认分支的校验必须用新路径,
654 // 否则会拿一个已不存在的目录去 Resolve,把成功的改名报成 400。
655 dir = s.repoPath(repo.Name)
656 setFlash(w, "项目已重命名为 "+repo.Name)
657 }
658 }
659 defaultBranch := strings.TrimSpace(r.FormValue("default_branch"))
660 if defaultBranch != "" {
661 if !gitx.ValidRef(defaultBranch) {
662 s.renderError(w, r, http.StatusBadRequest, "非法的默认分支名")
663 return
664 }
665 if _, err := gitx.Resolve(dir, defaultBranch); err != nil {
666 s.renderError(w, r, http.StatusBadRequest, "默认分支不存在")
667 return
668 }
669 repo.DefaultBranch = defaultBranch
670 _ = gitx.SetDefaultBranch(dir, defaultBranch)
671 }
672 repo.Description = desc
673 repo.AIEnabled = r.FormValue("ai_enabled") == "on"
674 repo.IsArchived = r.FormValue("is_archived") == "on"
675 if err := s.st.UpdateRepo(repo); err != nil {
676 s.renderError(w, r, http.StatusInternalServerError, "保存失败:"+err.Error())
677 return
678 }
679 setFlash(w, "项目设置已保存")
680 http.Redirect(w, r, "/"+repo.Name+"/settings", http.StatusFound)
681}
682
683func (s *Server) handleRepoDelete(w http.ResponseWriter, r *http.Request) {
684 repo, _, ok := s.loadRepo(w, r)
685 if !ok {
686 return
687 }
688 u := userFrom(r.Context())
689 if !canManageRepo(u, repo) {
690 s.renderError(w, r, http.StatusForbidden, "没有删除权限")
691 return
692 }
693 if !s.checkCSRF(r) {
694 s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
695 return
696 }
697 if strings.TrimSpace(r.FormValue("confirm")) != repo.Name {
698 setFlash(w, "删除失败:请输入完整的项目名以确认")
699 http.Redirect(w, r, "/"+repo.Name+"/settings", http.StatusFound)
700 return
701 }
702 if err := s.st.DeleteRepo(repo.ID); err != nil {
703 s.renderError(w, r, http.StatusInternalServerError, "删除失败:"+err.Error())
704 return
705 }
706 _ = removeRepoDir(s.repoPath(repo.Name))
707 setFlash(w, fmt.Sprintf("项目 %s 已删除", repo.Name))
708 http.Redirect(w, r, "/", http.StatusFound)
709}