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