package server
import (
"errors"
"fmt"
"html/template"
"mime"
"net/http"
"net/url"
"os"
"path"
"strconv"
"strings"
"gitcat/internal/gitx"
"gitcat/internal/store"
)
const commitsPerPage = 30
// 页面预览的限制。
const (
// maxBlobPreview 是允许读进内存做页面预览的文件体积上限。
maxBlobPreview = 2 << 20
// maxPreviewLines 是预览时最多渲染的行数。
maxPreviewLines = 5000
)
// loadRepo 读取 URL 中的仓库并校验磁盘数据。
func (s *Server) loadRepo(w http.ResponseWriter, r *http.Request) (*store.Repo, string, bool) {
name := r.PathValue("repo")
repo, err := s.st.RepoByName(name)
if err != nil {
s.renderError(w, r, http.StatusNotFound, "项目 "+name+" 不存在")
return nil, "", false
}
dir := s.repoPath(repo.Name)
if !gitx.IsBareRepo(dir) {
s.renderError(w, r, http.StatusInternalServerError, "仓库数据缺失,请联系管理员")
return nil, "", false
}
return repo, dir, true
}
// resolveRefAndPath 解决分支名带斜杠时的歧义:优先匹配最长的 ref。
func resolveRefAndPath(dir, ref string, rest []string) (string, string) {
if !gitx.ValidRef(ref) {
return "", strings.Join(rest, "/")
}
candidates := []string{ref}
for _, seg := range rest {
candidates = append(candidates, candidates[len(candidates)-1]+"/"+seg)
}
for i := len(candidates) - 1; i >= 0; i-- {
cand := candidates[i]
if !gitx.ValidRef(cand) {
continue
}
for _, full := range []string{"refs/heads/" + cand, "refs/tags/" + cand, cand} {
if _, err := gitx.Resolve(dir, full); err == nil {
return cand, strings.Join(rest[i:], "/")
}
}
}
return ref, strings.Join(rest, "/")
}
func splitPath(p string) []string {
p = strings.Trim(p, "/")
if p == "" {
return nil
}
return strings.Split(p, "/")
}
// refFromPath 取出并校验 URL 中的 ref / sha。
//
// 这些值会直接成为 git 命令行的参数:以 "-" 开头会被 git 当成选项,
// 含空格或 ".." 的值会被拆开或指向别的对象。统一在入口拦掉。
func (s *Server) refFromPath(w http.ResponseWriter, r *http.Request, key string) (string, bool) {
v := r.PathValue(key)
if !gitx.ValidRef(v) {
s.renderError(w, r, http.StatusBadRequest, "非法的引用名")
return "", false
}
return v, true
}
// pickRef 选择展示用的 ref:优先指定值,其次默认分支,最后任意分支。
func (s *Server) pickRef(dir string, repo *store.Repo, want string) string {
if want != "" && gitx.ValidRef(want) {
if _, err := gitx.Resolve(dir, want); err == nil {
return want
}
}
if repo.DefaultBranch != "" {
if _, err := gitx.Resolve(dir, repo.DefaultBranch); err == nil {
return repo.DefaultBranch
}
}
if b := gitx.CurrentBranch(dir); b != "" {
if _, err := gitx.Resolve(dir, b); err == nil {
return b
}
}
branches, err := gitx.Branches(dir)
if err == nil && len(branches) > 0 {
return branches[0].Name
}
return repo.DefaultBranch
}
func cloneURL(r *http.Request, name string) string {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
if proto := r.Header.Get("X-Forwarded-Proto"); proto == "https" || proto == "http" {
scheme = proto
}
return fmt.Sprintf("%s://%s/%s.git", scheme, r.Host, name)
}
// repoCommon 填充项目页面通用的数据(侧边栏等)。
func (s *Server) repoCommon(r *http.Request, repo *store.Repo, dir string, ref string) map[string]any {
u := userFrom(r.Context())
data := map[string]any{}
data["Repo"] = repo
data["Ref"] = ref
data["CanManage"] = canManageRepo(u, repo)
data["CloneURL"] = cloneURL(r, repo.Name)
data["CloneHTTP"] = cloneURL(r, repo.Name)
branches, _ := gitx.Branches(dir)
tags, _ := gitx.Tags(dir)
data["Branches"] = branches
data["Tags"] = tags
data["TagCount"] = len(tags)
data["BranchCount"] = len(branches)
pushes, _ := s.st.PushesByRepo(repo.ID, 6)
data["RecentPushes"] = pushes
data["LatestPush"], _ = s.st.LatestPush(repo.ID)
data["IsEmpty"] = !gitx.HasCommits(dir)
// 统计信息在这里统一填充:repo_home 模板的“关于”卡片在空仓库与目录页
// 也要渲染这些字段,缺任意一项都会让模板执行中断。
data["CommitCount"] = 0
data["FileCount"] = 0
data["SizeText"] = humanSize(gitx.RepoSize(dir))
if data["IsEmpty"] == false {
data["CommitCount"] = gitx.CountCommits(dir, ref)
data["FileCount"] = gitx.FileCount(dir, ref)
}
data["Languages"] = gitx.Languages(dir, ref, 6)
data["Contributors"] = gitx.Contributors(dir, ref, 10)
if commits, err := gitx.Log(dir, ref, 5, 0); err == nil {
data["LatestCommits"] = commits
}
if c, ok := gitx.LastCommitForPath(dir, ref, ""); ok {
data["LastCommit"] = c
}
return data
}
func (s *Server) handleRepoHome(w http.ResponseWriter, r *http.Request) {
repo, dir, ok := s.loadRepo(w, r)
if !ok {
return
}
p := s.page(r, repo.Name)
p.Active = "repo"
ref := s.pickRef(dir, repo, r.URL.Query().Get("ref"))
data := s.repoCommon(r, repo, dir, ref)
p.Data = data
if data["IsEmpty"] == true {
s.render(w, r, "repo_home", p)
return
}
entries, err := gitx.Tree(dir, ref, "")
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, "读取文件列表失败:"+err.Error())
return
}
lastCommits := gitx.LastCommits(dir, ref, "", 80)
type dirEntry struct {
gitx.TreeEntry
LastCommit gitx.Commit
HasCommit bool
}
var list []dirEntry
for _, e := range entries {
de := dirEntry{TreeEntry: e}
if c, ok := lastCommitFor(lastCommits, e); ok {
de.LastCommit = c
de.HasCommit = true
}
list = append(list, de)
}
p.Data["Entries"] = list
p.Data["Path"] = ""
p.Data["Breadcrumbs"] = nil
if readmeName, content, found := gitx.Readme(dir, ref); found {
isMarkdown := strings.HasSuffix(strings.ToLower(readmeName), ".md") || strings.HasSuffix(strings.ToLower(readmeName), ".markdown")
p.Data["ReadmeName"] = readmeName
p.Data["ReadmeIsMarkdown"] = isMarkdown
if isMarkdown {
// 必须标记为 template.HTML,否则会被模板再次转义
p.Data["ReadmeHTML"] = template.HTML(renderMarkdown(string(content)))
} else {
p.Data["ReadmeText"] = string(content)
}
}
s.render(w, r, "repo_home", p)
}
// lastCommitFor 查找列表条目对应的最近一次提交。
// 目录本身在 git 里没有提交记录,回退到该目录下最新的一条提交,
// 这样文件夹那一行也能显示是谁改的。
func lastCommitFor(lastCommits map[string]gitx.Commit, e gitx.TreeEntry) (gitx.Commit, bool) {
if c, ok := lastCommits[e.Path]; ok {
return c, true
}
if !e.IsDir() {
return gitx.Commit{}, false
}
prefix := strings.TrimSuffix(e.Path, "/") + "/"
best := gitx.Commit{}
found := false
for p, c := range lastCommits {
if !strings.HasPrefix(p, prefix) {
continue
}
if !found || c.When.After(best.When) {
best, found = c, true
}
}
return best, found
}
func (s *Server) handleRepoTree(w http.ResponseWriter, r *http.Request) {
repo, dir, ok := s.loadRepo(w, r)
if !ok {
return
}
want, ok := s.refFromPath(w, r, "ref")
if !ok {
return
}
ref, sub := resolveRefAndPath(dir, want, splitPath(r.PathValue("path")))
if ref == "" {
s.renderError(w, r, http.StatusBadRequest, "非法的引用名")
return
}
if _, err := gitx.Resolve(dir, ref); err != nil {
s.renderError(w, r, http.StatusNotFound, "找不到引用 "+ref)
return
}
if sub != "" && !gitx.TreeExists(dir, ref, sub) {
s.renderError(w, r, http.StatusNotFound, "路径 "+sub+" 不存在")
return
}
entries, err := gitx.Tree(dir, ref, sub)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, "读取目录失败:"+err.Error())
return
}
lastCommits := gitx.LastCommits(dir, ref, sub, 80)
type dirEntry struct {
gitx.TreeEntry
LastCommit gitx.Commit
HasCommit bool
}
var list []dirEntry
for _, e := range entries {
de := dirEntry{TreeEntry: e}
if c, ok := lastCommitFor(lastCommits, e); ok {
de.LastCommit = c
de.HasCommit = true
}
list = append(list, de)
}
p := s.page(r, repo.Name+" · "+sub)
p.Active = "repo"
p.Data = s.repoCommon(r, repo, dir, ref)
p.Data["Entries"] = list
p.Data["Path"] = sub
p.Data["Breadcrumbs"] = breadcrumbs(sub)
if sub != "" {
if c, ok := gitx.LastCommitForPath(dir, ref, sub); ok {
p.Data["LastCommit"] = c
}
}
s.render(w, r, "repo_home", p)
}
type crumb struct {
Name string
Path string
}
func breadcrumbs(sub string) []crumb {
var out []crumb
parts := splitPath(sub)
for i, part := range parts {
out = append(out, crumb{Name: part, Path: strings.Join(parts[:i+1], "/")})
}
return out
}
func (s *Server) handleRepoBlob(w http.ResponseWriter, r *http.Request) {
repo, dir, ok := s.loadRepo(w, r)
if !ok {
return
}
want, ok := s.refFromPath(w, r, "ref")
if !ok {
return
}
ref, file := resolveRefAndPath(dir, want, splitPath(r.PathValue("path")))
if ref == "" {
s.renderError(w, r, http.StatusBadRequest, "非法的引用名")
return
}
if file == "" {
http.Redirect(w, r, "/"+repo.Name, http.StatusFound)
return
}
if !gitx.TreeExists(dir, ref, file) {
s.renderError(w, r, http.StatusNotFound, "文件 "+file+" 不存在")
return
}
// 先拿体积再决定要不要读进内存。以前是无条件 Blob() 读完整个文件,
// 才在后面用 size 判断"是否过大"——一个几百 MB 的文件就能把进程撑爆。
size := gitx.BlobSize(dir, ref, file)
tooLarge := size > maxBlobPreview
var content []byte
if !tooLarge {
var err error
content, err = gitx.BlobLimited(dir, ref, file, maxBlobPreview)
if errors.Is(err, gitx.ErrTooLarge) {
tooLarge, size = true, maxBlobPreview+1
} else if err != nil {
s.renderError(w, r, http.StatusInternalServerError, "读取文件失败:"+err.Error())
return
}
}
isBinary := len(content) > 0 && gitx.IsBinary(content)
if len(content) == 0 && !tooLarge {
// 空文件
isBinary = false
}
p := s.page(r, repo.Name+" · "+path.Base(file))
p.Active = "repo"
p.Data = s.repoCommon(r, repo, dir, ref)
p.Data["Path"] = file
p.Data["FileName"] = path.Base(file)
p.Data["Breadcrumbs"] = breadcrumbs(file)
p.Data["BlobSize"] = humanSize(size)
p.Data["IsBinary"] = isBinary
p.Data["TooLarge"] = tooLarge
ext := strings.ToLower(path.Ext(file))
p.Data["IsImage"] = inlineSafeExt[ext]
if !isBinary && !tooLarge {
text := string(content)
lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
if len(lines) > maxPreviewLines {
lines = lines[:maxPreviewLines]
p.Data["TruncatedLines"] = true
}
p.Data["Lines"] = lines
}
// 行数用流式统计,超大文件不必读进内存也能显示。
if n, err := gitx.LineCount(dir, ref, file); err == nil {
p.Data["LineCount"] = n
} else if len(content) > 0 {
p.Data["LineCount"] = strings.Count(string(content), "\n") + 1
}
lastCommits := gitx.LastCommits(dir, ref, file, 1)
if c, ok := lastCommits[file]; ok {
p.Data["LastCommit"] = c
}
s.render(w, r, "repo_blob", p)
}
// inlineSafeExt 是允许在浏览器里直接内联渲染的扩展名白名单。
//
// 仓库里的文件属于不可信内容:HTML / SVG / XML 天生带脚本能力,一旦以内联
// 方式从 gitcat 自己的源返回,提交者放一个 evil.html 就等于对所有访客执行
// 任意脚本(存储型 XSS)。所以白名单只留位图,其余一律降级为
// text/plain 或 attachment。
var inlineSafeExt = map[string]bool{
".png": true, ".jpg": true, ".jpeg": true, ".gif": true,
".webp": true, ".bmp": true, ".ico": true,
}
// forceDownloadExt 是浏览器会当作「活动内容」处理的扩展名。
//
// 它们多半是文本文件,会落进下面的 text/plain 内联分支。虽然 text/plain
// 本身不会执行脚本,但既然代价只是少一次「点开看源码」,就没必要赌浏览器
// 的嗅探行为——一律强制下载。
var forceDownloadExt = map[string]bool{
".html": true, ".htm": true, ".xhtml": true, ".shtml": true,
".js": true, ".mjs": true, ".cjs": true, ".jsx": true, ".ts": true,
".xml": true, ".xsl": true, ".xslt": true, ".svg": true, ".svgz": true,
".swf": true, ".hta": true, ".vtt": true, ".wasm": true,
}
// maxInlineSize 是 raw 原样查看的大小上限,超过则提示下载。
const maxInlineSize = 8 << 20
// rawSecurityHeaders 施加在 raw 响应上。
//
// sandbox 会把响应放进一个不透明来源(unique origin):即便将来某天
// Content-Type 判断出了偏差,脚本也执行不了,更碰不到 gitcat 的 Cookie。
func rawSecurityHeaders(w http.ResponseWriter) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Security-Policy", "sandbox")
w.Header().Set("Cache-Control", "no-cache")
}
func (s *Server) handleRepoRaw(w http.ResponseWriter, r *http.Request) {
_, dir, ok := s.loadRepo(w, r)
if !ok {
return
}
want, ok := s.refFromPath(w, r, "ref")
if !ok {
return
}
ref, file := resolveRefAndPath(dir, want, splitPath(r.PathValue("path")))
if ref == "" || file == "" {
http.NotFound(w, r)
return
}
if !gitx.TreeExists(dir, ref, file) {
http.NotFound(w, r)
return
}
ext := strings.ToLower(path.Ext(file))
size := gitx.BlobSize(dir, ref, file)
ctype := ""
disposition := "attachment"
switch {
case inlineSafeExt[ext]:
ctype = mime.TypeByExtension(ext)
disposition = "inline"
case forceDownloadExt[ext]:
ctype = "application/octet-stream"
case size <= maxInlineSize && !looksBinaryName(ext):
// 其余文本统一按 text/plain 内联:浏览器不会执行其中的任何标记,
// 同时保留「点开直接看源码」的体验。
ctype = "text/plain; charset=utf-8"
disposition = "inline"
default:
ctype = "application/octet-stream"
}
if ctype == "" {
ctype = "application/octet-stream"
}
rawSecurityHeaders(w)
w.Header().Set("Content-Type", ctype)
w.Header().Set("Content-Disposition", disposition+"; filename*=UTF-8''"+url.PathEscape(path.Base(file)))
if size > 0 {
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
}
// 流式输出:不再把整个文件读进内存,GB 级文件也能正常下载。
if err := gitx.BlobReader(dir, ref, file, w); err != nil {
// 响应头已发出,无法再改状态码;只记录并中断传输。
fmt.Fprintf(os.Stderr, "输出原始文件 %s:%s 失败: %v\n", ref, file, err)
}
}
// looksBinaryName 按扩展名粗判二进制,用于在流式响应里决定内联还是下载。
func looksBinaryName(ext string) bool {
switch ext {
case ".zip", ".gz", ".tar", ".bz2", ".xz", ".7z", ".rar", ".jar", ".war",
".exe", ".dll", ".so", ".dylib", ".bin", ".dat", ".db", ".sqlite",
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".svg",
".mp3", ".mp4", ".avi", ".mov", ".mkv", ".webm", ".ogg", ".wav",
".ttf", ".otf", ".woff", ".woff2", ".eot":
return true
}
return false
}
func (s *Server) handleRepoCommits(w http.ResponseWriter, r *http.Request) {
repo, dir, ok := s.loadRepo(w, r)
if !ok {
return
}
want, ok := s.refFromPath(w, r, "ref")
if !ok {
return
}
ref := s.pickRef(dir, repo, want)
// 空仓库还没有任何提交,HEAD 指向的分支尚不存在。仓库主页照样会给出
// “提交历史”入口,所以这里渲染空状态,而不是把它变成 404。
empty := !gitx.HasCommits(dir)
if !empty {
if _, err := gitx.Resolve(dir, ref); err != nil {
s.renderError(w, r, http.StatusNotFound, "找不到引用 "+ref)
return
}
}
pageNo, _ := strconv.Atoi(r.URL.Query().Get("page"))
if pageNo < 1 {
pageNo = 1
}
var commits []gitx.Commit
total := 0
if !empty {
var err error
commits, err = gitx.Log(dir, ref, commitsPerPage, (pageNo-1)*commitsPerPage)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, "读取提交历史失败:"+err.Error())
return
}
total = gitx.CountCommits(dir, ref)
}
p := s.page(r, repo.Name+" · 提交历史")
p.Active = "repo"
p.Data = s.repoCommon(r, repo, dir, ref)
p.Data["Commits"] = commits
p.Data["Page"] = pageNo
p.Data["Total"] = total
p.Data["HasPrev"] = pageNo > 1
p.Data["HasNext"] = pageNo*commitsPerPage < total
p.Data["PrevPage"] = pageNo - 1
p.Data["NextPage"] = pageNo + 1
s.render(w, r, "repo_commits", p)
}
func (s *Server) handleRepoCommit(w http.ResponseWriter, r *http.Request) {
repo, dir, ok := s.loadRepo(w, r)
if !ok {
return
}
sha, ok := s.refFromPath(w, r, "sha")
if !ok {
return
}
detail, err := gitx.Show(dir, sha)
if err != nil {
s.renderError(w, r, http.StatusNotFound, "找不到提交 "+sha)
return
}
p := s.page(r, repo.Name+" · "+detail.Short())
p.Active = "repo"
p.Data = s.repoCommon(r, repo, dir, s.pickRef(dir, repo, ""))
p.Data["Commit"] = detail
if len(detail.Parents) > 0 {
p.Data["Parent"] = detail.Parents[0]
}
additions, deletions := 0, 0
for _, f := range detail.Files {
additions += f.Additions
deletions += f.Deletions
}
p.Data["Additions"] = additions
p.Data["Deletions"] = deletions
p.Data["FileChanged"] = len(detail.Files)
s.render(w, r, "repo_commit", p)
}
func (s *Server) handleRepoBranches(w http.ResponseWriter, r *http.Request) {
repo, dir, ok := s.loadRepo(w, r)
if !ok {
return
}
ref := s.pickRef(dir, repo, "")
p := s.page(r, repo.Name+" · 分支与标签")
p.Active = "repo"
p.Data = s.repoCommon(r, repo, dir, ref)
s.render(w, r, "repo_branches", p)
}
func (s *Server) handleRepoActivity(w http.ResponseWriter, r *http.Request) {
repo, dir, ok := s.loadRepo(w, r)
if !ok {
return
}
ref := s.pickRef(dir, repo, "")
pushes, _ := s.st.PushesByRepo(repo.ID, 60)
p := s.page(r, repo.Name+" · 动态")
p.Active = "repo"
p.Data = s.repoCommon(r, repo, dir, ref)
p.Data["Pushes"] = pushes
p.Data["PushActors"] = s.pushActors(pushes)
s.render(w, r, "repo_activity", p)
}
func (s *Server) handleRepoSettingsPage(w http.ResponseWriter, r *http.Request) {
repo, dir, ok := s.loadRepo(w, r)
if !ok {
return
}
u := userFrom(r.Context())
if !canManageRepo(u, repo) {
s.renderError(w, r, http.StatusForbidden, "只有项目创建者或管理员可以修改设置")
return
}
p := s.page(r, repo.Name+" · 设置")
p.Active = "repo"
p.Data = s.repoCommon(r, repo, dir, s.pickRef(dir, repo, ""))
s.render(w, r, "repo_settings", p)
}
func (s *Server) handleRepoSettingsSave(w http.ResponseWriter, r *http.Request) {
repo, dir, ok := s.loadRepo(w, r)
if !ok {
return
}
u := userFrom(r.Context())
if !canManageRepo(u, repo) {
s.renderError(w, r, http.StatusForbidden, "只有项目创建者或管理员可以修改设置")
return
}
if !s.checkCSRF(r) {
s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
return
}
desc := strings.TrimSpace(r.FormValue("description"))
if len(desc) > 300 {
s.renderError(w, r, http.StatusBadRequest, "项目描述不能超过 300 个字符")
return
}
if raw := strings.TrimSpace(r.FormValue("new_name")); raw != "" {
newName, err := validateNewRepoName(raw)
if err != nil {
s.renderError(w, r, http.StatusBadRequest, err.Error())
return
}
if newName != repo.Name {
if err := s.renameRepo(repo, newName); err != nil {
s.renderError(w, r, http.StatusBadRequest, "重命名失败:"+err.Error())
return
}
// 目录已经搬走,后续对默认分支的校验必须用新路径,
// 否则会拿一个已不存在的目录去 Resolve,把成功的改名报成 400。
dir = s.repoPath(repo.Name)
setFlash(w, "项目已重命名为 "+repo.Name)
}
}
defaultBranch := strings.TrimSpace(r.FormValue("default_branch"))
if defaultBranch != "" {
if !gitx.ValidRef(defaultBranch) {
s.renderError(w, r, http.StatusBadRequest, "非法的默认分支名")
return
}
if _, err := gitx.Resolve(dir, defaultBranch); err != nil {
s.renderError(w, r, http.StatusBadRequest, "默认分支不存在")
return
}
repo.DefaultBranch = defaultBranch
_ = gitx.SetDefaultBranch(dir, defaultBranch)
}
repo.Description = desc
repo.AIEnabled = r.FormValue("ai_enabled") == "on"
repo.IsArchived = r.FormValue("is_archived") == "on"
if err := s.st.UpdateRepo(repo); err != nil {
s.renderError(w, r, http.StatusInternalServerError, "保存失败:"+err.Error())
return
}
setFlash(w, "项目设置已保存")
http.Redirect(w, r, "/"+repo.Name+"/settings", http.StatusFound)
}
func (s *Server) handleRepoDelete(w http.ResponseWriter, r *http.Request) {
repo, _, ok := s.loadRepo(w, r)
if !ok {
return
}
u := userFrom(r.Context())
if !canManageRepo(u, repo) {
s.renderError(w, r, http.StatusForbidden, "没有删除权限")
return
}
if !s.checkCSRF(r) {
s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
return
}
if strings.TrimSpace(r.FormValue("confirm")) != repo.Name {
setFlash(w, "删除失败:请输入完整的项目名以确认")
http.Redirect(w, r, "/"+repo.Name+"/settings", http.StatusFound)
return
}
if err := s.st.DeleteRepo(repo.ID); err != nil {
s.renderError(w, r, http.StatusInternalServerError, "删除失败:"+err.Error())
return
}
_ = removeRepoDir(s.repoPath(repo.Name))
setFlash(w, fmt.Sprintf("项目 %s 已删除", repo.Name))
http.Redirect(w, r, "/", http.StatusFound)
}