package server import ( "fmt" "net/http" "os" "path/filepath" "strings" "gitcat/internal/gitx" "gitcat/internal/store" ) // safeRepoPath 校验并返回仓库在磁盘上的路径。 // // name 是仓库名(不含 .git 后缀)。名称来自数据库而非 URL,但仍要求它 // base 化、不含分隔符与 "..",并额外确认拼出来的路径没有跳出 repos 目录—— // 这是 RemoveAll / Rename 前的最后一道防线。 func (s *Server) safeRepoPath(name string) (string, error) { if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") { return "", fmt.Errorf("非法的项目名: %q", name) } full := filepath.Clean(filepath.Join(s.repoRoot, name+".git")) if filepath.Dir(full) != filepath.Clean(s.repoRoot) { return "", fmt.Errorf("非法的项目名: %q", name) } return full, nil } // removeRepoDir 删除仓库目录。 func removeRepoDir(path string) error { if !strings.HasSuffix(path, ".git") { return os.ErrInvalid } dir := filepath.Base(filepath.Dir(path)) if dir == "." || dir == string(filepath.Separator) { return os.ErrInvalid } return os.RemoveAll(path) } // renameRepoDir 把裸仓库目录从 oldName 改到 newName。 // // 大小写不敏感的文件系统(Windows、默认配置的 macOS)上, // foo.git → Foo.git 会被内核判定为"目标已存在"而报错,但目录其实 // 就是同一个。这种情况只要 old/new 指向同一份数据就无需搬动。 func (s *Server) renameRepoDir(oldName, newName string) error { oldPath, err := s.safeRepoPath(oldName) if err != nil { return err } newPath, err := s.safeRepoPath(newName) if err != nil { return err } if oldPath == newPath { return nil } if _, err := os.Stat(newPath); err == nil { if same, sameErr := sameFile(oldPath, newPath); sameErr == nil && same { return nil } return fmt.Errorf("磁盘上已存在目录 %s", filepath.Base(newPath)) } if err := os.Rename(oldPath, newPath); err != nil { return err } return nil } func sameFile(a, b string) (bool, error) { fa, err := os.Stat(a) if err != nil { return false, err } fb, err := os.Stat(b) if err != nil { return false, err } return os.SameFile(fa, fb), nil } // renameRepo 完成仓库改名:先搬磁盘目录,再改数据库,失败则回滚。 // // 顺序很关键——反过来做的话,中途失败会留下一个"数据库指向不存在的 // 目录"的仓库,比"目录还没改名"更难恢复。改名与 push 可能并发, // 因此全程持有仓库锁。 func (s *Server) renameRepo(repo *store.Repo, newName string) error { oldName := repo.Name if newName == oldName { return nil } mu := s.repoLock(oldName) mu.Lock() defer mu.Unlock() // 目标名可能已经被别人占用,重新读一次避免用调用方读到的陈旧数据判断。 fresh, err := s.st.RepoByID(repo.ID) if err != nil { return err } if fresh.Name != oldName { return fmt.Errorf("项目名称已在别处变更,请刷新后重试") } if _, err := s.st.RepoByName(newName); err == nil { return fmt.Errorf("项目 %s 已存在", newName) } if err := s.renameRepoDir(oldName, newName); err != nil { return err } if err := s.st.RenameRepo(repo.ID, newName); err != nil { // 回滚磁盘,尽量把系统恢复到改动前的状态。 if rbErr := s.renameRepoDir(newName, oldName); rbErr != nil { return fmt.Errorf("%w(且磁盘目录回滚失败:%v,请手工检查 data/repos/%s.git)", err, rbErr, oldName) } return err } repo.Name = newName return nil } // validateNewRepoName 校验改名目标,并返回去掉 .git 后缀的最终名称。 func validateNewRepoName(raw string) (string, error) { name := strings.TrimSpace(raw) name = strings.TrimSuffix(name, ".git") switch { case name == "": return "", fmt.Errorf("请填写新的项目名称") case !gitx.ValidRepoName(name): return "", fmt.Errorf("项目名称只能包含字母、数字、点、下划线、短横线,且需以字母或数字开头") case reservedRepoNames[strings.ToLower(name)]: return "", fmt.Errorf("该名称为系统保留名,请换一个") } return name, nil } // handleAvatar 提供用户头像文件。 func (s *Server) handleAvatar(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") if name == "" || name != filepath.Base(name) || strings.Contains(name, "..") { http.NotFound(w, r) return } full := filepath.Join(s.avatarDir, name) if st, err := os.Stat(full); err != nil || st.IsDir() { http.NotFound(w, r) return } w.Header().Set("Cache-Control", "public, max-age=604800") w.Header().Set("X-Content-Type-Options", "nosniff") http.ServeFile(w, r, full) }