仰望星辰工作室

gitcat

gitcat/ internal/server/admin.go 15.1 KB · 536 行 原始文件
1package server
2
3import (
4 "context"
5 "fmt"
6 "net/http"
7 "strconv"
8 "strings"
9 "time"
10
11 "gitcat/internal/ai"
12 "gitcat/internal/gitx"
13 "gitcat/internal/store"
14)
15
16func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
17 if s.requireAdmin(w, r) == nil {
18 return
19 }
20 users, repos, pushes, _ := s.st.Counts()
21 recentPushes, _ := s.st.RecentPushes(10)
22 allUsers, _ := s.st.ListUsers()
23 if len(allUsers) > 6 {
24 allUsers = allUsers[:6]
25 }
26 cfg := s.AIConfig()
27 p := s.page(r, "管理后台")
28 p.Active = "admin"
29 p.Data["Tab"] = "dash"
30 p.Data["StatUsers"] = users
31 p.Data["StatRepos"] = repos
32 p.Data["StatPushes"] = pushes
33 p.Data["StorageText"] = humanSize(s.storageUsed())
34 p.Data["RecentPushes"] = recentPushes
35 p.Data["RecentUsers"] = allUsers
36 p.Data["AIReady"] = cfg.Ready()
37 p.Data["AIModel"] = cfg.Model
38 p.Data["AIEnabled"] = cfg.Enabled
39 p.Data["GoVersion"] = goVersion()
40 p.Data["GitVersion"] = gitx.Version()
41 p.Data["DataDir"] = s.cfg.DataDir
42 p.Data["RepoRoot"] = s.repoRoot
43 s.render(w, r, "admin_dashboard", p)
44}
45
46// ---------------------------------------------------------------- 用户管理
47
48func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
49 if s.requireAdmin(w, r) == nil {
50 return
51 }
52 users, err := s.st.ListUsers()
53 if err != nil {
54 s.renderError(w, r, http.StatusInternalServerError, "读取用户失败")
55 return
56 }
57 p := s.page(r, "用户管理")
58 p.Active = "admin"
59 p.Data["Tab"] = "users"
60 p.Data["Users"] = users
61 s.render(w, r, "admin_users", p)
62}
63
64func (s *Server) handleAdminUserCreate(w http.ResponseWriter, r *http.Request) {
65 admin := s.requireAdmin(w, r)
66 if admin == nil {
67 return
68 }
69 if !s.checkCSRF(r) {
70 s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
71 return
72 }
73 username := strings.TrimSpace(r.FormValue("username"))
74 display := strings.TrimSpace(r.FormValue("display_name"))
75 email := strings.TrimSpace(r.FormValue("email"))
76 password := r.FormValue("password")
77 isAdmin := r.FormValue("is_admin") == "on"
78
79 fail := func(msg string) {
80 setFlash(w, "创建失败:"+msg)
81 http.Redirect(w, r, "/admin/users", http.StatusFound)
82 }
83 switch {
84 case !validUsername(username):
85 fail("用户名需为 2-32 位字母、数字、下划线、短横线或点号")
86 return
87 case len(password) < 6:
88 fail("密码至少需要 6 位")
89 return
90 case strings.EqualFold(username, "admin") && username != "admin":
91 fail("用户名不合法")
92 return
93 }
94 hash, err := hashPassword(password)
95 if err != nil {
96 fail("密码处理失败")
97 return
98 }
99 u, err := s.st.CreateUser(username, display, email, hash, isAdmin)
100 if err != nil {
101 fail(err.Error())
102 return
103 }
104 if u.DisplayName == "" {
105 _ = s.st.UpdateProfile(u.ID, username, email, "", "")
106 }
107 setFlash(w, fmt.Sprintf("已创建账号 %s,请把初始密码告知该成员", u.Username))
108 http.Redirect(w, r, "/admin/users/"+strconv.FormatInt(u.ID, 10), http.StatusFound)
109}
110
111func (s *Server) adminUserFromPath(w http.ResponseWriter, r *http.Request) (*store.User, bool) {
112 id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
113 if err != nil {
114 s.renderError(w, r, http.StatusNotFound, "用户不存在")
115 return nil, false
116 }
117 u, err := s.st.UserByID(id)
118 if err != nil {
119 s.renderError(w, r, http.StatusNotFound, "用户不存在")
120 return nil, false
121 }
122 return u, true
123}
124
125func (s *Server) handleAdminUserDetail(w http.ResponseWriter, r *http.Request) {
126 if s.requireAdmin(w, r) == nil {
127 return
128 }
129 u, ok := s.adminUserFromPath(w, r)
130 if !ok {
131 return
132 }
133 repos, _ := s.st.ListRepos("")
134 var owned []any
135 for _, rc := range repos {
136 if rc.OwnerID != nil && *rc.OwnerID == u.ID {
137 owned = append(owned, rc)
138 }
139 }
140 pushes, _ := s.st.RecentPushes(200)
141 var mine []any
142 for _, psh := range pushes {
143 if psh.UserID != nil && *psh.UserID == u.ID {
144 mine = append(mine, psh)
145 }
146 if len(mine) >= 10 {
147 break
148 }
149 }
150 p := s.page(r, "用户 "+u.Username)
151 p.Active = "admin"
152 p.Data["Tab"] = "users"
153 p.Data["U"] = u
154 p.Data["OwnedRepos"] = owned
155 p.Data["RecentPushes"] = mine
156 s.render(w, r, "admin_user", p)
157}
158
159func (s *Server) handleAdminUserSave(w http.ResponseWriter, r *http.Request) {
160 admin := s.requireAdmin(w, r)
161 if admin == nil {
162 return
163 }
164 u, ok := s.adminUserFromPath(w, r)
165 if !ok {
166 return
167 }
168 if !s.checkCSRF(r) {
169 s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
170 return
171 }
172 display := strings.TrimSpace(r.FormValue("display_name"))
173 email := strings.TrimSpace(r.FormValue("email"))
174 bio := strings.TrimSpace(r.FormValue("bio"))
175 isAdmin := r.FormValue("is_admin") == "on"
176 isDisabled := r.FormValue("is_disabled") == "on"
177
178 if u.ID == admin.ID {
179 // 防止管理员误操作把自己锁在门外
180 isAdmin = true
181 isDisabled = false
182 }
183 admins, _ := s.st.CountAdmins()
184 if u.IsAdmin && !isAdmin && admins <= 1 {
185 setFlash(w, "不能取消最后一个管理员的权限")
186 http.Redirect(w, r, "/admin/users/"+strconv.FormatInt(u.ID, 10), http.StatusFound)
187 return
188 }
189 if err := s.st.UpdateProfile(u.ID, display, email, bio, u.Avatar); err != nil {
190 s.renderError(w, r, http.StatusInternalServerError, "保存失败:"+err.Error())
191 return
192 }
193 if err := s.st.SetUserFlags(u.ID, isAdmin, isDisabled); err != nil {
194 s.renderError(w, r, http.StatusInternalServerError, "保存失败:"+err.Error())
195 return
196 }
197 if pw := r.FormValue("new_password"); pw != "" {
198 if len(pw) < 6 {
199 setFlash(w, "新密码至少需要 6 位,密码未修改")
200 http.Redirect(w, r, "/admin/users/"+strconv.FormatInt(u.ID, 10), http.StatusFound)
201 return
202 }
203 hash, err := hashPassword(pw)
204 if err == nil {
205 _ = s.st.SetPassword(u.ID, hash)
206 }
207 }
208 setFlash(w, "已保存用户 "+u.Username+" 的信息")
209 http.Redirect(w, r, "/admin/users/"+strconv.FormatInt(u.ID, 10), http.StatusFound)
210}
211
212func (s *Server) handleAdminUserDelete(w http.ResponseWriter, r *http.Request) {
213 admin := s.requireAdmin(w, r)
214 if admin == nil {
215 return
216 }
217 u, ok := s.adminUserFromPath(w, r)
218 if !ok {
219 return
220 }
221 if !s.checkCSRF(r) {
222 s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
223 return
224 }
225 if u.ID == admin.ID {
226 setFlash(w, "不能删除当前登录的账号")
227 http.Redirect(w, r, "/admin/users", http.StatusFound)
228 return
229 }
230 if u.IsAdmin {
231 admins, _ := s.st.CountAdmins()
232 if admins <= 1 {
233 setFlash(w, "至少需要保留一个管理员")
234 http.Redirect(w, r, "/admin/users", http.StatusFound)
235 return
236 }
237 }
238 if err := s.st.DeleteUser(u.ID); err != nil {
239 s.renderError(w, r, http.StatusInternalServerError, "删除失败:"+err.Error())
240 return
241 }
242 setFlash(w, "已删除账号 "+u.Username)
243 http.Redirect(w, r, "/admin/users", http.StatusFound)
244}
245
246// ---------------------------------------------------------------- 仓库管理
247
248func (s *Server) adminRepoFromPath(w http.ResponseWriter, r *http.Request) (*store.Repo, bool) {
249 id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
250 if err != nil {
251 s.renderError(w, r, http.StatusNotFound, "项目不存在")
252 return nil, false
253 }
254 repo, err := s.st.RepoByID(id)
255 if err != nil {
256 s.renderError(w, r, http.StatusNotFound, "项目不存在")
257 return nil, false
258 }
259 return repo, true
260}
261
262func (s *Server) handleAdminRepos(w http.ResponseWriter, r *http.Request) {
263 if s.requireAdmin(w, r) == nil {
264 return
265 }
266 cards, err := s.st.ListRepos(strings.TrimSpace(r.URL.Query().Get("q")))
267 if err != nil {
268 s.renderError(w, r, http.StatusInternalServerError, "读取项目失败")
269 return
270 }
271 type row struct {
272 Card *store.RepoCard
273 Size string
274 }
275 var rows []row
276 for _, c := range cards {
277 rows = append(rows, row{Card: c, Size: humanSize(gitx.RepoSize(s.repoPath(c.Name)))})
278 }
279 p := s.page(r, "项目管理")
280 p.Active = "admin"
281 p.Data["Tab"] = "repos"
282 p.Data["Rows"] = rows
283 p.Data["Query"] = r.URL.Query().Get("q")
284 s.render(w, r, "admin_repos", p)
285}
286
287func (s *Server) handleAdminRepoDetail(w http.ResponseWriter, r *http.Request) {
288 if s.requireAdmin(w, r) == nil {
289 return
290 }
291 repo, ok := s.adminRepoFromPath(w, r)
292 if !ok {
293 return
294 }
295 dir := s.repoPath(repo.Name)
296 users, _ := s.st.ListUsers()
297 pushes, _ := s.st.PushesByRepo(repo.ID, 20)
298 branches, _ := gitx.Branches(dir)
299 p := s.page(r, "项目 "+repo.Name)
300 p.Active = "admin"
301 p.Data["Tab"] = "repos"
302 p.Data["Repo"] = repo
303 p.Data["Users"] = users
304 p.Data["Pushes"] = pushes
305 p.Data["Branches"] = branches
306 p.Data["SizeText"] = humanSize(gitx.RepoSize(dir))
307 p.Data["CommitCount"] = gitx.CountCommits(dir, repo.DefaultBranch)
308 p.Data["CloneURL"] = cloneURL(r, repo.Name)
309 p.Data["Missing"] = !gitx.IsBareRepo(dir)
310 s.render(w, r, "admin_repo", p)
311}
312
313func (s *Server) handleAdminRepoSave(w http.ResponseWriter, r *http.Request) {
314 if s.requireAdmin(w, r) == nil {
315 return
316 }
317 repo, ok := s.adminRepoFromPath(w, r)
318 if !ok {
319 return
320 }
321 if !s.checkCSRF(r) {
322 s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
323 return
324 }
325 redirect := "/admin/repos/" + strconv.FormatInt(repo.ID, 10)
326
327 desc := strings.TrimSpace(r.FormValue("description"))
328 if len(desc) > 300 {
329 setFlash(w, "项目描述不能超过 300 个字符")
330 http.Redirect(w, r, redirect, http.StatusFound)
331 return
332 }
333 repo.Description = desc
334 repo.AIEnabled = r.FormValue("ai_enabled") == "on"
335 repo.IsArchived = r.FormValue("is_archived") == "on"
336
337 if ownerID := strings.TrimSpace(r.FormValue("owner_id")); ownerID != "" {
338 id, err := strconv.ParseInt(ownerID, 10, 64)
339 if err == nil {
340 if _, err := s.st.UserByID(id); err == nil {
341 repo.OwnerID = &id
342 }
343 }
344 } else if r.FormValue("owner_none") == "on" {
345 repo.OwnerID = nil
346 }
347 if branch := strings.TrimSpace(r.FormValue("default_branch")); branch != "" {
348 repo.DefaultBranch = branch
349 _ = gitx.SetDefaultBranch(s.repoPath(repo.Name), branch)
350 }
351 if err := s.st.UpdateRepo(repo); err != nil {
352 s.renderError(w, r, http.StatusInternalServerError, "保存失败:"+err.Error())
353 return
354 }
355 // UpdateRepo 不写 owner_id,这里单独更新
356 _ = s.st.SetRepoOwner(repo.ID, repo.OwnerID)
357 setFlash(w, "项目 "+repo.Name+" 已更新")
358 http.Redirect(w, r, redirect, http.StatusFound)
359}
360
361func (s *Server) handleAdminRepoDelete(w http.ResponseWriter, r *http.Request) {
362 if s.requireAdmin(w, r) == nil {
363 return
364 }
365 repo, ok := s.adminRepoFromPath(w, r)
366 if !ok {
367 return
368 }
369 if !s.checkCSRF(r) {
370 s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
371 return
372 }
373 if strings.TrimSpace(r.FormValue("confirm")) != repo.Name {
374 setFlash(w, "删除失败:请输入完整的项目名以确认")
375 http.Redirect(w, r, "/admin/repos/"+strconv.FormatInt(repo.ID, 10), http.StatusFound)
376 return
377 }
378 if err := s.st.DeleteRepo(repo.ID); err != nil {
379 s.renderError(w, r, http.StatusInternalServerError, "删除失败:"+err.Error())
380 return
381 }
382 _ = removeRepoDir(s.repoPath(repo.Name))
383 setFlash(w, fmt.Sprintf("项目 %s 已彻底删除", repo.Name))
384 http.Redirect(w, r, "/admin/repos", http.StatusFound)
385}
386
387// ---------------------------------------------------------------- AI 设置
388
389func (s *Server) handleAdminAIPage(w http.ResponseWriter, r *http.Request) {
390 if s.requireAdmin(w, r) == nil {
391 return
392 }
393 set := s.st.Settings()
394 p := s.page(r, "AI 接入设置")
395 p.Active = "admin"
396 p.Data["Tab"] = "ai"
397 p.Data["Enabled"] = set["ai_enabled"] == "1"
398 p.Data["BaseURL"] = set["ai_base_url"]
399 p.Data["Model"] = set["ai_model"]
400 p.Data["HasKey"] = set["ai_api_key"] != ""
401 p.Data["Language"] = set["ai_language"]
402 p.Data["Settings"] = set
403 s.render(w, r, "admin_ai", p)
404}
405
406func (s *Server) handleAdminAISave(w http.ResponseWriter, r *http.Request) {
407 if s.requireAdmin(w, r) == nil {
408 return
409 }
410 if !s.checkCSRF(r) {
411 s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
412 return
413 }
414 target := "/admin/ai"
415 if r.FormValue("action") == "test" {
416 target = "/admin/ai/test"
417 }
418
419 base := strings.TrimSpace(r.FormValue("ai_base_url"))
420 model := strings.TrimSpace(r.FormValue("ai_model"))
421 lang := r.FormValue("ai_language")
422 if lang != "en" {
423 lang = "zh"
424 }
425 if base != "" && !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
426 base = "https://" + base
427 }
428 _ = s.st.SetSetting("ai_base_url", base)
429 _ = s.st.SetSetting("ai_model", model)
430 _ = s.st.SetSetting("ai_language", lang)
431 _ = s.st.SetSetting("ai_enabled", boolSetting(r.FormValue("ai_enabled") == "on"))
432
433 if r.FormValue("clear_key") == "on" {
434 _ = s.st.SetSetting("ai_api_key", "")
435 } else if key := strings.TrimSpace(r.FormValue("ai_api_key")); key != "" {
436 _ = s.st.SetSetting("ai_api_key", key)
437 }
438
439 if target == "/admin/ai/test" {
440 cfg := s.AIConfig()
441 cfg.Enabled = true
442 if !cfg.Ready() {
443 setFlash(w, "测试失败:请先填写接口地址与模型名称")
444 http.Redirect(w, r, "/admin/ai", http.StatusFound)
445 return
446 }
447 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second)
448 defer cancel()
449 out, err := ai.TestConnection(ctx, cfg)
450 if err != nil {
451 setFlash(w, "测试失败:"+err.Error())
452 } else {
453 preview := strings.ReplaceAll(out, "\n", " / ")
454 // 按字符截断,避免切断多字节字符
455 if runes := []rune(preview); len(runes) > 160 {
456 preview = string(runes[:160]) + "…"
457 }
458 setFlash(w, "AI 连接正常,返回示例:"+preview)
459 }
460 http.Redirect(w, r, "/admin/ai", http.StatusFound)
461 return
462 }
463
464 setFlash(w, "AI 设置已保存")
465 http.Redirect(w, r, target, http.StatusFound)
466}
467
468func (s *Server) handleAdminAITest(w http.ResponseWriter, r *http.Request) {
469 http.Redirect(w, r, "/admin/ai", http.StatusFound)
470}
471
472func boolSetting(b bool) string {
473 if b {
474 return "1"
475 }
476 return "0"
477}
478
479// ---------------------------------------------------------------- 站点设置
480
481func (s *Server) handleAdminSettingsPage(w http.ResponseWriter, r *http.Request) {
482 if s.requireAdmin(w, r) == nil {
483 return
484 }
485 set := s.st.Settings()
486 p := s.page(r, "站点设置")
487 p.Active = "admin"
488 p.Data["Tab"] = "settings"
489 p.Data["SiteName"] = set["site_name"]
490 p.Data["Announcement"] = set["announcement"]
491 p.Data["Settings"] = set
492 p.Data["Addr"] = s.cfg.Addr
493 p.Data["DataDir"] = s.cfg.DataDir
494 s.render(w, r, "admin_settings", p)
495}
496
497func (s *Server) handleAdminSettingsSave(w http.ResponseWriter, r *http.Request) {
498 if s.requireAdmin(w, r) == nil {
499 return
500 }
501 if !s.checkCSRF(r) {
502 s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
503 return
504 }
505 name := strings.TrimSpace(r.FormValue("site_name"))
506 announcement := strings.TrimSpace(r.FormValue("announcement"))
507 if name == "" {
508 setFlash(w, "站点名称不能为空")
509 http.Redirect(w, r, "/admin/settings", http.StatusFound)
510 return
511 }
512 if len(name) > 60 || len(announcement) > 500 {
513 setFlash(w, "站点名称或公告过长")
514 http.Redirect(w, r, "/admin/settings", http.StatusFound)
515 return
516 }
517 _ = s.st.SetSetting("site_name", name)
518 _ = s.st.SetSetting("announcement", announcement)
519 setFlash(w, "站点设置已保存")
520 http.Redirect(w, r, "/admin/settings", http.StatusFound)
521}
522
523// ---------------------------------------------------------------- 全站动态
524
525func (s *Server) handleAdminActivity(w http.ResponseWriter, r *http.Request) {
526 if s.requireAdmin(w, r) == nil {
527 return
528 }
529 pushes, _ := s.st.RecentPushes(100)
530 p := s.page(r, "操作动态")
531 p.Active = "admin"
532 p.Data["Tab"] = "activity"
533 p.Data["Pushes"] = pushes
534 p.Data["PushActors"] = s.pushActors(pushes)
535 s.render(w, r, "admin_activity", p)
536}