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 }