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


commit a64833ae44f768414babbc9db96452078a83dc47
equwal <truex@equwal.com>
2026-09-22 16:42:38 -0700

Home and account pages: links to all payment pages

SBM_TEAMS_LARGE_URL pre-sells team bookmarks for 11 people or more. The
home page shows it next to SBM_TEAMS_URL. SBM_SUPPORT_URL is the page of
a supporter subscription. The home page and the account page link to
it. The server does not start when one of these values is not an http
or https address.

 README.md      |  7 +++++--
 main.go        | 51 +++++++++++++++++++++++++++++++--------------------
 pages.html     |  9 +++++++--
 server_test.go | 28 ++++++++++++++++++++++------
 4 files changed, 65 insertions(+), 30 deletions(-)
diff --git a/README.md b/README.md
index d9e4a53..64ec000 100644
--- a/README.md
+++ b/README.md
@@ -99,8 +99,11 @@ All settings come from the environment. With Docker, put them in `.env`.
     SBM_ADDR        address to listen on (default 127.0.0.1:8750)
     SBM_DATA        data directory (default ./data)
     SBM_CONTACT     email address on the privacy page (optional)
-    SBM_TEAMS_URL   page that pre-sells team bookmarks (optional): the
-                    home page links to it
+    SBM_TEAMS_URL   page that pre-sells team bookmarks for up to 10
+                    people (optional): the home page links to it
+    SBM_TEAMS_LARGE_URL  the same for teams of 11 people or more
+    SBM_SUPPORT_URL page of a supporter subscription (optional): the home
+                    page and the account page link to it
     SBM_TRIAL_DAYS  days of sync before payment, with billing on (default 30)
     SBM_BILLING_START  date when billing starts, as 2026-10-01: accounts
                     from before it get the full trial from that date
diff --git a/main.go b/main.go
index f84950a..9f4584d 100644
--- a/main.go
+++ b/main.go
@@ -41,10 +41,12 @@ type server struct {
 	trial   time.Duration
 	since   time.Time // start of billing: no trial ends before since + trial
 	contact string
-	teams   string            // address of the page that pre-sells team bookmarks, or ""
-	prices  map[string]string // plan ("month", "year"): Stripe price ID
-	labels  map[string]string // plan: price as text
-	limit   limiter
+	// Payment pages, or "": the pre-order of team bookmarks for up to 10
+	// people, the same for 11 people or more, and a supporter subscription.
+	teams, teamsLarge, support string
+	prices                     map[string]string // plan ("month", "year"): Stripe price ID
+	labels                     map[string]string // plan: price as text
+	limit                      limiter
 	// mail sends an email. It is nil when the server has no SMTP server:
 	// then accounts need no email check.
 	mail func(to, subject, body string) error
@@ -66,25 +68,23 @@ func main() {
 			log.Fatalf("SBM_BILLING_START: %v", err)
 		}
 	}
