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 + in a link. A browser reads it back. + for _, want := range []string{ + `<a href="/feed/follow?name=Alpha&url=https%3A%2F%2Fa.org%2Frss.xml">Share</a>`, + `<a href="/feed/follow?name=Beta+%26+Co&url=https%3A%2F%2Fb.org%2Ffeed">Share</a>`, + `<a href="/feed/follow?name=Alpha&name=Beta+%26+Co&url=https%3A%2F%2Fa.org%2Frss.xml&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}}&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>