gitcat
1package server
2
3import (
4 "fmt"
5 "net/http"
6 "os"
7 "path/filepath"
8 "strings"
9
10 "gitcat/internal/gitx"
11 "gitcat/internal/store"
12)
13
14// safeRepoPath 校验并返回仓库在磁盘上的路径。
15//
16// name 是仓库名(不含 .git 后缀)。名称来自数据库而非 URL,但仍要求它
17// base 化、不含分隔符与 "..",并额外确认拼出来的路径没有跳出 repos 目录——
18// 这是 RemoveAll / Rename 前的最后一道防线。
19func (s *Server) safeRepoPath(name string) (string, error) {
20 if name == "" || name != filepath.Base(name) ||
21 strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
22 return "", fmt.Errorf("非法的项目名: %q", name)
23 }
24 full := filepath.Clean(filepath.Join(s.repoRoot, name+".git"))
25 if filepath.Dir(full) != filepath.Clean(s.repoRoot) {
26 return "", fmt.Errorf("非法的项目名: %q", name)
27 }
28 return full, nil
29}
30
31// removeRepoDir 删除仓库目录。
32func removeRepoDir(path string) error {
33 if !strings.HasSuffix(path, ".git") {
34 return os.ErrInvalid
35 }
36 dir := filepath.Base(filepath.Dir(path))
37 if dir == "." || dir == string(filepath.Separator) {
38 return os.ErrInvalid
39 }
40 return os.RemoveAll(path)
41}
42
43// renameRepoDir 把裸仓库目录从 oldName 改到 newName。
44//
45// 大小写不敏感的文件系统(Windows、默认配置的 macOS)上,
46// foo.git → Foo.git 会被内核判定为"目标已存在"而报错,但目录其实
47// 就是同一个。这种情况只要 old/new 指向同一份数据就无需搬动。
48func (s *Server) renameRepoDir(oldName, newName string) error {
49 oldPath, err := s.safeRepoPath(oldName)
50 if err != nil {
51 return err
52 }
53 newPath, err := s.safeRepoPath(newName)
54 if err != nil {
55 return err
56 }
57 if oldPath == newPath {
58 return nil
59 }
60 if _, err := os.Stat(newPath); err == nil {
61 if same, sameErr := sameFile(oldPath, newPath); sameErr == nil && same {
62 return nil
63 }
64 return fmt.Errorf("磁盘上已存在目录 %s", filepath.Base(newPath))
65 }
66 if err := os.Rename(oldPath, newPath); err != nil {
67 return err
68 }
69 return nil
70}
71
72func sameFile(a, b string) (bool, error) {
73 fa, err := os.Stat(a)
74 if err != nil {
75 return false, err
76 }
77 fb, err := os.Stat(b)
78 if err != nil {
79 return false, err
80 }
81 return os.SameFile(fa, fb), nil
82}
83
84// renameRepo 完成仓库改名:先搬磁盘目录,再改数据库,失败则回滚。
85//
86// 顺序很关键——反过来做的话,中途失败会留下一个"数据库指向不存在的
87// 目录"的仓库,比"目录还没改名"更难恢复。改名与 push 可能并发,
88// 因此全程持有仓库锁。
89func (s *Server) renameRepo(repo *store.Repo, newName string) error {
90 oldName := repo.Name
91 if newName == oldName {
92 return nil
93 }
94
95 mu := s.repoLock(oldName)
96 mu.Lock()
97 defer mu.Unlock()
98
99 // 目标名可能已经被别人占用,重新读一次避免用调用方读到的陈旧数据判断。
100 fresh, err := s.st.RepoByID(repo.ID)
101 if err != nil {
102 return err
103 }
104 if fresh.Name != oldName {
105 return fmt.Errorf("项目名称已在别处变更,请刷新后重试")
106 }
107 if _, err := s.st.RepoByName(newName); err == nil {
108 return fmt.Errorf("项目 %s 已存在", newName)
109 }
110
111 if err := s.renameRepoDir(oldName, newName); err != nil {
112 return err
113 }
114 if err := s.st.RenameRepo(repo.ID, newName); err != nil {
115 // 回滚磁盘,尽量把系统恢复到改动前的状态。
116 if rbErr := s.renameRepoDir(newName, oldName); rbErr != nil {
117 return fmt.Errorf("%w(且磁盘目录回滚失败:%v,请手工检查 data/repos/%s.git)", err, rbErr, oldName)
118 }
119 return err
120 }
121 repo.Name = newName
122 return nil
123}
124
125// validateNewRepoName 校验改名目标,并返回去掉 .git 后缀的最终名称。
126func validateNewRepoName(raw string) (string, error) {
127 name := strings.TrimSpace(raw)
128 name = strings.TrimSuffix(name, ".git")
129 switch {
130 case name == "":
131 return "", fmt.Errorf("请填写新的项目名称")
132 case !gitx.ValidRepoName(name):
133 return "", fmt.Errorf("项目名称只能包含字母、数字、点、下划线、短横线,且需以字母或数字开头")
134 case reservedRepoNames[strings.ToLower(name)]:
135 return "", fmt.Errorf("该名称为系统保留名,请换一个")
136 }
137 return name, nil
138}
139
140// handleAvatar 提供用户头像文件。
141func (s *Server) handleAvatar(w http.ResponseWriter, r *http.Request) {
142 name := r.PathValue("name")
143 if name == "" || name != filepath.Base(name) || strings.Contains(name, "..") {
144 http.NotFound(w, r)
145 return
146 }
147 full := filepath.Join(s.avatarDir, name)
148 if st, err := os.Stat(full); err != nil || st.IsDir() {
149 http.NotFound(w, r)
150 return
151 }
152 w.Header().Set("Cache-Control", "public, max-age=604800")
153 w.Header().Set("X-Content-Type-Options", "nosniff")
154 http.ServeFile(w, r, full)
155}