gitcat
1package server
2
3import (
4 "net/http"
5 "strings"
6 "time"
7 "unicode"
8
9 "golang.org/x/crypto/bcrypt"
10
11 "gitcat/internal/store"
12)
13
14const sessionTTL = 30 * 24 * time.Hour
15
16// hashPassword 生成 bcrypt 哈希。
17func hashPassword(pw string) (string, error) {
18 b, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
19 return string(b), err
20}
21
22// checkPassword 校验明文密码。
23func checkPassword(hash, pw string) bool {
24 return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil
25}
26
27// validUsername 校验用户名格式。
28func validUsername(name string) bool {
29 if len(name) < 2 || len(name) > 32 {
30 return false
31 }
32 for _, r := range name {
33 if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' && r != '-' && r != '.' {
34 return false
35 }
36 }
37 return true
38}
39
40// ---------------------------------------------------------------- 安装
41
42func (s *Server) handleInstallPage(w http.ResponseWriter, r *http.Request) {
43 if s.st.Installed() {
44 http.Redirect(w, r, "/", http.StatusFound)
45 return
46 }
47 p := s.page(r, "安装 gitcat")
48 s.render(w, r, "install", p)
49}
50
51func (s *Server) handleInstallSubmit(w http.ResponseWriter, r *http.Request) {
52 if s.st.Installed() {
53 http.Redirect(w, r, "/", http.StatusFound)
54 return
55 }
56 siteName := strings.TrimSpace(r.FormValue("site_name"))
57 username := strings.TrimSpace(r.FormValue("username"))
58 display := strings.TrimSpace(r.FormValue("display_name"))
59 password := r.FormValue("password")
60 password2 := r.FormValue("password2")
61
62 fail := func(msg string) {
63 p := s.page(r, "安装 gitcat")
64 p.Error = msg
65 p.Data["SiteName"] = siteName
66 p.Data["Username"] = username
67 p.Data["DisplayName"] = display
68 w.WriteHeader(http.StatusBadRequest)
69 s.render(w, r, "install", p)
70 }
71
72 switch {
73 case siteName == "":
74 fail("请填写站点名称")
75 return
76 case len(siteName) > 60:
77 fail("站点名称不能超过 60 个字符")
78 return
79 case !validUsername(username):
80 fail("用户名需为 2-32 位字母、数字、下划线、短横线或点号")
81 return
82 case len(password) < 6:
83 fail("密码至少需要 6 位")
84 return
85 case password != password2:
86 fail("两次输入的密码不一致")
87 return
88 }
89
90 hash, err := hashPassword(password)
91 if err != nil {
92 fail("密码处理失败:" + err.Error())
93 return
94 }
95 user, err := s.st.CreateUser(username, display, "", hash, true)
96 if err != nil {
97 fail(err.Error())
98 return
99 }
100 if display == "" {
101 _ = s.st.UpdateProfile(user.ID, username, "", "", "")
102 }
103 _ = s.st.SetSetting("site_name", siteName)
104 _ = s.st.SetSetting("installed", "1")
105 _ = s.st.SetSetting("ai_enabled", "0")
106 _ = s.st.SetSetting("ai_language", "zh")
107
108 // 安装完成后直接登录
109 if err := s.startSession(w, r, user); err != nil {
110 fail("创建会话失败:" + err.Error())
111 return
112 }
113 setFlash(w, "欢迎使用 gitcat,安装已完成 🎉")
114 http.Redirect(w, r, "/", http.StatusFound)
115}
116
117// ---------------------------------------------------------------- 登录
118
119func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
120 if userFrom(r.Context()) != nil {
121 http.Redirect(w, r, "/", http.StatusFound)
122 return
123 }
124 p := s.page(r, "登录")
125 p.Data["Next"] = safeNext(r.URL.Query().Get("next"))
126 s.render(w, r, "login", p)
127}
128
129func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
130 username := strings.TrimSpace(r.FormValue("username"))
131 password := r.FormValue("password")
132 next := safeNext(r.FormValue("next"))
133
134 fail := func(msg string) {
135 p := s.page(r, "登录")
136 p.Error = msg
137 p.Data["Next"] = next
138 p.Data["Username"] = username
139 w.WriteHeader(http.StatusUnauthorized)
140 s.render(w, r, "login", p)
141 }
142
143 u, err := s.st.UserByUsername(username)
144 if err != nil || !checkPassword(u.PasswordHash, password) {
145 time.Sleep(300 * time.Millisecond) // 轻微限速,缓解暴力破解
146 fail("用户名或密码不正确")
147 return
148 }
149 if u.IsDisabled {
150 fail("该账号已被管理员停用")
151 return
152 }
153 if err := s.startSession(w, r, u); err != nil {
154 fail("创建会话失败:" + err.Error())
155 return
156 }
157 _ = s.st.TouchLogin(u.ID)
158 if next == "" {
159 next = "/"
160 }
161 http.Redirect(w, r, next, http.StatusFound)
162}
163
164func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
165 if sess := sessionFrom(r.Context()); sess != nil {
166 if !s.checkCSRF(r) {
167 s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
168 return
169 }
170 _ = s.st.DeleteSession(sess.ID)
171 }
172 http.SetCookie(w, &http.Cookie{Name: SessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
173 http.Redirect(w, r, "/", http.StatusFound)
174}
175
176// startSession 创建会话并写入 Cookie。
177func (s *Server) startSession(w http.ResponseWriter, r *http.Request, u *store.User) error {
178 id := randomToken(32)
179 csrf := randomToken(16)
180 if err := s.st.CreateSession(id, u.ID, csrf, sessionTTL, remoteIP(r), r.UserAgent()); err != nil {
181 return err
182 }
183 http.SetCookie(w, &http.Cookie{
184 Name: SessionCookieName,
185 Value: id,
186 Path: "/",
187 HttpOnly: true,
188 SameSite: http.SameSiteLaxMode,
189 MaxAge: int(sessionTTL.Seconds()),
190 })
191 return nil
192}
193
194// safeNext 防止开放重定向。
195func safeNext(next string) string {
196 if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
197 return ""
198 }
199 return next
200}
201
202// basicAuthUser 解析 HTTP Basic 认证中的 gitcat 账号(用于 git push / clone)。
203func (s *Server) basicAuthUser(r *http.Request) *store.User {
204 username, password, ok := r.BasicAuth()
205 if !ok {
206 return nil
207 }
208 u, err := s.st.UserByUsername(username)
209 if err != nil || u.IsDisabled {
210 return nil
211 }
212 if !checkPassword(u.PasswordHash, password) {
213 return nil
214 }
215 return u
216}
217
218// challengeGit 要求客户端提供账号密码。
219func challengeGit(w http.ResponseWriter) {
220 w.Header().Set("WWW-Authenticate", `Basic realm="gitcat: 请使用工作室账号登录", charset="UTF-8"`)
221 http.Error(w, "需要登录后操作", http.StatusUnauthorized)
222}