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 }