repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

commit
bd6d79c
parent
92b70e7
author
Eric Bower
date
2025-02-06 11:32:28 -0500 EST
feat(feeds): unsubscribe link in email digests

fix(feeds): show inline content by default
8 files changed,  +100, -35
M auth/api.go
+1, -0
1@@ -541,6 +541,7 @@ func AddPlusFeedForUser(dbpool db.DB, userID, email string) error {
2 	href := fmt.Sprintf("https://auth.pico.sh/rss/%s", token)
3 	text := fmt.Sprintf(`=: email %s
4 =: digest_interval 1day
5+=: inline_content true
6 => %s
7 => https://blog.pico.sh/rss`, email, href)
8 	now := time.Now()
M auth/api_test.go
+3, -1
 1@@ -63,7 +63,9 @@ func TestPaymentWebhook(t *testing.T) {
 2 		}
 3 		expectedText := `=: email auth@pico.test
 4 =: digest_interval 1day
 5-=> https://auth.pico.sh/rss/123`
 6+=: inline_content true
 7+=> https://auth.pico.sh/rss/123
 8+=> https://blog.pico.sh/rss`
 9 		if post.Text != expectedText {
10 			t.Errorf("Want pico plus feed file %s, got %s", expectedText, post.Text)
11 		}
M feeds/api.go
+65, -14
  1@@ -10,18 +10,6 @@ import (
  2 	"github.com/picosh/pico/shared"
  3 )
  4 
  5-func createStaticRoutes() []shared.Route {
  6-	return []shared.Route{
  7-		shared.NewRoute("GET", "/main.css", shared.ServeFile("main.css", "text/css")),
  8-		shared.NewRoute("GET", "/card.png", shared.ServeFile("card.png", "image/png")),
  9-		shared.NewRoute("GET", "/favicon-16x16.png", shared.ServeFile("favicon-16x16.png", "image/png")),
 10-		shared.NewRoute("GET", "/favicon-32x32.png", shared.ServeFile("favicon-32x32.png", "image/png")),
 11-		shared.NewRoute("GET", "/apple-touch-icon.png", shared.ServeFile("apple-touch-icon.png", "image/png")),
 12-		shared.NewRoute("GET", "/favicon.ico", shared.ServeFile("favicon.ico", "image/x-icon")),
 13-		shared.NewRoute("GET", "/robots.txt", shared.ServeFile("robots.txt", "text/plain")),
 14-	}
 15-}
 16-
 17 func keepAliveHandler(w http.ResponseWriter, r *http.Request) {
 18 	dbpool := shared.GetDB(r)
 19 	logger := shared.GetLogger(r)
 20@@ -29,11 +17,20 @@ func keepAliveHandler(w http.ResponseWriter, r *http.Request) {
 21 
 22 	post, err := dbpool.FindPost(postID)
 23 	if err != nil {
 24-		logger.Info("post not found")
 25+		logger.Error("post not found", "err", err)
 26 		http.Error(w, "post not found", http.StatusNotFound)
 27 		return
 28 	}
 29 
 30+	user, err := dbpool.FindUser(post.UserID)
 31+	if err != nil {
 32+		logger.Error("user not found", "err", err)
 33+		http.Error(w, "user not found", http.StatusNotFound)
 34+		return
 35+	}
 36+	logger = shared.LoggerWithUser(logger, user)
 37+	logger = logger.With("post", post.ID, "filename", post.Filename)
 38+
 39 	now := time.Now()
 40 	expiresAt := now.AddDate(0, 3, 0)
 41 	post.ExpiresAt = &expiresAt
 42@@ -46,9 +43,13 @@ func keepAliveHandler(w http.ResponseWriter, r *http.Request) {
 43 
 44 	w.Header().Add("Content-Type", "text/plain")
 45 
 46+	logger.Info(
 47+		"Success! This feed will stay active until %s or by clicking the link in your digest email again",
 48+		"expiresAt", now,
 49+	)
 50 	txt := fmt.Sprintf(
 51 		"Success! This feed will stay active until %s or by clicking the link in your digest email again",
 52-		time.Now(),
 53+		now,
 54 	)
 55 	_, err = w.Write([]byte(txt))
 56 	if err != nil {
 57@@ -57,10 +58,48 @@ func keepAliveHandler(w http.ResponseWriter, r *http.Request) {
 58 	}
 59 }
 60 
 61+func unsubHandler(w http.ResponseWriter, r *http.Request) {
 62+	dbpool := shared.GetDB(r)
 63+	logger := shared.GetLogger(r)
 64+	postID, _ := url.PathUnescape(shared.GetField(r, 0))
 65+
 66+	post, err := dbpool.FindPost(postID)
 67+	if err != nil {
 68+		logger.Error("post not found", "err", err)
 69+		http.Error(w, "post not found", http.StatusNotFound)
 70+		return
 71+	}
 72+
 73+	user, err := dbpool.FindUser(post.UserID)
 74+	if err != nil {
 75+		logger.Error("user not found", "err", err)
 76+		http.Error(w, "user not found", http.StatusNotFound)
 77+		return
 78+	}
 79+	logger = shared.LoggerWithUser(logger, user)
 80+	logger = logger.With("post", post.ID, "filename", post.Filename)
 81+
 82+	logger.Info("unsubscribe")
 83+	err = dbpool.RemovePosts([]string{post.ID})
 84+	if err != nil {
 85+		logger.Error("could not remove post", "err", err)
 86+		http.Error(w, "could not remove post", http.StatusInternalServerError)
 87+		return
 88+	}
 89+
 90+	txt := "Success! This feed digest post has been removed from our system."
 91+	_, err = w.Write([]byte(txt))
 92+	if err != nil {
 93+		logger.Error("could not write to writer", "err", err)
 94+		http.Error(w, "server error", 500)
 95+	}
 96+}
 97+
 98 func createMainRoutes(staticRoutes []shared.Route) []shared.Route {
 99 	routes := []shared.Route{
100 		shared.NewRoute("GET", "/", shared.CreatePageHandler("html/marketing.page.tmpl")),
101 		shared.NewRoute("GET", "/keep-alive/(.+)", keepAliveHandler),
102+		shared.NewRoute("GET", "/unsub/(.+)", unsubHandler),
103 	}
104 
105 	routes = append(
106@@ -71,6 +110,18 @@ func createMainRoutes(staticRoutes []shared.Route) []shared.Route {
107 	return routes
108 }
109 
110+func createStaticRoutes() []shared.Route {
111+	return []shared.Route{
112+		shared.NewRoute("GET", "/main.css", shared.ServeFile("main.css", "text/css")),
113+		shared.NewRoute("GET", "/card.png", shared.ServeFile("card.png", "image/png")),
114+		shared.NewRoute("GET", "/favicon-16x16.png", shared.ServeFile("favicon-16x16.png", "image/png")),
115+		shared.NewRoute("GET", "/favicon-32x32.png", shared.ServeFile("favicon-32x32.png", "image/png")),
116+		shared.NewRoute("GET", "/apple-touch-icon.png", shared.ServeFile("apple-touch-icon.png", "image/png")),
117+		shared.NewRoute("GET", "/favicon.ico", shared.ServeFile("favicon.ico", "image/x-icon")),
118+		shared.NewRoute("GET", "/robots.txt", shared.ServeFile("robots.txt", "text/plain")),
119+	}
120+}
121+
122 func StartApiServer() {
123 	cfg := NewConfigSite()
124 	db := postgres.NewDB(cfg.DbURL, cfg.Logger)
M feeds/cron.go
+4, -0
 1@@ -63,6 +63,7 @@ type DigestFeed struct {
 2 	Feeds        []*Feed
 3 	Options      DigestOptions
 4 	KeepAliveURL string
 5+	UnsubURL     string
 6 	DaysLeft     string
 7 	ShowBanner   bool
 8 }
 9@@ -200,6 +201,7 @@ func (f *Fetcher) RunPost(logger *slog.Logger, user *db.User, post *db.Post, ski
10 			continue
11 		}
12 
13+		logger.Info("found rss feed url", "url", u)
14 		urls = append(urls, u)
15 	}
16 
17@@ -418,6 +420,7 @@ type MsgBody struct {
18 }
19 
20 func (f *Fetcher) FetchAll(logger *slog.Logger, urls []string, inlineContent bool, username string, post *db.Post) (*MsgBody, error) {
21+	logger.Info("fetching feeds", "inlineContent", inlineContent)
22 	fp := gofeed.NewParser()
23 	daysLeft := ""
24 	showBanner := false
25@@ -431,6 +434,7 @@ func (f *Fetcher) FetchAll(logger *slog.Logger, urls []string, inlineContent boo
26 	}
27 	feeds := &DigestFeed{
28 		KeepAliveURL: fmt.Sprintf("https://feeds.pico.sh/keep-alive/%s", post.ID),
29+		UnsubURL:     fmt.Sprintf("https://feeds.pico.sh/unsub/%s", post.ID),
30 		DaysLeft:     daysLeft,
31 		ShowBanner:   showBanner,
32 		Options:      DigestOptions{InlineContent: inlineContent},
M feeds/html/digest.page.tmpl
+19, -15
 1@@ -17,35 +17,39 @@ img {
 2 {{end}}
 3 
 4 <div class="feeds">
 5-{{range .Feeds}}
 6-<div style="margin-bottom: 10px;">
 7+  {{range .Feeds}}
 8+  <div style="margin-bottom: 10px;">
 9     <h1 style="margin-bottom: 3px;"><a href="{{.Link}}">{{.Title}}</a></h1>
10     <div>{{.Description}}</div>
11-</div>
12+  </div>
13 
14-<div class="summary">
15+  <div class="summary">
16     <h2>Summary</h2>
17     {{range .Items}}
18     <ul>
19-        <li><a href="{{.Link}}">{{.Title}}</a></li>
20+      <li><a href="{{.Link}}">{{.Title}}</a></li>
21     </ul>
22     {{end}}
23     <hr />
24-</div>
25+  </div>
26 
27-{{if $.Options.InlineContent}}
28-<div>
29+  {{if $.Options.InlineContent}}
30+  <div>
31     {{range .Items}}
32     <div>
33-        <h1><a href="{{.Link}}">{{.Title}}</a></h1>
34-        <div>{{.Description}}</div>
35-        <div>{{.Content}}</div>
36+      <h1><a href="{{.Link}}">{{.Title}}</a></h1>
37+      <div>{{.Description}}</div>
38+      <div>{{.Content}}</div>
39     </div>
40     <hr />
41     {{end}}
42-</div>
43-{{end}}
44+  </div>
45+  {{end}}
46 
47-<hr style="margin: 10px 0;" />
48-{{end}}
49+  <hr style="margin: 10px 0;" />
50+  {{end}}
51 </div>
52+
53+<blockquote>
54+  <a href="{{.UnsubURL}}">unsubscribe</a> to this digest.
55+</blockquote>
M feeds/html/digest_text.page.tmpl
+2, -0
1@@ -16,3 +16,5 @@
2     ---
3 
4 {{end}}
5+
6+> {{.UnsubURL}} to unsubscribe to this digest.
M feeds/scp_hooks.go
+2, -2
 1@@ -76,8 +76,8 @@ func (p *FeedHooks) FileValidate(s ssh.Session, data *filehandlers.PostMetaData)
 2 func (p *FeedHooks) FileMeta(s ssh.Session, data *filehandlers.PostMetaData) error {
 3 	if data.Data.LastDigest == nil {
 4 		now := time.Now()
 5-		// only fetch posts in the last week
 6-		dd := now.AddDate(0, 0, -7)
 7+		// let it run on the next loop
 8+		dd := now.AddDate(0, 0, -31)
 9 		data.Data.LastDigest = &dd
10 	}
11 
M shared/listparser.go
+4, -3
 1@@ -220,9 +220,10 @@ func ListParseText(text string) *ListParsedText {
 2 	textItems := SplitByNewline(text)
 3 	items := []*ListItem{}
 4 	meta := ListMetaData{
 5-		ListType: "disc",
 6-		Tags:     []string{},
 7-		Layout:   "default",
 8+		ListType:      "disc",
 9+		Tags:          []string{},
10+		Layout:        "default",
11+		InlineContent: true,
12 	}
13 	pre := false
14 	skip := false