package server import ( "net/http" "strings" "time" "unicode" "golang.org/x/crypto/bcrypt" "gitcat/internal/store" ) const sessionTTL = 30 * 24 * time.Hour // hashPassword 生成 bcrypt 哈希。 func hashPassword(pw string) (string, error) { b, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) return string(b), err } // checkPassword 校验明文密码。 func checkPassword(hash, pw string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil } // validUsername 校验用户名格式。 func validUsername(name string) bool { if len(name) < 2 || len(name) > 32 { return false } for _, r := range name { if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' && r != '-' && r != '.' { return false } } return true } // ---------------------------------------------------------------- 安装 func (s *Server) handleInstallPage(w http.ResponseWriter, r *http.Request) { if s.st.Installed() { http.Redirect(w, r, "/", http.StatusFound) return } p := s.page(r, "安装 gitcat") s.render(w, r, "install", p) } func (s *Server) handleInstallSubmit(w http.ResponseWriter, r *http.Request) { if s.st.Installed() { http.Redirect(w, r, "/", http.StatusFound) return } siteName := strings.TrimSpace(r.FormValue("site_name")) username := strings.TrimSpace(r.FormValue("username")) display := strings.TrimSpace(r.FormValue("display_name")) password := r.FormValue("password") password2 := r.FormValue("password2") fail := func(msg string) { p := s.page(r, "安装 gitcat") p.Error = msg p.Data["SiteName"] = siteName p.Data["Username"] = username p.Data["DisplayName"] = display w.WriteHeader(http.StatusBadRequest) s.render(w, r, "install", p) } switch { case siteName == "": fail("请填写站点名称") return case len(siteName) > 60: fail("站点名称不能超过 60 个字符") return case !validUsername(username): fail("用户名需为 2-32 位字母、数字、下划线、短横线或点号") return case len(password) < 6: fail("密码至少需要 6 位") return case password != password2: fail("两次输入的密码不一致") return } hash, err := hashPassword(password) if err != nil { fail("密码处理失败:" + err.Error()) return } user, err := s.st.CreateUser(username, display, "", hash, true) if err != nil { fail(err.Error()) return } if display == "" { _ = s.st.UpdateProfile(user.ID, username, "", "", "") } _ = s.st.SetSetting("site_name", siteName) _ = s.st.SetSetting("installed", "1") _ = s.st.SetSetting("ai_enabled", "0") _ = s.st.SetSetting("ai_language", "zh") // 安装完成后直接登录 if err := s.startSession(w, r, user); err != nil { fail("创建会话失败:" + err.Error()) return } setFlash(w, "欢迎使用 gitcat,安装已完成 🎉") http.Redirect(w, r, "/", http.StatusFound) } // ---------------------------------------------------------------- 登录 func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) { if userFrom(r.Context()) != nil { http.Redirect(w, r, "/", http.StatusFound) return } p := s.page(r, "登录") p.Data["Next"] = safeNext(r.URL.Query().Get("next")) s.render(w, r, "login", p) } func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) { username := strings.TrimSpace(r.FormValue("username")) password := r.FormValue("password") next := safeNext(r.FormValue("next")) fail := func(msg string) { p := s.page(r, "登录") p.Error = msg p.Data["Next"] = next p.Data["Username"] = username w.WriteHeader(http.StatusUnauthorized) s.render(w, r, "login", p) } u, err := s.st.UserByUsername(username) if err != nil || !checkPassword(u.PasswordHash, password) { time.Sleep(300 * time.Millisecond) // 轻微限速,缓解暴力破解 fail("用户名或密码不正确") return } if u.IsDisabled { fail("该账号已被管理员停用") return } if err := s.startSession(w, r, u); err != nil { fail("创建会话失败:" + err.Error()) return } _ = s.st.TouchLogin(u.ID) if next == "" { next = "/" } http.Redirect(w, r, next, http.StatusFound) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { if sess := sessionFrom(r.Context()); sess != nil { if !s.checkCSRF(r) { s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试") return } _ = s.st.DeleteSession(sess.ID) } http.SetCookie(w, &http.Cookie{Name: SessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true}) http.Redirect(w, r, "/", http.StatusFound) } // startSession 创建会话并写入 Cookie。 func (s *Server) startSession(w http.ResponseWriter, r *http.Request, u *store.User) error { id := randomToken(32) csrf := randomToken(16) if err := s.st.CreateSession(id, u.ID, csrf, sessionTTL, remoteIP(r), r.UserAgent()); err != nil { return err } http.SetCookie(w, &http.Cookie{ Name: SessionCookieName, Value: id, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int(sessionTTL.Seconds()), }) return nil } // safeNext 防止开放重定向。 func safeNext(next string) string { if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") { return "" } return next } // basicAuthUser 解析 HTTP Basic 认证中的 gitcat 账号(用于 git push / clone)。 func (s *Server) basicAuthUser(r *http.Request) *store.User { username, password, ok := r.BasicAuth() if !ok { return nil } u, err := s.st.UserByUsername(username) if err != nil || u.IsDisabled { return nil } if !checkPassword(u.PasswordHash, password) { return nil } return u } // challengeGit 要求客户端提供账号密码。 func challengeGit(w http.ResponseWriter) { w.Header().Set("WWW-Authenticate", `Basic realm="gitcat: 请使用工作室账号登录", charset="UTF-8"`) http.Error(w, "需要登录后操作", http.StatusUnauthorized) }