Sends a branded Resend email when a question receives its first answer, records accepted and failed sends, and adds collapsed answer editing with cancel behavior. Co-authored-by: codegirl-007 <s.raide@gmail.com>
202 lines
5.5 KiB
Go
202 lines
5.5 KiB
Go
package web
|
||
|
||
import (
|
||
"crypto/subtle"
|
||
"database/sql"
|
||
"errors"
|
||
"log"
|
||
"net/http"
|
||
"net/url"
|
||
"regexp"
|
||
"strings"
|
||
"unicode/utf8"
|
||
|
||
"golang.org/x/crypto/bcrypt"
|
||
|
||
"plumber/internal/store"
|
||
)
|
||
|
||
var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_]{3,20}$`)
|
||
|
||
const (
|
||
minPasswordRunes = 8
|
||
maxPasswordBytes = 72 // bcrypt truncation limit
|
||
)
|
||
|
||
// loginDummyHash is compared when the username is unknown so login timing
|
||
// does not reveal whether an account exists (same bcrypt cost as real hashes).
|
||
var loginDummyHash = mustBcrypt("timing-dummy-not-a-real-password")
|
||
|
||
func mustBcrypt(s string) []byte {
|
||
h, err := bcrypt.GenerateFromPassword([]byte(s), bcrypt.DefaultCost)
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
return h
|
||
}
|
||
|
||
func safeNext(raw string) string {
|
||
if raw == "" {
|
||
return "/"
|
||
}
|
||
u, err := url.Parse(raw)
|
||
if err != nil || u.IsAbs() || !strings.HasPrefix(u.Path, "/") || strings.HasPrefix(u.Path, "//") {
|
||
return "/"
|
||
}
|
||
return u.RequestURI()
|
||
}
|
||
|
||
func passwordValid(password string) (ok bool, msg string) {
|
||
if utf8.RuneCountInString(password) < minPasswordRunes {
|
||
return false, "Password must be at least 8 characters."
|
||
}
|
||
if len(password) > maxPasswordBytes {
|
||
return false, "Password must be at most 72 bytes."
|
||
}
|
||
return true, ""
|
||
}
|
||
|
||
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
|
||
if currentUser(r) != nil {
|
||
http.Redirect(w, r, safeNext(r.URL.Query().Get("next")), http.StatusSeeOther)
|
||
return
|
||
}
|
||
s.exec(w, "login", authPage{
|
||
page: s.basePage(r, "Sign in"),
|
||
Next: r.URL.Query().Get("next"),
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||
if !s.requireCSRF(w, r) {
|
||
return
|
||
}
|
||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||
password := r.PostFormValue("password")
|
||
next := safeNext(r.PostFormValue("next"))
|
||
userKey := store.NormalizeUsername(username)
|
||
ip := s.clientIP(r)
|
||
if !s.allowLoginAttempt(w, r, userKey) {
|
||
return
|
||
}
|
||
|
||
u, err := s.store.UserByUsername(r.Context(), username)
|
||
hash := loginDummyHash
|
||
switch {
|
||
case err == nil:
|
||
hash = []byte(u.PasswordHash)
|
||
case errors.Is(err, sql.ErrNoRows):
|
||
// unknown user — still bcrypt against dummy hash
|
||
default:
|
||
log.Printf("login lookup: %v", err)
|
||
_ = bcrypt.CompareHashAndPassword(loginDummyHash, []byte(password))
|
||
http.Error(w, "service temporarily unavailable", http.StatusServiceUnavailable)
|
||
return
|
||
}
|
||
if err != nil || bcrypt.CompareHashAndPassword(hash, []byte(password)) != nil {
|
||
s.loginFail.record(loginFailKey(ip, userKey))
|
||
w.WriteHeader(http.StatusUnauthorized)
|
||
s.exec(w, "login", authPage{
|
||
page: s.basePage(r, "Sign in"),
|
||
Username: username,
|
||
Next: next,
|
||
Error: "Wrong username or password.",
|
||
})
|
||
return
|
||
}
|
||
s.loginFail.clear(loginFailKey(ip, userKey))
|
||
if err := s.sessions.RenewToken(r.Context()); err != nil {
|
||
http.Error(w, "could not start session", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
s.sessions.Put(r.Context(), "user_id", u.ID)
|
||
http.Redirect(w, r, next, http.StatusSeeOther)
|
||
}
|
||
|
||
func (s *Server) handleRegisterForm(w http.ResponseWriter, r *http.Request) {
|
||
if currentUser(r) != nil {
|
||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||
return
|
||
}
|
||
s.exec(w, "register", authPage{page: s.basePage(r, "Create account")})
|
||
}
|
||
|
||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||
if !s.requireCSRF(w, r) {
|
||
return
|
||
}
|
||
if !s.allowRegisterAttempt(w, r) {
|
||
return
|
||
}
|
||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||
emailRaw := r.PostFormValue("email")
|
||
password := r.PostFormValue("password")
|
||
setupSecret := r.PostFormValue("setup_secret")
|
||
p := authPage{page: s.basePage(r, "Create account"), Username: username, Email: strings.TrimSpace(emailRaw)}
|
||
if !usernameRe.MatchString(username) {
|
||
p.Error = "Username must be 3–20 letters, numbers, or underscores."
|
||
s.exec(w, "register", p)
|
||
return
|
||
}
|
||
email, emailErr := store.ValidateEmail(emailRaw)
|
||
if emailErr != "" {
|
||
p.Error = emailErr
|
||
s.exec(w, "register", p)
|
||
return
|
||
}
|
||
if ok, msg := passwordValid(password); !ok {
|
||
p.Error = msg
|
||
s.exec(w, "register", p)
|
||
return
|
||
}
|
||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||
if err != nil {
|
||
http.Error(w, "could not save password", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
role := store.RoleUser
|
||
if setupSecretMatches(s.cfg.AdminSetupSecret, setupSecret) {
|
||
role = store.RoleAdmin
|
||
}
|
||
u := &store.User{
|
||
Username: username,
|
||
Email: email,
|
||
PasswordHash: string(hash),
|
||
Role: role,
|
||
}
|
||
if err := s.store.CreateUser(r.Context(), u); err != nil {
|
||
if errors.Is(err, store.ErrDuplicateUsername) {
|
||
p.Error = "That username is taken."
|
||
s.exec(w, "register", p)
|
||
return
|
||
}
|
||
if errors.Is(err, store.ErrDuplicateEmail) {
|
||
p.Error = "That email is already registered."
|
||
s.exec(w, "register", p)
|
||
return
|
||
}
|
||
log.Printf("register create: %v", err)
|
||
http.Error(w, "could not create account", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if err := s.sessions.RenewToken(r.Context()); err != nil {
|
||
log.Printf("register session: %v", err)
|
||
s.sessions.Put(r.Context(), "flash", "Account created — please sign in.")
|
||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||
return
|
||
}
|
||
s.sessions.Put(r.Context(), "user_id", u.ID)
|
||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||
}
|
||
|
||
func setupSecretMatches(want, provided string) bool {
|
||
if want == "" || provided == "" {
|
||
return false
|
||
}
|
||
return subtle.ConstantTimeCompare([]byte(provided), []byte(want)) == 1
|
||
}
|
||
|
||
func (s *Server) handleAuthPrompt(w http.ResponseWriter, r *http.Request) {
|
||
s.exec(w, "signin-prompt", s.basePage(r, ""))
|
||
}
|