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 (18206 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.
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/url"
20 	"os"
21 	"strconv"
22 	"strings"
23 	"sync"
24 	"time"
25 )
26 
27 const maxFile = 4 << 20 // bytes in a bookmark file
28 
29 //go:embed pages.html
30 var pagesHTML string
31 
32 var pages = template.Must(template.New("").Parse(pagesHTML))
33 
34 type server struct {
35 	store   *Store
36 	stripe  *Stripe // nil when billing is off
37 	site    string  // public address, such as https://sbm.example.org
38 	origin  string  // scheme and host of site
39 	secure  bool    // site is https
40 	trial   time.Duration
41 	since   time.Time // start of billing: no trial ends before since + trial
42 	contact string
43 	prices  map[string]string // plan ("month", "year"): Stripe price ID
44 	labels  map[string]string // plan: price as text
45 	limit   limiter
46 }
47 
48 func main() {
49 	site := strings.TrimRight(env("SBM_URL", "http://localhost:8750"), "/")
50 	u, err := url.Parse(site)
51 	if err != nil || u.Host == "" {
52 		log.Fatalf("SBM_URL: not an address: %q", site)
53 	}
54 	days, err := strconv.Atoi(env("SBM_TRIAL_DAYS", "30"))
55 	if err != nil {
56 		log.Fatalf("SBM_TRIAL_DAYS: %v", err)
57 	}
58 	var since time.Time
59 	if v := os.Getenv("SBM_BILLING_START"); v != "" {
60 		if since, err = time.Parse("2006-01-02", v); err != nil {
61 			log.Fatalf("SBM_BILLING_START: %v", err)
62 		}
63 	}
64 	store, err := openStore(env("SBM_DATA", "data"))
65 	if err != nil {
66 		log.Fatal(err)
67 	}
68 	s := &server{
69 		store:   store,
70 		site:    site,
71 		origin:  u.Scheme + "://" + u.Host,
72 		secure:  u.Scheme == "https",
73 		trial:   time.Duration(days) * 24 * time.Hour,
74 		since:   since,
75 		contact: os.Getenv("SBM_CONTACT"),
76 		prices:  map[string]string{"month": os.Getenv("STRIPE_PRICE_MONTH"), "year": os.Getenv("STRIPE_PRICE_YEAR")},
77 		labels:  map[string]string{"month": "monthly", "year": "yearly"},
78 	}
79 	if key := os.Getenv("STRIPE_SECRET_KEY"); key != "" {
80 		for _, v := range []string{"STRIPE_WEBHOOK_SECRET", "STRIPE_PRICE_MONTH", "STRIPE_PRICE_YEAR"} {
81 			if os.Getenv(v) == "" {
82 				log.Fatalf("STRIPE_SECRET_KEY is set, so %s must be set too", v)
83 			}
84 		}
85 		s.stripe = &Stripe{Key: key, Webhook: os.Getenv("STRIPE_WEBHOOK_SECRET"),
86 			API: "https://api.stripe.com", Client: &http.Client{Timeout: 20 * time.Second}}
87 		for plan, price := range s.prices {
88 			if l, err := s.stripe.label(price); err != nil {
89 				log.Printf("price of the %s plan: %v", plan, err)
90 			} else {
91 				s.labels[plan] = l
92 			}
93 		}
94 	}
95 	addr := env("SBM_ADDR", "127.0.0.1:8750")
96 	log.Printf("sbm-sync on %s for %s, billing %v", addr, site, s.stripe != nil)
97 	hs := &http.Server{
98 		Addr:              addr,
99 		Handler:           s.routes(),
100 		ReadHeaderTimeout: 10 * time.Second,
101 		ReadTimeout:       60 * time.Second,
102 		WriteTimeout:      60 * time.Second,
103 		IdleTimeout:       120 * time.Second,
104 		MaxHeaderBytes:    16 << 10,
105 	}
106 	log.Fatal(hs.ListenAndServe())
107 }
108 
109 func env(name, fallback string) string {
110 	if v := os.Getenv(name); v != "" {
111 		return v
112 	}
113 	return fallback
114 }
115 
116 func (s *server) routes() http.Handler {
117 	m := http.NewServeMux()
118 	m.HandleFunc("GET /{$}", s.home)
119 	m.HandleFunc("GET /privacy", s.privacy)
120 	m.HandleFunc("GET /signup", s.form("signup"))
121 	m.HandleFunc("POST /signup", s.signup)
122 	m.HandleFunc("GET /login", s.form("login"))
123 	m.HandleFunc("POST /login", s.login)
124 	m.HandleFunc("POST /logout", s.logout)
125 	m.HandleFunc("GET /account", s.account)
126 	m.HandleFunc("POST /account/checkout", s.checkout)
127 	m.HandleFunc("POST /account/portal", s.portal)
128 	m.HandleFunc("POST /account/delete", s.remove)
129 	m.HandleFunc("POST /stripe", s.webhook)
130 	m.HandleFunc("POST /api/login", s.apiLogin)
131 	m.HandleFunc("POST /api/logout", s.apiLogout)
132 	m.HandleFunc("POST /api/sync", s.sync)
133 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
134 		h := w.Header()
135 		h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; "+
136 			"form-action 'self' https://checkout.stripe.com https://billing.stripe.com; "+
137 			"frame-ancestors 'none'; base-uri 'none'")
138 		h.Set("Referrer-Policy", "same-origin")
139 		h.Set("X-Content-Type-Options", "nosniff")
140 		if s.secure {
141 			h.Set("Strict-Transport-Security", "max-age=31536000")
142 		}
143 		m.ServeHTTP(w, r)
144 	})
145 }
146 
147 // ---- web pages ----
148 
149 type page struct {
150 	Title, Error, Email, State, URL, Contact string
151 	Month, Year                              string
152 	TrialDays                                int
153 	Billing, CanSubscribe                    bool
154 	Customer                                 string
155 }
156 
157 func (s *server) page(title string) page {
158 	return page{Title: title, URL: s.site, Contact: s.contact, Billing: s.stripe != nil,
159 		Month: s.labels["month"], Year: s.labels["year"], TrialDays: int(s.trial.Hours() / 24)}
160 }
161 
162 func (s *server) render(w http.ResponseWriter, status int, name string, p page) {
163 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
164 	w.WriteHeader(status)
165 	if err := pages.ExecuteTemplate(w, name, p); err != nil {
166 		log.Printf("page %s: %v", name, err)
167 	}
168 }
169 
170 func (s *server) home(w http.ResponseWriter, r *http.Request) {
171 	p := s.page("")
172 	if a := s.user(r); a != nil {
173 		p.Email = a.Email
174 	}
175 	s.render(w, http.StatusOK, "home", p)
176 }
177 
178 func (s *server) privacy(w http.ResponseWriter, r *http.Request) {
179 	s.render(w, http.StatusOK, "privacy", s.page("Privacy"))
180 }
181 
182 func (s *server) form(name string) http.HandlerFunc {
183 	title := map[string]string{"signup": "Create an account", "login": "Sign in"}[name]
184 	return func(w http.ResponseWriter, r *http.Request) {
185 		s.render(w, http.StatusOK, name, s.page(title))
186 	}
187 }
188 
189 // user gives the account of the sign-in cookie, or nil.
190 func (s *server) user(r *http.Request) *Account {
191 	c, err := r.Cookie("sbm")
192 	if err != nil {
193 		return nil
194 	}
195 	return s.store.byTok(c.Value)
196 }
197 
198 // sameSite refuses a form that a page of another site sent. The cookie is
199 // SameSite=Lax too; this is the second guard.
200 func (s *server) sameSite(w http.ResponseWriter, r *http.Request) bool {
201 	if o := r.Header.Get("Origin"); o != "" && o != s.origin {
202 		http.Error(w, "this form must come from "+s.origin, http.StatusForbidden)
203 		return false
204 	}
205 	return true
206 }
207 
208 func (s *server) setCookie(w http.ResponseWriter, token string, age int) {
209 	http.SetCookie(w, &http.Cookie{Name: "sbm", Value: token, Path: "/", MaxAge: age,
210 		HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteLaxMode})
211 }
212 
213 func (s *server) signup(w http.ResponseWriter, r *http.Request) {
214 	if !s.sameSite(w, r) {
215 		return
216 	}
217 	email := r.PostFormValue("email")
218 	p := s.page("Create an account")
219 	p.Email = email
220 	if !s.limit.allow("signup "+clientIP(r), 10, time.Hour) {
221 		p.Error = "Too many new accounts from your address. Try again later."
222 		s.render(w, http.StatusTooManyRequests, "signup", p)
223 		return
224 	}
225 	a, err := s.store.signup(email, r.PostFormValue("password"))
226 	if errors.Is(err, errTaken) || errors.Is(err, errEmail) || errors.Is(err, errPassword) {
227 		p.Error = strings.ToUpper(err.Error()[:1]) + err.Error()[1:] + "."
228 		s.render(w, http.StatusBadRequest, "signup", p)
229 		return
230 	} else if err != nil {
231 		s.fail(w, err)
232 		return
233 	}
234 	log.Printf("new account %s", a.ID)
235 	s.startSession(w, r, a)
236 }
237 
238 func (s *server) login(w http.ResponseWriter, r *http.Request) {
239 	if !s.sameSite(w, r) {
240 		return
241 	}
242 	email := r.PostFormValue("email")
243 	p := s.page("Sign in")
244 	p.Email = email
245 	if !s.limit.allow("login "+clientIP(r), 20, 15*time.Minute) {
246 		p.Error = "Too many attempts. Try again later."
247 		s.render(w, http.StatusTooManyRequests, "login", p)
248 		return
249 	}
250 	a, err := s.store.check(email, r.PostFormValue("password"))
251 	if err != nil {
252 		p.Error = "Wrong email or password."
253 		s.render(w, http.StatusUnauthorized, "login", p)
254 		return
255 	}
256 	s.startSession(w, r, a)
257 }
258 
259 func (s *server) startSession(w http.ResponseWriter, r *http.Request, a *Account) {
260 	token, err := s.store.signIn(a)
261 	if err != nil {
262 		s.fail(w, err)
263 		return
264 	}
265 	s.setCookie(w, token, 365*24*3600)
266 	http.Redirect(w, r, "/account", http.StatusSeeOther)
267 }
268 
269 func (s *server) logout(w http.ResponseWriter, r *http.Request) {
270 	if !s.sameSite(w, r) {
271 		return
272 	}
273 	if c, err := r.Cookie("sbm"); err == nil {
274 		if err := s.store.signOut(c.Value); err != nil {
275 			s.fail(w, err)
276 			return
277 		}
278 	}
279 	s.setCookie(w, "", -1)
280 	http.Redirect(w, r, "/", http.StatusSeeOther)
281 }
282 
283 func paid(status string) bool {
284 	return status == "active" || status == "trialing" || status == "past_due"
285 }
286 
287 // active tells if sync works for the account: always without billing, else
288 // during the free trial or with a subscription.
289 func (s *server) active(a Account) bool {
290 	return s.stripe == nil || paid(a.Status) || s.trialLeft(a) > 0
291 }
292 
293 // trialLeft gives the time until the free trial ends. The trial starts at
294 // sign-up, or when billing starts, if that is later: accounts from before
295 // billing get the full trial too.
296 func (s *server) trialLeft(a Account) time.Duration {
297 	start := a.Created
298 	if s.since.After(start) {
299 		start = s.since
300 	}
301 	return s.trial - time.Since(start)
302 }
303 
304 func (s *server) account(w http.ResponseWriter, r *http.Request) {
305 	u := s.user(r)
306 	if u == nil {
307 		http.Redirect(w, r, "/login", http.StatusSeeOther)
308 		return
309 	}
310 	s.showAccount(w, http.StatusOK, s.store.view(u), "")
311 }
312 
313 func (s *server) showAccount(w http.ResponseWriter, status int, a Account, problem string) {
314 	p := s.page("Your account")
315 	p.Email, p.Error, p.Customer = a.Email, problem, a.Customer
316 	left := s.trialLeft(a)
317 	switch {
318 	case s.stripe == nil:
319 		p.State = "Sync is on."
320 	case a.Status == "past_due":
321 		p.State = "Your last payment failed. Update your card in Manage billing."
322 	case paid(a.Status):
323 		p.State = "Your subscription is active."
324 	case left > 0:
325 		days := int(left.Hours()/24) + 1
326 		p.State = "Free trial: " + strconv.Itoa(days) + " days left."
327 		if days == 1 {
328 			p.State = "Free trial: 1 day left."
329 		}
330 	default:
331 		p.State = "Your free trial has ended. Sync is paused until you subscribe."
332 	}
333 	p.CanSubscribe = s.stripe != nil && !paid(a.Status)
334 	s.render(w, status, "account", p)
335 }
336 
337 func (s *server) checkout(w http.ResponseWriter, r *http.Request) {
338 	u := s.user(r)
339 	if !s.sameSite(w, r) {
340 		return
341 	}
342 	if u == nil || s.stripe == nil {
343 		http.Redirect(w, r, "/account", http.StatusSeeOther)
344 		return
345 	}
346 	price := s.prices[r.PostFormValue("plan")]
347 	if price == "" {
348 		http.Error(w, "no such plan", http.StatusBadRequest)
349 		return
350 	}
351 	to, err := s.stripe.checkout(s.store.view(u), price, s.site)
352 	if err != nil {
353 		log.Printf("checkout for %s: %v", u.ID, err)
354 		s.showAccount(w, http.StatusBadGateway, s.store.view(u), "Stripe did not answer. Try again later.")
355 		return
356 	}
357 	http.Redirect(w, r, to, http.StatusSeeOther)
358 }
359 
360 func (s *server) portal(w http.ResponseWriter, r *http.Request) {
361 	u := s.user(r)
362 	if !s.sameSite(w, r) {
363 		return
364 	}
365 	if u == nil || s.stripe == nil {
366 		http.Redirect(w, r, "/account", http.StatusSeeOther)
367 		return
368 	}
369 	a := s.store.view(u)
370 	if a.Customer == "" {
371 		http.Redirect(w, r, "/account", http.StatusSeeOther)
372 		return
373 	}
374 	to, err := s.stripe.portal(a.Customer, s.site)
375 	if err != nil {
376 		log.Printf("portal for %s: %v", u.ID, err)
377 		s.showAccount(w, http.StatusBadGateway, a, "Stripe did not answer. Try again later.")
378 		return
379 	}
380 	http.Redirect(w, r, to, http.StatusSeeOther)
381 }
382 
383 func (s *server) remove(w http.ResponseWriter, r *http.Request) {
384 	u := s.user(r)
385 	if !s.sameSite(w, r) {
386 		return
387 	}
388 	if u == nil {
389 		http.Redirect(w, r, "/login", http.StatusSeeOther)
390 		return
391 	}
392 	a := s.store.view(u)
393 	if _, err := s.store.check(a.Email, r.PostFormValue("password")); err != nil {
394 		s.showAccount(w, http.StatusUnauthorized, a, "Wrong password. The account is not deleted.")
395 		return
396 	}
397 	// End the subscription first: a deleted account must not pay.
398 	if a.Customer != "" && s.stripe != nil {
399 		if err := s.stripe.deleteCustomer(a.Customer); err != nil {
400 			log.Printf("delete customer of %s: %v", a.ID, err)
401 			s.showAccount(w, http.StatusBadGateway, a, "Stripe did not answer, so the account is not deleted. Try again later.")
402 			return
403 		}
404 	}
405 	if err := s.store.remove(u); err != nil {
406 		s.fail(w, err)
407 		return
408 	}
409 	log.Printf("deleted account %s", a.ID)
410 	s.setCookie(w, "", -1)
411 	http.Redirect(w, r, "/", http.StatusSeeOther)
412 }
413 
414 // webhook takes the events of Stripe that change a subscription.
415 func (s *server) webhook(w http.ResponseWriter, r *http.Request) {
416 	if s.stripe == nil {
417 		http.NotFound(w, r)
418 		return
419 	}
420 	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
421 	if err != nil {
422 		http.Error(w, "cannot read the event", http.StatusBadRequest)
423 		return
424 	}
425 	if err := verify(body, r.Header.Get("Stripe-Signature"), s.stripe.Webhook, time.Now()); err != nil {
426 		http.Error(w, err.Error(), http.StatusBadRequest)
427 		return
428 	}
429 	var ev struct {
430 		Type    string `json:"type"`
431 		Created int64  `json:"created"`
432 		Data    struct {
433 			Object struct {
434 				Customer  string            `json:"customer"`
435 				Reference string            `json:"client_reference_id"`
436 				Status    string            `json:"status"`
437 				Metadata  map[string]string `json:"metadata"`
438 			} `json:"object"`
439 		} `json:"data"`
440 	}
441 	if err := json.Unmarshal(body, &ev); err != nil {
442 		http.Error(w, "cannot read the event", http.StatusBadRequest)
443 		return
444 	}
445 	o := ev.Data.Object
446 	switch ev.Type {
447 	case "checkout.session.completed":
448 		if a := s.store.byID(o.Reference); a != nil && o.Customer != "" {
449 			err = s.store.setCustomer(a, o.Customer)
450 		}
451 	case "customer.subscription.created", "customer.subscription.updated", "customer.subscription.deleted":
452 		a := s.store.byCustomer(o.Customer)
453 		if a == nil {
454 			if a = s.store.byID(o.Metadata["account"]); a != nil {
455 				err = s.store.setCustomer(a, o.Customer)
456 			}
457 		}
458 		if a != nil && err == nil {
459 			status := o.Status
460 			if ev.Type == "customer.subscription.deleted" {
461 				status = "canceled"
462 			}
463 			err = s.store.setStatus(a, status, ev.Created)
464 			log.Printf("account %s: subscription %s", a.ID, status)
465 		}
466 	}
467 	if err != nil {
468 		s.fail(w, err) // Stripe sends the event again later
469 		return
470 	}
471 	w.WriteHeader(http.StatusOK)
472 }
473 
474 // ---- API for bm-sync, the app and the add-on ----
475 
476 // apiUser gives the account of the "Authorization: Bearer <token>" header.
477 func (s *server) apiUser(r *http.Request) *Account {
478 	token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
479 	if !ok {
480 		return nil
481 	}
482 	return s.store.byTok(strings.TrimSpace(token))
483 }
484 
485 // apiLogin takes the form fields email and password and gives a token.
486 func (s *server) apiLogin(w http.ResponseWriter, r *http.Request) {
487 	if !s.limit.allow("login "+clientIP(r), 20, 15*time.Minute) {
488 		http.Error(w, "too many attempts: try again later", http.StatusTooManyRequests)
489 		return
490 	}
491 	a, err := s.store.check(r.PostFormValue("email"), r.PostFormValue("password"))
492 	if err != nil {
493 		http.Error(w, "wrong email or password", http.StatusUnauthorized)
494 		return
495 	}
496 	token, err := s.store.signIn(a)
497 	if err != nil {
498 		s.fail(w, err)
499 		return
500 	}
501 	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
502 	io.WriteString(w, token+"\n")
503 }
504 
505 func (s *server) apiLogout(w http.ResponseWriter, r *http.Request) {
506 	token, _ := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
507 	if err := s.store.signOut(strings.TrimSpace(token)); err != nil {
508 		s.fail(w, err)
509 		return
510 	}
511 	w.WriteHeader(http.StatusNoContent)
512 }
513 
514 // sync takes the bookmark file of a device as the body, and the version that
515 // the device got last time as ?base=. It gives the merged file, with the
516 // name of its version in the Sbm-Version header. The device writes the file
517 // and sends that name as base next time.
518 func (s *server) sync(w http.ResponseWriter, r *http.Request) {
519 	u := s.apiUser(r)
520 	if u == nil {
521 		http.Error(w, "not signed in: sign in again", http.StatusUnauthorized)
522 		return
523 	}
524 	if !s.active(s.store.view(u)) {
525 		http.Error(w, "sbm Sync is paused for this account: see "+s.site+"/account", http.StatusPaymentRequired)
526 		return
527 	}
528 	base := r.URL.Query().Get("base")
529 	if base != "" && !hashName.MatchString(base) {
530 		http.Error(w, "base is not a version name", http.StatusBadRequest)
531 		return
532 	}
533 	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxFile))
534 	var tooBig *http.MaxBytesError
535 	if errors.As(err, &tooBig) {
536 		http.Error(w, "the bookmark file is too large", http.StatusRequestEntityTooLarge)
537 		return
538 	} else if err != nil {
539 		http.Error(w, "cannot read the bookmark file", http.StatusBadRequest)
540 		return
541 	}
542 	text, version, err := s.store.sync(u, base, string(body))
543 	if errors.Is(err, errGone) {
544 		http.Error(w, "not signed in: sign in again", http.StatusUnauthorized)
545 		return
546 	} else if err != nil {
547 		s.fail(w, err)
548 		return
549 	}
550 	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
551 	w.Header().Set("Sbm-Version", version)
552 	w.Header().Set("Access-Control-Expose-Headers", "Sbm-Version")
553 	io.WriteString(w, text)
554 }
555 
556 func (s *server) fail(w http.ResponseWriter, err error) {
557 	log.Print(err)
558 	http.Error(w, "server error: try again later", http.StatusInternalServerError)
559 }
560 
561 // clientIP gives the address of the client. Behind a proxy on the same
562 // machine, the proxy gives it in X-Real-IP.
563 func clientIP(r *http.Request) string {
564 	host, _, err := net.SplitHostPort(r.RemoteAddr)
565 	if err != nil {
566 		host = r.RemoteAddr
567 	}
568 	if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
569 		if real := r.Header.Get("X-Real-IP"); real != "" {
570 			return real
571 		}
572 	}
573 	return host
574 }
575 
576 // limiter counts attempts for each key in a sliding window.
577 type limiter struct {
578 	mu   sync.Mutex
579 	hits map[string][]time.Time
580 }
581 
582 func (l *limiter) allow(key string, n int, per time.Duration) bool {
583 	l.mu.Lock()
584 	defer l.mu.Unlock()
585 	now := time.Now()
586 	if l.hits == nil || len(l.hits) > 10000 {
587 		l.hits = map[string][]time.Time{}
588 	}
589 	recent := l.hits[key][:0]
590 	for _, t := range l.hits[key] {
591 		if now.Sub(t) < per {
592 			recent = append(recent, t)
593 		}
594 	}
595 	if len(recent) >= n {
596 		l.hits[key] = recent
597 		return false
598 	}
599 	l.hits[key] = append(recent, now)
600 	return true
601 }