// Package server 实现 gitcat 的 HTTP 服务:网页界面、Git Smart HTTP 与后台管理。 package server import ( "bytes" "context" "crypto/rand" "encoding/hex" "errors" "fmt" "html/template" "io/fs" "net" "net/http" "net/url" "os" "path/filepath" "runtime" "sort" "strconv" "strings" "sync" "sync/atomic" "time" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/renderer/html" "gitcat/internal/ai" "gitcat/internal/gitx" "gitcat/internal/store" "gitcat/webroot" ) // Config 是服务运行配置。 type Config struct { Addr string DataDir string Version string // Commit / BuildDate 由构建时注入,仅用于后台展示与升级记录。 Commit string BuildDate string // ExePath 是当前可执行文件路径;一键升级靠它定位替换目标。 // 为空(go run 场景)时升级只提示不替换。 ExePath string // Restart 用于在升级完成后重启进程;为 nil 时只提示手工重启。 Restart func() } // Server 持有全部运行时依赖。 type Server struct { cfg Config st *store.Store mux *http.ServeMux tmpl map[string]*template.Template repoRoot string avatarDir string static http.Handler locks sync.Map // repo name -> *sync.Mutex md goldmark.Markdown upgrading atomic.Bool // 防止并发的升级 / 重启请求叠加 } // New 构造服务器。 func New(cfg Config, st *store.Store) (*Server, error) { repoRoot := filepath.Join(cfg.DataDir, "repos") avatarDir := filepath.Join(cfg.DataDir, "avatars") for _, dir := range []string{cfg.DataDir, repoRoot, avatarDir} { if err := os.MkdirAll(dir, 0o755); err != nil { return nil, err } } s := &Server{ cfg: cfg, st: st, mux: http.NewServeMux(), tmpl: map[string]*template.Template{}, repoRoot: repoRoot, avatarDir: avatarDir, md: goldmark.New( goldmark.WithExtensions(extension.GFM, extension.Table, extension.Strikethrough, extension.TaskList), goldmark.WithRendererOptions(html.WithHardWraps()), ), } if err := s.buildTemplates(); err != nil { return nil, err } staticFS, err := fs.Sub(webroot.FS, "static") if err != nil { return nil, err } s.static = http.StripPrefix("/static/", cacheControl(http.FileServer(http.FS(staticFS)))) s.routes() return s, nil } // ---------------------------------------------------------------- 模板 var tmplFuncs = template.FuncMap{ "dict": func(values ...any) map[string]any { m := map[string]any{} for i := 0; i+1 < len(values); i += 2 { key, _ := values[i].(string) m[key] = values[i+1] } return m }, "add": func(a, b int) int { return a + b }, "sub": func(a, b int) int { return a - b }, "short": func(s string) string { if len(s) > 7 { return s[:7] } return s }, "md": func(s string) template.HTML { return template.HTML(renderMarkdown(s)) }, "humanSize": func(n int64) string { return humanSize(n) }, "num": func(n int) string { return humanInt(n) }, "timeago": func(t time.Time) string { return timeAgo(t) }, "datetime": func(t time.Time) string { if t.IsZero() { return "-" } return t.Local().Format("2006-01-02 15:04") }, "date": func(t time.Time) string { if t.IsZero() { return "-" } return t.Local().Format("2006-01-02") }, "unix": func(ts int64) time.Time { return time.Unix(ts, 0) }, "avatar": func(u *store.User) string { if u == nil || u.Avatar == "" { return "" } return "/media/avatars/" + u.Avatar }, "initial": func(s string) string { for _, r := range s { return strings.ToUpper(string(r)) } return "?" }, "percent": func(f float64) string { return fmt.Sprintf("%.1f", f) }, "both": func(a, b string) string { return a + b }, "deref": func(p *int64) int64 { if p == nil { return 0 } return *p }, "ava": func(s string) string { h := 0 for _, r := range s { h = (h*31 + int(r)) % 9973 } return "g" + strconv.Itoa(h%6) }, "oneline": func(s string) string { r := strings.NewReplacer("\r", " ", "*", "", "`", "", "#", "", "[", " ", "]", " ", "(", " ", ")", " ") var parts []string for _, l := range strings.Split(r.Replace(s), "\n") { l = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(l), "- ")) if l != "" { parts = append(parts, l) } } out := strings.Join(parts, " · ") runes := []rune(out) if len(runes) > 96 { out = string(runes[:96]) + "…" } return out }, "hasPrefix": func(s, p string) bool { return strings.HasPrefix(s, p) }, } func (s *Server) buildTemplates() error { pages := []string{ "error", "install", "login", "home", "new_project", "repo_home", "repo_commits", "repo_commit", "repo_blob", "repo_branches", "repo_activity", "repo_settings", "profile", "admin_dashboard", "admin_users", "admin_user", "admin_repos", "admin_repo", "admin_ai", "admin_settings", "admin_activity", "admin_upgrade", } for _, page := range pages { t, err := template.New("layout.html").Funcs(tmplFuncs).ParseFS(webroot.FS, "templates/layout.html", "templates/"+page+".html") if err != nil { return fmt.Errorf("解析模板 %s: %w", page, err) } s.tmpl[page] = t } return nil } func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, p *Page) { t, ok := s.tmpl[page] if !ok { http.Error(w, "模板不存在: "+page, http.StatusInternalServerError) return } p.SiteName = s.siteName() p.Version = s.cfg.Version p.User = userFrom(r.Context()) if sess := sessionFrom(r.Context()); sess != nil { p.CSRF = sess.CSRF } if p.Flash == "" { p.Flash = takeFlash(w, r) } w.Header().Set("Content-Type", "text/html; charset=utf-8") // 先渲染到缓冲区:模板一旦执行到一半出错,直接写响应会留下被截断的 // “半个页面”且状态码仍是 200,问题很难被发现。 var buf bytes.Buffer if err := t.ExecuteTemplate(&buf, "layout.html", p); err != nil { fmt.Fprintf(os.Stderr, "渲染 %s 出错: %v\n", page, err) http.Error(w, "页面渲染失败,请稍后重试", http.StatusInternalServerError) return } _, _ = w.Write(buf.Bytes()) } func (s *Server) siteName() string { if v := s.st.GetSetting("site_name"); v != "" { return v } return "gitcat" } // Page 是所有页面共用的模板数据。 type Page struct { Title string SiteName string Version string User *store.User CSRF string Flash string Error string Active string Data map[string]any } func (s *Server) page(r *http.Request, title string) *Page { return &Page{Title: title, Data: map[string]any{}} } // ---------------------------------------------------------------- 路由 func (s *Server) routes() { m := s.mux m.HandleFunc("GET /{$}", s.handleHome) m.HandleFunc("GET /install", s.handleInstallPage) m.HandleFunc("POST /install", s.handleInstallSubmit) m.HandleFunc("GET /login", s.handleLoginPage) m.HandleFunc("POST /login", s.handleLoginSubmit) m.HandleFunc("POST /logout", s.handleLogout) m.HandleFunc("GET /new", s.handleNewProjectPage) m.HandleFunc("POST /new", s.handleNewProjectSubmit) m.HandleFunc("GET /settings", s.handleProfilePage) m.HandleFunc("POST /settings/profile", s.handleProfileSave) m.HandleFunc("POST /settings/avatar", s.handleAvatarUpload) m.HandleFunc("POST /settings/password", s.handlePasswordChange) // 项目页面 m.HandleFunc("GET /{repo}", s.handleRepoHome) m.HandleFunc("GET /{repo}/tree/{ref}/{path...}", s.handleRepoTree) m.HandleFunc("GET /{repo}/blob/{ref}/{path...}", s.handleRepoBlob) m.HandleFunc("GET /{repo}/raw/{ref}/{path...}", s.handleRepoRaw) m.HandleFunc("GET /{repo}/commits/{ref}", s.handleRepoCommits) m.HandleFunc("GET /{repo}/commit/{sha}", s.handleRepoCommit) m.HandleFunc("GET /{repo}/branches", s.handleRepoBranches) m.HandleFunc("GET /{repo}/activity", s.handleRepoActivity) m.HandleFunc("GET /{repo}/settings", s.handleRepoSettingsPage) m.HandleFunc("POST /{repo}/settings", s.handleRepoSettingsSave) m.HandleFunc("POST /{repo}/delete", s.handleRepoDelete) // 管理后台 m.HandleFunc("GET /admin", s.handleAdminDashboard) m.HandleFunc("GET /admin/users", s.handleAdminUsers) m.HandleFunc("POST /admin/users", s.handleAdminUserCreate) m.HandleFunc("GET /admin/users/{id}", s.handleAdminUserDetail) m.HandleFunc("POST /admin/users/{id}", s.handleAdminUserSave) m.HandleFunc("POST /admin/users/{id}/delete", s.handleAdminUserDelete) m.HandleFunc("GET /admin/repos", s.handleAdminRepos) m.HandleFunc("GET /admin/repos/{id}", s.handleAdminRepoDetail) m.HandleFunc("POST /admin/repos/{id}", s.handleAdminRepoSave) m.HandleFunc("POST /admin/repos/{id}/delete", s.handleAdminRepoDelete) m.HandleFunc("GET /admin/ai", s.handleAdminAIPage) m.HandleFunc("POST /admin/ai", s.handleAdminAISave) m.HandleFunc("POST /admin/ai/test", s.handleAdminAITest) m.HandleFunc("GET /admin/settings", s.handleAdminSettingsPage) m.HandleFunc("POST /admin/settings", s.handleAdminSettingsSave) m.HandleFunc("GET /admin/activity", s.handleAdminActivity) // 版本与一键升级 m.HandleFunc("GET /admin/upgrade", s.handleAdminUpgradePage) m.HandleFunc("POST /admin/upgrade", s.handleAdminUpgradeAction) // 静态资源在 Handler 中单独处理(避免与 /{repo}/tree/... 通配路由冲突) m.HandleFunc("GET /media/avatars/{name}", s.handleAvatar) m.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) { http.ServeFileFS(w, r, webroot.FS, "static/favicon.svg") }) } func cacheControl(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, ".css") || strings.HasSuffix(r.URL.Path, ".js") || strings.HasSuffix(r.URL.Path, ".svg") || strings.HasSuffix(r.URL.Path, ".png") { w.Header().Set("Cache-Control", "public, max-age=300") } h.ServeHTTP(w, r) }) } // Handler 组装完整中间件链。 func (s *Server) Handler() http.Handler { var h http.Handler = s.mux h = s.withSession(h) h = s.withInstallCheck(h) h = s.withSecurityHeaders(h) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // 静态资源:/static/... if strings.HasPrefix(r.URL.Path, "/static/") { s.withSecurityHeaders(s.static).ServeHTTP(w, r) return } // Git Smart HTTP:/{repo}.git/... if name, action, ok := parseGitPath(r.URL.Path); ok { s.withSecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.handleGitAction(w, r, name, action) })).ServeHTTP(w, r) return } h.ServeHTTP(w, r) }) } func parseGitPath(p string) (name, action string, ok bool) { p = strings.TrimPrefix(p, "/") parts := strings.Split(p, "/") if len(parts) < 2 { return "", "", false } if !strings.HasSuffix(parts[0], ".git") { return "", "", false } name = strings.TrimSuffix(parts[0], ".git") if name == "" { return "", "", false } action = strings.Join(parts[1:], "/") switch action { case "info/refs", "git-upload-pack", "git-receive-pack": return name, action, true } return "", "", false } // ---------------------------------------------------------------- 中间件 type ctxKey int const ( ctxUser ctxKey = iota ctxSession ) // SessionCookieName 是会话 Cookie 名。 const SessionCookieName = "gitcat_session" func userFrom(ctx context.Context) *store.User { u, _ := ctx.Value(ctxUser).(*store.User) return u } func sessionFrom(ctx context.Context) *store.Session { sess, _ := ctx.Value(ctxSession).(*store.Session) return sess } func (s *Server) withSecurityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("X-Content-Type-Options", "nosniff") h.Set("Referrer-Policy", "same-origin") h.Set("X-Frame-Options", "SAMEORIGIN") // README 与 AI 摘要会以 template.HTML 直出,goldmark 默认不开启 // html.WithUnsafe,但这层 CSP 是第二道保险:万一将来有人打开了 // unsafe,脚本也会被这里挡住。 h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "+ "script-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'") next.ServeHTTP(w, r) }) } func (s *Server) withSession(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { c, err := r.Cookie(SessionCookieName) if err == nil && c.Value != "" { sess, u, err := s.st.SessionByID(c.Value) switch { case err == nil: ctx := context.WithValue(r.Context(), ctxSession, sess) ctx = context.WithValue(ctx, ctxUser, u) r = r.WithContext(ctx) case errors.Is(err, store.ErrNotFound): // 会话确实失效(过期、被删除或账号停用)才清 Cookie。 http.SetCookie(w, &http.Cookie{Name: SessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true}) default: // 数据库临时故障等情况保留 Cookie,避免把用户无谓地踢下线。 fmt.Fprintf(os.Stderr, "读取会话失败: %v\n", err) } } next.ServeHTTP(w, r) }) } func (s *Server) withInstallCheck(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if s.st.Installed() || strings.HasPrefix(r.URL.Path, "/install") || strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/favicon.ico" { next.ServeHTTP(w, r) return } http.Redirect(w, r, "/install", http.StatusFound) }) } // requireLogin 返回已登录用户;未登录时按需跳转或返回 401。 func (s *Server) requireLogin(w http.ResponseWriter, r *http.Request) *store.User { u := userFrom(r.Context()) if u == nil { if strings.HasPrefix(r.URL.Path, "/admin") { http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound) return nil } http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound) return nil } return u } func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) *store.User { u := s.requireLogin(w, r) if u == nil { return nil } if !u.IsAdmin { s.renderError(w, r, http.StatusForbidden, "需要管理员权限") return nil } return u } func (s *Server) renderError(w http.ResponseWriter, r *http.Request, code int, msg string) { w.WriteHeader(code) p := s.page(r, fmt.Sprintf("%d", code)) p.Error = msg p.Data["Code"] = code p.Data["Message"] = msg s.render(w, r, "error", p) } // checkCSRF 校验表单里的 csrf 令牌。 func (s *Server) checkCSRF(r *http.Request) bool { sess := sessionFrom(r.Context()) if sess == nil { return false } token := r.FormValue("csrf") return token != "" && token == sess.CSRF } // ---------------------------------------------------------------- 小工具 func randomToken(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return strconv.FormatInt(time.Now().UnixNano(), 36) } return hex.EncodeToString(b) } func setFlash(w http.ResponseWriter, msg string) { http.SetCookie(w, &http.Cookie{Name: "gitcat_flash", Value: url.QueryEscape(msg), Path: "/", MaxAge: 30, HttpOnly: true}) } func takeFlash(w http.ResponseWriter, r *http.Request) string { c, err := r.Cookie("gitcat_flash") if err != nil || c.Value == "" { return "" } http.SetCookie(w, &http.Cookie{Name: "gitcat_flash", Value: "", Path: "/", MaxAge: -1, HttpOnly: true}) msg, err := url.QueryUnescape(c.Value) if err != nil { return "" } return msg } func humanSize(n int64) string { units := []string{"B", "KB", "MB", "GB", "TB"} f := float64(n) i := 0 for f >= 1024 && i < len(units)-1 { f /= 1024 i++ } if i == 0 { return fmt.Sprintf("%d %s", n, units[0]) } return fmt.Sprintf("%.1f %s", f, units[i]) } func humanInt(n int) string { s := strconv.Itoa(n) if len(s) <= 3 { return s } var out []byte for i, c := range []byte(s) { if i > 0 && (len(s)-i)%3 == 0 { out = append(out, ',') } out = append(out, c) } return string(out) } func timeAgo(t time.Time) string { if t.IsZero() { return "-" } d := time.Since(t) if d < 0 { d = 0 } switch { case d < time.Minute: return "刚刚" case d < time.Hour: return fmt.Sprintf("%d 分钟前", int(d.Minutes())) case d < 24*time.Hour: return fmt.Sprintf("%d 小时前", int(d.Hours())) case d < 30*24*time.Hour: return fmt.Sprintf("%d 天前", int(d.Hours()/24)) case d < 365*24*time.Hour: return fmt.Sprintf("%d 个月前", int(d.Hours()/24/30)) default: return fmt.Sprintf("%d 年前", int(d.Hours()/24/365)) } } func renderMarkdown(src string) string { var buf strings.Builder md := goldmark.New( goldmark.WithExtensions(extension.GFM, extension.Table, extension.Strikethrough, extension.TaskList), ) if err := md.Convert([]byte(src), &buf); err != nil { return template.HTMLEscapeString(src) } return buf.String() } // remoteIP 取出客户端 IP。 func remoteIP(r *http.Request) string { if v := r.Header.Get("X-Forwarded-For"); v != "" { return strings.TrimSpace(strings.Split(v, ",")[0]) } host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host } // repoPath 返回仓库在磁盘上的裸仓库路径。 func (s *Server) repoPath(name string) string { return filepath.Join(s.repoRoot, name+".git") } // repoLock 返回仓库级别的互斥锁。 func (s *Server) repoLock(name string) *sync.Mutex { v, _ := s.locks.LoadOrStore(strings.ToLower(name), &sync.Mutex{}) return v.(*sync.Mutex) } // repoLocked 在持有仓库锁的情况下执行 f。 func (s *Server) repoLocked(name string, f func() error) error { mu := s.repoLock(name) mu.Lock() defer mu.Unlock() return f() } // AIConfig 读取后台配置的 AI 参数。 func (s *Server) AIConfig() ai.Config { set := s.st.Settings() return ai.Config{ Enabled: set["ai_enabled"] == "1", BaseURL: set["ai_base_url"], APIKey: set["ai_api_key"], Model: set["ai_model"], Language: set["ai_language"], } } // pushActors 为推送动态解析推送者账号,用于展示真实头像。 func (s *Server) pushActors(pushes []*store.Push) map[string]*store.User { out := map[string]*store.User{} users, err := s.st.ListUsers() if err != nil { return out } byName := make(map[string]*store.User, len(users)) for _, u := range users { byName[strings.ToLower(u.Username)] = u } for _, p := range pushes { if u, ok := byName[strings.ToLower(p.ActorUsername)]; ok { out[p.ActorUsername] = u } } return out } // storageUsed 统计仓库目录占用的磁盘空间。 func (s *Server) storageUsed() int64 { entries, err := os.ReadDir(s.repoRoot) if err != nil { return 0 } var total int64 for _, e := range entries { if e.IsDir() { total += gitx.RepoSize(filepath.Join(s.repoRoot, e.Name())) } } return total } // goVersion 返回运行时 Go 版本。 func goVersion() string { return runtime.Version() } // sortedKeys 便于模板中稳定输出。 func sortedKeys[V any](m map[string]V) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) return keys }