gitcat
1// Package server 实现 gitcat 的 HTTP 服务:网页界面、Git Smart HTTP 与后台管理。
2package server
3
4import (
5 "bytes"
6 "context"
7 "crypto/rand"
8 "encoding/hex"
9 "errors"
10 "fmt"
11 "html/template"
12 "io/fs"
13 "net"
14 "net/http"
15 "net/url"
16 "os"
17 "path/filepath"
18 "runtime"
19 "sort"
20 "strconv"
21 "strings"
22 "sync"
23 "sync/atomic"
24 "time"
25
26 "github.com/yuin/goldmark"
27 "github.com/yuin/goldmark/extension"
28 "github.com/yuin/goldmark/renderer/html"
29
30 "gitcat/internal/ai"
31 "gitcat/internal/gitx"
32 "gitcat/internal/store"
33 "gitcat/webroot"
34)
35
36// Config 是服务运行配置。
37type Config struct {
38 Addr string
39 DataDir string
40 Version string
41 // Commit / BuildDate 由构建时注入,仅用于后台展示与升级记录。
42 Commit string
43 BuildDate string
44 // ExePath 是当前可执行文件路径;一键升级靠它定位替换目标。
45 // 为空(go run 场景)时升级只提示不替换。
46 ExePath string
47 // Restart 用于在升级完成后重启进程;为 nil 时只提示手工重启。
48 Restart func()
49}
50
51// Server 持有全部运行时依赖。
52type Server struct {
53 cfg Config
54 st *store.Store
55 mux *http.ServeMux
56 tmpl map[string]*template.Template
57 repoRoot string
58 avatarDir string
59 static http.Handler
60 locks sync.Map // repo name -> *sync.Mutex
61 md goldmark.Markdown
62 upgrading atomic.Bool // 防止并发的升级 / 重启请求叠加
63}
64
65// New 构造服务器。
66func New(cfg Config, st *store.Store) (*Server, error) {
67 repoRoot := filepath.Join(cfg.DataDir, "repos")
68 avatarDir := filepath.Join(cfg.DataDir, "avatars")
69 for _, dir := range []string{cfg.DataDir, repoRoot, avatarDir} {
70 if err := os.MkdirAll(dir, 0o755); err != nil {
71 return nil, err
72 }
73 }
74 s := &Server{
75 cfg: cfg,
76 st: st,
77 mux: http.NewServeMux(),
78 tmpl: map[string]*template.Template{},
79 repoRoot: repoRoot,
80 avatarDir: avatarDir,
81 md: goldmark.New(
82 goldmark.WithExtensions(extension.GFM, extension.Table, extension.Strikethrough, extension.TaskList),
83 goldmark.WithRendererOptions(html.WithHardWraps()),
84 ),
85 }
86 if err := s.buildTemplates(); err != nil {
87 return nil, err
88 }
89 staticFS, err := fs.Sub(webroot.FS, "static")
90 if err != nil {
91 return nil, err
92 }
93 s.static = http.StripPrefix("/static/", cacheControl(http.FileServer(http.FS(staticFS))))
94 s.routes()
95 return s, nil
96}
97
98// ---------------------------------------------------------------- 模板
99
100var tmplFuncs = template.FuncMap{
101 "dict": func(values ...any) map[string]any {
102 m := map[string]any{}
103 for i := 0; i+1 < len(values); i += 2 {
104 key, _ := values[i].(string)
105 m[key] = values[i+1]
106 }
107 return m
108 },
109 "add": func(a, b int) int { return a + b },
110 "sub": func(a, b int) int { return a - b },
111 "short": func(s string) string {
112 if len(s) > 7 {
113 return s[:7]
114 }
115 return s
116 },
117 "md": func(s string) template.HTML { return template.HTML(renderMarkdown(s)) },
118 "humanSize": func(n int64) string { return humanSize(n) },
119 "num": func(n int) string { return humanInt(n) },
120 "timeago": func(t time.Time) string { return timeAgo(t) },
121 "datetime": func(t time.Time) string {
122 if t.IsZero() {
123 return "-"
124 }
125 return t.Local().Format("2006-01-02 15:04")
126 },
127 "date": func(t time.Time) string {
128 if t.IsZero() {
129 return "-"
130 }
131 return t.Local().Format("2006-01-02")
132 },
133 "unix": func(ts int64) time.Time { return time.Unix(ts, 0) },
134 "avatar": func(u *store.User) string {
135 if u == nil || u.Avatar == "" {
136 return ""
137 }
138 return "/media/avatars/" + u.Avatar
139 },
140 "initial": func(s string) string {
141 for _, r := range s {
142 return strings.ToUpper(string(r))
143 }
144 return "?"
145 },
146 "percent": func(f float64) string { return fmt.Sprintf("%.1f", f) },
147 "both": func(a, b string) string { return a + b },
148 "deref": func(p *int64) int64 {
149 if p == nil {
150 return 0
151 }
152 return *p
153 },
154 "ava": func(s string) string {
155 h := 0
156 for _, r := range s {
157 h = (h*31 + int(r)) % 9973
158 }
159 return "g" + strconv.Itoa(h%6)
160 },
161 "oneline": func(s string) string {
162 r := strings.NewReplacer("\r", " ", "*", "", "`", "", "#", "",
163 "[", " ", "]", " ", "(", " ", ")", " ")
164 var parts []string
165 for _, l := range strings.Split(r.Replace(s), "\n") {
166 l = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(l), "- "))
167 if l != "" {
168 parts = append(parts, l)
169 }
170 }
171 out := strings.Join(parts, " · ")
172 runes := []rune(out)
173 if len(runes) > 96 {
174 out = string(runes[:96]) + "…"
175 }
176 return out
177 },
178 "hasPrefix": func(s, p string) bool { return strings.HasPrefix(s, p) },
179}
180
181func (s *Server) buildTemplates() error {
182 pages := []string{
183 "error", "install", "login", "home", "new_project",
184 "repo_home", "repo_commits", "repo_commit", "repo_blob", "repo_branches", "repo_activity", "repo_settings",
185 "profile",
186 "admin_dashboard", "admin_users", "admin_user", "admin_repos", "admin_repo", "admin_ai", "admin_settings", "admin_activity", "admin_upgrade",
187 }
188 for _, page := range pages {
189 t, err := template.New("layout.html").Funcs(tmplFuncs).ParseFS(webroot.FS,
190 "templates/layout.html", "templates/"+page+".html")
191 if err != nil {
192 return fmt.Errorf("解析模板 %s: %w", page, err)
193 }
194 s.tmpl[page] = t
195 }
196 return nil
197}
198
199func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, p *Page) {
200 t, ok := s.tmpl[page]
201 if !ok {
202 http.Error(w, "模板不存在: "+page, http.StatusInternalServerError)
203 return
204 }
205 p.SiteName = s.siteName()
206 p.Version = s.cfg.Version
207 p.User = userFrom(r.Context())
208 if sess := sessionFrom(r.Context()); sess != nil {
209 p.CSRF = sess.CSRF
210 }
211 if p.Flash == "" {
212 p.Flash = takeFlash(w, r)
213 }
214 w.Header().Set("Content-Type", "text/html; charset=utf-8")
215 // 先渲染到缓冲区:模板一旦执行到一半出错,直接写响应会留下被截断的
216 // “半个页面”且状态码仍是 200,问题很难被发现。
217 var buf bytes.Buffer
218 if err := t.ExecuteTemplate(&buf, "layout.html", p); err != nil {
219 fmt.Fprintf(os.Stderr, "渲染 %s 出错: %v\n", page, err)
220 http.Error(w, "页面渲染失败,请稍后重试", http.StatusInternalServerError)
221 return
222 }
223 _, _ = w.Write(buf.Bytes())
224}
225
226func (s *Server) siteName() string {
227 if v := s.st.GetSetting("site_name"); v != "" {
228 return v
229 }
230 return "gitcat"
231}
232
233// Page 是所有页面共用的模板数据。
234type Page struct {
235 Title string
236 SiteName string
237 Version string
238 User *store.User
239 CSRF string
240 Flash string
241 Error string
242 Active string
243 Data map[string]any
244}
245
246func (s *Server) page(r *http.Request, title string) *Page {
247 return &Page{Title: title, Data: map[string]any{}}
248}
249
250// ---------------------------------------------------------------- 路由
251
252func (s *Server) routes() {
253 m := s.mux
254
255 m.HandleFunc("GET /{$}", s.handleHome)
256 m.HandleFunc("GET /install", s.handleInstallPage)
257 m.HandleFunc("POST /install", s.handleInstallSubmit)
258 m.HandleFunc("GET /login", s.handleLoginPage)
259 m.HandleFunc("POST /login", s.handleLoginSubmit)
260 m.HandleFunc("POST /logout", s.handleLogout)
261 m.HandleFunc("GET /new", s.handleNewProjectPage)
262 m.HandleFunc("POST /new", s.handleNewProjectSubmit)
263 m.HandleFunc("GET /settings", s.handleProfilePage)
264 m.HandleFunc("POST /settings/profile", s.handleProfileSave)
265 m.HandleFunc("POST /settings/avatar", s.handleAvatarUpload)
266 m.HandleFunc("POST /settings/password", s.handlePasswordChange)
267
268 // 项目页面
269 m.HandleFunc("GET /{repo}", s.handleRepoHome)
270 m.HandleFunc("GET /{repo}/tree/{ref}/{path...}", s.handleRepoTree)
271 m.HandleFunc("GET /{repo}/blob/{ref}/{path...}", s.handleRepoBlob)
272 m.HandleFunc("GET /{repo}/raw/{ref}/{path...}", s.handleRepoRaw)
273 m.HandleFunc("GET /{repo}/commits/{ref}", s.handleRepoCommits)
274 m.HandleFunc("GET /{repo}/commit/{sha}", s.handleRepoCommit)
275 m.HandleFunc("GET /{repo}/branches", s.handleRepoBranches)
276 m.HandleFunc("GET /{repo}/activity", s.handleRepoActivity)
277 m.HandleFunc("GET /{repo}/settings", s.handleRepoSettingsPage)
278 m.HandleFunc("POST /{repo}/settings", s.handleRepoSettingsSave)
279 m.HandleFunc("POST /{repo}/delete", s.handleRepoDelete)
280
281 // 管理后台
282 m.HandleFunc("GET /admin", s.handleAdminDashboard)
283 m.HandleFunc("GET /admin/users", s.handleAdminUsers)
284 m.HandleFunc("POST /admin/users", s.handleAdminUserCreate)
285 m.HandleFunc("GET /admin/users/{id}", s.handleAdminUserDetail)
286 m.HandleFunc("POST /admin/users/{id}", s.handleAdminUserSave)
287 m.HandleFunc("POST /admin/users/{id}/delete", s.handleAdminUserDelete)
288 m.HandleFunc("GET /admin/repos", s.handleAdminRepos)
289 m.HandleFunc("GET /admin/repos/{id}", s.handleAdminRepoDetail)
290 m.HandleFunc("POST /admin/repos/{id}", s.handleAdminRepoSave)
291 m.HandleFunc("POST /admin/repos/{id}/delete", s.handleAdminRepoDelete)
292 m.HandleFunc("GET /admin/ai", s.handleAdminAIPage)
293 m.HandleFunc("POST /admin/ai", s.handleAdminAISave)
294 m.HandleFunc("POST /admin/ai/test", s.handleAdminAITest)
295 m.HandleFunc("GET /admin/settings", s.handleAdminSettingsPage)
296 m.HandleFunc("POST /admin/settings", s.handleAdminSettingsSave)
297 m.HandleFunc("GET /admin/activity", s.handleAdminActivity)
298
299 // 版本与一键升级
300 m.HandleFunc("GET /admin/upgrade", s.handleAdminUpgradePage)
301 m.HandleFunc("POST /admin/upgrade", s.handleAdminUpgradeAction)
302
303 // 静态资源在 Handler 中单独处理(避免与 /{repo}/tree/... 通配路由冲突)
304 m.HandleFunc("GET /media/avatars/{name}", s.handleAvatar)
305 m.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {
306 http.ServeFileFS(w, r, webroot.FS, "static/favicon.svg")
307 })
308}
309
310func cacheControl(h http.Handler) http.Handler {
311 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
312 if strings.HasSuffix(r.URL.Path, ".css") || strings.HasSuffix(r.URL.Path, ".js") ||
313 strings.HasSuffix(r.URL.Path, ".svg") || strings.HasSuffix(r.URL.Path, ".png") {
314 w.Header().Set("Cache-Control", "public, max-age=300")
315 }
316 h.ServeHTTP(w, r)
317 })
318}
319
320// Handler 组装完整中间件链。
321func (s *Server) Handler() http.Handler {
322 var h http.Handler = s.mux
323 h = s.withSession(h)
324 h = s.withInstallCheck(h)
325 h = s.withSecurityHeaders(h)
326 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
327 // 静态资源:/static/...
328 if strings.HasPrefix(r.URL.Path, "/static/") {
329 s.withSecurityHeaders(s.static).ServeHTTP(w, r)
330 return
331 }
332 // Git Smart HTTP:/{repo}.git/...
333 if name, action, ok := parseGitPath(r.URL.Path); ok {
334 s.withSecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
335 s.handleGitAction(w, r, name, action)
336 })).ServeHTTP(w, r)
337 return
338 }
339 h.ServeHTTP(w, r)
340 })
341}
342
343func parseGitPath(p string) (name, action string, ok bool) {
344 p = strings.TrimPrefix(p, "/")
345 parts := strings.Split(p, "/")
346 if len(parts) < 2 {
347 return "", "", false
348 }
349 if !strings.HasSuffix(parts[0], ".git") {
350 return "", "", false
351 }
352 name = strings.TrimSuffix(parts[0], ".git")
353 if name == "" {
354 return "", "", false
355 }
356 action = strings.Join(parts[1:], "/")
357 switch action {
358 case "info/refs", "git-upload-pack", "git-receive-pack":
359 return name, action, true
360 }
361 return "", "", false
362}
363
364// ---------------------------------------------------------------- 中间件
365
366type ctxKey int
367
368const (
369 ctxUser ctxKey = iota
370 ctxSession
371)
372
373// SessionCookieName 是会话 Cookie 名。
374const SessionCookieName = "gitcat_session"
375
376func userFrom(ctx context.Context) *store.User {
377 u, _ := ctx.Value(ctxUser).(*store.User)
378 return u
379}
380
381func sessionFrom(ctx context.Context) *store.Session {
382 sess, _ := ctx.Value(ctxSession).(*store.Session)
383 return sess
384}
385
386func (s *Server) withSecurityHeaders(next http.Handler) http.Handler {
387 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
388 h := w.Header()
389 h.Set("X-Content-Type-Options", "nosniff")
390 h.Set("Referrer-Policy", "same-origin")
391 h.Set("X-Frame-Options", "SAMEORIGIN")
392 // README 与 AI 摘要会以 template.HTML 直出,goldmark 默认不开启
393 // html.WithUnsafe,但这层 CSP 是第二道保险:万一将来有人打开了
394 // unsafe,脚本也会被这里挡住。
395 h.Set("Content-Security-Policy",
396 "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "+
397 "script-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'")
398 next.ServeHTTP(w, r)
399 })
400}
401
402func (s *Server) withSession(next http.Handler) http.Handler {
403 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
404 c, err := r.Cookie(SessionCookieName)
405 if err == nil && c.Value != "" {
406 sess, u, err := s.st.SessionByID(c.Value)
407 switch {
408 case err == nil:
409 ctx := context.WithValue(r.Context(), ctxSession, sess)
410 ctx = context.WithValue(ctx, ctxUser, u)
411 r = r.WithContext(ctx)
412 case errors.Is(err, store.ErrNotFound):
413 // 会话确实失效(过期、被删除或账号停用)才清 Cookie。
414 http.SetCookie(w, &http.Cookie{Name: SessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
415 default:
416 // 数据库临时故障等情况保留 Cookie,避免把用户无谓地踢下线。
417 fmt.Fprintf(os.Stderr, "读取会话失败: %v\n", err)
418 }
419 }
420 next.ServeHTTP(w, r)
421 })
422}
423
424func (s *Server) withInstallCheck(next http.Handler) http.Handler {
425 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
426 if s.st.Installed() || strings.HasPrefix(r.URL.Path, "/install") ||
427 strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/favicon.ico" {
428 next.ServeHTTP(w, r)
429 return
430 }
431 http.Redirect(w, r, "/install", http.StatusFound)
432 })
433}
434
435// requireLogin 返回已登录用户;未登录时按需跳转或返回 401。
436func (s *Server) requireLogin(w http.ResponseWriter, r *http.Request) *store.User {
437 u := userFrom(r.Context())
438 if u == nil {
439 if strings.HasPrefix(r.URL.Path, "/admin") {
440 http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound)
441 return nil
442 }
443 http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound)
444 return nil
445 }
446 return u
447}
448
449func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) *store.User {
450 u := s.requireLogin(w, r)
451 if u == nil {
452 return nil
453 }
454 if !u.IsAdmin {
455 s.renderError(w, r, http.StatusForbidden, "需要管理员权限")
456 return nil
457 }
458 return u
459}
460
461func (s *Server) renderError(w http.ResponseWriter, r *http.Request, code int, msg string) {
462 w.WriteHeader(code)
463 p := s.page(r, fmt.Sprintf("%d", code))
464 p.Error = msg
465 p.Data["Code"] = code
466 p.Data["Message"] = msg
467 s.render(w, r, "error", p)
468}
469
470// checkCSRF 校验表单里的 csrf 令牌。
471func (s *Server) checkCSRF(r *http.Request) bool {
472 sess := sessionFrom(r.Context())
473 if sess == nil {
474 return false
475 }
476 token := r.FormValue("csrf")
477 return token != "" && token == sess.CSRF
478}
479
480// ---------------------------------------------------------------- 小工具
481
482func randomToken(n int) string {
483 b := make([]byte, n)
484 if _, err := rand.Read(b); err != nil {
485 return strconv.FormatInt(time.Now().UnixNano(), 36)
486 }
487 return hex.EncodeToString(b)
488}
489
490func setFlash(w http.ResponseWriter, msg string) {
491 http.SetCookie(w, &http.Cookie{Name: "gitcat_flash", Value: url.QueryEscape(msg), Path: "/", MaxAge: 30, HttpOnly: true})
492}
493
494func takeFlash(w http.ResponseWriter, r *http.Request) string {
495 c, err := r.Cookie("gitcat_flash")
496 if err != nil || c.Value == "" {
497 return ""
498 }
499 http.SetCookie(w, &http.Cookie{Name: "gitcat_flash", Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
500 msg, err := url.QueryUnescape(c.Value)
501 if err != nil {
502 return ""
503 }
504 return msg
505}
506
507func humanSize(n int64) string {
508 units := []string{"B", "KB", "MB", "GB", "TB"}
509 f := float64(n)
510 i := 0
511 for f >= 1024 && i < len(units)-1 {
512 f /= 1024
513 i++
514 }
515 if i == 0 {
516 return fmt.Sprintf("%d %s", n, units[0])
517 }
518 return fmt.Sprintf("%.1f %s", f, units[i])
519}
520
521func humanInt(n int) string {
522 s := strconv.Itoa(n)
523 if len(s) <= 3 {
524 return s
525 }
526 var out []byte
527 for i, c := range []byte(s) {
528 if i > 0 && (len(s)-i)%3 == 0 {
529 out = append(out, ',')
530 }
531 out = append(out, c)
532 }
533 return string(out)
534}
535
536func timeAgo(t time.Time) string {
537 if t.IsZero() {
538 return "-"
539 }
540 d := time.Since(t)
541 if d < 0 {
542 d = 0
543 }
544 switch {
545 case d < time.Minute:
546 return "刚刚"
547 case d < time.Hour:
548 return fmt.Sprintf("%d 分钟前", int(d.Minutes()))
549 case d < 24*time.Hour:
550 return fmt.Sprintf("%d 小时前", int(d.Hours()))
551 case d < 30*24*time.Hour:
552 return fmt.Sprintf("%d 天前", int(d.Hours()/24))
553 case d < 365*24*time.Hour:
554 return fmt.Sprintf("%d 个月前", int(d.Hours()/24/30))
555 default:
556 return fmt.Sprintf("%d 年前", int(d.Hours()/24/365))
557 }
558}
559
560func renderMarkdown(src string) string {
561 var buf strings.Builder
562 md := goldmark.New(
563 goldmark.WithExtensions(extension.GFM, extension.Table, extension.Strikethrough, extension.TaskList),
564 )
565 if err := md.Convert([]byte(src), &buf); err != nil {
566 return template.HTMLEscapeString(src)
567 }
568 return buf.String()
569}
570
571// remoteIP 取出客户端 IP。
572func remoteIP(r *http.Request) string {
573 if v := r.Header.Get("X-Forwarded-For"); v != "" {
574 return strings.TrimSpace(strings.Split(v, ",")[0])
575 }
576 host, _, err := net.SplitHostPort(r.RemoteAddr)
577 if err != nil {
578 return r.RemoteAddr
579 }
580 return host
581}
582
583// repoPath 返回仓库在磁盘上的裸仓库路径。
584func (s *Server) repoPath(name string) string {
585 return filepath.Join(s.repoRoot, name+".git")
586}
587
588// repoLock 返回仓库级别的互斥锁。
589func (s *Server) repoLock(name string) *sync.Mutex {
590 v, _ := s.locks.LoadOrStore(strings.ToLower(name), &sync.Mutex{})
591 return v.(*sync.Mutex)
592}
593
594// repoLocked 在持有仓库锁的情况下执行 f。
595func (s *Server) repoLocked(name string, f func() error) error {
596 mu := s.repoLock(name)
597 mu.Lock()
598 defer mu.Unlock()
599 return f()
600}
601
602// AIConfig 读取后台配置的 AI 参数。
603func (s *Server) AIConfig() ai.Config {
604 set := s.st.Settings()
605 return ai.Config{
606 Enabled: set["ai_enabled"] == "1",
607 BaseURL: set["ai_base_url"],
608 APIKey: set["ai_api_key"],
609 Model: set["ai_model"],
610 Language: set["ai_language"],
611 }
612}
613
614// pushActors 为推送动态解析推送者账号,用于展示真实头像。
615func (s *Server) pushActors(pushes []*store.Push) map[string]*store.User {
616 out := map[string]*store.User{}
617 users, err := s.st.ListUsers()
618 if err != nil {
619 return out
620 }
621 byName := make(map[string]*store.User, len(users))
622 for _, u := range users {
623 byName[strings.ToLower(u.Username)] = u
624 }
625 for _, p := range pushes {
626 if u, ok := byName[strings.ToLower(p.ActorUsername)]; ok {
627 out[p.ActorUsername] = u
628 }
629 }
630 return out
631}
632
633// storageUsed 统计仓库目录占用的磁盘空间。
634func (s *Server) storageUsed() int64 {
635 entries, err := os.ReadDir(s.repoRoot)
636 if err != nil {
637 return 0
638 }
639 var total int64
640 for _, e := range entries {
641 if e.IsDir() {
642 total += gitx.RepoSize(filepath.Join(s.repoRoot, e.Name()))
643 }
644 }
645 return total
646}
647
648// goVersion 返回运行时 Go 版本。
649func goVersion() string { return runtime.Version() }
650
651// sortedKeys 便于模板中稳定输出。
652func sortedKeys[V any](m map[string]V) []string {
653 keys := make([]string, 0, len(m))
654 for k := range m {
655 keys = append(keys, k)
656 }
657 sort.Strings(keys)
658 return keys
659}