feedjob.go (13190 bytes)
1 package main 2 3 // The feed job: `sbm-sync feed`. A systemd timer runs it every 30 minutes 4 // (contrib/sbm-feed.timer). For each account it: 5 // 6 // 1. writes an sfeedrc from feeds.txt and runs sfeed_update, which fetches 7 // each feed and merges the new items into items/<file>; 8 // 2. when the account asked for it, looks through the bookmarks of the 9 // account for feeds with sfeed_web, and keeps what it found in explore; 10 // 3. unless the account turned it off, sends the items that are new since 11 // the last digest by email, once a day, in the form of sfeed_plain. 12 // 13 // The job writes only its own files under feeds/<account>/. The web server 14 // writes only feeds.txt and accounts.json. So the two never write the same 15 // file. 16 17 import ( 18 "context" 19 "errors" 20 "fmt" 21 "io/fs" 22 "log" 23 "net" 24 "net/url" 25 "os" 26 "os/exec" 27 "path/filepath" 28 "slices" 29 "strconv" 30 "strings" 31 "time" 32 ) 33 34 const ( 35 digestEvery = 24 * time.Hour 36 crawlPerRun = 20 // bookmarked pages that explore looks at in one run 37 feedsPerPage = 5 // feeds that explore keeps from one page 38 updateTime = 10 * time.Minute 39 ) 40 41 // feedTools are the programs and the clock that the job uses. Tests 42 // replace them. 43 type feedTools struct { 44 update func(sfeedrc string) error // runs sfeed_update 45 plain func(files []string) (string, error) // runs sfeed_plain on sfeed files 46 discover func(page string) ([]string, error) // gives "URL<tab>type" for each feed of a page, as curl | sfeed_web 47 lookup func(host string) ([]net.IP, error) // resolves a host name 48 now func() time.Time 49 } 50 51 // execTools gives the tools that run the programs. 52 func execTools() feedTools { 53 return feedTools{ 54 update: func(sfeedrc string) error { 55 ctx, cancel := context.WithTimeout(context.Background(), updateTime) 56 defer cancel() 57 out, err := exec.CommandContext(ctx, "sfeed_update", sfeedrc).CombinedOutput() 58 for _, l := range lines(string(out)) { 59 log.Print(l) 60 } 61 return err 62 }, 63 plain: func(files []string) (string, error) { 64 out, err := exec.Command("sfeed_plain", files...).Output() 65 return string(out), err 66 }, 67 discover: func(page string) ([]string, error) { 68 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 69 defer cancel() 70 curl := exec.CommandContext(ctx, "curl", "-sfL", "--proto", "=http,https", "--max-redirs", "3", 71 "-m", "10", "--max-filesize", "1048576", "-A", "sbm-sync (+https://github.com/equwal/sbm-sync)", page) 72 web := exec.CommandContext(ctx, "sfeed_web", page) 73 pipe, err := curl.StdoutPipe() 74 if err != nil { 75 return nil, err 76 } 77 web.Stdin = pipe 78 if err := curl.Start(); err != nil { 79 return nil, err 80 } 81 out, err := web.Output() 82 curl.Wait() // a page that does not load gives no feeds 83 if err != nil { 84 return nil, err 85 } 86 return lines(string(out)), nil 87 }, 88 lookup: net.LookupIP, 89 now: time.Now, 90 } 91 } 92 93 // runFeeds does the work of the job for every account. 94 func (s *server) runFeeds() { 95 accounts := s.store.all() 96 for _, a := range accounts { 97 if err := s.feedAccount(a); err != nil { 98 log.Printf("account %s: %v", a.ID, err) 99 } 100 } 101 log.Printf("feeds of %d accounts done", len(accounts)) 102 } 103 104 // sfeedrc gives the sfeed_update settings for the feeds: the item files go 105 // to itemsDir. Each URL passed feedURL, and each file name has only safe 106 // characters, so the single quotes hold. 107 // 108 // The fetch asks only for a feed that changed since the item file was 109 // written (-z), but not when that file is empty: sfeed_update touches the 110 // file before the first fetch, and curl would then skip the whole feed as 111 // not newer than the file. 112 func sfeedrc(itemsDir string, feeds []Feed) string { 113 const curl = "curl --proto '=http,https' -L --max-redirs 3 -A 'sbm-sync (+https://github.com/equwal/sbm-sync)' -f -s -m 20 --max-filesize 5242880" 114 var b strings.Builder 115 b.WriteString("# sbm-sync makes this file from feeds.txt. Do not edit it.\n") 116 b.WriteString("sfeedpath='" + itemsDir + "'\n") 117 b.WriteString("maxjobs=4\n") 118 b.WriteString("fetch() {\n\tif [ -s \"$3\" ]; then\n\t\t" + curl + " -z \"$3\" \"$2\" 2>/dev/null\n\telse\n\t\t" + curl + " \"$2\" 2>/dev/null\n\tfi\n}\n") 119 b.WriteString("feeds() {\n") 120 for _, f := range feeds { 121 b.WriteString("\tfeed '" + f.File + "' '" + f.URL + "'\n") 122 } 123 b.WriteString("}\n") 124 return b.String() 125 } 126 127 // publicIP tells if an address is on the public internet. 128 func publicIP(ip net.IP) bool { 129 if !ip.IsGlobalUnicast() || ip.IsPrivate() { 130 return false 131 } 132 // 100.64.0.0/10 is for carrier networks, as private as 10.0.0.0/8. 133 if v4 := ip.To4(); v4 != nil && v4[0] == 100 && v4[1]&0xc0 == 64 { 134 return false 135 } 136 return true 137 } 138 139 // public tells if the host of the URL is on the public internet. The job 140 // fetches only such hosts, so that a feed URL cannot reach the services on 141 // this machine or in its private networks. cache keeps the answers of one 142 // run. 143 func (s *server) public(rawURL string, cache map[string]bool) bool { 144 p, err := url.Parse(rawURL) 145 if err != nil || p.Hostname() == "" { 146 return false 147 } 148 host := p.Hostname() 149 if ok, seen := cache[host]; seen { 150 return ok 151 } 152 var ips []net.IP 153 if ip := net.ParseIP(host); ip != nil { 154 ips = []net.IP{ip} 155 } else if r, err := s.feeds.lookup(host); err == nil { 156 ips = r 157 } 158 ok := len(ips) > 0 159 for _, ip := range ips { 160 if !publicIP(ip) { 161 ok = false 162 } 163 } 164 cache[host] = ok 165 return ok 166 } 167 168 // feedAccount does the work of the job for one account. 169 func (s *server) feedAccount(p *Account) error { 170 a := s.store.view(p) 171 dir := s.store.feedDir(a.ID) 172 itemsDir := filepath.Join(dir, "items") 173 text, err := s.store.readFeeds(p) 174 if err != nil { 175 return err 176 } 177 cache := map[string]bool{} 178 followed := parseFeeds(text) 179 var feeds []Feed 180 for _, f := range followed { 181 if _, err := feedURL(f.URL); err != nil || !s.public(f.URL, cache) { 182 log.Printf("account %s: feed %s: not a public http address, skipped", a.ID, f.URL) 183 continue 184 } 185 feeds = append(feeds, f) 186 } 187 if len(feeds) == 0 && !a.Feed.Explore { 188 if _, err := os.Stat(dir); errors.Is(err, fs.ErrNotExist) { 189 return nil // the account never used feeds 190 } 191 } 192 if err := os.MkdirAll(itemsDir, 0o700); err != nil { 193 return err 194 } 195 rcPath := filepath.Join(dir, "sfeedrc") 196 if len(feeds) > 0 { 197 rc := sfeedrc(itemsDir, feeds) 198 if old, _ := os.ReadFile(rcPath); string(old) != rc { 199 if err := writeFile(rcPath, []byte(rc)); err != nil { 200 return err 201 } 202 } 203 if err := s.feeds.update(rcPath); err != nil { 204 log.Printf("account %s: sfeed_update: %v", a.ID, err) 205 } 206 } else { 207 os.Remove(rcPath) 208 } 209 // The items of feeds that the account left go. 210 keep := map[string]bool{} 211 for _, f := range feeds { 212 keep[f.File] = true 213 } 214 entries, err := os.ReadDir(itemsDir) 215 if err != nil { 216 return err 217 } 218 for _, e := range entries { 219 if !keep[e.Name()] { 220 os.Remove(filepath.Join(itemsDir, e.Name())) 221 } 222 } 223 if a.Feed.Explore { 224 if err := s.explore(p, dir, cache); err != nil { 225 log.Printf("account %s: explore: %v", a.ID, err) 226 } 227 } 228 if a.Feed.NoMail { 229 os.Remove(filepath.Join(dir, "mailed")) 230 os.Remove(filepath.Join(dir, "digest")) 231 os.Remove(filepath.Join(dir, "known")) 232 } else if s.mail != nil && s.verified(a) && s.active(a) { 233 // The digest gets all followed feeds, also a feed that this run 234 // skipped because its host did not resolve. So that feed stays known. 235 if err := s.digest(a, dir, followed); err != nil { 236 log.Printf("account %s: digest: %v", a.ID, err) 237 } 238 } 239 return nil 240 } 241 242 // explore looks at up to crawlPerRun bookmarked pages that it did not look 243 // at before, newest first, and adds the feeds that it finds to the explore 244 // file. A page without a feed, or one that the job cannot fetch, gets a 245 // line with "-", so that the job does not look at it again. 246 func (s *server) explore(p *Account, dir string, cache map[string]bool) error { 247 text, _, err := s.store.read(p, "") 248 if err != nil { 249 return err 250 } 251 path := filepath.Join(dir, "explore") 252 old, err := os.ReadFile(path) 253 if err != nil && !errors.Is(err, fs.ErrNotExist) { 254 return err 255 } 256 seen := map[string]bool{} 257 for _, l := range lines(string(old)) { 258 page, _, _ := strings.Cut(l, "\t") 259 seen[page] = true 260 } 261 var add []string 262 n := 0 263 for _, b := range slices.Backward(parse(text)) { 264 if n >= crawlPerRun { 265 break 266 } 267 link := b.Link() 268 if link == "" || seen[link] { 269 continue 270 } 271 seen[link] = true 272 if _, err := feedURL(link); err != nil || !s.public(link, cache) { 273 add = append(add, link+"\t-\t") 274 continue 275 } 276 n++ 277 found, err := s.feeds.discover(link) 278 if err != nil { 279 log.Printf("account %s: explore %s: %v", p.ID, link, err) 280 } 281 kept := 0 282 for _, f := range found { 283 u, typ, _ := strings.Cut(f, "\t") 284 if _, err := feedURL(u); err != nil || kept >= feedsPerPage { 285 continue 286 } 287 add = append(add, link+"\t"+u+"\t"+typ) 288 kept++ 289 } 290 if kept == 0 { 291 add = append(add, link+"\t-\t") 292 } 293 } 294 if len(add) == 0 { 295 return nil 296 } 297 return writeFile(path, []byte(string(old)+join(add))) 298 } 299 300 // digest sends the items that came in since the last digest, once a day. 301 // The first run for an account sends nothing: it only notes the items of 302 // that day, so that nobody gets the whole history. A feed that the account 303 // follows later is the same: the first run that finds items of the feed 304 // notes them and sends none of them. 305 // 306 // The file known has a line for each feed that the digest noted: the URL, 307 // a tab and the item file. A feed that gets another item file, for example 308 // under a new name, is a new feed, because the keys of its items change. A 309 // new feed without items does not become known, because its first fetch 310 // can have failed. So the first items of a feed that starts empty are 311 // noted too, and not sent. 312 func (s *server) digest(a Account, dir string, feeds []Feed) error { 313 mailedPath, digestPath, knownPath := filepath.Join(dir, "mailed"), filepath.Join(dir, "digest"), filepath.Join(dir, "known") 314 now := s.feeds.now() 315 stamp := []byte(strconv.FormatInt(now.Unix(), 10) + "\n") 316 id := func(f Feed) string { return f.URL + "\t" + f.File } 317 type feedItems struct { 318 f Feed 319 items []Item 320 } 321 var all []feedItems 322 var keys, ids []string 323 for _, f := range feeds { 324 ids = append(ids, id(f)) 325 b, err := os.ReadFile(filepath.Join(dir, "items", f.File)) 326 if err != nil { 327 continue 328 } 329 items := parseItems(string(b), f) 330 all = append(all, feedItems{f, items}) 331 for _, it := range items { 332 keys = append(keys, it.Key) 333 } 334 } 335 old, err := os.ReadFile(mailedPath) 336 if errors.Is(err, fs.ErrNotExist) { 337 if err := writeFile(mailedPath, []byte(join(keys))); err != nil { 338 return err 339 } 340 if err := writeFile(knownPath, []byte(join(ids))); err != nil { 341 return err 342 } 343 return writeFile(digestPath, stamp) 344 } else if err != nil { 345 return err 346 } 347 // Without the file known, as after an update from a version without 348 // it, the digest knows all feeds that the account follows now. 349 kb, err := os.ReadFile(knownPath) 350 noKnown := errors.Is(err, fs.ErrNotExist) 351 if noKnown { 352 kb = []byte(join(ids)) 353 } else if err != nil { 354 return err 355 } 356 known := set(lines(string(kb))) 357 var noted []string 358 for _, fi := range all { 359 if !known[id(fi.f)] && len(fi.items) > 0 { 360 known[id(fi.f)] = true 361 for _, it := range fi.items { 362 noted = append(noted, it.Key) 363 } 364 } 365 } 366 if len(noted) > 0 { 367 old = append(old, join(noted)...) 368 if err := writeFile(mailedPath, old); err != nil { 369 return err 370 } 371 } 372 // A feed that the account left is not known any more. When the account 373 // follows it again, it is a new feed. 374 var keep []string 375 for _, k := range ids { 376 if known[k] { 377 keep = append(keep, k) 378 } 379 } 380 if k := join(keep); noKnown || k != string(kb) { 381 if err := writeFile(knownPath, []byte(k)); err != nil { 382 return err 383 } 384 } 385 b, _ := os.ReadFile(digestPath) 386 last, _ := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64) 387 if now.Sub(time.Unix(last, 0)) < digestEvery { 388 return nil 389 } 390 mailed := set(lines(string(old))) 391 tmp, err := os.MkdirTemp("", "sbm-digest-") 392 if err != nil { 393 return err 394 } 395 defer os.RemoveAll(tmp) 396 var files, newKeys []string 397 for _, fi := range all { 398 var ls []string 399 for _, it := range fi.items { 400 if !mailed[it.Key] { 401 ls = append(ls, it.Raw) 402 newKeys = append(newKeys, it.Key) 403 } 404 } 405 if len(ls) == 0 { 406 continue 407 } 408 path := filepath.Join(tmp, fi.f.File) 409 if err := os.WriteFile(path, []byte(join(ls)), 0o600); err != nil { 410 return err 411 } 412 files = append(files, path) 413 } 414 if len(newKeys) == 0 { 415 return nil 416 } 417 body, err := s.feeds.plain(files) 418 if err != nil { 419 return err 420 } 421 subject := fmt.Sprintf("sbm feed: %d new items", len(newKeys)) 422 if len(newKeys) == 1 { 423 subject = "sbm feed: 1 new item" 424 } 425 body = strings.TrimRight(body, "\n") + "\n\nYour feed: " + s.site + "/feed\n" + 426 "To stop these emails, turn off the daily digest in the settings of that page.\n\n" + 427 "Also from the author of sbm: SubRead, read along with an audiobook: https://subread.space/\n" 428 if err := s.mail(a.Email, subject, body); err != nil { 429 return err 430 } 431 if err := writeFile(mailedPath, []byte(string(old)+join(newKeys))); err != nil { 432 return err 433 } 434 return writeFile(digestPath, stamp) 435 }