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


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 }