- 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
+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"]
+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+}
+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 }
+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
+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 }