Recently Written · git

sbm-sync

Sync server for sbm bookmark files: one small Go program, plain files, AGPL

git clone https://github.com/equwal/sbm-sync

Log | Files | Refs


main.go (28672 bytes)

1 // sbm-sync keeps the sbm bookmark file of a user the same on each device.
2 //
3 // A device sends its copy of the file and the name of the version that it
4 // got last time. The server merges the changes of the device into its own
5 // copy, keeps the result and sends it back. Accounts are an email address
6 // and a password. Billing through Stripe is off unless STRIPE_SECRET_KEY is
7 // set. Accounts confirm their email address only when SBM_SMTP_HOST is set.
8 package main
9 
10 import (
11 	_ "embed"
12 	"encoding/json"
13 	"errors"
14 	"html/template"
15 	"io"
16 	"log"
17 	"net"
18 	"net/http"
19 	"net/mail"
20 	"net/url"
21 	"os"
22 	"strconv"
23 	"strings"
24 	"sync"
25 	"time"
26 )
27 
28 const maxFile = 4 << 20 // bytes in a bookmark file
29 
30 //go:embed pages.html
31 var pagesHTML string
32 
33 var pages = template.Must(template.New("").Parse(pagesHTML))
34 
35 type server struct {
36 	store   *Store
37 	stripe  *Stripe // nil when billing is off
38 	site    string  // public address, such as https://sbm.example.org
39 	origin  string  // scheme and host of site
40 	secure  bool    // site is https
41 	trial   time.Duration
42 	since   time.Time // start of billing: no trial ends before since + trial
43 	contact string
44 	// Payment pages, or "": the pre-order of team bookmarks for up to 10
45 	// people, the same for 11 people or more, a supporter subscription, and
46 	// the page where supporters manage or cancel their subscription.
47 	teams, teamsLarge, support, manage string
48 	prices                             map[string]string // plan ("month", "year"): Stripe price ID
49 	labels                             map[string]string // plan: price as text
50 	limit                              limiter
51 	// mail sends an email. It is nil when the server has no SMTP server:
52 	// then accounts need no email check.
53 	mail func(to, subject, body string) error
54 	// feeds are the programs of the feed job, see feedjob.go.
55 	feeds feedTools
56 }
57 
58 func main() {
59 	site := strings.TrimRight(env("SBM_URL", "http://localhost:8750"), "/")
60 	u, err := url.Parse(site)
61 	if err != nil || u.Host == "" {
62 		log.Fatalf("SBM_URL: not an address: %q", site)
63 	}
64 	days, err := strconv.Atoi(env("SBM_TRIAL_DAYS", "30"))
65 	if err != nil {
66 		log.Fatalf("SBM_TRIAL_DAYS: %v", err)
67 	}
68 	var since time.Time
69 	if v := os.Getenv("SBM_BILLING_START"); v != "" {
70 		if since, err = time.Parse("2006-01-02", v); err != nil {
71 			log.Fatalf("SBM_BILLING_START: %v", err)
72 		}
73 	}
74 	store, err := openStore(env("SBM_DATA", "data"))
75 	if err != nil {
76 		log.Fatal(err)
77 	}
78 	if store.defaultFeeds, err = defaultFeeds(os.Getenv("SBM_DEFAULT_FEEDS")); err != nil {
79 		log.Fatal(err)
80 	}
81 	s := &server{
82 		store:      store,
83 		site:       site,
84 		origin:     u.Scheme + "://" + u.Host,
85 		secure:     u.Scheme == "https",
86 		trial:      time.Duration(days) * 24 * time.Hour,
87 		since:      since,
88 		contact:    os.Getenv("SBM_CONTACT"),
89 		teams:      link("SBM_TEAMS_URL"),
90 		teamsLarge: link("SBM_TEAMS_LARGE_URL"),
91 		support:    link("SBM_SUPPORT_URL"),
92 		manage:     link("SBM_MANAGE_URL"),
93 		prices:     map[string]string{"month": os.Getenv("STRIPE_PRICE_MONTH"), "year": os.Getenv("STRIPE_PRICE_YEAR")},
94 		labels:     map[string]string{"month": "monthly", "year": "yearly"},
95 	}
96 	if key := os.Getenv("STRIPE_SECRET_KEY"); key != "" {
97 		for _, v := range []string{"STRIPE_WEBHOOK_SECRET", "STRIPE_PRICE_MONTH", "STRIPE_PRICE_YEAR"} {
98 			if os.Getenv(v) == "" {
99 				log.Fatalf("STRIPE_SECRET_KEY is set, so %s must be set too", v)
100 			}
101 		}
102 		s.stripe = &Stripe{Key: key, Webhook: os.Getenv("STRIPE_WEBHOOK_SECRET"),
103 			API: "https://api.stripe.com", Client: &http.Client{Timeout: 20 * time.Second}}
104 		for plan, price := range s.prices {
105 			if l, err := s.stripe.label(price); err != nil {
106 				log.Printf("price of the %s plan: %v", plan, err)
107 			} else {
108 				s.labels[plan] = l
109 			}
110 		}
111 	}
112 	if host := os.Getenv("SBM_SMTP_HOST"); host != "" {
113 		from, err := mail.ParseAddress(env("SBM_MAIL_FROM", os.Getenv("SBM_SMTP_USER")))
114 		if err != nil {
115 			log.Fatalf("SBM_MAIL_FROM: %v", err)
116 		}
117 		port := env("SBM_SMTP_PORT", "587")
118 		m := &Mailer{Addr: net.JoinHostPort(host, port), TLS: port == "465",
119 			User: os.Getenv("SBM_SMTP_USER"), Password: os.Getenv("SBM_SMTP_PASSWORD"),
120 			From: from, Hello: u.Hostname()}
121 		s.mail = m.send
122 	}
123 	if len(os.Args) > 1 {
124 		if os.Args[1] != "feed" {
125 			log.Fatalf("unknown command %q: the one command is feed", os.Args[1])
126 		}
127 		s.feeds = execTools()
128 		s.runFeeds()
129 		return
130 	}
131 	addr := env("SBM_ADDR", "127.0.0.1:8750")
132 	log.Printf("sbm-sync on %s for %s, billing %v, email check %v", addr, site, s.stripe != nil, s.mail != nil)
133 	hs := &http.Server{
134 		Addr:              addr,
135 		Handler:           s.routes(),
136 		ReadHeaderTimeout: 10 * time.Second,
137 		ReadTimeout:       60 * time.Second,
138 		WriteTimeout:      60 * time.Second,
139 		IdleTimeout:       120 * time.Second,
140 		MaxHeaderBytes:    16 << 10,
141 	}
142 	log.Fatal(hs.ListenAndServe())
143 }
144 
145 func env(name, fallback string) string {
146 	if v := os.Getenv(name); v != "" {
147 		return v
148 	}
149 	return fallback
150 }
151 
152 // link gives the address in the environment variable name, or "". The
153 // server does not start when the value is not an http or https address.
154 func link(name string) string {
155 	v := os.Getenv(name)
156 	if u, err := url.Parse(v); v != "" && (err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http")) {
157 		log.Fatalf("%s: not an address: %q", name, v)
158 	}
159 	return v
160 }
161 
162 func (s *server) routes() http.Handler {
163 	m := http.NewServeMux()
164 	m.HandleFunc("GET /{$}", s.home)
165 	m.HandleFunc("GET /privacy", s.privacy)
166 	m.HandleFunc("GET /terms", s.terms)
167 	m.HandleFunc("GET /signup", s.form("signup"))
168 	m.HandleFunc("POST /signup", s.signup)
169 	m.HandleFunc("GET /login", s.form("login"))
170 	m.HandleFunc("POST /login", s.login)
171 	m.HandleFunc("POST /logout", s.logout)
172 	m.HandleFunc("GET /account", s.account)
173 	m.HandleFunc("POST /account/verify", s.resend)
174 	m.HandleFunc("GET /verify", s.confirm)
175 	m.HandleFunc("POST /account/checkout", s.checkout)
176 	m.HandleFunc("POST /account/portal", s.portal)
177 	m.HandleFunc("POST /account/delete", s.remove)
178 	m.HandleFunc("GET /done", s.done)
179 	m.HandleFunc("POST /stripe", s.webhook)
180 	m.HandleFunc("POST /api/login", s.apiLogin)
181 	m.HandleFunc("POST /api/logout", s.apiLogout)
182 	m.HandleFunc("POST /api/sync", s.sync)
183 	m.HandleFunc("GET /api/account", s.apiAccount)
184 	m.HandleFunc("POST /api/checkout", s.apiCheckout)
185 	m.HandleFunc("POST /api/portal", s.apiPortal)
186 	m.HandleFunc("GET /bookmarks", s.bookmarks)
187 	m.HandleFunc("POST /bookmarks/add", s.addBookmark)
188 	m.HandleFunc("GET /bookmarks/edit", s.editBookmark)
189 	m.HandleFunc("POST /bookmarks/edit", s.saveBookmark)
190 	m.HandleFunc("GET /bookmarks/file", s.bookmarkFile)
191 	m.HandleFunc("POST /bookmarks/file", s.saveFile)
192 	m.HandleFunc("POST /bookmarks/import", s.importFile)
193 	m.HandleFunc("GET /bookmarks.txt", s.download)
194 	m.HandleFunc("GET /search.js", s.script)
195 	m.HandleFunc("GET /opensearch.xml", s.openSearch)
196 	m.HandleFunc("GET /feed", s.feed)
197 	m.HandleFunc("POST /feed/add", s.followFeed)
198 	m.HandleFunc("POST /feed/delete", s.unfollowFeed)
199 	m.HandleFunc("POST /feed/settings", s.saveFeedSettings)
200 	m.HandleFunc("GET /feed/follow", s.follow)
201 	m.HandleFunc("POST /feed/follow", s.followFeeds)
202 	m.HandleFunc("GET /api/feed", s.apiFeed)
203 	m.HandleFunc("GET /api/feeds", s.apiFeeds)
204 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
205 		h := w.Header()
206 		// Scripts only from this server: the live search of the bookmark
207 		// pages gets its results with fetch.
208 		h.Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'unsafe-inline'; "+
209 			"form-action 'self' https://checkout.stripe.com https://billing.stripe.com; "+
210 			"frame-ancestors 'none'; base-uri 'none'")
211 		h.Set("Referrer-Policy", "same-origin")
212 		h.Set("X-Content-Type-Options", "nosniff")
213 		if s.secure {
214 			h.Set("Strict-Transport-Security", "max-age=31536000")
215 		}
216 		m.ServeHTTP(w, r)
217 	})
218 }
219 
220 // ---- web pages ----
221 
222 type page struct {
223 	Title, Error, Email, State, URL, Contact string
224 	Teams, TeamsLarge, Support, Manage       string // addresses of payment pages
225 	Month, Year                              string
226 	TrialDays                                int
227 	Billing, CanSubscribe, Unconfirmed       bool
228 	Active                                   bool // the account can sync and use its bookmarks here
229 	Customer                                 string
230 }
231 
232 func (s *server) page(title string) page {
233 	return page{Title: title, URL: s.site, Contact: s.contact, Billing: s.stripe != nil,
234 		Teams: s.teams, TeamsLarge: s.teamsLarge, Support: s.support, Manage: s.manage,
235 		Month: s.labels["month"], Year: s.labels["year"], TrialDays: int(s.trial.Hours() / 24)}
236 }
237 
238 func (s *server) render(w http.ResponseWriter, status int, name string, p any) {
239 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
240 	w.WriteHeader(status)
241 	if err := pages.ExecuteTemplate(w, name, p); err != nil {
242 		log.Printf("page %s: %v", name, err)
243 	}
244 }
245 
246 func (s *server) home(w http.ResponseWriter, r *http.Request) {
247 	p := s.page("")
248 	if u := s.user(r); u != nil {
249 		a := s.store.view(u)
250 		p.Email, p.Active = a.Email, s.verified(a) && s.active(a)
251 	}
252 	s.render(w, http.StatusOK, "home", p)
253 }
254 
255 func (s *server) privacy(w http.ResponseWriter, r *http.Request) {
256 	s.render(w, http.StatusOK, "privacy", s.page("Privacy"))
257 }
258 
259 // terms shows the terms of sale. The Chrome Web Store asks for them when an
260 // add-on leads to payments.
261 func (s *server) terms(w http.ResponseWriter, r *http.Request) {
262 	s.render(w, http.StatusOK, "terms", s.page("Terms of sale"))
263 }
264 
265 func (s *server) form(name string) http.HandlerFunc {
266 	title := map[string]string{"signup": "Create an account", "login": "Sign in"}[name]
267 	return func(w http.ResponseWriter, r *http.Request) {
268 		s.render(w, http.StatusOK, name, s.page(title))
269 	}
270 }
271 
272 // user gives the account of the sign-in cookie, or nil.
273 func (s *server) user(r *http.Request) *Account {
274 	c, err := r.Cookie("sbm")
275 	if err != nil {
276 		return nil
277 	}
278 	return s.store.byTok(c.Value)
279 }
280 
281 // sameSite refuses a form that a page of another site sent. The cookie is
282 // SameSite=Lax too; this is the second guard.
283 func (s *server) sameSite(w http.ResponseWriter, r *http.Request) bool {
284 	if o := r.Header.Get("Origin"); o != "" && o != s.origin {
285 		http.Error(w, "this form must come from "+s.origin, http.StatusForbidden)
286 		return false
287 	}
288 	return true
289 }
290 
291 func (s *server) setCookie(w http.ResponseWriter, token string, age int) {
292 	http.SetCookie(w, &http.Cookie{Name: "sbm", Value: token, Path: "/", MaxAge: age,
293 		HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteLaxMode})
294 }
295 
296 func (s *server) signup(w http.ResponseWriter, r *http.Request) {
297 	if !s.sameSite(w, r) {
298 		return
299 	}
300 	email := r.PostFormValue("email")
301 	p := s.page("Create an account")
302 	p.Email = email
303 	if !s.limit.allow("signup "+clientIP(r), 10, time.Hour) {
304 		p.Error = "Too many new accounts from your address. Try again later."
305 		s.render(w, http.StatusTooManyRequests, "signup", p)
306 		return
307 	}
308 	code := ""
309 	if s.mail != nil {
310 		code = random(16)
311 	}
312 	a, err := s.store.signup(email, r.PostFormValue("password"), code)
313 	if errors.Is(err, errTaken) || errors.Is(err, errEmail) || errors.Is(err, errPassword) {
314 		p.Error = strings.ToUpper(err.Error()[:1]) + err.Error()[1:] + "."
315 		s.render(w, http.StatusBadRequest, "signup", p)
316 		return
317 	} else if err != nil {
318 		s.fail(w, err)
319 		return
320 	}
321 	log.Printf("new account %s", a.ID)
322 	if code != "" {
323 		// The account page tells how to send the email again.
324 		if err := s.sendCheck(a.Email, code); err != nil {
325 			log.Printf("email check for %s: %v", a.ID, err)
326 		}
327 	}
328 	s.startSession(w, r, a, "/account")
329 }
330 
331 // verified tells if the account can sync: its email is confirmed, or the
332 // server sends no email and so checks no address.
333 func (s *server) verified(a Account) bool {
334 	return s.mail == nil || a.Verify == ""
335 }
336 
337 // sendCheck sends the link that confirms the email address.
338 func (s *server) sendCheck(email, code string) error {
339 	return s.mail(email, "Confirm your email address for sbm Sync",
340 		"Open this link to confirm your email address for sbm Sync:\n\n"+
341 			s.site+"/verify?code="+code+"\n\n"+
342 			"Sync starts after you confirm. If you did not make an account on\n"+
343 			s.site+", ignore this email.\n")
344 }
345 
346 // resend sends the email check again, with a new code.
347 func (s *server) resend(w http.ResponseWriter, r *http.Request) {
348 	u := s.user(r)
349 	if !s.sameSite(w, r) {
350 		return
351 	}
352 	if u == nil || s.verified(s.store.view(u)) {
353 		http.Redirect(w, r, "/account", http.StatusSeeOther)
354 		return
355 	}
356 	a := s.store.view(u)
357 	if !s.limit.allow("mail "+a.ID, 5, time.Hour) {
358 		s.showAccount(w, http.StatusTooManyRequests, a, "Too many emails in one hour. Try again later.")
359 		return
360 	}
361 	code, err := s.store.newCode(u)
362 	if err != nil {
363 		s.fail(w, err)
364 		return
365 	}
366 	if err := s.sendCheck(a.Email, code); err != nil {
367 		log.Printf("email check for %s: %v", a.ID, err)
368 		s.showAccount(w, http.StatusBadGateway, a, "The email did not go out. Try again later.")
369 		return
370 	}
371 	http.Redirect(w, r, "/account", http.StatusSeeOther)
372 }
373 
374 // confirm takes the link of the email check.
375 func (s *server) confirm(w http.ResponseWriter, r *http.Request) {
376 	a, err := s.store.confirm(r.URL.Query().Get("code"))
377 	if errors.Is(err, errCode) {
378 		p := s.page("Confirm your email address")
379 		p.Error = "This link does not work: it is wrong, or a newer email replaced it. Sign in to send a new link."
380 		s.render(w, http.StatusBadRequest, "confirmed", p)
381 		return
382 	} else if err != nil {
383 		s.fail(w, err)
384 		return
385 	}
386 	log.Printf("account %s: email confirmed", a.ID)
387 	// The browser of the account goes back to the share page it came from.
388 	if to := s.next(r, ""); to != "" && s.user(r) != nil {
389 		http.Redirect(w, r, to, http.StatusSeeOther)
390 		return
391 	}
392 	p := s.page("Email address confirmed")
393 	p.Email = s.store.view(a).Email
394 	s.render(w, http.StatusOK, "confirmed", p)
395 }
396 
397 func (s *server) login(w http.ResponseWriter, r *http.Request) {
398 	if !s.sameSite(w, r) {
399 		return
400 	}
401 	email := r.PostFormValue("email")
402 	p := s.page("Sign in")
403 	p.Email = email
404 	if !s.limit.allow("login "+clientIP(r), 20, 15*time.Minute) {
405 		p.Error = "Too many attempts. Try again later."
406 		s.render(w, http.StatusTooManyRequests, "login", p)
407 		return
408 	}
409 	a, err := s.store.check(email, r.PostFormValue("password"))
410 	if err != nil {
411 		p.Error = "Wrong email or password."
412 		s.render(w, http.StatusUnauthorized, "login", p)
413 		return
414 	}
415 	s.startSession(w, r, a, "/bookmarks")
416 }
417 
418 // startSession signs the browser in and sends it to the page at the path
419 // to, or back to the share page that it came from.
420 func (s *server) startSession(w http.ResponseWriter, r *http.Request, a *Account, to string) {
421 	token, err := s.store.signIn(a)
422 	if err != nil {
423 		s.fail(w, err)
424 		return
425 	}
426 	s.setCookie(w, token, 365*24*3600)
427 	http.Redirect(w, r, s.next(r, to), http.StatusSeeOther)
428 }
429 
430 func (s *server) logout(w http.ResponseWriter, r *http.Request) {
431 	if !s.sameSite(w, r) {
432 		return
433 	}
434 	if c, err := r.Cookie("sbm"); err == nil {
435 		if err := s.store.signOut(c.Value); err != nil {
436 			s.fail(w, err)
437 			return
438 		}
439 	}
440 	s.setCookie(w, "", -1)
441 	http.Redirect(w, r, "/", http.StatusSeeOther)
442 }
443 
444 func paid(status string) bool {
445 	return status == "active" || status == "trialing" || status == "past_due"
446 }
447 
448 // active tells if sync works for the account: always without billing, else
449 // during the free trial or with a subscription.
450 func (s *server) active(a Account) bool {
451 	return s.stripe == nil || paid(a.Status) || s.trialLeft(a) > 0
452 }
453 
454 // trialLeft gives the time until the free trial ends. The trial starts at
455 // sign-up, or when billing starts, if that is later: accounts from before
456 // billing get the full trial too.
457 func (s *server) trialLeft(a Account) time.Duration {
458 	start := a.Created
459 	if s.since.After(start) {
460 		start = s.since
461 	}
462 	return s.trial - time.Since(start)
463 }
464 
465 func (s *server) account(w http.ResponseWriter, r *http.Request) {
466 	u := s.user(r)
467 	if u == nil {
468 		http.Redirect(w, r, "/login", http.StatusSeeOther)
469 		return
470 	}
471 	s.showAccount(w, http.StatusOK, s.store.view(u), "")
472 }
473 
474 func (s *server) showAccount(w http.ResponseWriter, status int, a Account, problem string) {
475 	p := s.page("Your account")
476 	p.Email, p.Error, p.Customer = a.Email, problem, a.Customer
477 	p.Unconfirmed = !s.verified(a)
478 	p.Active = s.verified(a) && s.active(a)
479 	p.State = s.state(a)
480 	p.CanSubscribe = s.stripe != nil && !paid(a.Status)
481 	s.render(w, status, "account", p)
482 }
483 
484 // state tells in one sentence if sync works for the account, and why.
485 func (s *server) state(a Account) string {
486 	left := s.trialLeft(a)
487 	switch {
488 	case s.stripe == nil:
489 		return "Sync is on."
490 	case a.Status == "past_due":
491 		return "Your last payment failed. Update your card in Manage billing."
492 	case paid(a.Status):
493 		return "Your subscription is active."
494 	case left > 0:
495 		days := int(left.Hours()/24) + 1
496 		if days == 1 {
497 			return "Free trial: 1 day left."
498 		}
499 		return "Free trial: " + strconv.Itoa(days) + " days left."
500 	default:
501 		return "Your free trial has ended. Sync is paused until you subscribe."
502 	}
503 }
504 
505 func (s *server) checkout(w http.ResponseWriter, r *http.Request) {
506 	u := s.user(r)
507 	if !s.sameSite(w, r) {
508 		return
509 	}
510 	if u == nil || s.stripe == nil {
511 		http.Redirect(w, r, "/account", http.StatusSeeOther)
512 		return
513 	}
514 	price := s.prices[r.PostFormValue("plan")]
515 	if price == "" {
516 		http.Error(w, "no such plan", http.StatusBadRequest)
517 		return
518 	}
519 	to, err := s.stripe.checkout(s.store.view(u), price, s.site+"/account")
520 	if err != nil {
521 		log.Printf("checkout for %s: %v", u.ID, err)
522 		s.showAccount(w, http.StatusBadGateway, s.store.view(u), "Stripe did not answer. Try again later.")
523 		return
524 	}
525 	http.Redirect(w, r, to, http.StatusSeeOther)
526 }
527 
528 func (s *server) portal(w http.ResponseWriter, r *http.Request) {
529 	u := s.user(r)
530 	if !s.sameSite(w, r) {
531 		return
532 	}
533 	if u == nil || s.stripe == nil {
534 		http.Redirect(w, r, "/account", http.StatusSeeOther)
535 		return
536 	}
537 	a := s.store.view(u)
538 	if a.Customer == "" {
539 		http.Redirect(w, r, "/account", http.StatusSeeOther)
540 		return
541 	}
542 	to, err := s.stripe.portal(a.Customer, s.site+"/account")
543 	if err != nil {
544 		log.Printf("portal for %s: %v", u.ID, err)
545 		s.showAccount(w, http.StatusBadGateway, a, "Stripe did not answer. Try again later.")
546 		return
547 	}
548 	http.Redirect(w, r, to, http.StatusSeeOther)
549 }
550 
551 func (s *server) remove(w http.ResponseWriter, r *http.Request) {
552 	u := s.user(r)
553 	if !s.sameSite(w, r) {
554 		return
555 	}
556 	if u == nil {
557 		http.Redirect(w, r, "/login", http.StatusSeeOther)
558 		return
559 	}
560 	a := s.store.view(u)
561 	if _, err := s.store.check(a.Email, r.PostFormValue("password")); err != nil {
562 		s.showAccount(w, http.StatusUnauthorized, a, "Wrong password. The account is not deleted.")
563 		return
564 	}
565 	// End the subscription first: a deleted account must not pay.
566 	if a.Customer != "" && s.stripe != nil {
567 		if err := s.stripe.deleteCustomer(a.Customer); err != nil {
568 			log.Printf("delete customer of %s: %v", a.ID, err)
569 			s.showAccount(w, http.StatusBadGateway, a, "Stripe did not answer, so the account is not deleted. Try again later.")
570 			return
571 		}
572 	}
573 	if err := s.store.remove(u); err != nil {
574 		s.fail(w, err)
575 		return
576 	}
577 	log.Printf("deleted account %s", a.ID)
578 	s.setCookie(w, "", -1)
579 	http.Redirect(w, r, "/", http.StatusSeeOther)
580 }
581 
582 // done is the page where Stripe sends a customer after a payment that the
583 // add-on started. This website does not know the sign-in of the add-on, so
584 // the page tells the customer to go back to the add-on.
585 func (s *server) done(w http.ResponseWriter, r *http.Request) {
586 	s.render(w, http.StatusOK, "done", s.page("Back to sbm"))
587 }
588 
589 // webhook takes the events of Stripe that change a subscription.
590 func (s *server) webhook(w http.ResponseWriter, r *http.Request) {
591 	if s.stripe == nil {
592 		http.NotFound(w, r)
593 		return
594 	}
595 	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
596 	if err != nil {
597 		http.Error(w, "cannot read the event", http.StatusBadRequest)
598 		return
599 	}
600 	if err := verify(body, r.Header.Get("Stripe-Signature"), s.stripe.Webhook, time.Now()); err != nil {
601 		http.Error(w, err.Error(), http.StatusBadRequest)
602 		return
603 	}
604 	var ev struct {
605 		Type    string `json:"type"`
606 		Created int64  `json:"created"`
607 		Data    struct {
608 			Object struct {
609 				Customer  string            `json:"customer"`
610 				Reference string            `json:"client_reference_id"`
611 				Status    string            `json:"status"`
612 				Metadata  map[string]string `json:"metadata"`
613 			} `json:"object"`
614 		} `json:"data"`
615 	}
616 	if err := json.Unmarshal(body, &ev); err != nil {
617 		http.Error(w, "cannot read the event", http.StatusBadRequest)
618 		return
619 	}
620 	o := ev.Data.Object
621 	switch ev.Type {
622 	case "checkout.session.completed":
623 		if a := s.store.byID(o.Reference); a != nil && o.Customer != "" {
624 			err = s.store.setCustomer(a, o.Customer)
625 		}
626 	case "customer.subscription.created", "customer.subscription.updated", "customer.subscription.deleted":
627 		a := s.store.byCustomer(o.Customer)
628 		if a == nil {
629 			if a = s.store.byID(o.Metadata["account"]); a != nil {
630 				err = s.store.setCustomer(a, o.Customer)
631 			}
632 		}
633 		if a != nil && err == nil {
634 			status := o.Status
635 			if ev.Type == "customer.subscription.deleted" {
636 				status = "canceled"
637 			}
638 			err = s.store.setStatus(a, status, ev.Created)
639 			log.Printf("account %s: subscription %s", a.ID, status)
640 		}
641 	}
642 	if err != nil {
643 		s.fail(w, err) // Stripe sends the event again later
644 		return
645 	}
646 	w.WriteHeader(http.StatusOK)
647 }
648 
649 // ---- API for bm-sync, the app and the add-on ----
650 
651 // apiUser gives the account of the "Authorization: Bearer <token>" header.
652 func (s *server) apiUser(r *http.Request) *Account {
653 	token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
654 	if !ok {
655 		return nil
656 	}
657 	return s.store.byTok(strings.TrimSpace(token))
658 }
659 
660 // apiLogin takes the form fields email and password and gives a token.
661 func (s *server) apiLogin(w http.ResponseWriter, r *http.Request) {
662 	if !s.limit.allow("login "+clientIP(r), 20, 15*time.Minute) {
663 		http.Error(w, "too many attempts: try again later", http.StatusTooManyRequests)
664 		return
665 	}
666 	a, err := s.store.check(r.PostFormValue("email"), r.PostFormValue("password"))
667 	if err != nil {
668 		http.Error(w, "wrong email or password", http.StatusUnauthorized)
669 		return
670 	}
671 	token, err := s.store.signIn(a)
672 	if err != nil {
673 		s.fail(w, err)
674 		return
675 	}
676 	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
677 	io.WriteString(w, token+"\n")
678 }
679 
680 func (s *server) apiLogout(w http.ResponseWriter, r *http.Request) {
681 	token, _ := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
682 	if err := s.store.signOut(strings.TrimSpace(token)); err != nil {
683 		s.fail(w, err)
684 		return
685 	}
686 	w.WriteHeader(http.StatusNoContent)
687 }
688 
689 // sync takes the bookmark file of a device as the body, and the version that
690 // the device got last time as ?base=. It gives the merged file, with the
691 // name of its version in the Sbm-Version header. The device writes the file
692 // and sends that name as base next time.
693 func (s *server) sync(w http.ResponseWriter, r *http.Request) {
694 	u := s.apiOwner(w, r)
695 	if u == nil {
696 		return
697 	}
698 	base := r.URL.Query().Get("base")
699 	if base != "" && !hashName.MatchString(base) {
700 		http.Error(w, "base is not a version name", http.StatusBadRequest)
701 		return
702 	}
703 	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxFile))
704 	var tooBig *http.MaxBytesError
705 	if errors.As(err, &tooBig) {
706 		http.Error(w, "the bookmark file is too large", http.StatusRequestEntityTooLarge)
707 		return
708 	} else if err != nil {
709 		http.Error(w, "cannot read the bookmark file", http.StatusBadRequest)
710 		return
711 	}
712 	text, version, err := s.store.sync(u, base, string(body))
713 	if errors.Is(err, errGone) {
714 		http.Error(w, "not signed in: sign in again", http.StatusUnauthorized)
715 		return
716 	} else if err != nil {
717 		s.fail(w, err)
718 		return
719 	}
720 	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
721 	w.Header().Set("Sbm-Version", version)
722 	w.Header().Set("Access-Control-Expose-Headers", "Sbm-Version")
723 	io.WriteString(w, text)
724 }
725 
726 // accountInfo is the answer of GET /api/account: the state of the account
727 // and the payments that it can start. The add-on shows it.
728 type accountInfo struct {
729 	Email      string     `json:"email"`
730 	State      string     `json:"state"`
731 	Plans      []planInfo `json:"plans"`  // subscriptions that the account can start
732 	Portal     bool       `json:"portal"` // POST /api/portal works: the account is a Stripe customer
733 	Terms      string     `json:"terms"`  // address of the terms of sale
734 	Support    string     `json:"support,omitempty"`
735 	Manage     string     `json:"manage,omitempty"`
736 	Teams      string     `json:"teams,omitempty"`
737 	TeamsLarge string     `json:"teams_large,omitempty"`
738 }
739 
740 type planInfo struct {
741 	ID    string `json:"id"`    // value of the form field plan of POST /api/checkout
742 	Label string `json:"label"` // price as text, such as "$30 a year"
743 }
744 
745 func (s *server) apiAccount(w http.ResponseWriter, r *http.Request) {
746 	u := s.apiUser(r)
747 	if u == nil {
748 		http.Error(w, "not signed in: sign in again", http.StatusUnauthorized)
749 		return
750 	}
751 	a := s.store.view(u)
752 	info := accountInfo{Email: a.Email, State: s.state(a), Plans: []planInfo{},
753 		Portal: s.stripe != nil && a.Customer != "", Terms: s.site + "/terms", Support: s.support, Teams: s.teams}
754 	if s.stripe != nil && !paid(a.Status) {
755 		for _, id := range []string{"year", "month"} {
756 			info.Plans = append(info.Plans, planInfo{id, s.labels[id]})
757 		}
758 	}
759 	// As on the web pages: a link that needs another link goes without it.
760 	if s.support != "" {
761 		info.Manage = s.manage
762 	}
763 	if s.teams != "" {
764 		info.TeamsLarge = s.teamsLarge
765 	}
766 	w.Header().Set("Content-Type", "application/json")
767 	json.NewEncoder(w).Encode(info)
768 }
769 
770 // apiCheckout starts a subscription to the plan in the form field plan. It
771 // gives the address of the Stripe Checkout page as text.
772 func (s *server) apiCheckout(w http.ResponseWriter, r *http.Request) {
773 	a, ok := s.apiBilling(w, r)
774 	if !ok {
775 		return
776 	}
777 	price := s.prices[r.PostFormValue("plan")]
778 	if price == "" {
779 		http.Error(w, "no such plan", http.StatusBadRequest)
780 		return
781 	}
782 	if paid(a.Status) {
783 		http.Error(w, "you have a subscription already: see Manage billing", http.StatusConflict)
784 		return
785 	}
786 	to, err := s.stripe.checkout(a, price, s.site+"/done")
787 	s.sendAddress(w, a, to, err)
788 }
789 
790 // apiPortal gives the address of the Stripe customer portal as text.
791 func (s *server) apiPortal(w http.ResponseWriter, r *http.Request) {
792 	a, ok := s.apiBilling(w, r)
793 	if !ok {
794 		return
795 	}
796 	if a.Customer == "" {
797 		http.Error(w, "you have no billing yet: subscribe first", http.StatusConflict)
798 		return
799 	}
800 	to, err := s.stripe.portal(a.Customer, s.site+"/done")
801 	s.sendAddress(w, a, to, err)
802 }
803 
804 // apiBilling gives the account of a billing request. It answers the request
805 // itself, and gives false, when it is not signed in or the server has no
806 // billing.
807 func (s *server) apiBilling(w http.ResponseWriter, r *http.Request) (Account, bool) {
808 	u := s.apiUser(r)
809 	if u == nil {
810 		http.Error(w, "not signed in: sign in again", http.StatusUnauthorized)
811 		return Account{}, false
812 	}
813 	if s.stripe == nil {
814 		http.Error(w, "this server has no billing", http.StatusNotFound)
815 		return Account{}, false
816 	}
817 	return s.store.view(u), true
818 }
819 
820 // sendAddress answers with the address of a Stripe page, as text.
821 func (s *server) sendAddress(w http.ResponseWriter, a Account, to string, err error) {
822 	if err != nil {
823 		log.Printf("billing for %s: %v", a.ID, err)
824 		http.Error(w, "Stripe did not answer: try again later", http.StatusBadGateway)
825 		return
826 	}
827 	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
828 	io.WriteString(w, to+"\n")
829 }
830 
831 func (s *server) fail(w http.ResponseWriter, err error) {
832 	log.Print(err)
833 	http.Error(w, "server error: try again later", http.StatusInternalServerError)
834 }
835 
836 // clientIP gives the address of the client. Behind a proxy on the same
837 // machine, the proxy gives it in X-Real-IP.
838 func clientIP(r *http.Request) string {
839 	host, _, err := net.SplitHostPort(r.RemoteAddr)
840 	if err != nil {
841 		host = r.RemoteAddr
842 	}
843 	if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
844 		if real := r.Header.Get("X-Real-IP"); real != "" {
845 			return real
846 		}
847 	}
848 	return host
849 }
850 
851 // limiter counts attempts for each key in a sliding window.
852 type limiter struct {
853 	mu   sync.Mutex
854 	hits map[string][]time.Time
855 }
856 
857 func (l *limiter) allow(key string, n int, per time.Duration) bool {
858 	l.mu.Lock()
859 	defer l.mu.Unlock()
860 	now := time.Now()
861 	if l.hits == nil || len(l.hits) > 10000 {
862 		l.hits = map[string][]time.Time{}
863 	}
864 	recent := l.hits[key][:0]
865 	for _, t := range l.hits[key] {
866 		if now.Sub(t) < per {
867 			recent = append(recent, t)
868 		}
869 	}
870 	if len(recent) >= n {
871 		l.hits[key] = recent
872 		return false
873 	}
874 	l.hits[key] = append(recent, now)
875 	return true
876 }