settings.go
⎇
Raw
1package web
2
3import (
4 "io"
5 "net/http"
6 "strconv"
7 "strings"
8
9 "github.com/go-chi/chi/v5"
10 gossh "golang.org/x/crypto/ssh"
11
12 "hearthforge/internal/avatar"
13 "hearthforge/internal/db"
14 "hearthforge/internal/util"
15 "hearthforge/internal/web/views"
16)
17
18// validKeyTypes are the SSH key types the settings page accepts.
19var validKeyTypes = map[string]bool{
20 "ssh-rsa": true,
21 "ssh-ed25519": true,
22 "ecdsa-sha2-nistp256": true,
23 "ecdsa-sha2-nistp384": true,
24 "ecdsa-sha2-nistp521": true,
25 "sk-ssh-ed25519@openssh.com": true,
26 "sk-ecdsa-sha2-nistp256@openssh.com": true,
27}
28
29// yearSeconds is the theme cookie lifetime, matching YEAR_SECONDS.
30const yearSeconds = 365 * 24 * 60 * 60
31
32// Caps for the two SSH key fields that end up on the settings page. Both are
33// well above any real key: an ed25519 line is about 100 bytes, an RSA-4096
34// line about 750, and the trailing comment is usually "user@host".
35const (
36 maxSSHKeyNameBytes = 100
37 maxSSHKeyBytes = 4096
38)
39
40// settingsRoutes registers the settings page and the admin user actions.
41func (s *Server) settingsRoutes(r chi.Router) {
42 // The theme cookie needs no account.
43 r.Post("/settings/theme", s.setTheme)
44
45 r.Group(func(r chi.Router) {
46 r.Use(s.requireAuth)
47 r.Get("/settings", s.settingsPage)
48 r.Post("/settings/password", s.changePassword)
49 r.Post("/settings/password/remove", s.removePassword)
50 r.Post("/settings/passkey/revoke", s.revokePasskey)
51 r.Post("/settings/ssh-keys", s.addSSHKey)
52 r.Post("/settings/ssh-keys/delete", s.deleteSSHKey)
53 r.Post("/settings/avatar", s.uploadAvatar)
54 r.Post("/settings/avatar/delete", s.resetAvatar)
55 })
56
57 r.Group(func(r chi.Router) {
58 r.Use(s.requireAdmin)
59 r.Post("/admin/users", s.adminCreateUser)
60 r.Post("/admin/users/delete", s.adminDeleteUser)
61 r.Post("/admin/users/approve", s.adminApproveUser)
62 r.Post("/admin/users/deny", s.adminDenyUser)
63 r.Post("/admin/users/approve-all", s.adminApproveAll)
64 r.Post("/admin/users/deny-all", s.adminDenyAll)
65 })
66}
67
68// settingsError redirects back to the settings page with an error message.
69func settingsError(w http.ResponseWriter, r *http.Request, msg string) {
70 redirectTo(w, r, "/settings?error="+queryEscape(msg))
71}
72
73func settingsSuccess(w http.ResponseWriter, r *http.Request, key string) {
74 redirectTo(w, r, "/settings?success="+key)
75}
76
77func (s *Server) settingsPage(w http.ResponseWriter, r *http.Request) {
78 u := User(r)
79 row, err := s.DB.UserByID(r.Context(), u.ID)
80 if err != nil || row == nil {
81 http.Error(w, "Database error", http.StatusInternalServerError)
82 return
83 }
84 passkeys, err := s.DB.ListPasskeys(r.Context(), u.ID)
85 if err != nil {
86 http.Error(w, "Database error", http.StatusInternalServerError)
87 return
88 }
89 sshKeys, err := s.DB.ListSSHKeys(r.Context(), u.ID)
90 if err != nil {
91 http.Error(w, "Database error", http.StatusInternalServerError)
92 return
93 }
94 var pending []db.PendingUser
95 if u.IsAdmin && s.Cfg.RegistrationType == "queue" {
96 if pending, err = s.DB.PendingUsers(r.Context()); err != nil {
97 http.Error(w, "Database error", http.StatusInternalServerError)
98 return
99 }
100 }
101
102 theme := "auto"
103 if c, err := r.Cookie("theme"); err == nil && c.Value != "" {
104 theme = c.Value
105 }
106 q := r.URL.Query()
107 views.Render(w, http.StatusOK, views.Settings(s.Cfg, u, row.PasswordHash != nil,
108 passkeys, sshKeys, theme, q.Get("success"), q.Get("error"), pending))
109}
110
111func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
112 u := User(r)
113 if err := r.ParseForm(); err != nil {
114 http.Error(w, "Bad request", http.StatusBadRequest)
115 return
116 }
117 newPassword := r.FormValue("new_password")
118 if tooLong(w, newPassword, s.Cfg.MaxPasswordBytes) ||
119 tooLong(w, r.FormValue("current_password"), s.Cfg.MaxPasswordBytes) {
120 return
121 }
122 if len(newPassword) < minPasswordLength {
123 settingsError(w, r, "Password must be at least 8 characters")
124 return
125 }
126 if newPassword != r.FormValue("confirm_password") {
127 settingsError(w, r, "Passwords do not match")
128 return
129 }
130
131 row, err := s.DB.UserByID(r.Context(), u.ID)
132 if err != nil || row == nil {
133 http.Error(w, "Database error", http.StatusInternalServerError)
134 return
135 }
136 if row.PasswordHash != nil {
137 current := r.FormValue("current_password")
138 if current == "" {
139 settingsError(w, r, "Current password is required")
140 return
141 }
142 ok, err := db.VerifyPassword(*row.PasswordHash, current)
143 if err != nil || !ok {
144 settingsError(w, r, "Current password is incorrect")
145 return
146 }
147 }
148
149 hash, err := db.HashPassword(newPassword)
150 if err != nil {
151 http.Error(w, "Server error", http.StatusInternalServerError)
152 return
153 }
154 if err := s.DB.SetPasswordHash(r.Context(), u.ID, &hash); err != nil {
155 http.Error(w, "Database error", http.StatusInternalServerError)
156 return
157 }
158 settingsSuccess(w, r, "password")
159}
160
161func (s *Server) removePassword(w http.ResponseWriter, r *http.Request) {
162 u := User(r)
163 removed, err := s.DB.ClearPasswordHash(r.Context(), u.ID)
164 if err != nil {
165 http.Error(w, "Database error", http.StatusInternalServerError)
166 return
167 }
168 if !removed {
169 settingsError(w, r, "Cannot remove password without a passkey")
170 return
171 }
172 settingsSuccess(w, r, "password_removed")
173}
174
175func (s *Server) revokePasskey(w http.ResponseWriter, r *http.Request) {
176 u := User(r)
177 id, err := formID(r, "id")
178 if err != nil {
179 settingsError(w, r, "Passkey not found")
180 return
181 }
182 owner, found, err := s.DB.PasskeyOwner(r.Context(), id)
183 if err != nil {
184 http.Error(w, "Database error", http.StatusInternalServerError)
185 return
186 }
187 if !found || owner != u.ID {
188 settingsError(w, r, "Passkey not found")
189 return
190 }
191 revoked, err := s.DB.RevokePasskey(r.Context(), u.ID, id)
192 if err != nil {
193 http.Error(w, "Database error", http.StatusInternalServerError)
194 return
195 }
196 if !revoked {
197 settingsError(w, r, "Cannot revoke last auth method")
198 return
199 }
200 settingsSuccess(w, r, "passkey_revoked")
201}
202
203func (s *Server) setTheme(w http.ResponseWriter, r *http.Request) {
204 if err := r.ParseForm(); err != nil {
205 http.Error(w, "Bad request", http.StatusBadRequest)
206 return
207 }
208 theme := r.FormValue("theme")
209 if theme != "auto" && theme != "light" && theme != "dark" {
210 settingsError(w, r, "Invalid theme")
211 return
212 }
213 http.SetCookie(w, &http.Cookie{
214 Name: "theme",
215 Value: theme,
216 Path: "/",
217 SameSite: http.SameSiteLaxMode,
218 Secure: s.Cfg.PublicHTTPS,
219 MaxAge: yearSeconds,
220 })
221 settingsSuccess(w, r, "theme")
222}
223
224func (s *Server) addSSHKey(w http.ResponseWriter, r *http.Request) {
225 u := User(r)
226 if err := r.ParseForm(); err != nil {
227 http.Error(w, "Bad request", http.StatusBadRequest)
228 return
229 }
230 line := strings.TrimSpace(r.FormValue("public_key"))
231 // The key line is stored and its comment is rendered on the settings page.
232 if tooLong(w, line, maxSSHKeyBytes) {
233 return
234 }
235 keyType, _, _ := strings.Cut(line, " ")
236 if !validKeyTypes[keyType] {
237 settingsError(w, r, "Unsupported key type")
238 return
239 }
240 key, _, _, _, err := gossh.ParseAuthorizedKey([]byte(line))
241 if err != nil {
242 settingsError(w, r, "Invalid public key")
243 return
244 }
245
246 name := strings.TrimSpace(r.FormValue("name"))
247 if tooLong(w, name, maxSSHKeyNameBytes) {
248 return
249 }
250 if name == "" {
251 name = "Unnamed key"
252 }
253 // internal/sshd looks a key up by exactly this string.
254 err = s.DB.CreateSSHKey(r.Context(), u.ID, name, line, gossh.FingerprintSHA256(key), db.NowISO())
255 if isUniqueViolation(err) {
256 settingsError(w, r, "This key is already registered")
257 return
258 }
259 if err != nil {
260 http.Error(w, "Database error", http.StatusInternalServerError)
261 return
262 }
263 settingsSuccess(w, r, "ssh_key_added")
264}
265
266func (s *Server) deleteSSHKey(w http.ResponseWriter, r *http.Request) {
267 u := User(r)
268 id, err := formID(r, "id")
269 if err != nil {
270 settingsError(w, r, "Key not found")
271 return
272 }
273 owner, found, err := s.DB.SSHKeyOwner(r.Context(), id)
274 if err != nil {
275 http.Error(w, "Database error", http.StatusInternalServerError)
276 return
277 }
278 if !found || owner != u.ID {
279 settingsError(w, r, "Key not found")
280 return
281 }
282 if err := s.DB.DeleteSSHKey(r.Context(), id); err != nil {
283 http.Error(w, "Database error", http.StatusInternalServerError)
284 return
285 }
286 settingsSuccess(w, r, "ssh_key_deleted")
287}
288
289func (s *Server) uploadAvatar(w http.ResponseWriter, r *http.Request) {
290 u := User(r)
291 if s.limited(w, r, uploadLimiter, false) {
292 return
293 }
294 file, _, err := r.FormFile("avatar")
295 if err != nil {
296 http.Error(w, "No avatar file", http.StatusBadRequest)
297 return
298 }
299 defer file.Close()
300 data, err := io.ReadAll(io.LimitReader(file, s.Cfg.MaxUserUploadBytes+1))
301 if err != nil {
302 http.Error(w, "Could not read the upload", http.StatusBadRequest)
303 return
304 }
305 if int64(len(data)) > s.Cfg.MaxUserUploadBytes {
306 http.Error(w, "Avatar file too large", http.StatusBadRequest)
307 return
308 }
309 if err := avatar.Save(s.Cfg.AvatarsDir(), u.ID, data); err != nil {
310 http.Error(w, "File is not a supported image type", http.StatusBadRequest)
311 return
312 }
313 if err := s.DB.BumpAvatarVersion(r.Context(), u.ID); err != nil {
314 http.Error(w, "Database error", http.StatusInternalServerError)
315 return
316 }
317 redirectTo(w, r, "/settings")
318}
319
320func (s *Server) resetAvatar(w http.ResponseWriter, r *http.Request) {
321 u := User(r)
322 if err := avatar.SaveDefault(s.Cfg.AvatarsDir(), u.ID, u.Username); err != nil {
323 http.Error(w, "Could not reset the avatar", http.StatusInternalServerError)
324 return
325 }
326 if err := s.DB.BumpAvatarVersion(r.Context(), u.ID); err != nil {
327 http.Error(w, "Database error", http.StatusInternalServerError)
328 return
329 }
330 redirectTo(w, r, "/settings")
331}
332
333func (s *Server) adminCreateUser(w http.ResponseWriter, r *http.Request) {
334 if err := r.ParseForm(); err != nil {
335 http.Error(w, "Bad request", http.StatusBadRequest)
336 return
337 }
338 username := r.FormValue("username")
339 password := r.FormValue("password")
340 if len(username) > s.Cfg.MaxUsernameBytes || len(password) > s.Cfg.MaxPasswordBytes {
341 http.Error(w, "Request too large", http.StatusRequestEntityTooLarge)
342 return
343 }
344 if !util.ValidUsername(username) {
345 settingsError(w, r, "Username may only contain letters, numbers, hyphens, and underscores")
346 return
347 }
348 if username == db.AdminUsername {
349 settingsError(w, r, "That username is reserved")
350 return
351 }
352 if len(password) < minPasswordLength {
353 settingsError(w, r, "Password must be at least 8 characters")
354 return
355 }
356
357 hash, err := db.HashPassword(password)
358 if err != nil {
359 http.Error(w, "Server error", http.StatusInternalServerError)
360 return
361 }
362 id, err := s.DB.CreateUser(r.Context(), username, &hash, db.NowISO(), false, nil)
363 if isUniqueViolation(err) {
364 settingsError(w, r, "Username already taken")
365 return
366 }
367 if err != nil {
368 http.Error(w, "Database error", http.StatusInternalServerError)
369 return
370 }
371 _ = avatar.SaveDefault(s.Cfg.AvatarsDir(), id, username)
372 settingsSuccess(w, r, "user_created")
373}
374
375func (s *Server) adminDeleteUser(w http.ResponseWriter, r *http.Request) {
376 if err := r.ParseForm(); err != nil {
377 http.Error(w, "Bad request", http.StatusBadRequest)
378 return
379 }
380 username := r.FormValue("username")
381 if username == db.AdminUsername {
382 settingsError(w, r, "Cannot delete admin user")
383 return
384 }
385 target, err := s.DB.UserByName(r.Context(), username)
386 if err != nil {
387 http.Error(w, "Database error", http.StatusInternalServerError)
388 return
389 }
390 if target == nil {
391 settingsError(w, r, "User not found")
392 return
393 }
394 if err := s.DB.DeleteUser(r.Context(), target.ID); err != nil {
395 http.Error(w, "Database error", http.StatusInternalServerError)
396 return
397 }
398 settingsSuccess(w, r, "user_deleted")
399}
400
401func (s *Server) adminApproveUser(w http.ResponseWriter, r *http.Request) {
402 id, err := formID(r, "id")
403 if err != nil {
404 settingsError(w, r, "User not found")
405 return
406 }
407 if err := s.DB.ApprovePendingUser(r.Context(), id); err != nil {
408 http.Error(w, "Database error", http.StatusInternalServerError)
409 return
410 }
411 settingsSuccess(w, r, "user_approved")
412}
413
414func (s *Server) adminDenyUser(w http.ResponseWriter, r *http.Request) {
415 id, err := formID(r, "id")
416 if err != nil {
417 settingsError(w, r, "User not found")
418 return
419 }
420 if err := s.DB.RejectPendingUser(r.Context(), id); err != nil {
421 http.Error(w, "Database error", http.StatusInternalServerError)
422 return
423 }
424 settingsSuccess(w, r, "user_denied")
425}
426
427func (s *Server) adminApproveAll(w http.ResponseWriter, r *http.Request) {
428 if err := s.DB.ApproveAllPendingUsers(r.Context()); err != nil {
429 http.Error(w, "Database error", http.StatusInternalServerError)
430 return
431 }
432 settingsSuccess(w, r, "all_approved")
433}
434
435func (s *Server) adminDenyAll(w http.ResponseWriter, r *http.Request) {
436 if err := s.DB.RejectAllPendingUsers(r.Context()); err != nil {
437 http.Error(w, "Database error", http.StatusInternalServerError)
438 return
439 }
440 settingsSuccess(w, r, "all_denied")
441}
442
443// formID reads a numeric form field.
444func formID(r *http.Request, name string) (int64, error) {
445 if err := r.ParseForm(); err != nil {
446 return 0, err
447 }
448 return strconv.ParseInt(r.FormValue(name), 10, 64)
449}
450