package web import ( "io" "net/http" "strconv" "strings" "github.com/go-chi/chi/v5" gossh "golang.org/x/crypto/ssh" "hearthforge/internal/avatar" "hearthforge/internal/db" "hearthforge/internal/util" "hearthforge/internal/web/views" ) // validKeyTypes are the SSH key types the settings page accepts. var validKeyTypes = map[string]bool{ "ssh-rsa": true, "ssh-ed25519": true, "ecdsa-sha2-nistp256": true, "ecdsa-sha2-nistp384": true, "ecdsa-sha2-nistp521": true, "sk-ssh-ed25519@openssh.com": true, "sk-ecdsa-sha2-nistp256@openssh.com": true, } // yearSeconds is the theme cookie lifetime, matching YEAR_SECONDS. const yearSeconds = 365 * 24 * 60 * 60 // Caps for the two SSH key fields that end up on the settings page. Both are // well above any real key: an ed25519 line is about 100 bytes, an RSA-4096 // line about 750, and the trailing comment is usually "user@host". const ( maxSSHKeyNameBytes = 100 maxSSHKeyBytes = 4096 ) // settingsRoutes registers the settings page and the admin user actions. func (s *Server) settingsRoutes(r chi.Router) { // The theme cookie needs no account. r.Post("/settings/theme", s.setTheme) r.Group(func(r chi.Router) { r.Use(s.requireAuth) r.Get("/settings", s.settingsPage) r.Post("/settings/password", s.changePassword) r.Post("/settings/password/remove", s.removePassword) r.Post("/settings/passkey/revoke", s.revokePasskey) r.Post("/settings/ssh-keys", s.addSSHKey) r.Post("/settings/ssh-keys/delete", s.deleteSSHKey) r.Post("/settings/avatar", s.uploadAvatar) r.Post("/settings/avatar/delete", s.resetAvatar) }) r.Group(func(r chi.Router) { r.Use(s.requireAdmin) r.Post("/admin/users", s.adminCreateUser) r.Post("/admin/users/delete", s.adminDeleteUser) r.Post("/admin/users/approve", s.adminApproveUser) r.Post("/admin/users/deny", s.adminDenyUser) r.Post("/admin/users/approve-all", s.adminApproveAll) r.Post("/admin/users/deny-all", s.adminDenyAll) }) } // settingsError redirects back to the settings page with an error message. func settingsError(w http.ResponseWriter, r *http.Request, msg string) { redirectTo(w, r, "/settings?error="+queryEscape(msg)) } func settingsSuccess(w http.ResponseWriter, r *http.Request, key string) { redirectTo(w, r, "/settings?success="+key) } func (s *Server) settingsPage(w http.ResponseWriter, r *http.Request) { u := User(r) row, err := s.DB.UserByID(r.Context(), u.ID) if err != nil || row == nil { http.Error(w, "Database error", http.StatusInternalServerError) return } passkeys, err := s.DB.ListPasskeys(r.Context(), u.ID) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } sshKeys, err := s.DB.ListSSHKeys(r.Context(), u.ID) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } var pending []db.PendingUser if u.IsAdmin && s.Cfg.RegistrationType == "queue" { if pending, err = s.DB.PendingUsers(r.Context()); err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } } theme := "auto" if c, err := r.Cookie("theme"); err == nil && c.Value != "" { theme = c.Value } q := r.URL.Query() views.Render(w, http.StatusOK, views.Settings(s.Cfg, u, row.PasswordHash != nil, passkeys, sshKeys, theme, q.Get("success"), q.Get("error"), pending)) } func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) { u := User(r) if err := r.ParseForm(); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } newPassword := r.FormValue("new_password") if tooLong(w, newPassword, s.Cfg.MaxPasswordBytes) || tooLong(w, r.FormValue("current_password"), s.Cfg.MaxPasswordBytes) { return } if len(newPassword) < minPasswordLength { settingsError(w, r, "Password must be at least 8 characters") return } if newPassword != r.FormValue("confirm_password") { settingsError(w, r, "Passwords do not match") return } row, err := s.DB.UserByID(r.Context(), u.ID) if err != nil || row == nil { http.Error(w, "Database error", http.StatusInternalServerError) return } if row.PasswordHash != nil { current := r.FormValue("current_password") if current == "" { settingsError(w, r, "Current password is required") return } ok, err := db.VerifyPassword(*row.PasswordHash, current) if err != nil || !ok { settingsError(w, r, "Current password is incorrect") return } } hash, err := db.HashPassword(newPassword) if err != nil { http.Error(w, "Server error", http.StatusInternalServerError) return } if err := s.DB.SetPasswordHash(r.Context(), u.ID, &hash); err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } settingsSuccess(w, r, "password") } func (s *Server) removePassword(w http.ResponseWriter, r *http.Request) { u := User(r) removed, err := s.DB.ClearPasswordHash(r.Context(), u.ID) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } if !removed { settingsError(w, r, "Cannot remove password without a passkey") return } settingsSuccess(w, r, "password_removed") } func (s *Server) revokePasskey(w http.ResponseWriter, r *http.Request) { u := User(r) id, err := formID(r, "id") if err != nil { settingsError(w, r, "Passkey not found") return } owner, found, err := s.DB.PasskeyOwner(r.Context(), id) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } if !found || owner != u.ID { settingsError(w, r, "Passkey not found") return } revoked, err := s.DB.RevokePasskey(r.Context(), u.ID, id) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } if !revoked { settingsError(w, r, "Cannot revoke last auth method") return } settingsSuccess(w, r, "passkey_revoked") } func (s *Server) setTheme(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } theme := r.FormValue("theme") if theme != "auto" && theme != "light" && theme != "dark" { settingsError(w, r, "Invalid theme") return } http.SetCookie(w, &http.Cookie{ Name: "theme", Value: theme, Path: "/", SameSite: http.SameSiteLaxMode, Secure: s.Cfg.PublicHTTPS, MaxAge: yearSeconds, }) settingsSuccess(w, r, "theme") } func (s *Server) addSSHKey(w http.ResponseWriter, r *http.Request) { u := User(r) if err := r.ParseForm(); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } line := strings.TrimSpace(r.FormValue("public_key")) // The key line is stored and its comment is rendered on the settings page. if tooLong(w, line, maxSSHKeyBytes) { return } keyType, _, _ := strings.Cut(line, " ") if !validKeyTypes[keyType] { settingsError(w, r, "Unsupported key type") return } key, _, _, _, err := gossh.ParseAuthorizedKey([]byte(line)) if err != nil { settingsError(w, r, "Invalid public key") return } name := strings.TrimSpace(r.FormValue("name")) if tooLong(w, name, maxSSHKeyNameBytes) { return } if name == "" { name = "Unnamed key" } // internal/sshd looks a key up by exactly this string. err = s.DB.CreateSSHKey(r.Context(), u.ID, name, line, gossh.FingerprintSHA256(key), db.NowISO()) if isUniqueViolation(err) { settingsError(w, r, "This key is already registered") return } if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } settingsSuccess(w, r, "ssh_key_added") } func (s *Server) deleteSSHKey(w http.ResponseWriter, r *http.Request) { u := User(r) id, err := formID(r, "id") if err != nil { settingsError(w, r, "Key not found") return } owner, found, err := s.DB.SSHKeyOwner(r.Context(), id) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } if !found || owner != u.ID { settingsError(w, r, "Key not found") return } if err := s.DB.DeleteSSHKey(r.Context(), id); err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } settingsSuccess(w, r, "ssh_key_deleted") } func (s *Server) uploadAvatar(w http.ResponseWriter, r *http.Request) { u := User(r) if s.limited(w, r, uploadLimiter, false) { return } file, _, err := r.FormFile("avatar") if err != nil { http.Error(w, "No avatar file", http.StatusBadRequest) return } defer file.Close() data, err := io.ReadAll(io.LimitReader(file, s.Cfg.MaxUserUploadBytes+1)) if err != nil { http.Error(w, "Could not read the upload", http.StatusBadRequest) return } if int64(len(data)) > s.Cfg.MaxUserUploadBytes { http.Error(w, "Avatar file too large", http.StatusBadRequest) return } if err := avatar.Save(s.Cfg.AvatarsDir(), u.ID, data); err != nil { http.Error(w, "File is not a supported image type", http.StatusBadRequest) return } if err := s.DB.BumpAvatarVersion(r.Context(), u.ID); err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } redirectTo(w, r, "/settings") } func (s *Server) resetAvatar(w http.ResponseWriter, r *http.Request) { u := User(r) if err := avatar.SaveDefault(s.Cfg.AvatarsDir(), u.ID, u.Username); err != nil { http.Error(w, "Could not reset the avatar", http.StatusInternalServerError) return } if err := s.DB.BumpAvatarVersion(r.Context(), u.ID); err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } redirectTo(w, r, "/settings") } func (s *Server) adminCreateUser(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } username := r.FormValue("username") password := r.FormValue("password") if len(username) > s.Cfg.MaxUsernameBytes || len(password) > s.Cfg.MaxPasswordBytes { http.Error(w, "Request too large", http.StatusRequestEntityTooLarge) return } if !util.ValidUsername(username) { settingsError(w, r, "Username may only contain letters, numbers, hyphens, and underscores") return } if username == db.AdminUsername { settingsError(w, r, "That username is reserved") return } if len(password) < minPasswordLength { settingsError(w, r, "Password must be at least 8 characters") return } hash, err := db.HashPassword(password) if err != nil { http.Error(w, "Server error", http.StatusInternalServerError) return } id, err := s.DB.CreateUser(r.Context(), username, &hash, db.NowISO(), false, nil) if isUniqueViolation(err) { settingsError(w, r, "Username already taken") return } if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } _ = avatar.SaveDefault(s.Cfg.AvatarsDir(), id, username) settingsSuccess(w, r, "user_created") } func (s *Server) adminDeleteUser(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } username := r.FormValue("username") if username == db.AdminUsername { settingsError(w, r, "Cannot delete admin user") return } target, err := s.DB.UserByName(r.Context(), username) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } if target == nil { settingsError(w, r, "User not found") return } if err := s.DB.DeleteUser(r.Context(), target.ID); err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } settingsSuccess(w, r, "user_deleted") } func (s *Server) adminApproveUser(w http.ResponseWriter, r *http.Request) { id, err := formID(r, "id") if err != nil { settingsError(w, r, "User not found") return } if err := s.DB.ApprovePendingUser(r.Context(), id); err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } settingsSuccess(w, r, "user_approved") } func (s *Server) adminDenyUser(w http.ResponseWriter, r *http.Request) { id, err := formID(r, "id") if err != nil { settingsError(w, r, "User not found") return } if err := s.DB.RejectPendingUser(r.Context(), id); err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } settingsSuccess(w, r, "user_denied") } func (s *Server) adminApproveAll(w http.ResponseWriter, r *http.Request) { if err := s.DB.ApproveAllPendingUsers(r.Context()); err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } settingsSuccess(w, r, "all_approved") } func (s *Server) adminDenyAll(w http.ResponseWriter, r *http.Request) { if err := s.DB.RejectAllPendingUsers(r.Context()); err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } settingsSuccess(w, r, "all_denied") } // formID reads a numeric form field. func formID(r *http.Request, name string) (int64, error) { if err := r.ParseForm(); err != nil { return 0, err } return strconv.ParseInt(r.FormValue(name), 10, 64) }