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


feed.go (21750 bytes)

1 package main
2 
3 // Feeds. An account can follow RSS and Atom feeds. The list of an account
4 // is one plain file, feeds.txt, with one feed on each line:
5 //
6 //	URL<tab>name
7 //
8 // The command `sbm-sync feed` (feedjob.go) fetches the feeds with
9 // sfeed_update into one sfeed(5) file for each feed, looks through the
10 // bookmarks of the account for feeds with sfeed_web when the account asked
11 // for it, and sends a daily digest of the new items by email. The page /feed
12 // shows the items, the feeds and the settings. GET /api/feed gives the items
13 // in the sfeed format, for sfeed_plain and the other sfeed tools.
14 //
15 // The files of an account are under feeds/<account ID>/:
16 //
17 //	feeds.txt    the feeds, written by the web pages
18 //	sfeedrc      the sfeed_update settings, made from feeds.txt by the job
19 //	items/<file> the items of one feed, written by sfeed_update
20 //	explore      the feeds found on the bookmarked pages, written by the job
21 //	mailed       the keys of the items that went out in a digest
22 //	digest       the time of the last digest
23 
24 import (
25 	"errors"
26 	"fmt"
27 	"io"
28 	"io/fs"
29 	"net/http"
30 	"net/url"
31 	"os"
32 	"path/filepath"
33 	"regexp"
34 	"slices"
35 	"strconv"
36 	"strings"
37 	"time"
38 )
39 
40 const (
41 	maxFeeds    = 200  // feeds that an account can follow
42 	maxFeedURL  = 2048 // bytes in the URL of a feed
43 	maxFeedName = 100  // characters in the name of a feed
44 	maxFileName = 40   // characters in the file name of a feed, before a number
45 	maxShare    = 50   // feeds in one share link
46 	itemsOnPage = 100
47 	// nextCookie keeps the share page that a visitor came from, so that the
48 	// browser goes back to it after the sign-in or the email check.
49 	nextCookie = "sbm_next"
50 )
51 
52 var (
53 	errFeedURL   = errors.New("the URL must start with http:// or https:// and have no spaces or quotes")
54 	errFeedLong  = errors.New("the URL is longer than 2048 characters")
55 	errFeedKnown = errors.New("you follow this feed already")
56 	errFeedMany  = errors.New("you follow 200 feeds already, the most that this server keeps")
57 	notFile      = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
58 )
59 
60 // feedSettings are the feed choices of an account. The daily email is on
61 // by default: the account gave its address at sign-up.
62 type feedSettings struct {
63 	NewTab  bool `json:"newtab,omitempty"`  // the feed page opens links in a new tab
64 	NoMail  bool `json:"nomail,omitempty"`  // no email with the new items once a day
65 	Explore bool `json:"explore,omitempty"` // the job looks through the bookmarks for feeds
66 }
67 
68 // Feed is one line of feeds.txt. File is the name of the item file of the
69 // feed under items/, and the name that sfeed_update knows the feed by.
70 type Feed struct {
71 	URL, Name, File string
72 }
73 
74 // Share gives the share link of the feed: the page that follows it.
75 func (f Feed) Share() string {
76 	return shareLink([]Feed{f})
77 }
78 
79 // shareLink gives the address of the page that follows the feeds, for
80 // anyone with the link. The first maxShare feeds go in.
81 func shareLink(feeds []Feed) string {
82 	v := url.Values{}
83 	for i, f := range feeds {
84 		if i >= maxShare {
85 			break
86 		}
87 		v.Add("url", f.URL)
88 		v.Add("name", f.Name)
89 	}
90 	return "/feed/follow?" + v.Encode()
91 }
92 
93 // sharedFeeds gives the feeds of a share link: the url and name fields, in
94 // pairs. The values that are not feed addresses come back in bad.
95 func sharedFeeds(q url.Values) (feeds []Feed, bad []string) {
96 	names := q["name"]
97 	for i, u := range q["url"] {
98 		if i >= maxShare {
99 			break
100 		}
101 		addr, err := feedURL(u)
102 		if err != nil {
103 			bad = append(bad, u)
104 			continue
105 		}
106 		name := ""
107 		if i < len(names) {
108 			name = names[i]
109 		}
110 		feeds = append(feeds, Feed{URL: addr, Name: feedName(name, addr)})
111 	}
112 	return feeds, bad
113 }
114 
115 // feedURL checks the URL of a feed. It takes only http and https addresses
116 // of printable ASCII without quotes, so that the URL can go into the
117 // sfeedrc, a shell script, between single quotes.
118 func feedURL(raw string) (string, error) {
119 	u := strings.TrimSpace(raw)
120 	if u == "" {
121 		return "", errNoURL
122 	}
123 	if len(u) > maxFeedURL {
124 		return "", errFeedLong
125 	}
126 	for i := 0; i < len(u); i++ {
127 		if c := u[i]; c <= ' ' || c > '~' || c == '\'' {
128 			return "", errFeedURL
129 		}
130 	}
131 	p, err := url.Parse(u)
132 	if err != nil || p.Host == "" || (p.Scheme != "http" && p.Scheme != "https") {
133 		return "", errFeedURL
134 	}
135 	return u, nil
136 }
137 
138 // feedName gives the name of a feed for feeds.txt: one line of at most
139 // maxFeedName characters. Without a name, the host of the URL is the name.
140 func feedName(name, u string) string {
141 	n := strings.Join(strings.Fields(name), " ")
142 	if n == "" {
143 		n = Bookmark{URL: u}.Host()
144 	}
145 	if r := []rune(n); len(r) > maxFeedName {
146 		n = string(r[:maxFeedName])
147 	}
148 	return n
149 }
150 
151 // fileName gives the base of the file name of a feed: the letters, digits,
152 // dots, dashes and underscores of its name, or "feed".
153 func fileName(name string) string {
154 	f := strings.Trim(notFile.ReplaceAllString(name, "-"), "-.")
155 	if len(f) > maxFileName {
156 		f = strings.Trim(f[:maxFileName], "-.")
157 	}
158 	if f == "" {
159 		return "feed"
160 	}
161 	return f
162 }
163 
164 // parseFeeds gives the feeds of feeds.txt, in the order of the file. Each
165 // feed gets a file name that no other feed of the file has.
166 func parseFeeds(text string) []Feed {
167 	var out []Feed
168 	used := map[string]bool{}
169 	for _, l := range lines(text) {
170 		u, name, _ := strings.Cut(l, "\t")
171 		if u == "" {
172 			continue
173 		}
174 		f := Feed{URL: u, Name: name, File: fileName(name)}
175 		for i := 2; used[f.File]; i++ {
176 			f.File = fileName(name) + "-" + strconv.Itoa(i)
177 		}
178 		used[f.File] = true
179 		out = append(out, f)
180 	}
181 	return out
182 }
183 
184 // Item is one line of an sfeed(5) file.
185 type Item struct {
186 	Time   time.Time // zero when the feed gives none
187 	Title  string
188 	Link   string // the address, or "" when it is not http or https
189 	Author string
190 	Feed   string // the name of the feed
191 	File   string // the item file of the feed
192 	Key    string // "file<tab>id" names the item in mailed; the link or the title stand in for a missing id
193 	Raw    string // the line as sfeed wrote it
194 }
195 
196 // unescape undoes the escapes of the sfeed format: \t, \n and \\.
197 func unescape(s string) string {
198 	if !strings.Contains(s, `\`) {
199 		return s
200 	}
201 	var b strings.Builder
202 	for i := 0; i < len(s); i++ {
203 		if s[i] == '\\' && i+1 < len(s) {
204 			switch s[i+1] {
205 			case 't':
206 				b.WriteByte('\t')
207 				i++
208 				continue
209 			case 'n':
210 				b.WriteByte('\n')
211 				i++
212 				continue
213 			case '\\':
214 				b.WriteByte('\\')
215 				i++
216 				continue
217 			}
218 		}
219 		b.WriteByte(s[i])
220 	}
221 	return b.String()
222 }
223 
224 // parseItems gives the items of the sfeed file of a feed, in the order of
225 // the file.
226 func parseItems(text string, f Feed) []Item {
227 	var out []Item
228 	for _, l := range lines(text) {
229 		c := strings.Split(l, "\t")
230 		if len(c) < 3 {
231 			continue
232 		}
233 		for len(c) < 9 {
234 			c = append(c, "")
235 		}
236 		it := Item{Title: unescape(c[1]), Author: unescape(c[6]), Feed: f.Name, File: f.File, Raw: l}
237 		if t, err := strconv.ParseInt(c[0], 10, 64); err == nil {
238 			it.Time = time.Unix(t, 0).UTC()
239 		}
240 		link := unescape(c[2])
241 		if link != "" {
242 			it.Link = Bookmark{URL: link}.Link()
243 		}
244 		id := c[5]
245 		if id == "" {
246 			id = c[2]
247 		}
248 		if id == "" {
249 			id = c[1]
250 		}
251 		it.Key = f.File + "\t" + id
252 		if it.Title == "" {
253 			it.Title = link
254 		}
255 		if it.Title == "" {
256 			it.Title = "(no title)"
257 		}
258 		out = append(out, it)
259 	}
260 	return out
261 }
262 
263 // ---- the files of an account ----
264 
265 func (s *Store) feedDir(id string) string {
266 	return filepath.Join(s.dir, "feeds", id)
267 }
268 
269 // defaultFeeds gives the feeds.txt of a new account from the setting
270 // SBM_DEFAULT_FEEDS: feed URLs with spaces between them. The host of each
271 // URL is its name.
272 func defaultFeeds(setting string) (string, error) {
273 	var ls []string
274 	for _, u := range strings.Fields(setting) {
275 		u, err := feedURL(u)
276 		if err != nil {
277 			return "", fmt.Errorf("SBM_DEFAULT_FEEDS: %q: %v", u, err)
278 		}
279 		ls = append(ls, u+"\t"+feedName("", u))
280 	}
281 	return join(ls), nil
282 }
283 
284 // feedsText gives feeds.txt of the account. The caller holds the lock of
285 // the account. An account without the file has the default feeds.
286 func (s *Store) feedsText(a *Account) (string, error) {
287 	b, err := os.ReadFile(filepath.Join(s.feedDir(a.ID), "feeds.txt"))
288 	if errors.Is(err, fs.ErrNotExist) {
289 		return s.defaultFeeds, nil
290 	}
291 	return string(b), err
292 }
293 
294 // readFeeds gives feeds.txt of the account.
295 func (s *Store) readFeeds(a *Account) (string, error) {
296 	m := s.lock(a.ID)
297 	m.Lock()
298 	defer m.Unlock()
299 	return s.feedsText(a)
300 }
301 
302 // changeFeeds gives feeds.txt to f and writes what f gives back. No other
303 // change to the file comes between the two.
304 func (s *Store) changeFeeds(a *Account, f func(text string) (string, error)) error {
305 	m := s.lock(a.ID)
306 	m.Lock()
307 	defer m.Unlock()
308 	dir := s.feedDir(a.ID)
309 	old, err := s.feedsText(a)
310 	if err != nil {
311 		return err
312 	}
313 	text, err := f(old)
314 	if err != nil {
315 		return err
316 	}
317 	if err := os.MkdirAll(dir, 0o700); err != nil {
318 		return err
319 	}
320 	return writeFile(filepath.Join(dir, "feeds.txt"), []byte(text))
321 }
322 
323 func (s *Store) setFeed(a *Account, f feedSettings) error {
324 	s.mu.Lock()
325 	defer s.mu.Unlock()
326 	a.Feed = f
327 	return s.save()
328 }
329 
330 // all gives every account, oldest first.
331 func (s *Store) all() []*Account {
332 	s.mu.Lock()
333 	defer s.mu.Unlock()
334 	out := make([]*Account, 0, len(s.accounts))
335 	for _, a := range s.accounts {
336 		out = append(out, a)
337 	}
338 	slices.SortFunc(out, func(x, y *Account) int { return x.Created.Compare(y.Created) })
339 	return out
340 }
341 
342 // feedItems gives the items of the feeds of the account, newest first.
343 // Items without a time come last.
344 func (s *server) feedItems(a *Account, feeds []Feed) []Item {
345 	var all []Item
346 	for _, f := range feeds {
347 		b, err := os.ReadFile(filepath.Join(s.store.feedDir(a.ID), "items", f.File))
348 		if err != nil {
349 			continue // not fetched yet
350 		}
351 		all = append(all, parseItems(string(b), f)...)
352 	}
353 	slices.SortStableFunc(all, func(x, y Item) int { return y.Time.Compare(x.Time) })
354 	return all
355 }
356 
357 // suggestion is a feed that the job found on a bookmarked page.
358 type suggestion struct {
359 	URL, Page, Name string
360 }
361 
362 // suggestions gives the feeds of the explore file that the account does not
363 // follow, the number of bookmarked pages that the job looked at, and the
364 // number of bookmarks with a web address.
365 func (s *server) suggestions(a *Account, feeds []Feed) ([]suggestion, int, int, error) {
366 	text, _, err := s.store.read(a, "")
367 	if err != nil {
368 		return nil, 0, 0, err
369 	}
370 	names := map[string]string{}
371 	total := 0
372 	for _, b := range parse(text) {
373 		if l := b.Link(); l != "" {
374 			names[l] = b.Desc
375 			total++
376 		}
377 	}
378 	b, err := os.ReadFile(filepath.Join(s.store.feedDir(a.ID), "explore"))
379 	if err != nil && !errors.Is(err, fs.ErrNotExist) {
380 		return nil, 0, 0, err
381 	}
382 	known := map[string]bool{}
383 	for _, f := range feeds {
384 		known[norm(f.URL)] = true
385 	}
386 	pages := map[string]bool{}
387 	var out []suggestion
388 	for _, l := range lines(string(b)) {
389 		f := strings.Split(l, "\t")
390 		if len(f) < 2 {
391 			continue
392 		}
393 		if _, ok := names[f[0]]; ok {
394 			pages[f[0]] = true // a page that is a bookmark still
395 		}
396 		if f[1] == "-" || known[norm(f[1])] {
397 			continue
398 		}
399 		known[norm(f[1])] = true
400 		out = append(out, suggestion{URL: f[1], Page: f[0], Name: feedName(names[f[0]], f[1])})
401 	}
402 	return out, len(pages), total, nil
403 }
404 
405 // ---- pages ----
406 
407 // feedPage is the data of the feed page.
408 type feedPage struct {
409 	page
410 	Feeds       []Feed
411 	ShareAll    string // the share link of all feeds
412 	Items       []Item
413 	Start, Next int    // the first item on the page, and the start of the next page, or 0
414 	Total       int    // items that match
415 	Filter      string // the file name of the one feed that the page shows, or ""
416 	FilterName  string
417 	Settings    feedSettings
418 	HasMail     bool   // the server sends email, so the digest is a choice
419 	Open        string // "add" when the form to follow a feed must be open
420 	Form        struct{ URL, Name string }
421 	Done        string
422 	Suggestions []suggestion
423 	Checked     int // bookmarked pages that the job looked at
424 	Bookmarks   int // bookmarks with a web address
425 }
426 
427 var feedDone = map[string]string{
428 	"added":    "You follow the feed. Its items come with the next update, within 30 minutes.",
429 	"deleted":  "You do not follow the feed any more.",
430 	"settings": "Settings saved.",
431 }
432 
433 // feedData gives the feed page of the account: the items from start, or
434 // only the items of the feed with the file name filter.
435 func (s *server) feedData(a *Account, filter string, start int) (feedPage, error) {
436 	p := feedPage{page: s.page("Your feed"), Filter: filter, Start: start, HasMail: s.mail != nil,
437 		Settings: s.store.view(a).Feed}
438 	text, err := s.store.readFeeds(a)
439 	if err != nil {
440 		return p, err
441 	}
442 	p.Feeds = parseFeeds(text)
443 	p.ShareAll = shareLink(p.Feeds)
444 	items := s.feedItems(a, p.Feeds)
445 	if filter != "" {
446 		items = slices.DeleteFunc(items, func(it Item) bool { return it.File != filter })
447 		for _, f := range p.Feeds {
448 			if f.File == filter {
449 				p.FilterName = f.Name
450 			}
451 		}
452 	}
453 	p.Total = len(items)
454 	if start > len(items) {
455 		start = len(items)
456 	}
457 	p.Items = items[start:min(start+itemsOnPage, len(items))]
458 	if start+itemsOnPage < len(items) {
459 		p.Next = start + itemsOnPage
460 	}
461 	if p.Settings.Explore {
462 		p.Suggestions, p.Checked, p.Bookmarks, err = s.suggestions(a, p.Feeds)
463 	}
464 	return p, err
465 }
466 
467 // feed shows the items of the feeds of the account, newest first: from item
468 // ?start=, and only the feed ?feed= when that is given.
469 func (s *server) feed(w http.ResponseWriter, r *http.Request) {
470 	u := s.owner(w, r)
471 	if u == nil {
472 		return
473 	}
474 	q := r.URL.Query()
475 	start, _ := strconv.Atoi(q.Get("start"))
476 	p, err := s.feedData(u, q.Get("feed"), max(start, 0))
477 	if err != nil {
478 		s.fail(w, err)
479 		return
480 	}
481 	p.Done = feedDone[q.Get("done")]
482 	if q.Get("done") == "followed" {
483 		added, _ := strconv.Atoi(q.Get("added"))
484 		known, _ := strconv.Atoi(q.Get("known"))
485 		p.Done = fmt.Sprintf("You follow %d new feeds. %d were in your list already.", added, known)
486 	}
487 	s.render(w, http.StatusOK, "feed", p)
488 }
489 
490 // ---- share links ----
491 
492 // setNext keeps path in the next cookie, or removes the cookie when path
493 // is "".
494 func (s *server) setNext(w http.ResponseWriter, path string) {
495 	age := 3600
496 	if path == "" {
497 		age = -1
498 	}
499 	http.SetCookie(w, &http.Cookie{Name: nextCookie, Value: path, Path: "/", MaxAge: age,
500 		HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteLaxMode})
501 }
502 
503 // next gives the page to go to after a sign-in: the share page of the next
504 // cookie, or fallback. Only a share page counts, so the cookie cannot send
505 // the browser anywhere else.
506 func (s *server) next(r *http.Request, fallback string) string {
507 	if c, err := r.Cookie(nextCookie); err == nil && strings.HasPrefix(c.Value, "/feed/follow?") {
508 		return c.Value
509 	}
510 	return fallback
511 }
512 
513 // sharedFeed is a feed of a share link on the follow page.
514 type sharedFeed struct {
515 	Feed
516 	Known bool // the account follows the feed already
517 }
518 
519 type followPage struct {
520 	page
521 	SignedIn bool
522 	Feeds    []sharedFeed
523 	Bad      []string // values of the link that are not feed addresses
524 	New      int      // feeds that the account does not follow yet
525 }
526 
527 // followPage shows the feeds of a share link. A visitor is asked to sign
528 // in or to create an account, and comes back here after that. An account
529 // gets a button that follows the feeds.
530 func (s *server) follow(w http.ResponseWriter, r *http.Request) {
531 	feeds, bad := sharedFeeds(r.URL.Query())
532 	p := followPage{page: s.page("Follow feeds"), Bad: bad}
533 	for _, f := range feeds {
534 		p.Feeds = append(p.Feeds, sharedFeed{Feed: f})
535 	}
536 	u := s.user(r)
537 	if u == nil {
538 		s.setNext(w, r.URL.RequestURI())
539 		s.render(w, http.StatusOK, "follow", p)
540 		return
541 	}
542 	if a := s.store.view(u); !s.verified(a) || !s.active(a) {
543 		s.setNext(w, r.URL.RequestURI())
544 		http.Redirect(w, r, "/account", http.StatusSeeOther)
545 		return
546 	}
547 	s.setNext(w, "")
548 	text, err := s.store.readFeeds(u)
549 	if err != nil {
550 		s.fail(w, err)
551 		return
552 	}
553 	known := map[string]bool{}
554 	for _, f := range parseFeeds(text) {
555 		known[norm(f.URL)] = true
556 	}
557 	p.SignedIn = true
558 	for i := range p.Feeds {
559 		p.Feeds[i].Known = known[norm(p.Feeds[i].URL)]
560 		if !p.Feeds[i].Known {
561 			p.New++
562 		}
563 	}
564 	s.render(w, http.StatusOK, "follow", p)
565 }
566 
567 // followFeeds adds the feeds of the form to feeds.txt, as the button of the
568 // follow page sends them. Feeds that the account follows already are
569 // skipped.
570 func (s *server) followFeeds(w http.ResponseWriter, r *http.Request) {
571 	if !s.sameSite(w, r) {
572 		return
573 	}
574 	u := s.owner(w, r)
575 	if u == nil {
576 		return
577 	}
578 	r.Body = http.MaxBytesReader(w, r.Body, maxForm)
579 	if err := r.ParseForm(); err != nil {
580 		http.Error(w, "cannot read the form", http.StatusBadRequest)
581 		return
582 	}
583 	feeds, _ := sharedFeeds(r.PostForm)
584 	added, known := 0, 0
585 	err := s.store.changeFeeds(u, func(text string) (string, error) {
586 		ls := lines(text)
587 		have := map[string]bool{}
588 		for _, f := range parseFeeds(text) {
589 			have[norm(f.URL)] = true
590 		}
591 		for _, f := range feeds {
592 			switch {
593 			case have[norm(f.URL)]:
594 				known++
595 			case len(have) >= maxFeeds:
596 				return "", errFeedMany
597 			default:
598 				have[norm(f.URL)] = true
599 				ls = append(ls, f.URL+"\t"+f.Name)
600 				added++
601 			}
602 		}
603 		return join(ls), nil
604 	})
605 	if errors.Is(err, errFeedMany) {
606 		p, perr := s.feedData(u, "", 0)
607 		if perr != nil {
608 			s.fail(w, perr)
609 			return
610 		}
611 		p.Error = sentence(err)
612 		s.render(w, http.StatusBadRequest, "feed", p)
613 		return
614 	} else if err != nil {
615 		s.fail(w, err)
616 		return
617 	}
618 	http.Redirect(w, r, fmt.Sprintf("/feed?done=followed&added=%d&known=%d", added, known), http.StatusSeeOther)
619 }
620 
621 // followFeed adds the feed of the form to feeds.txt.
622 func (s *server) followFeed(w http.ResponseWriter, r *http.Request) {
623 	if !s.sameSite(w, r) {
624 		return
625 	}
626 	u := s.owner(w, r)
627 	if u == nil {
628 		return
629 	}
630 	r.Body = http.MaxBytesReader(w, r.Body, maxForm)
631 	raw, name := r.PostFormValue("url"), r.PostFormValue("name")
632 	addr, err := feedURL(raw)
633 	if err == nil {
634 		err = s.store.changeFeeds(u, func(text string) (string, error) {
635 			feeds := parseFeeds(text)
636 			if len(feeds) >= maxFeeds {
637 				return "", errFeedMany
638 			}
639 			for _, f := range feeds {
640 				if norm(f.URL) == norm(addr) {
641 					return "", errFeedKnown
642 				}
643 			}
644 			return join(append(lines(text), addr+"\t"+feedName(name, addr))), nil
645 		})
646 	}
647 	status := http.StatusBadRequest
648 	switch {
649 	case err == nil:
650 		http.Redirect(w, r, "/feed?done=added", http.StatusSeeOther)
651 		return
652 	case errors.Is(err, errFeedKnown):
653 		status = http.StatusConflict
654 	case errors.Is(err, errNoURL), errors.Is(err, errFeedURL), errors.Is(err, errFeedLong), errors.Is(err, errFeedMany):
655 	default:
656 		s.fail(w, err)
657 		return
658 	}
659 	p, perr := s.feedData(u, "", 0)
660 	if perr != nil {
661 		s.fail(w, perr)
662 		return
663 	}
664 	p.Error, p.Open, p.Form.URL, p.Form.Name = sentence(err), "add", raw, name
665 	s.render(w, status, "feed", p)
666 }
667 
668 // unfollowFeed removes the feed with the URL of the form from feeds.txt.
669 func (s *server) unfollowFeed(w http.ResponseWriter, r *http.Request) {
670 	if !s.sameSite(w, r) {
671 		return
672 	}
673 	u := s.owner(w, r)
674 	if u == nil {
675 		return
676 	}
677 	r.Body = http.MaxBytesReader(w, r.Body, maxForm)
678 	k := norm(strings.TrimSpace(r.PostFormValue("url")))
679 	err := s.store.changeFeeds(u, func(text string) (string, error) {
680 		var out []string
681 		for _, l := range lines(text) {
682 			if k == "" || key(l) != k {
683 				out = append(out, l)
684 			}
685 		}
686 		return join(out), nil
687 	})
688 	if err != nil {
689 		s.fail(w, err)
690 		return
691 	}
692 	http.Redirect(w, r, "/feed?done=deleted", http.StatusSeeOther)
693 }
694 
695 // saveFeedSettings takes the check boxes of the settings form.
696 func (s *server) saveFeedSettings(w http.ResponseWriter, r *http.Request) {
697 	if !s.sameSite(w, r) {
698 		return
699 	}
700 	u := s.owner(w, r)
701 	if u == nil {
702 		return
703 	}
704 	r.Body = http.MaxBytesReader(w, r.Body, maxForm)
705 	f := feedSettings{
706 		NewTab:  r.PostFormValue("newtab") != "",
707 		NoMail:  s.store.view(u).Feed.NoMail,
708 		Explore: r.PostFormValue("explore") != "",
709 	}
710 	// The form has the email box only when the server sends email.
711 	if s.mail != nil {
712 		f.NoMail = r.PostFormValue("mail") == ""
713 	}
714 	if err := s.store.setFeed(u, f); err != nil {
715 		s.fail(w, err)
716 		return
717 	}
718 	http.Redirect(w, r, "/feed?done=settings", http.StatusSeeOther)
719 }
720 
721 // ---- API ----
722 
723 // apiOwner gives the account of the token when it can use its bookmarks
724 // and feeds: as for sync, its email is confirmed and sync is not paused.
725 // Else apiOwner answers the request and gives nil.
726 func (s *server) apiOwner(w http.ResponseWriter, r *http.Request) *Account {
727 	u := s.apiUser(r)
728 	if u == nil {
729 		http.Error(w, "not signed in: sign in again", http.StatusUnauthorized)
730 		return nil
731 	}
732 	if !s.verified(s.store.view(u)) {
733 		http.Error(w, "confirm your email address first: open the link in the email from sbm Sync, or see "+s.site+"/account", http.StatusForbidden)
734 		return nil
735 	}
736 	if !s.active(s.store.view(u)) {
737 		http.Error(w, "sbm Sync is paused for this account: see "+s.site+"/account", http.StatusPaymentRequired)
738 		return nil
739 	}
740 	return u
741 }
742 
743 // apiFeed gives the items of all feeds of the account in the sfeed format,
744 // newest first, for the sfeed tools:
745 //
746 //	curl -H "Authorization: Bearer $token" https://sbmsync.com/api/feed | sfeed_plain
747 func (s *server) apiFeed(w http.ResponseWriter, r *http.Request) {
748 	u := s.apiOwner(w, r)
749 	if u == nil {
750 		return
751 	}
752 	text, err := s.store.readFeeds(u)
753 	if err != nil {
754 		s.fail(w, err)
755 		return
756 	}
757 	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
758 	for _, it := range s.feedItems(u, parseFeeds(text)) {
759 		io.WriteString(w, it.Raw+"\n")
760 	}
761 }
762 
763 // apiFeeds gives feeds.txt: the feeds that the account follows.
764 func (s *server) apiFeeds(w http.ResponseWriter, r *http.Request) {
765 	u := s.apiOwner(w, r)
766 	if u == nil {
767 		return
768 	}
769 	text, err := s.store.readFeeds(u)
770 	if err != nil {
771 		s.fail(w, err)
772 		return
773 	}
774 	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
775 	io.WriteString(w, text)
776 }