package app import ( "context" "errors" "fmt" "net/http" "net/url" "github.com/jackc/pgx/v5" ) // --- email bodies --------------------------------------------------------- func (a *App) tokenLink(raw string) string { return a.baseURL + "/set-password?token=" + url.QueryEscape(raw) } func mailText(intro, link string) string { return fmt.Sprintf("%s\n\nOpen this link to set your password:\n%s\n\nIf you weren't expecting this, you can ignore it.\n", intro, link) } func mailHTML(intro, cta, link string) string { return fmt.Sprintf( `

%s

%s

If you weren't expecting this, you can ignore it.

`, intro, link, cta) } func (a *App) sendInvite(ctx context.Context, userID int64, email string) error { raw, err := a.issueToken(ctx, userID, "invite", inviteTTL) if err != nil { return err } link := a.tokenLink(raw) return a.mailer.Send([]string{email}, "You're invited to Spread", mailHTML("You've been invited to Spread. Set a password to get started.", "Set your password", link), mailText("You've been invited to Spread. Set a password to get started.", link)) } func (a *App) sendReset(ctx context.Context, userID int64, email string) error { raw, err := a.issueToken(ctx, userID, "reset", resetTTL) if err != nil { return err } link := a.tokenLink(raw) return a.mailer.Send([]string{email}, "Reset your Spread password", mailHTML("Someone asked to reset your Spread password.", "Choose a new password", link), mailText("Someone asked to reset your Spread password.", link)) } // --- forgot password ------------------------------------------------------ func (a *App) handleForgotForm(w http.ResponseWriter, r *http.Request) { a.render(w, r, http.StatusOK, "forgot", pageData{Title: "Reset password"}) } func (a *App) handleForgot(w http.ResponseWriter, r *http.Request) { email := normEmail(r.PostFormValue("email")) var uid int64 err := a.pool.QueryRow(r.Context(), `SELECT id FROM users WHERE email = $1`, email).Scan(&uid) if err == nil { if err := a.sendReset(r.Context(), uid, email); err != nil { http.Error(w, "could not send reset email", http.StatusInternalServerError) return } } else if !errors.Is(err, pgx.ErrNoRows) { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Always the same response, so we don't reveal which emails have accounts. a.render(w, r, http.StatusOK, "forgot", pageData{ Title: "Reset password", Notice: "If that email has an account, a reset link is on its way.", }) } // --- set / reset password (shared by invites and resets) ------------------ func (a *App) handleSetPasswordForm(w http.ResponseWriter, r *http.Request) { raw := r.URL.Query().Get("token") if err := a.peekToken(r.Context(), raw); err != nil { a.render(w, r, http.StatusBadRequest, "tokenerror", pageData{Title: "Link expired"}) return } a.render(w, r, http.StatusOK, "setpassword", pageData{Title: "Set your password", Token: raw}) } func (a *App) handleSetPassword(w http.ResponseWriter, r *http.Request) { raw := r.PostFormValue("token") password := r.PostFormValue("password") confirm := r.PostFormValue("confirm") if len(password) < 8 || password != confirm { msg := "Password must be at least 8 characters." if password != confirm { msg = "Those passwords don't match." } a.render(w, r, http.StatusBadRequest, "setpassword", pageData{Title: "Set your password", Token: raw, Error: msg}) return } userID, err := a.consumeToken(r.Context(), raw) if err != nil { a.render(w, r, http.StatusBadRequest, "tokenerror", pageData{Title: "Link expired"}) return } if err := a.setPassword(r.Context(), userID, password); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Log them straight in. sessRaw, err := a.createSession(r.Context(), userID) if err != nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } a.setSessionCookie(w, sessRaw) dest := "/app" if sub, err := a.subscriberForUser(r.Context(), userID); err == nil && sub.IsInternal { dest = "/admin" } http.Redirect(w, r, dest, http.StatusSeeOther) }