settings.go
⎇
Raw
1package web
2
3import (
4 "context"
5 "io"
6 "net/http"
7 "os"
8 "strconv"
9 "strings"
10
11 "github.com/go-chi/chi/v5"
12 gossh "golang.org/x/crypto/ssh"
13
14 "hearthforge/internal/avatar"
15 "hearthforge/internal/db"
16 "hearthforge/internal/util"
17 "hearthforge/internal/web/views"
18)
19
20// validKeyTypes are the SSH key types the settings page accepts.
21var validKeyTypes = map[string]bool{
22 "ssh-rsa": true,
23 "ssh-ed25519": true,
24 "ecdsa-sha2-nistp256": true,
25 "ecdsa-sha2-nistp384": true,
26 "ecdsa-sha2-nistp521": true,
27 "sk-ssh-ed25519@openssh.com": true,
28 "sk-ecdsa-sha2-nistp256@openssh.com": true,
29}
30
31// yearSeconds is the theme cookie lifetime, matching YEAR_SECONDS.
32const yearSeconds = 365 * 24 * 60 * 60
33
34// Caps for the two SSH key fields that end up on the settings page. Both are
35// well above any real key: an ed25519 line is about 100 bytes, an RSA-4096
36// line about 750, and the trailing comment is usually "user@host".
37const (
38 maxSSHKeyNameBytes = 100
39 maxSSHKeyBytes = 4096
40)
41
42// settingsRoutes registers the settings page and the admin user actions.
43func (s *Server) settingsRoutes(r chi.Router) {
44 // The theme cookie needs no account.
45 r.Post("/settings/theme", s.setTheme)
46
47 r.Group(func(r chi.Router) {
48 r.Use(s.requireAuth)
49 r.Get("/settings", s.settingsPage)
50 r.Post("/settings/password", s.changePassword)
51 r.Post("/settings/password/remove", s.removePassword)
52 r.Post("/settings/passkey/revoke", s.revokePasskey)
53 r.Post("/settings/ssh-keys", s.addSSHKey)
54 r.Post("/settings/ssh-keys/delete", s.deleteSSHKey)
55 r.Post("/settings/avatar", s.uploadAvatar)
56 r.Post("/settings/avatar/delete", s.resetAvatar)
57 })
58
59 r.Group(func(r chi.Router) {
60 r.Use(s.requireAdmin)
61 r.Post("/admin/users", s.adminCreateUser)
62 r.Post("/admin/users/delete", s.adminDeleteUser)
63 r.Post("/admin/users/reset", s.adminResetUser)
64 r.Post("/admin/users/approve", s.adminApproveUser)
65 r.Post("/admin/users/deny", s.adminDenyUser)
66 r.Post("/admin/users/approve-all", s.adminApproveAll)
67 r.Post("/admin/users/deny-all", s.adminDenyAll)
68 r.Post("/admin/repos/drop", s.adminDropRepo)
69 r.Post("/admin/repos/drop-all", s.adminDropAllRepos)
70 })
71}
72
73// settingsError redirects back to the settings page with an error message.
74func settingsError(w http.ResponseWriter, r *http.Request, msg string) {
75 redirectTo(w, r, "/settings?error="+queryEscape(msg))
76}
77
78func settingsSuccess(w http.ResponseWriter, r *http.Request, key string) {
79 redirectTo(w, r, "/settings?success="+key)
80}
81
82func (s *Server) settingsPage(w http.ResponseWriter, r *http.Request) {
83 u := User(r)
84 row, err := s.DB.UserByID(r.Context(), u.ID)
85 if err != nil || row == nil {
86 http.Error(w, "Database error", http.StatusInternalServerError)
87 return
88 }
89 passkeys, err := s.DB.ListPasskeys(r.Context(), u.ID)
90 if err != nil {
91 http.Error(w, "Database error", http.StatusInternalServerError)
92 return
93 }
94 sshKeys, err := s.DB.ListSSHKeys(r.Context(), u.ID)
95 if err != nil {
96 http.Error(w, "Database error", http.StatusInternalServerError)
97 return
98 }
99 var pending []db.PendingUser
100 if u.IsAdmin && s.Cfg.RegistrationType == "queue" {
101 if pending, err = s.DB.PendingUsers(r.Context()); err != nil {
102 http.Error(w, "Database error", http.StatusInternalServerError)
103 return
104 }
105 }
106
107 var missing []db.RepoCounts
108 if u.IsAdmin {
109 if missing, err = s.missingRepos(r.Context()); err != nil {
110 http.Error(w, "Database error", http.StatusInternalServerError)
111 return
112 }
113 }
114
115 theme := "auto"
116 if c, err := r.Cookie("theme"); err == nil && c.Value != "" {
117 theme = c.Value
118 }
119 reauth := ""
120 if !recentLogin(u) {
121 reauth = reauthURL("/settings")
122 }
123 q := r.URL.Query()
124 views.Render(w, http.StatusOK, views.Settings(s.Cfg, u, row.PasswordHash != nil,
125 passkeys, sshKeys, theme, q.Get("success"), q.Get("error"), reauth, pending, missing))
126}
127
128func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
129 u := User(r)
130 if !s.allowed(r, passwordLimiter, false) {
131 settingsError(w, r, "Too many attempts. Please try again later.")
132 return
133 }
134 if err := r.ParseForm(); err != nil {
135 http.Error(w, "Bad request", http.StatusBadRequest)
136 return
137 }
138 newPassword := r.FormValue("new_password")
139 if tooLong(w, newPassword, s.Cfg.MaxPasswordBytes) ||
140 tooLong(w, r.FormValue("current_password"), s.Cfg.MaxPasswordBytes) {
141 return
142 }
143 if len(newPassword) < minPasswordLength {
144 settingsError(w, r, "Password must be at least 8 characters")
145 return
146 }
147 if newPassword != r.FormValue("confirm_password") {
148 settingsError(w, r, "Passwords do not match")
149 return
150 }
151
152 row, err := s.DB.UserByID(r.Context(), u.ID)
153 if err != nil || row == nil {
154 http.Error(w, "Database error", http.StatusInternalServerError)
155 return
156 }
157 if row.PasswordHash != nil {
158 current := r.FormValue("current_password")
159 if current == "" {
160 settingsError(w, r, "Current password is required")
161 return
162 }
163 ok, err := db.VerifyPassword(*row.PasswordHash, current)
164 if err != nil || !ok {
165 settingsError(w, r, "Current password is incorrect")
166 return
167 }
168 } else if !recentLogin(u) {
169 redirectTo(w, r, reauthURL("/settings"))
170 return
171 }
172
173 hash, err := db.HashPassword(newPassword)
174 if err != nil {
175 http.Error(w, "Server error", http.StatusInternalServerError)
176 return
177 }
178 if err := s.DB.SetPasswordHash(r.Context(), u.ID, &hash); err != nil {
179 http.Error(w, "Database error", http.StatusInternalServerError)
180 return
181 }
182 if err := s.dropOtherSessions(r, u.ID); err != nil {
183 http.Error(w, "Database error", http.StatusInternalServerError)
184 return
185 }
186 settingsSuccess(w, r, "password")
187}
188
189func (s *Server) removePassword(w http.ResponseWriter, r *http.Request) {
190 u := User(r)
191 if !recentLogin(u) {
192 redirectTo(w, r, reauthURL("/settings"))
193 return
194 }
195 removed, err := s.DB.ClearPasswordHash(r.Context(), u.ID)
196 if err != nil {
197 http.Error(w, "Database error", http.StatusInternalServerError)
198 return
199 }
200 if !removed {
201 settingsError(w, r, "Cannot remove password without a passkey")
202 return
203 }
204 if err := s.dropOtherSessions(r, u.ID); err != nil {
205 http.Error(w, "Database error", http.StatusInternalServerError)
206 return
207 }
208 settingsSuccess(w, r, "password_removed")
209}
210
211func (s *Server) revokePasskey(w http.ResponseWriter, r *http.Request) {
212 u := User(r)
213 if !recentLogin(u) {
214 redirectTo(w, r, reauthURL("/settings"))
215 return
216 }
217 id, err := formID(r, "id")
218 if err != nil {
219 settingsError(w, r, "Passkey not found")
220 return
221 }
222 owner, found, err := s.DB.PasskeyOwner(r.Context(), id)
223 if err != nil {
224 http.Error(w, "Database error", http.StatusInternalServerError)
225 return
226 }
227 if !found || owner != u.ID {
228 settingsError(w, r, "Passkey not found")
229 return
230 }
231 revoked, err := s.DB.RevokePasskey(r.Context(), u.ID, id)
232 if err != nil {
233 http.Error(w, "Database error", http.StatusInternalServerError)
234 return
235 }
236 if !revoked {
237 settingsError(w, r, "Cannot revoke last auth method")
238 return
239 }
240 if err := s.dropOtherSessions(r, u.ID); err != nil {
241 http.Error(w, "Database error", http.StatusInternalServerError)
242 return
243 }
244 settingsSuccess(w, r, "passkey_revoked")
245}
246
247func (s *Server) setTheme(w http.ResponseWriter, r *http.Request) {
248 if err := r.ParseForm(); err != nil {
249 http.Error(w, "Bad request", http.StatusBadRequest)
250 return
251 }
252 theme := r.FormValue("theme")
253 if theme != "auto" && theme != "light" && theme != "dark" {
254 settingsError(w, r, "Invalid theme")
255 return
256 }
257 http.SetCookie(w, &http.Cookie{
258 Name: "theme",
259 Value: theme,
260 Path: "/",
261 SameSite: http.SameSiteLaxMode,
262 Secure: s.Cfg.PublicHTTPS,
263 MaxAge: yearSeconds,
264 })
265 settingsSuccess(w, r, "theme")
266}
267
268func (s *Server) addSSHKey(w http.ResponseWriter, r *http.Request) {
269 u := User(r)
270 if err := r.ParseForm(); err != nil {
271 http.Error(w, "Bad request", http.StatusBadRequest)
272 return
273 }
274 line := strings.TrimSpace(r.FormValue("public_key"))
275 // The key line is stored and its comment is rendered on the settings page.
276 if tooLong(w, line, maxSSHKeyBytes) {
277 return
278 }
279 keyType, _, _ := strings.Cut(line, " ")
280 if !validKeyTypes[keyType] {
281 settingsError(w, r, "Unsupported key type")
282 return
283 }
284 key, _, _, _, err := gossh.ParseAuthorizedKey([]byte(line))
285 if err != nil {
286 settingsError(w, r, "Invalid public key")
287 return
288 }
289
290 name := strings.TrimSpace(r.FormValue("name"))
291 if tooLong(w, name, maxSSHKeyNameBytes) {
292 return
293 }
294 if name == "" {
295 name = "Unnamed key"
296 }
297 // internal/sshd looks a key up by exactly this string.
298 err = s.DB.CreateSSHKey(r.Context(), u.ID, name, line, gossh.FingerprintSHA256(key), db.NowISO())
299 if isUniqueViolation(err) {
300 settingsError(w, r, "This key is already registered")
301 return
302 }
303 if err != nil {
304 http.Error(w, "Database error", http.StatusInternalServerError)
305 return
306 }
307 settingsSuccess(w, r, "ssh_key_added")
308}
309
310func (s *Server) deleteSSHKey(w http.ResponseWriter, r *http.Request) {
311 u := User(r)
312 id, err := formID(r, "id")
313 if err != nil {
314 settingsError(w, r, "Key not found")
315 return
316 }
317 owner, found, err := s.DB.SSHKeyOwner(r.Context(), id)
318 if err != nil {
319 http.Error(w, "Database error", http.StatusInternalServerError)
320 return
321 }
322 if !found || owner != u.ID {
323 settingsError(w, r, "Key not found")
324 return
325 }
326 if err := s.DB.DeleteSSHKey(r.Context(), id); err != nil {
327 http.Error(w, "Database error", http.StatusInternalServerError)
328 return
329 }
330 settingsSuccess(w, r, "ssh_key_deleted")
331}
332
333func (s *Server) uploadAvatar(w http.ResponseWriter, r *http.Request) {
334 u := User(r)
335 if s.limited(w, r, uploadLimiter, false) {
336 return
337 }
338 file, _, err := r.FormFile("avatar")
339 if err != nil {
340 http.Error(w, "No avatar file", http.StatusBadRequest)
341 return
342 }
343 defer file.Close()
344 data, err := io.ReadAll(io.LimitReader(file, s.Cfg.MaxUserUploadBytes+1))
345 if err != nil {
346 http.Error(w, "Could not read the upload", http.StatusBadRequest)
347 return
348 }
349 if int64(len(data)) > s.Cfg.MaxUserUploadBytes {
350 http.Error(w, "Avatar file too large", http.StatusBadRequest)
351 return
352 }
353 if err := avatar.Save(s.Cfg.AvatarsDir(), u.ID, data); err != nil {
354 http.Error(w, "File is not a supported image type", http.StatusBadRequest)
355 return
356 }
357 if err := s.DB.BumpAvatarVersion(r.Context(), u.ID); err != nil {
358 http.Error(w, "Database error", http.StatusInternalServerError)
359 return
360 }
361 redirectTo(w, r, "/settings")
362}
363
364func (s *Server) resetAvatar(w http.ResponseWriter, r *http.Request) {
365 u := User(r)
366 if err := avatar.SaveDefault(s.Cfg.AvatarsDir(), u.ID, u.Username); err != nil {
367 http.Error(w, "Could not reset the avatar", http.StatusInternalServerError)
368 return
369 }
370 if err := s.DB.BumpAvatarVersion(r.Context(), u.ID); err != nil {
371 http.Error(w, "Database error", http.StatusInternalServerError)
372 return
373 }
374 redirectTo(w, r, "/settings")
375}
376
377func (s *Server) adminCreateUser(w http.ResponseWriter, r *http.Request) {
378 if err := r.ParseForm(); err != nil {
379 http.Error(w, "Bad request", http.StatusBadRequest)
380 return
381 }
382 username := r.FormValue("username")
383 password := r.FormValue("password")
384 if len(username) > s.Cfg.MaxUsernameBytes || len(password) > s.Cfg.MaxPasswordBytes {
385 http.Error(w, "Request too large", http.StatusRequestEntityTooLarge)
386 return
387 }
388 if !util.ValidUsername(username) {
389 settingsError(w, r, "Username may only contain letters, numbers, hyphens, and underscores")
390 return
391 }
392 if strings.EqualFold(username, db.AdminUsername) {
393 settingsError(w, r, "That username is reserved")
394 return
395 }
396 if len(password) < minPasswordLength {
397 settingsError(w, r, "Password must be at least 8 characters")
398 return
399 }
400
401 hash, err := db.HashPassword(password)
402 if err != nil {
403 http.Error(w, "Server error", http.StatusInternalServerError)
404 return
405 }
406 id, err := s.DB.CreateUser(r.Context(), username, &hash, db.NowISO(), false, nil)
407 if isUniqueViolation(err) {
408 settingsError(w, r, "Username already taken")
409 return
410 }
411 if err != nil {
412 http.Error(w, "Database error", http.StatusInternalServerError)
413 return
414 }
415 _ = avatar.SaveDefault(s.Cfg.AvatarsDir(), id, username)
416 settingsSuccess(w, r, "user_created")
417}
418
419func (s *Server) adminDeleteUser(w http.ResponseWriter, r *http.Request) {
420 if err := r.ParseForm(); err != nil {
421 http.Error(w, "Bad request", http.StatusBadRequest)
422 return
423 }
424 username := r.FormValue("username")
425 if username == db.AdminUsername {
426 settingsError(w, r, "Cannot delete admin user")
427 return
428 }
429 target, err := s.DB.UserByName(r.Context(), username)
430 if err != nil {
431 http.Error(w, "Database error", http.StatusInternalServerError)
432 return
433 }
434 if target == nil {
435 settingsError(w, r, "User not found")
436 return
437 }
438 if err := s.DB.DeleteUser(r.Context(), target.ID); err != nil {
439 http.Error(w, "Database error", http.StatusInternalServerError)
440 return
441 }
442 settingsSuccess(w, r, "user_deleted")
443}
444
445// adminResetUser gives a locked-out user a password chosen by the admin and
446// removes their passkeys and sessions.
447func (s *Server) adminResetUser(w http.ResponseWriter, r *http.Request) {
448 if err := r.ParseForm(); err != nil {
449 http.Error(w, "Bad request", http.StatusBadRequest)
450 return
451 }
452 username := r.FormValue("username")
453 password := r.FormValue("password")
454 if len(username) > s.Cfg.MaxUsernameBytes || len(password) > s.Cfg.MaxPasswordBytes {
455 http.Error(w, "Request too large", http.StatusRequestEntityTooLarge)
456 return
457 }
458 if username == db.AdminUsername {
459 settingsError(w, r, "Change the admin password under Password")
460 return
461 }
462 if !recentLogin(User(r)) {
463 redirectTo(w, r, reauthURL("/settings"))
464 return
465 }
466 if len(password) < minPasswordLength {
467 settingsError(w, r, "Password must be at least 8 characters")
468 return
469 }
470 target, err := s.DB.UserByName(r.Context(), username)
471 if err != nil {
472 http.Error(w, "Database error", http.StatusInternalServerError)
473 return
474 }
475 if target == nil {
476 settingsError(w, r, "User not found")
477 return
478 }
479 hash, err := db.HashPassword(password)
480 if err != nil {
481 http.Error(w, "Server error", http.StatusInternalServerError)
482 return
483 }
484 if err := s.DB.ResetCredentials(r.Context(), target.ID, hash); err != nil {
485 http.Error(w, "Database error", http.StatusInternalServerError)
486 return
487 }
488 settingsSuccess(w, r, "user_reset")
489}
490
491func (s *Server) adminApproveUser(w http.ResponseWriter, r *http.Request) {
492 id, err := formID(r, "id")
493 if err != nil {
494 settingsError(w, r, "User not found")
495 return
496 }
497 if err := s.DB.ApprovePendingUser(r.Context(), id); err != nil {
498 http.Error(w, "Database error", http.StatusInternalServerError)
499 return
500 }
501 settingsSuccess(w, r, "user_approved")
502}
503
504func (s *Server) adminDenyUser(w http.ResponseWriter, r *http.Request) {
505 id, err := formID(r, "id")
506 if err != nil {
507 settingsError(w, r, "User not found")
508 return
509 }
510 if err := s.DB.RejectPendingUser(r.Context(), id); err != nil {
511 http.Error(w, "Database error", http.StatusInternalServerError)
512 return
513 }
514 settingsSuccess(w, r, "user_denied")
515}
516
517func (s *Server) adminApproveAll(w http.ResponseWriter, r *http.Request) {
518 if err := s.DB.ApproveAllPendingUsers(r.Context()); err != nil {
519 http.Error(w, "Database error", http.StatusInternalServerError)
520 return
521 }
522 settingsSuccess(w, r, "all_approved")
523}
524
525func (s *Server) adminDenyAll(w http.ResponseWriter, r *http.Request) {
526 if err := s.DB.RejectAllPendingUsers(r.Context()); err != nil {
527 http.Error(w, "Database error", http.StatusInternalServerError)
528 return
529 }
530 settingsSuccess(w, r, "all_denied")
531}
532
533// formID reads a numeric form field.
534func formID(r *http.Request, name string) (int64, error) {
535 if err := r.ParseForm(); err != nil {
536 return 0, err
537 }
538 return strconv.ParseInt(r.FormValue(name), 10, 64)
539}
540
541// missingRepos lists repo rows whose git directory is gone. The check is
542// live, so a restored directory drops off the list on the next page load.
543func (s *Server) missingRepos(ctx context.Context) ([]db.RepoCounts, error) {
544 all, err := s.DB.AllRepoCounts(ctx)
545 if err != nil {
546 return nil, err
547 }
548 var missing []db.RepoCounts
549 for _, r := range all {
550 if _, err := os.Stat(s.Git.RepoPath(r.Name)); os.IsNotExist(err) {
551 missing = append(missing, r)
552 }
553 }
554 return missing, nil
555}
556
557func (s *Server) adminDropRepo(w http.ResponseWriter, r *http.Request) {
558 id, err := formID(r, "id")
559 if err != nil {
560 settingsError(w, r, "Repository not found")
561 return
562 }
563 missing, err := s.missingRepos(r.Context())
564 if err != nil {
565 http.Error(w, "Database error", http.StatusInternalServerError)
566 return
567 }
568 // Re-check on disk so a repo restored after the page rendered survives.
569 for _, m := range missing {
570 if m.ID == id {
571 if err := s.deleteRepoRow(r, id); err != nil {
572 http.Error(w, "Database error", http.StatusInternalServerError)
573 return
574 }
575 settingsSuccess(w, r, "repo_dropped")
576 return
577 }
578 }
579 settingsError(w, r, "Repository is not missing")
580}
581
582func (s *Server) adminDropAllRepos(w http.ResponseWriter, r *http.Request) {
583 missing, err := s.missingRepos(r.Context())
584 if err != nil {
585 http.Error(w, "Database error", http.StatusInternalServerError)
586 return
587 }
588 for _, m := range missing {
589 if err := s.deleteRepoRow(r, m.ID); err != nil {
590 http.Error(w, "Database error", http.StatusInternalServerError)
591 return
592 }
593 }
594 settingsSuccess(w, r, "repos_dropped")
595}
596