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 904c49289e16f033241db883ec6c482c035bbfb5
equwal <truex@equwal.com>
2026-09-25 13:16:57 -0700

Share links: a page that follows the feeds of a link

Each feed on the feed page has a Share link, and the list has one for
all its feeds: /feed/follow with the URLs and the names as query fields.
An account that opens the link sees the feeds and a button that follows
the ones it does not follow yet. A visitor is asked to sign in or to
create an account. A cookie keeps the link, so that the sign-in, the
sign-up and the email check bring the browser back to it. The cookie
sends the browser only to a follow page.

 README.md    |   4 ++
 feed.go      | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 feed_test.go | 131 +++++++++++++++++++++++++++++++++++++++++-
 main.go      |  12 +++-
 pages.html   |  21 ++++++-
 5 files changed, 347 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index a22e842..e6b328a 100644
--- a/README.md
+++ b/README.md
@@ -85,6 +85,10 @@ It uses [sfeed](https://codemadness.org/sfeed.html) on the server:
 - Explore: turn it on, and the server looks through your bookmarked pages
   for feeds with `sfeed_web`, 20 pages with each update, and offers what it
   finds.
+- Share: each feed has a share link, and the list has one for all feeds
+  (`/feed/follow?url=...&name=...`). An account that opens the link gets a
+  button that follows the feeds. A visitor is asked to sign in or to create
+  an account, and comes back to the link after that.
 - Digest: the server sends you the new items by email once a day, in the
   form of `sfeed_plain`, unless you turn that off in the settings of the
   page. The first day sends nothing, so that you do not get the whole
diff --git a/feed.go b/feed.go
index d2da8f1..4ccc1c7 100644
--- a/feed.go
+++ b/feed.go
@@ -42,7 +42,11 @@ const (
 	maxFeedURL  = 2048 // bytes in the URL of a feed
 	maxFeedName = 100  // characters in the name of a feed
 	maxFileName = 40   // characters in the file name of a feed, before a number
+	maxShare    = 50   // feeds in one share link
 	itemsOnPage = 100
+	// nextCookie keeps the share page that a visitor came from, so that the
+	// browser goes back to it after the sign-in or the email check.
+	nextCookie = "sbm_next"
 )
 
 var (
@@ -67,6 +71,47 @@ type Feed struct {
 	URL, Name, File string
 }
 
+// Share gives the share link of the feed: the page that follows it.
+func (f Feed) Share() string {
+	return shareLink([]Feed{f})
+}
+
+// shareLink gives the address of the page that follows the feeds, for
+// anyone with the link. The first maxShare feeds go in.
+func shareLink(feeds []Feed) string {
+	v := url.Values{}
+	for i, f := range feeds {
+		if i >= maxShare {
+			break
+		}
+		v.Add("url", f.URL)
+		v.Add("name", f.Name)
+	}
+	return "/feed/follow?" + v.Encode()
+}
+
+// sharedFeeds gives the feeds of a share link: the url and name fields, in
+// pairs. The values that are not feed addresses come back in bad.
+func sharedFeeds(q url.Values) (feeds []Feed, bad []string) {
+	names := q["name"]
+	for i, u := range q["url"] {
+		if i >= maxShare {
+			break
+		}
+		addr, err := feedURL(u)
+		if err != nil {
+			bad = append(bad, u)
+			continue
+		}
+		name := ""
+		if i < len(names) {
+			name = names[i]
+		}
+		feeds = append(feeds, Feed{URL: addr, Name: feedName(name, addr)})
+	}
+	return feeds, bad
+}
+
 // feedURL checks the URL of a feed. It takes only http and https addresses
 // of printable ASCII without quotes, so that the URL can go into the
 // sfeedrc, a shell script, between single quotes.
@@ -363,6 +408,7 @@ func (s *server) suggestions(a *Account, feeds []Feed) ([]suggestion, int, int,
 type feedPage struct {
 	page
 	Feeds       []Feed
+	ShareAll    string // the share link of all feeds
 	Items       []Item
 	Start, Next int    // the first item on the page, and the start of the next page, or 0
 	Total       int    // items that match
@@ -394,6 +440,7 @@ func (s *server) feedData(a *Account, filter string, start int) (feedPage, error
 		return p, err
 	}
 	p.Feeds = parseFeeds(text)
+	p.ShareAll = shareLink(p.Feeds)
 	items := s.feedItems(a, p.Feeds)
 	if filter != "" {
 		items = slices.DeleteFunc(items, func(it Item) bool { return it.File != filter })
@@ -432,9 +479,145 @@ func (s *server) feed(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 	p.Done = feedDone[q.Get("done")]
+	if q.Get("done") == "followed" {
+		added, _ := strconv.Atoi(q.Get("added"))
+		known, _ := strconv.Atoi(q.Get("known"))
+		p.Done = fmt.Sprintf("You follow %d new feeds. %d were in your list already.", added, known)
+	}
 	s.render(w, http.StatusOK, "feed", p)
 }
 
+// ---- share links ----
+
+// setNext keeps path in the next cookie, or removes the cookie when path
+// is "".
+func (s *server) setNext(w http.ResponseWriter, path string) {
+	age := 3600
+	if path == "" {
+		age = -1
+	}
+	http.SetCookie(w, &http.Cookie{Name: nextCookie, Value: path, Path: "/", MaxAge: age,
+		HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteLaxMode})
+}
+
+// next gives the page to go to after a sign-in: the share page of the next
+// cookie, or fallback. Only a share page counts, so the cookie cannot send
+// the browser anywhere else.
+func (s *server) next(r *http.Request, fallback string) string {
+	if c, err := r.Cookie(nextCookie); err == nil && strings.HasPrefix(c.Value, "/feed/follow?") {
+		return c.Value
+	}
+	return fallback
+}
+
+// sharedFeed is a feed of a share link on the follow page.
+type sharedFeed struct {
+	Feed
+	Known bool // the account follows the feed already
+}
+
+type followPage struct {
+	page
+	SignedIn bool
+	Feeds    []sharedFeed
+	Bad      []string // values of the link that are not feed addresses
+	New      int      // feeds that the account does not follow yet
+}
+
+// followPage shows the feeds of a share link. A visitor is asked to sign
+// in or to create an account, and comes back here after that. An account
+// gets a button that follows the feeds.
+func (s *server) follow(w http.ResponseWriter, r *http.Request) {
+	feeds, bad := sharedFeeds(r.URL.Query())
+	p := followPage{page: s.page("Follow feeds"), Bad: bad}
+	for _, f := range feeds {
+		p.Feeds = append(p.Feeds, sharedFeed{Feed: f})
+	}
+	u := s.user(r)
+	if u == nil {
+		s.setNext(w, r.URL.RequestURI())
+		s.render(w, http.StatusOK, "follow", p)
+		return
+	}
+	if a := s.store.view(u); !s.verified(a) || !s.active(a) {
+		s.setNext(w, r.URL.RequestURI())
+		http.Redirect(w, r, "/account", http.StatusSeeOther)
+		return
+	}
+	s.setNext(w, "")
+	text, err := s.store.readFeeds(u)
+	if err != nil {
+		s.fail(w, err)
+		return
+	}
+	known := map[string]bool{}
+	for _, f := range parseFeeds(text) {
+		known[norm(f.URL)] = true
+	}
+	p.SignedIn = true
+	for i := range p.Feeds {
+		p.Feeds[i].Known = known[norm(p.Feeds[i].URL)]
+		if !p.Feeds[i].Known {
+			p.New++
+		}
+	}
+	s.render(w, http.StatusOK, "follow", p)
+}
+
+// followFeeds adds the feeds of the form to feeds.txt, as the button of the
+// follow page sends them. Feeds that the account follows already are
+// skipped.
+func (s *server) followFeeds(w http.ResponseWriter, r *http.Request) {
+	if !s.sameSite(w, r) {
+		return
+	}
+	u := s.owner(w, r)
+	if u == nil {
+		return
+	}
+	r.Body = http.MaxBytesReader(w, r.Body, maxForm)
+	if err := r.ParseForm(); err != nil {
+		http.Error(w, "cannot read the form", http.StatusBadRequest)
+		return
+	}
+	feeds, _ := sharedFeeds(r.PostForm)
+	added, known := 0, 0
+	err := s.store.changeFeeds(u, func(text string) (string, error) {
+		ls := lines(text)
+		have := map[string]bool{}
+		for _, f := range parseFeeds(text) {
+			have[norm(f.URL)] = true
+		}
+		for _, f := range feeds {
+			switch {
+			case have[norm(f.URL)]:
+				known++
+			case len(have) >= maxFeeds:
+				return "", errFeedMany
+			default:
+				have[norm(f.URL)] = true
+				ls = append(ls, f.URL+"\t"+f.Name)
+				added++
+			}
+		}
+		return join(ls), nil
+	})
+	if errors.Is(err, errFeedMany) {
+		p, perr := s.feedData(u, "", 0)
+		if perr != nil {
+			s.fail(w, perr)
+			return
+		}
+		p.Error = sentence(err)
+		s.render(w, http.StatusBadRequest, "feed", p)
+		return
+	} else if err != nil {
+		s.fail(w, err)
+		return
+	}
+	http.Redirect(w, r, fmt.Sprintf("/feed?done=followed&added=%d&known=%d", added, known), http.StatusSeeOther)
+}
+
 // followFeed adds the feed of the form to feeds.txt.
 func (s *server) followFeed(w http.ResponseWriter, r *http.Request) {
 	if !s.sameSite(w, r) {
diff --git a/feed_test.go b/feed_test.go
index 174af58..51a37b4 100644
--- a/feed_test.go
+++ b/feed_test.go
@@ -57,7 +57,7 @@ func TestFeedPagesNeedSignIn(t *testing.T) {
 	if code, to, _ := e.get(c, "/feed"); code != http.StatusSeeOther || to != "/login" {
 		t.Errorf("GET /feed: %d to %q", code, to)
 	}
-	for _, path := range []string{"/feed/add", "/feed/delete", "/feed/settings"} {
+	for _, path := range []string{"/feed/add", "/feed/delete", "/feed/settings", "/feed/follow"} {
 		if code, to, _ := e.send(c, path, url.Values{"url": {"https://a.org/rss.xml"}}); code != http.StatusSeeOther || to != "/login" {
 			t.Errorf("POST %s: %d to %q", path, code, to)
 		}
@@ -329,6 +329,135 @@ func TestDeleteAccountRemovesTheFeeds(t *testing.T) {
 	}
 }
 
+func TestShareLinks(t *testing.T) {
+	e := newEnv(t, false)
+	c, tok, _ := e.account("")
+	e.setFeeds(tok, "https://a.org/rss.xml\tAlpha\nhttps://b.org/feed\tBeta & Co\n")
+	page := e.body(c, "/feed")
+	// html/template writes + as &#43; in a link. A browser reads it back.
+	for _, want := range []string{
+		`<a href="/feed/follow?name=Alpha&amp;url=https%3A%2F%2Fa.org%2Frss.xml">Share</a>`,
+		`<a href="/feed/follow?name=Beta&#43;%26&#43;Co&amp;url=https%3A%2F%2Fb.org%2Ffeed">Share</a>`,
+		`<a href="/feed/follow?name=Alpha&amp;name=Beta&#43;%26&#43;Co&amp;url=https%3A%2F%2Fa.org%2Frss.xml&amp;url=https%3A%2F%2Fb.org%2Ffeed">Share your list</a>`,
+	} {
+		if !strings.Contains(page, want) {
+			t.Errorf("the page lacks %q", want)
+		}
+	}
+	// The link of the list has at most maxShare feeds.
+	var many []Feed
+	for i := range maxShare + 10 {
+		many = append(many, Feed{URL: fmt.Sprintf("https://f%d.org/rss", i), Name: "F"})
+	}
+	if got := strings.Count(shareLink(many), "url="); got != maxShare {
+		t.Errorf("the link of %d feeds has %d feeds", len(many), got)
+	}
+}
+
+// A visitor who opens a share link is asked to sign in or to sign up, and
+// comes back to the link. An account gets a button that follows the feeds.
+func TestShareLinkFollowsAfterSignUp(t *testing.T) {
+	e := newEnv(t, false)
+	link := "/feed/follow?name=Alpha&name=Bad&url=https%3A%2F%2Fa.org%2Frss.xml&url=javascript%3Ax"
+	c := e.browser()
+	code, _, page := e.get(c, "/feed/follow")
+	if code != http.StatusOK || !strings.Contains(page, "This link has no feed in it.") {
+		t.Errorf("a link without feeds: %d\n%s", code, page)
+	}
+	code, _, page = e.get(c, link)
+	for _, want := range []string{"Feeds shared with you", `<a href="/login">Sign in</a>`, `<a href="/signup">create an account</a>`,
+		"Alpha<br><small>https://a.org/rss.xml</small>", "javascript:x<br><small class=\"error\">not a feed address</small>"} {
+		if !strings.Contains(page, want) {
+			t.Errorf("the share page lacks %q", want)
+		}
+	}
+	if code != http.StatusOK || strings.Contains(page, "<form") || strings.Contains(page, `href="/bookmarks"`) {
+		t.Errorf("the share page of a visitor: %d, has a form or the nav", code)
+	}
+	form := url.Values{"email": {"me@example.org"}, "password": {"password1"}}
+	if code, to, _ := e.send(c, "/signup", form); code != http.StatusSeeOther || to != link {
+		t.Fatalf("sign up from the share page: %d to %q", code, to)
+	}
+	code, _, page = e.get(c, link)
+	for _, want := range []string{"Follow these feeds?", `<button>Follow 1 feed</button>`,
+		`<input type="hidden" name="url" value="https://a.org/rss.xml"><input type="hidden" name="name" value="Alpha">`} {
+		if !strings.Contains(page, want) {
+			t.Errorf("the follow page lacks %q", want)
+		}
+	}
+	if code != http.StatusOK || strings.Contains(page, `value="javascript:x"`) {
+		t.Errorf("the follow page: %d, or it sends the bad address", code)
+	}
+	follow := url.Values{"url": {"https://a.org/rss.xml", "javascript:x"}, "name": {"Alpha", "Bad"}}
+	code, to, _ := e.send(c, "/feed/follow", follow)
+	if code != http.StatusSeeOther || to != "/feed?done=followed&added=1&known=0" {
+		t.Fatalf("follow: %d to %q", code, to)
+	}
+	if got := e.feedsOf(e.token("me@example.org")); got != "https://a.org/rss.xml\tAlpha\n" {
+		t.Fatalf("feeds.txt: %q", got)
+	}
+	if !strings.Contains(e.body(c, to), "You follow 1 new feeds. 0 were in your list already.") {
+		t.Error("the feed page does not tell what happened")
+	}
+	// The same link again: nothing new to follow.
+	if page := e.body(c, link); !strings.Contains(page, "you follow this feed already") || strings.Contains(page, "<form") {
+		t.Errorf("the follow page of a known feed:\n%s", page)
+	}
+	if _, to, _ := e.send(c, "/feed/follow", follow); to != "/feed?done=followed&added=0&known=1" {
+		t.Errorf("follow again: to %q", to)
+	}
+	// The visit as an account used the cookie up: a sign-in goes to the
+	// bookmarks, as usual.
+	e.post(c, "/logout", nil)
+	if _, to, _ := e.send(c, "/login", form); to != "/bookmarks" {
+		t.Errorf("sign in after the share page: to %q", to)
+	}
+}
+
+func TestShareLinkWaitsForTheEmailCheck(t *testing.T) {
+	e := newEnv(t, false)
+	e.withMail()
+	link := "/feed/follow?name=Alpha&url=https%3A%2F%2Fa.org%2Frss.xml"
+	c := e.browser()
+	e.get(c, link)
+	if _, to, _ := e.send(c, "/signup", url.Values{"email": {"me@example.org"}, "password": {"password1"}}); to != link {
+		t.Fatalf("sign up: to %q", to)
+	}
+	if code, to, _ := e.get(c, link); code != http.StatusSeeOther || to != "/account" {
+		t.Fatalf("the link before the check: %d to %q", code, to)
+	}
+	// The link of the email, opened in the same browser, goes back to the
+	// share page.
+	resp, err := c.Get(e.link())
+	if err != nil {
+		t.Fatal(err)
+	}
+	resp.Body.Close()
+	if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != link {
+		t.Fatalf("the email check: %d to %q", resp.StatusCode, resp.Header.Get("Location"))
+	}
+	if code, _, page := e.get(c, link); code != http.StatusOK || !strings.Contains(page, `<button>Follow 1 feed</button>`) {
+		t.Errorf("the link after the check: %d", code)
+	}
+	// In another browser, the email check shows the page as before.
+	e.post(c, "/account/verify", nil)
+	if code := e.open(e.link()); code != http.StatusBadRequest {
+		t.Errorf("a used code: %d", code)
+	}
+}
+
+// The next cookie sends the browser only to a share page.
+func TestNextCookieOnlyGoesToShareLinks(t *testing.T) {
+	e := newEnv(t, false)
+	e.signup("me@example.org")
+	c := e.browser()
+	u, _ := url.Parse(e.web.URL)
+	c.Jar.SetCookies(u, []*http.Cookie{{Name: nextCookie, Value: "/account/delete"}})
+	if _, to, _ := e.send(c, "/login", url.Values{"email": {"me@example.org"}, "password": {"password1"}}); to != "/bookmarks" {
+		t.Errorf("sign in with a cookie for another page: to %q", to)
+	}
+}
+
 // feedURLs draws strings that are often feed URLs and often not.
 var feedURLs = rapid.Custom(func(t *rapid.T) string {
 	head := rapid.SampledFrom([]string{"https://a.org/", "http://b.org", "HTTPS://c.org/x", "https://", "ftp://c.org/", "javascript:", "", " https://d.org/"}).Draw(t, "head")
diff --git a/main.go b/main.go
index 1783724..7a2e7d7 100644
--- a/main.go
+++ b/main.go
@@ -197,6 +197,8 @@ func (s *server) routes() http.Handler {
 	m.HandleFunc("POST /feed/add", s.followFeed)
 	m.HandleFunc("POST /feed/delete", s.unfollowFeed)
 	m.HandleFunc("POST /feed/settings", s.saveFeedSettings)
+	m.HandleFunc("GET /feed/follow", s.follow)
+	m.HandleFunc("POST /feed/follow", s.followFeeds)
 	m.HandleFunc("GET /api/feed", s.apiFeed)
 	m.HandleFunc("GET /api/feeds", s.apiFeeds)
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -382,6 +384,11 @@ func (s *server) confirm(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 	log.Printf("account %s: email confirmed", a.ID)
+	// The browser of the account goes back to the share page it came from.
+	if to := s.next(r, ""); to != "" && s.user(r) != nil {
+		http.Redirect(w, r, to, http.StatusSeeOther)
+		return
+	}
 	p := s.page("Email address confirmed")
 	p.Email = s.store.view(a).Email
 	s.render(w, http.StatusOK, "confirmed", p)
@@ -408,7 +415,8 @@ func (s *server) login(w http.ResponseWriter, r *http.Request) {
 	s.startSession(w, r, a, "/bookmarks")
 }
 
-// startSession signs the browser in and sends it to the page at the path to.
+// startSession signs the browser in and sends it to the page at the path
+// to, or back to the share page that it came from.
 func (s *server) startSession(w http.ResponseWriter, r *http.Request, a *Account, to string) {
 	token, err := s.store.signIn(a)
 	if err != nil {
@@ -416,7 +424,7 @@ func (s *server) startSession(w http.ResponseWriter, r *http.Request, a *Account
 		return
 	}
 	s.setCookie(w, token, 365*24*3600)
-	http.Redirect(w, r, to, http.StatusSeeOther)
+	http.Redirect(w, r, s.next(r, to), http.StatusSeeOther)
 }
 
 func (s *server) logout(w http.ResponseWriter, r *http.Request) {
diff --git a/pages.html b/pages.html
index 0f9b86e..d90a126 100644
--- a/pages.html
+++ b/pages.html
@@ -185,8 +185,10 @@ keeps one in <code>~/.config/sbm/sync</code>):</p>
 {{end}}</ul>
 {{if .Next}}<p><a href="/feed?start={{.Next}}{{with .Filter}}&amp;feed={{.}}{{end}}">Older items</a></p>{{end}}
 {{with .Feeds}}<h2>Feeds you follow</h2>
+<p class="note"><a href="{{$.ShareAll}}">Share your list</a>: anyone with that link can follow these feeds
+on sbm Sync, with an account or after they make one. Share opens the same kind of link for one feed.</p>
 <ul class="bookmarks">
-{{range .}}<li><a href="/feed?feed={{.File}}">{{.Name}}</a> <small>{{.URL}}</small>
+{{range .}}<li><a href="/feed?feed={{.File}}">{{.Name}}</a> <small>{{.URL}} · <a href="{{.Share}}">Share</a></small>
 <form method="post" action="/feed/delete"><input type="hidden" name="url" value="{{.URL}}"><button>Unfollow</button></form></li>
 {{end}}</ul>{{end}}
 {{if .Settings.Explore}}<h2>Explore</h2>
@@ -252,6 +254,23 @@ This page shows the first {{len .Rows}}.{{end}}{{if or .Query .Tag}} <a href="/b
 <script src="/search.js" defer></script>
 {{template "foot" .}}{{end}}
 
+{{define "follow"}}{{template "head" .}}
+{{if .SignedIn}}{{template "nav"}}{{end}}
+<h1>{{if .SignedIn}}Follow these feeds?{{else}}Feeds shared with you{{end}}</h1>
+{{with .Error}}<p class="error">{{.}}</p>{{end}}
+{{if or .Feeds .Bad}}<ul class="bookmarks">
+{{range .Feeds}}<li>{{.Name}}<br><small>{{.URL}}{{if .Known}} · you follow this feed already{{end}}</small></li>
+{{end}}{{range .Bad}}<li>{{.}}<br><small class="error">not a feed address</small></li>
+{{end}}</ul>{{else}}<p>This link has no feed in it.</p>{{end}}
+{{if .SignedIn}}{{if .New}}<form method="post" action="/feed/follow">
+{{range .Feeds}}{{if not .Known}}<input type="hidden" name="url" value="{{.URL}}"><input type="hidden" name="name" value="{{.Name}}">
+{{end}}{{end}}<button>Follow {{.New}} {{if eq .New 1}}feed{{else}}feeds{{end}}</button> <a href="/feed">Cancel</a>
+</form>{{else}}<p><a href="/feed">Your feed</a></p>{{end}}
+{{else}}<p>sbm Sync follows RSS and Atom feeds for you and shows the new items on one page. It is free.
+<a href="/login">Sign in</a> or <a href="/signup">create an account</a> to follow these feeds. You come back to this page after that.</p>
+{{end}}
+{{template "foot" .}}{{end}}
+
 {{define "edit"}}{{template "head" .}}
 {{template "nav"}}
 <h1>Edit a bookmark</h1>