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