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 }