Recently Written · git

recentlywritten

my writings (done recently)

git clone https://github.com/equwal/recentlywritten

Log | Files | Refs


accounts/handlers.go (8832 bytes)

1 package main
2 
3 import (
4 	"bytes"
5 	"database/sql"
6 	_ "embed"
7 	"errors"
8 	"html/template"
9 	"log"
10 	"net/http"
11 	"net/mail"
12 	"strings"
13 	"time"
14 )
15 
16 //go:embed templates.html
17 var templateText string
18 
19 var pages = template.Must(template.New("").Parse(templateText))
20 
21 type app struct {
22 	cfg  config
23 	db   *sql.DB
24 	mail mailer
25 	now  func() time.Time
26 	log  *log.Logger
27 }
28 
29 const sessionCookie = "rw_session"
30 
31 // routes gives the handler for all paths under /account/. The web server
32 // in front sends only these paths to this service.
33 //
34 // Each change of state uses POST. A GET request from a link in an email
35 // only shows a page with a button, because mail scanners open the links
36 // in email and must not use a sign-in link or unsubscribe a user. The
37 // session cookie is SameSite=Lax, so a form on another site cannot send a
38 // POST request with the cookie.
39 func (a *app) routes() http.Handler {
40 	mux := http.NewServeMux()
41 	mux.HandleFunc("GET /account/{$}", a.home)
42 	mux.HandleFunc("POST /account/signin", a.signin)
43 	mux.HandleFunc("GET /account/verify", a.verifyPage)
44 	mux.HandleFunc("POST /account/verify", a.verify)
45 	mux.HandleFunc("POST /account/settings", a.withUser(a.settings))
46 	mux.HandleFunc("POST /account/feed/rotate", a.withUser(a.rotateFeed))
47 	mux.HandleFunc("POST /account/signout", a.signout)
48 	mux.HandleFunc("POST /account/delete", a.withUser(a.deleteAccount))
49 	mux.HandleFunc("GET /account/unsubscribe", a.unsubscribePage)
50 	mux.HandleFunc("POST /account/unsubscribe", a.unsubscribe)
51 	mux.HandleFunc("GET /account/feed/{token}", a.feed)
52 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
53 		// The URLs of some pages contain tokens. These headers keep the
54 		// tokens out of caches and out of the Referer header.
55 		w.Header().Set("Cache-Control", "no-store")
56 		w.Header().Set("Referrer-Policy", "no-referrer")
57 		r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
58 		mux.ServeHTTP(w, r)
59 	})
60 }
61 
62 func (a *app) render(w http.ResponseWriter, status int, name string, data any) {
63 	var b bytes.Buffer
64 	if err := pages.ExecuteTemplate(&b, name, data); err != nil {
65 		a.fail(w, err)
66 		return
67 	}
68 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
69 	w.WriteHeader(status)
70 	b.WriteTo(w)
71 }
72 
73 func (a *app) fail(w http.ResponseWriter, err error) {
74 	a.log.Printf("error: %v", err)
75 	http.Error(w, "Internal error", http.StatusInternalServerError)
76 }
77 
78 func (a *app) currentUser(r *http.Request) (user, error) {
79 	c, err := r.Cookie(sessionCookie)
80 	if err != nil {
81 		return user{}, errNotFound
82 	}
83 	return sessionUser(a.db, c.Value, a.now())
84 }
85 
86 func (a *app) withUser(h func(http.ResponseWriter, *http.Request, user)) http.HandlerFunc {
87 	return func(w http.ResponseWriter, r *http.Request) {
88 		u, err := a.currentUser(r)
89 		if errors.Is(err, errNotFound) {
90 			http.Redirect(w, r, "/account/", http.StatusSeeOther)
91 			return
92 		}
93 		if err != nil {
94 			a.fail(w, err)
95 			return
96 		}
97 		h(w, r, u)
98 	}
99 }
100 
101 func (a *app) setSessionCookie(w http.ResponseWriter, value string, maxAge int) {
102 	http.SetCookie(w, &http.Cookie{
103 		Name:     sessionCookie,
104 		Value:    value,
105 		Path:     "/account/",
106 		MaxAge:   maxAge,
107 		HttpOnly: true,
108 		Secure:   strings.HasPrefix(a.cfg.BaseURL, "https://"),
109 		SameSite: http.SameSiteLaxMode,
110 	})
111 }
112 
113 type tagChoice struct {
114 	Name string
115 	On   bool
116 }
117 
118 func (a *app) home(w http.ResponseWriter, r *http.Request) {
119 	u, err := a.currentUser(r)
120 	if errors.Is(err, errNotFound) {
121 		a.render(w, http.StatusOK, "signin", map[string]string{})
122 		return
123 	}
124 	if err != nil {
125 		a.fail(w, err)
126 		return
127 	}
128 	muted, err := mutedTags(a.db, u.ID)
129 	if err != nil {
130 		a.fail(w, err)
131 		return
132 	}
133 	posts, err := loadPosts(a.cfg.SiteDir)
134 	if err != nil {
135 		a.fail(w, err)
136 		return
137 	}
138 	var tags []tagChoice
139 	for _, t := range allTags(posts) {
140 		tags = append(tags, tagChoice{Name: t, On: !muted[t]})
141 	}
142 	a.render(w, http.StatusOK, "settings", map[string]any{
143 		"Email":   u.Email,
144 		"EmailOn": u.EmailOn,
145 		"Tags":    tags,
146 		"FeedURL": a.cfg.BaseURL + "/account/feed/" + u.FeedToken,
147 	})
148 }
149 
150 // normalizeEmail returns the address in lower case, or an error if s is
151 // not one plain address such as "name@example.com".
152 func normalizeEmail(s string) (string, error) {
153 	s = strings.TrimSpace(s)
154 	addr, err := mail.ParseAddress(s)
155 	if err != nil || addr.Name != "" || addr.Address != s || len(s) > 254 {
156 		return "", errors.New("not a valid address")
157 	}
158 	return strings.ToLower(s), nil
159 }
160 
161 func (a *app) signin(w http.ResponseWriter, r *http.Request) {
162 	email, err := normalizeEmail(r.PostFormValue("email"))
163 	if err != nil {
164 		a.render(w, http.StatusBadRequest, "signin", map[string]string{"Error": "Type a valid email address."})
165 		return
166 	}
167 	token, err := createLoginToken(a.db, email, a.now())
168 	if err != nil {
169 		a.fail(w, err)
170 		return
171 	}
172 	// The page is the same when createLoginToken sends nothing, so the
173 	// page does not tell which addresses have an account.
174 	if token != "" {
175 		link := a.cfg.BaseURL + "/account/verify?t=" + token
176 		err = a.mail.Send(message{
177 			To:      email,
178 			Subject: "Sign in to Recently Written",
179 			Body: "Open this link to sign in to Recently Written:\n\n" + link +
180 				"\n\nThe link works one time and expires in 15 minutes.\n" +
181 				"If you did not ask for this email, ignore it.\n",
182 		})
183 		if err != nil {
184 			a.fail(w, err)
185 			return
186 		}
187 	}
188 	a.render(w, http.StatusOK, "sent", nil)
189 }
190 
191 func (a *app) verifyPage(w http.ResponseWriter, r *http.Request) {
192 	a.render(w, http.StatusOK, "verify", r.URL.Query().Get("t"))
193 }
194 
195 func (a *app) verify(w http.ResponseWriter, r *http.Request) {
196 	u, err := consumeLoginToken(a.db, r.PostFormValue("t"), a.now())
197 	if errors.Is(err, errNotFound) {
198 		a.render(w, http.StatusBadRequest, "badlink", nil)
199 		return
200 	}
201 	if err != nil {
202 		a.fail(w, err)
203 		return
204 	}
205 	token, err := createSession(a.db, u.ID, a.now())
206 	if err != nil {
207 		a.fail(w, err)
208 		return
209 	}
210 	a.setSessionCookie(w, token, int(sessionTTL.Seconds()))
211 	http.Redirect(w, r, "/account/", http.StatusSeeOther)
212 }
213 
214 func (a *app) settings(w http.ResponseWriter, r *http.Request, u user) {
215 	if err := r.ParseForm(); err != nil {
216 		http.Error(w, "Bad request", http.StatusBadRequest)
217 		return
218 	}
219 	posts, err := loadPosts(a.cfg.SiteDir)
220 	if err != nil {
221 		a.fail(w, err)
222 		return
223 	}
224 	checked := map[string]bool{}
225 	for _, t := range r.PostForm["tag"] {
226 		checked[t] = true
227 	}
228 	// A tag is muted if the form shows it and the user cleared its box.
229 	var muted []string
230 	for _, t := range allTags(posts) {
231 		if !checked[t] {
232 			muted = append(muted, t)
233 		}
234 	}
235 	if err := saveSettings(a.db, u.ID, r.PostForm.Get("email_on") == "1", muted); err != nil {
236 		a.fail(w, err)
237 		return
238 	}
239 	http.Redirect(w, r, "/account/", http.StatusSeeOther)
240 }
241 
242 func (a *app) rotateFeed(w http.ResponseWriter, r *http.Request, u user) {
243 	if err := rotateFeedToken(a.db, u.ID); err != nil {
244 		a.fail(w, err)
245 		return
246 	}
247 	http.Redirect(w, r, "/account/", http.StatusSeeOther)
248 }
249 
250 func (a *app) signout(w http.ResponseWriter, r *http.Request) {
251 	if c, err := r.Cookie(sessionCookie); err == nil {
252 		if err := deleteSession(a.db, c.Value); err != nil {
253 			a.fail(w, err)
254 			return
255 		}
256 	}
257 	a.setSessionCookie(w, "", -1)
258 	http.Redirect(w, r, "/account/", http.StatusSeeOther)
259 }
260 
261 func (a *app) deleteAccount(w http.ResponseWriter, r *http.Request, u user) {
262 	if err := deleteUser(a.db, u.ID); err != nil {
263 		a.fail(w, err)
264 		return
265 	}
266 	a.setSessionCookie(w, "", -1)
267 	a.render(w, http.StatusOK, "deleted", nil)
268 }
269 
270 func (a *app) unsubscribePage(w http.ResponseWriter, r *http.Request) {
271 	a.render(w, http.StatusOK, "unsubscribe", r.URL.Query().Get("t"))
272 }
273 
274 // unsubscribe takes the token from the form, or from the URL. A mail
275 // client that does RFC 8058 one-click unsubscribe sends a POST request to
276 // the URL in the List-Unsubscribe header, with the token in the URL.
277 func (a *app) unsubscribe(w http.ResponseWriter, r *http.Request) {
278 	token := r.PostFormValue("t")
279 	if token == "" {
280 		token = r.URL.Query().Get("t")
281 	}
282 	err := unsubscribe(a.db, token)
283 	if errors.Is(err, errNotFound) {
284 		a.render(w, http.StatusBadRequest, "badlink", nil)
285 		return
286 	}
287 	if err != nil {
288 		a.fail(w, err)
289 		return
290 	}
291 	a.render(w, http.StatusOK, "unsubscribed", nil)
292 }
293 
294 func (a *app) feed(w http.ResponseWriter, r *http.Request) {
295 	u, err := userByFeedToken(a.db, r.PathValue("token"))
296 	if errors.Is(err, errNotFound) {
297 		http.NotFound(w, r)
298 		return
299 	}
300 	if err != nil {
301 		a.fail(w, err)
302 		return
303 	}
304 	muted, err := mutedTags(a.db, u.ID)
305 	if err != nil {
306 		a.fail(w, err)
307 		return
308 	}
309 	posts, err := loadPosts(a.cfg.SiteDir)
310 	if err != nil {
311 		a.fail(w, err)
312 		return
313 	}
314 	ch := buildFeed(a.cfg.BaseURL, posts, muted, func(slug string) string {
315 		return loadBody(a.cfg.SiteDir, slug)
316 	})
317 	var b bytes.Buffer
318 	if err := writeFeed(&b, ch); err != nil {
319 		a.fail(w, err)
320 		return
321 	}
322 	w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
323 	b.WriteTo(w)
324 }