spread

https://git.tonybtw.com/spread.git git://git.tonybtw.com/spread.git
4,183 bytes raw
1
package app
2
3
import (
4
	"context"
5
	"errors"
6
	"fmt"
7
	"net/http"
8
	"net/url"
9
10
	"github.com/jackc/pgx/v5"
11
)
12
13
// --- email bodies ---------------------------------------------------------
14
15
func (a *App) tokenLink(raw string) string {
16
	return a.baseURL + "/set-password?token=" + url.QueryEscape(raw)
17
}
18
19
func mailText(intro, link string) string {
20
	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)
21
}
22
23
func mailHTML(intro, cta, link string) string {
24
	return fmt.Sprintf(
25
		`<p>%s</p><p><a href="%s">%s</a></p><p style="color:#888;font-size:12px">If you weren't expecting this, you can ignore it.</p>`,
26
		intro, link, cta)
27
}
28
29
func (a *App) sendInvite(ctx context.Context, userID int64, email string) error {
30
	raw, err := a.issueToken(ctx, userID, "invite", inviteTTL)
31
	if err != nil {
32
		return err
33
	}
34
	link := a.tokenLink(raw)
35
	return a.mailer.Send([]string{email}, "You're invited to Spread",
36
		mailHTML("You've been invited to Spread. Set a password to get started.", "Set your password", link),
37
		mailText("You've been invited to Spread. Set a password to get started.", link))
38
}
39
40
func (a *App) sendReset(ctx context.Context, userID int64, email string) error {
41
	raw, err := a.issueToken(ctx, userID, "reset", resetTTL)
42
	if err != nil {
43
		return err
44
	}
45
	link := a.tokenLink(raw)
46
	return a.mailer.Send([]string{email}, "Reset your Spread password",
47
		mailHTML("Someone asked to reset your Spread password.", "Choose a new password", link),
48
		mailText("Someone asked to reset your Spread password.", link))
49
}
50
51
// --- forgot password ------------------------------------------------------
52
53
func (a *App) handleForgotForm(w http.ResponseWriter, r *http.Request) {
54
	a.render(w, r, http.StatusOK, "forgot", pageData{Title: "Reset password"})
55
}
56
57
func (a *App) handleForgot(w http.ResponseWriter, r *http.Request) {
58
	email := normEmail(r.PostFormValue("email"))
59
60
	var uid int64
61
	err := a.pool.QueryRow(r.Context(), `SELECT id FROM users WHERE email = $1`, email).Scan(&uid)
62
	if err == nil {
63
		if err := a.sendReset(r.Context(), uid, email); err != nil {
64
			http.Error(w, "could not send reset email", http.StatusInternalServerError)
65
			return
66
		}
67
	} else if !errors.Is(err, pgx.ErrNoRows) {
68
		http.Error(w, err.Error(), http.StatusInternalServerError)
69
		return
70
	}
71
	// Always the same response, so we don't reveal which emails have accounts.
72
	a.render(w, r, http.StatusOK, "forgot", pageData{
73
		Title:  "Reset password",
74
		Notice: "If that email has an account, a reset link is on its way.",
75
	})
76
}
77
78
// --- set / reset password (shared by invites and resets) ------------------
79
80
func (a *App) handleSetPasswordForm(w http.ResponseWriter, r *http.Request) {
81
	raw := r.URL.Query().Get("token")
82
	if err := a.peekToken(r.Context(), raw); err != nil {
83
		a.render(w, r, http.StatusBadRequest, "tokenerror", pageData{Title: "Link expired"})
84
		return
85
	}
86
	a.render(w, r, http.StatusOK, "setpassword", pageData{Title: "Set your password", Token: raw})
87
}
88
89
func (a *App) handleSetPassword(w http.ResponseWriter, r *http.Request) {
90
	raw := r.PostFormValue("token")
91
	password := r.PostFormValue("password")
92
	confirm := r.PostFormValue("confirm")
93
94
	if len(password) < 8 || password != confirm {
95
		msg := "Password must be at least 8 characters."
96
		if password != confirm {
97
			msg = "Those passwords don't match."
98
		}
99
		a.render(w, r, http.StatusBadRequest, "setpassword", pageData{Title: "Set your password", Token: raw, Error: msg})
100
		return
101
	}
102
103
	userID, err := a.consumeToken(r.Context(), raw)
104
	if err != nil {
105
		a.render(w, r, http.StatusBadRequest, "tokenerror", pageData{Title: "Link expired"})
106
		return
107
	}
108
	if err := a.setPassword(r.Context(), userID, password); err != nil {
109
		http.Error(w, err.Error(), http.StatusInternalServerError)
110
		return
111
	}
112
113
	// Log them straight in.
114
	sessRaw, err := a.createSession(r.Context(), userID)
115
	if err != nil {
116
		http.Redirect(w, r, "/login", http.StatusSeeOther)
117
		return
118
	}
119
	a.setSessionCookie(w, sessRaw)
120
121
	dest := "/app"
122
	if sub, err := a.subscriberForUser(r.Context(), userID); err == nil && sub.IsInternal {
123
		dest = "/admin"
124
	}
125
	http.Redirect(w, r, dest, http.StatusSeeOther)
126
}