-	teams := os.Getenv("SBM_TEAMS_URL")
-	if t, err := url.Parse(teams); teams != "" && (err != nil || t.Host == "" || (t.Scheme != "https" && t.Scheme != "http")) {
-		log.Fatalf("SBM_TEAMS_URL: not an address: %q", teams)
-	}
 	store, err := openStore(env("SBM_DATA", "data"))
 	if err != nil {
 		log.Fatal(err)
 	}
 	s := &server{
-		store:   store,
-		site:    site,
-		origin:  u.Scheme + "://" + u.Host,
-		secure:  u.Scheme == "https",
-		trial:   time.Duration(days) * 24 * time.Hour,
-		since:   since,
-		contact: os.Getenv("SBM_CONTACT"),
-		teams:   teams,
-		prices:  map[string]string{"month": os.Getenv("STRIPE_PRICE_MONTH"), "year": os.Getenv("STRIPE_PRICE_YEAR")},
-		labels:  map[string]string{"month": "monthly", "year": "yearly"},
+		store:      store,
+		site:       site,
+		origin:     u.Scheme + "://" + u.Host,
+		secure:     u.Scheme == "https",
+		trial:      time.Duration(days) * 24 * time.Hour,
+		since:      since,
+		contact:    os.Getenv("SBM_CONTACT"),
+		teams:      link("SBM_TEAMS_URL"),
+		teamsLarge: link("SBM_TEAMS_LARGE_URL"),
+		support:    link("SBM_SUPPORT_URL"),
+		prices:     map[string]string{"month": os.Getenv("STRIPE_PRICE_MONTH"), "year": os.Getenv("STRIPE_PRICE_YEAR")},
+		labels:     map[string]string{"month": "monthly", "year": "yearly"},
 	}
 	if key := os.Getenv("STRIPE_SECRET_KEY"); key != "" {
 		for _, v := range []string{"STRIPE_WEBHOOK_SECRET", "STRIPE_PRICE_MONTH", "STRIPE_PRICE_YEAR"} {
@@ -134,6 +134,16 @@ func env(name, fallback string) string {
 	return fallback
 }
 
+// link gives the address in the environment variable name, or "". The
+// server does not start when the value is not an http or https address.
+func link(name string) string {
+	v := os.Getenv(name)
+	if u, err := url.Parse(v); v != "" && (err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http")) {
+		log.Fatalf("%s: not an address: %q", name, v)
+	}
+	return v
+}
+
 func (s *server) routes() http.Handler {
 	m := http.NewServeMux()
 	m.HandleFunc("GET /{$}", s.home)
@@ -171,7 +181,7 @@ func (s *server) routes() http.Handler {
 
 type page struct {
 	Title, Error, Email, State, URL, Contact string
-	Teams                                    string // address of the pre-order page
+	Teams, TeamsLarge, Support               string // addresses of payment pages
 	Month, Year                              string
 	TrialDays                                int
 	Billing, CanSubscribe, Unconfirmed       bool
@@ -179,7 +189,8 @@ type page struct {
 }
 
 func (s *server) page(title string) page {
-	return page{Title: title, URL: s.site, Contact: s.contact, Teams: s.teams, Billing: s.stripe != nil,
+	return page{Title: title, URL: s.site, Contact: s.contact, Billing: s.stripe != nil,
+		Teams: s.teams, TeamsLarge: s.teamsLarge, Support: s.support,
 		Month: s.labels["month"], Year: s.labels["year"], TrialDays: int(s.trial.Hours() / 24)}
 }
 
diff --git a/pages.html b/pages.html
index 2e9b62f..5d69f22 100644
--- a/pages.html
+++ b/pages.html
@@ -38,6 +38,9 @@ button { font: inherit; padding: .4em 1em; margin: .2em .5em .2em 0; }
 </html>
 {{end}}
 
+{{define "support"}}{{with .Support}}<p>sbm Sync is free. To pay for the server and the work on sbm,
+<a href="{{.}}">become a supporter</a>.</p>{{end}}{{end}}
+
 {{define "home"}}{{template "head" .}}
 <h1>Your bookmarks, the same everywhere</h1>
 <p><a href="https://github.com/equwal/sbm">sbm</a> keeps your bookmarks in one plain
@@ -48,8 +51,9 @@ Chrome.</p>
 <p>{{if .Email}}<a href="/account">Your account</a>{{else}}<a href="/signup">Create an account</a> or <a href="/login">sign in</a>.{{end}}</p>
 {{with .Teams}}<h2>Teams</h2>
 <p>Coming: bookmarks for teams. The members of a team share one bookmark
-file, and sync it on each of their devices. <a href="{{.}}">Pre-order the
-founding plan</a>.</p>{{end}}
+file, and sync it on each of their devices. Pre-order the founding plan: <a href="{{.}}">for up to 10 people</a>{{with $.TeamsLarge}},
+or <a href="{{.}}">for 11 people or more</a>{{end}}.</p>{{end}}
+{{template "support" .}}
 <p class="note">sbm Sync is free software under the AGPL-3.0. You can
 <a href="https://github.com/equwal/sbm-sync">run your own server</a>.</p>
 {{template "foot" .}}{{end}}
@@ -89,6 +93,7 @@ email that this server sent to {{.Email}}. Sync starts after that.</p>
 <button name="plan" value="month">Subscribe: {{.Month}}</button>
 </form>{{end}}
 {{if .Customer}}<form method="post" action="/account/portal"><button>Manage billing</button></form>{{end}}
+{{template "support" .}}
 
 <h2>Connect your devices</h2>
 <p>Sign in with this email and password on each device:</p>
diff --git a/server_test.go b/server_test.go
index fc04d93..8f50473 100644
--- a/server_test.go
+++ b/server_test.go
@@ -498,14 +498,30 @@ func TestOldVersionsGo(t *testing.T) {
 	}
 }
 
-func TestTeamsPreorderLink(t *testing.T) {
+func TestPaymentLinks(t *testing.T) {
 	e := newEnv(t, false)
-	if strings.Contains(e.body(http.DefaultClient, "/"), "Pre-order") {
-		t.Error("the home page offers teams without SBM_TEAMS_URL")
+	c := e.signup("me@example.org")
+	if home := e.body(c, "/"); strings.Contains(home, "Pre-order") || strings.Contains(home, "supporter") {
+		t.Error("the home page offers something without the settings")
+	}
+	// The link for large teams needs the link for small teams.
+	e.s.teamsLarge = "https://buy.stripe.com/large"
+	if strings.Contains(e.body(c, "/"), "large") {
+		t.Error("the home page links only to large teams")
+	}
+	e.s.teams, e.s.support = "https://buy.stripe.com/small", "https://buy.stripe.com/support"
+	home := e.body(c, "/")
+	for _, want := range []string{
+		`Pre-order the founding plan: <a href="https://buy.stripe.com/small">for up to 10 people</a>`,
+		`<a href="https://buy.stripe.com/large">for 11 people or more</a>`,
+		`<a href="https://buy.stripe.com/support">become a supporter</a>`,
+	} {
+		if !strings.Contains(home, want) {
+			t.Errorf("the home page lacks %q", want)
+		}
 	}
-	e.s.teams = "https://buy.stripe.com/test_x"
-	if !strings.Contains(e.body(http.DefaultClient, "/"), `<a href="https://buy.stripe.com/test_x">Pre-order`) {
-		t.Error("the home page has no pre-order link")
+	if !strings.Contains(e.body(c, "/account"), `<a href="https://buy.stripe.com/support">become a supporter</a>`) {
+		t.Error("the account page has no supporter link")
 	}
 }