repos / pico

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

commit
d6b7795
parent
fd4594c
author
Eric Bower
date
2025-02-05 21:50:30 -0500 EST
feat(feeds): cli

A remote CLI with some handy options: ls, rm, and run

`run {filename}` is the most interesting because it will run the feed
digest post immediately, ignoring last digest time validation.

This will be useful for people to debug their posts since that is a
pain point for a lot of users.
5 files changed,  +177, -9
M Dockerfile
+1, -0
1@@ -68,6 +68,7 @@ ARG APP=prose
2 
3 COPY --from=builder-ssh /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
4 COPY --from=builder-ssh /go/bin/${APP}-ssh ./ssh
5+COPY --from=builder-web /app/${APP}/html ./${APP}/html
6 
7 
8 ENTRYPOINT ["/app/ssh"]
A feeds/cli.go
+159, -0
  1@@ -0,0 +1,159 @@
  2+package feeds
  3+
  4+import (
  5+	"fmt"
  6+	"log/slog"
  7+	"text/tabwriter"
  8+	"time"
  9+
 10+	"github.com/charmbracelet/ssh"
 11+	"github.com/charmbracelet/wish"
 12+	"github.com/picosh/pico/db"
 13+	"github.com/picosh/pico/shared"
 14+	"github.com/picosh/utils"
 15+)
 16+
 17+func getUser(s ssh.Session, dbpool db.DB) (*db.User, error) {
 18+	if s.PublicKey() == nil {
 19+		return nil, fmt.Errorf("key not found")
 20+	}
 21+
 22+	key := utils.KeyForKeyText(s.PublicKey())
 23+
 24+	user, err := dbpool.FindUserByPubkey(key)
 25+	if err != nil {
 26+		return nil, err
 27+	}
 28+
 29+	if user.Name == "" {
 30+		return nil, fmt.Errorf("must have username set")
 31+	}
 32+
 33+	return user, nil
 34+}
 35+
 36+func WishMiddleware(dbpool db.DB, cfg *shared.ConfigSite) wish.Middleware {
 37+	return func(next ssh.Handler) ssh.Handler {
 38+		return func(sesh ssh.Session) {
 39+			args := sesh.Command()
 40+			if len(args) == 0 {
 41+				next(sesh)
 42+				return
 43+			}
 44+
 45+			user, err := getUser(sesh, dbpool)
 46+			if err != nil {
 47+				wish.Errorln(sesh, err)
 48+				return
 49+			}
 50+
 51+			cmd := args[0]
 52+			if cmd == "help" {
 53+				wish.Printf(sesh, "Commands: [help, ls, rm, run]\n\n")
 54+				writer := tabwriter.NewWriter(sesh, 0, 0, 1, ' ', tabwriter.TabIndent)
 55+				fmt.Fprintln(writer, "Cmd\tDesc")
 56+				fmt.Fprintf(
 57+					writer,
 58+					"%s\t%s\n",
 59+					"help", "this help text",
 60+				)
 61+				fmt.Fprintf(
 62+					writer,
 63+					"%s\t%s\n",
 64+					"ls", "list feed digest posts with metadata",
 65+				)
 66+				fmt.Fprintf(
 67+					writer,
 68+					"%s\t%s\n",
 69+					"rm {filename}", "removes feed digest post",
 70+				)
 71+				fmt.Fprintf(
 72+					writer,
 73+					"%s\t%s\n",
 74+					"run {filename}", "runs the feed digest post immediately, ignoring last digest time validation",
 75+				)
 76+				writer.Flush()
 77+				return
 78+			} else if cmd == "ls" {
 79+				posts, err := dbpool.FindPostsForUser(&db.Pager{Page: 0, Num: 1000}, user.ID, "feeds")
 80+				if err != nil {
 81+					wish.Errorln(sesh, err)
 82+					return
 83+				}
 84+
 85+				if len(posts.Data) == 0 {
 86+					wish.Println(sesh, "no posts found")
 87+				}
 88+
 89+				writer := tabwriter.NewWriter(sesh, 0, 0, 1, ' ', tabwriter.TabIndent)
 90+				fmt.Fprintln(writer, "Filename\tLast Digest\tNext Digest\tInterval\tFailed Attempts")
 91+				for _, post := range posts.Data {
 92+					parsed := shared.ListParseText(post.Text)
 93+					digestOption := DigestOptionToTime(*post.Data.LastDigest, parsed.DigestInterval)
 94+					fmt.Fprintf(
 95+						writer,
 96+						"%s\t%s\t%s\t%s\t%d/10\n",
 97+						post.Filename,
 98+						post.Data.LastDigest.Format(time.RFC3339),
 99+						digestOption.Format(time.RFC3339),
100+						parsed.DigestInterval,
101+						post.Data.Attempts,
102+					)
103+				}
104+				writer.Flush()
105+				return
106+			} else if cmd == "rm" {
107+				filename := args[1]
108+				wish.Printf(sesh, "removing digest post %s\n", filename)
109+				write := false
110+				if len(args) > 2 {
111+					writeRaw := args[2]
112+					if writeRaw == "--write" {
113+						write = true
114+					}
115+				}
116+
117+				post, err := dbpool.FindPostWithFilename(filename, user.ID, "feeds")
118+				if err != nil {
119+					wish.Errorln(sesh, err)
120+					return
121+				}
122+				if write {
123+					err = dbpool.RemovePosts([]string{post.ID})
124+					if err != nil {
125+						wish.Errorln(sesh, err)
126+					}
127+				}
128+				wish.Printf(sesh, "digest post removed %s\n", filename)
129+				if !write {
130+					wish.Println(sesh, "WARNING: *must* append with `--write` for the changes to persist.")
131+				}
132+				return
133+			} else if cmd == "run" {
134+				if len(args) < 2 {
135+					wish.Errorln(sesh, "must provide filename of post to run")
136+					return
137+				}
138+				filename := args[1]
139+				post, err := dbpool.FindPostWithFilename(filename, user.ID, "feeds")
140+				if err != nil {
141+					wish.Errorln(sesh, err)
142+					return
143+				}
144+				wish.Printf(sesh, "running feed post: %s\n", filename)
145+				fetcher := NewFetcher(dbpool, cfg)
146+				logger := slog.New(
147+					slog.NewTextHandler(sesh, &slog.HandlerOptions{}),
148+				)
149+				logger = shared.LoggerWithUser(logger, user)
150+				err = fetcher.RunPost(logger, user, post, true)
151+				if err != nil {
152+					wish.Errorln(sesh, err)
153+				}
154+				return
155+			}
156+
157+			next(sesh)
158+		}
159+	}
160+}
M feeds/cron.go
+13, -8
 1@@ -81,7 +81,7 @@ func itemToTemplate(item *gofeed.Item) *FeedItemTmpl {
 2 	}
 3 }
 4 
 5-func digestOptionToTime(lastDigest time.Time, interval string) time.Time {
 6+func DigestOptionToTime(lastDigest time.Time, interval string) time.Time {
 7 	day := 24 * time.Hour
 8 	if interval == "10min" {
 9 		return lastDigest.Add(10 * time.Minute)
10@@ -140,14 +140,14 @@ func (f *Fetcher) Validate(post *db.Post, parsed *shared.ListParsedText) error {
11 		}
12 	}
13 
14-	digestAt := digestOptionToTime(*lastDigest, parsed.DigestInterval)
15+	digestAt := DigestOptionToTime(*lastDigest, parsed.DigestInterval)
16 	if digestAt.After(now) {
17 		return fmt.Errorf("(%s) not time to digest, skipping", digestAt.Format(time.RFC3339))
18 	}
19 	return nil
20 }
21 
22-func (f *Fetcher) RunPost(logger *slog.Logger, user *db.User, post *db.Post) error {
23+func (f *Fetcher) RunPost(logger *slog.Logger, user *db.User, post *db.Post, skipValidation bool) error {
24 	logger = logger.With("filename", post.Filename)
25 	logger.Info("running feed post")
26 
27@@ -165,7 +165,11 @@ func (f *Fetcher) RunPost(logger *slog.Logger, user *db.User, post *db.Post) err
28 	err := f.Validate(post, parsed)
29 	if err != nil {
30 		logger.Info("validation failed", "err", err)
31-		return nil
32+		if skipValidation {
33+			logger.Info("overriding validation error, continuing")
34+		} else {
35+			return nil
36+		}
37 	}
38 
39 	urls := []string{}
40@@ -214,14 +218,15 @@ func (f *Fetcher) RunPost(logger *slog.Logger, user *db.User, post *db.Post) err
41 		post.Data.Attempts += 1
42 		logger.Error("could not fetch urls", "err", err, "attempts", post.Data.Attempts)
43 
44-		errBody := fmt.Sprintf(`There was an error attempting to fetch your feeds (%d) times.  After (5) attempts we remove the file from our system.  Please check all the URLs and re-upload.
45+		maxAttempts := 10
46+		errBody := fmt.Sprintf(`There was an error attempting to fetch your feeds (%d) times.  After (%d) attempts we remove the file from our system.  Please check all the URLs and re-upload.
47 Also, we have centralized logs in our pico.sh TUI that will display realtime feed errors so you can debug.
48 
49 
50 %s
51 
52 
53-%s`, post.Data.Attempts, errForUser.Error(), post.Text)
54+%s`, post.Data.Attempts, maxAttempts, errForUser.Error(), post.Text)
55 		err = f.SendEmail(
56 			logger, user.Name,
57 			parsed.Email,
58@@ -232,7 +237,7 @@ Also, we have centralized logs in our pico.sh TUI that will display realtime fee
59 			return err
60 		}
61 
62-		if post.Data.Attempts >= 5 {
63+		if post.Data.Attempts >= maxAttempts {
64 			err = f.db.RemovePosts([]string{post.ID})
65 			if err != nil {
66 				return err
67@@ -280,7 +285,7 @@ func (f *Fetcher) RunUser(user *db.User) error {
68 	}
69 
70 	for _, post := range posts.Data {
71-		err = f.RunPost(logger, user, post)
72+		err = f.RunPost(logger, user, post, false)
73 		if err != nil {
74 			logger.Error("run post failed", "err", err)
75 		}
M feeds/scp_hooks.go
+3, -1
 1@@ -76,7 +76,9 @@ 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-		data.Data.LastDigest = &now
 6+		// only fetch posts in the last week
 7+		dd := now.AddDate(0, 0, -7)
 8+		data.Data.LastDigest = &dd
 9 	}
10 
11 	return nil
M feeds/ssh.go
+1, -0
1@@ -34,6 +34,7 @@ func createRouter(handler *filehandlers.FileHandlerRouter) proxy.Router {
2 			wishrsync.Middleware(handler),
3 			auth.Middleware(handler),
4 			wsh.PtyMdw(wsh.DeprecatedNotice()),
5+			WishMiddleware(handler.DBPool, handler.Cfg),
6 			wsh.LogMiddleware(handler.GetLogger()),
7 		}
8 	}