rss email digests over ssh because you're a cool kid
herald.dunkirk.sh
go
rss
rss-reader
ssh
charm
1package ssh
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "io/fs"
8 "strings"
9 "time"
10
11 "github.com/charmbracelet/log"
12 "github.com/charmbracelet/ssh"
13 "github.com/kierank/herald/config"
14 "github.com/kierank/herald/scheduler"
15 "github.com/kierank/herald/store"
16 "github.com/pkg/sftp"
17)
18
19func SFTPHandler(st *store.DB, sched *scheduler.Scheduler, logger *log.Logger) func(ssh.Session) {
20 return func(s ssh.Session) {
21 user, ok := s.Context().Value("user").(*store.User)
22 if !ok {
23 logger.Error("SFTP: no user in context")
24 return
25 }
26
27 handler := &sftpHandler{
28 store: st,
29 scheduler: sched,
30 logger: logger,
31 user: user,
32 session: s,
33 }
34
35 server := sftp.NewRequestServer(s, sftp.Handlers{
36 FileGet: handler,
37 FilePut: handler,
38 FileCmd: handler,
39 FileList: handler,
40 })
41
42 if err := server.Serve(); err == io.EOF {
43 _ = server.Close()
44 } else if err != nil {
45 logger.Error("SFTP server error", "err", err)
46 }
47 }
48}
49
50type sftpHandler struct {
51 store *store.DB
52 scheduler *scheduler.Scheduler
53 logger *log.Logger
54 user *store.User
55 session ssh.Session
56}
57
58// Fileread for downloads
59func (h *sftpHandler) Fileread(r *sftp.Request) (io.ReaderAt, error) {
60 filename := strings.TrimPrefix(r.Filepath, "/")
61 if filename == "" || filename == "." {
62 return nil, fmt.Errorf("invalid path")
63 }
64
65 cfg, err := h.store.GetConfig(h.session.Context(), h.user.ID, filename)
66 if err != nil {
67 return nil, fmt.Errorf("config not found: %w", err)
68 }
69
70 return &bytesReaderAt{data: []byte(cfg.RawText)}, nil
71}
72
73// Filewrite for uploads
74func (h *sftpHandler) Filewrite(r *sftp.Request) (io.WriterAt, error) {
75 filename := strings.TrimPrefix(r.Filepath, "/")
76 if filename == "" || filename == "." {
77 return nil, fmt.Errorf("invalid filename")
78 }
79
80 if !strings.HasSuffix(filename, ".txt") {
81 return nil, fmt.Errorf("only .txt files are supported")
82 }
83
84 h.logger.Debug("SFTP write", "filename", filename, "user_id", h.user.ID)
85
86 return &configWriter{
87 handler: h,
88 filename: filename,
89 buffer: []byte{},
90 }, nil
91}
92
93// Filecmd handles file operations
94func (h *sftpHandler) Filecmd(r *sftp.Request) error {
95 filename := strings.TrimPrefix(r.Filepath, "/")
96
97 switch r.Method {
98 case "Setstat":
99 // Allow setstat (used by scp)
100 return nil
101 case "Remove":
102 if filename == "" || filename == "." {
103 return fmt.Errorf("invalid filename")
104 }
105 return h.store.DeleteConfig(h.session.Context(), h.user.ID, filename)
106 case "Rename":
107 return fmt.Errorf("rename not supported")
108 case "Mkdir", "Rmdir":
109 return fmt.Errorf("directories not supported")
110 default:
111 return sftp.ErrSSHFxOpUnsupported
112 }
113}
114
115// Filelist for directory listings
116func (h *sftpHandler) Filelist(r *sftp.Request) (sftp.ListerAt, error) {
117 switch r.Method {
118 case "List":
119 configs, err := h.store.ListConfigs(h.session.Context(), h.user.ID)
120 if err != nil {
121 return nil, err
122 }
123 infos := make([]fs.FileInfo, len(configs))
124 for i, cfg := range configs {
125 infos[i] = &configFileInfo{cfg: cfg}
126 }
127 return listerAt(infos), nil
128 case "Stat":
129 filename := strings.TrimPrefix(r.Filepath, "/")
130 if filename == "" || filename == "." || filename == "/" {
131 // Return root directory info
132 return listerAt{&dirInfo{}}, nil
133 }
134 cfg, err := h.store.GetConfig(h.session.Context(), h.user.ID, filename)
135 if err != nil {
136 return nil, err
137 }
138 return listerAt{&configFileInfo{cfg: cfg}}, nil
139 default:
140 return nil, sftp.ErrSSHFxOpUnsupported
141 }
142}
143
144type configWriter struct {
145 handler *sftpHandler
146 filename string
147 buffer []byte
148}
149
150func (w *configWriter) WriteAt(p []byte, off int64) (int, error) {
151 // Expand buffer if needed
152 needed := int(off) + len(p)
153 if needed > len(w.buffer) {
154 newBuf := make([]byte, needed)
155 copy(newBuf, w.buffer)
156 w.buffer = newBuf
157 }
158 copy(w.buffer[off:], p)
159 return len(p), nil
160}
161
162func (w *configWriter) Close() error {
163 content := string(w.buffer)
164
165 parsed, err := config.Parse(content)
166 if err != nil {
167 return fmt.Errorf("failed to parse config: %w", err)
168 }
169
170 if err := config.Validate(parsed); err != nil {
171 return fmt.Errorf("invalid config: %w", err)
172 }
173
174 nextRun, err := calculateNextRun(parsed.CronExpr)
175 if err != nil {
176 return fmt.Errorf("failed to calculate next run: %w", err)
177 }
178
179 ctx := w.handler.session.Context()
180
181 // Try to get existing config
182 existingCfg, err := w.handler.store.GetConfig(ctx, w.handler.user.ID, w.filename)
183 var cfg *store.Config
184
185 if err == nil {
186 // Config exists - update it
187 if err := w.handler.store.UpdateConfig(ctx, existingCfg.ID, parsed.Email, parsed.CronExpr, parsed.Digest, parsed.Inline, content, nextRun); err != nil {
188 return fmt.Errorf("failed to update config: %w", err)
189 }
190 cfg = existingCfg
191 cfg.Email = parsed.Email
192 cfg.CronExpr = parsed.CronExpr
193 cfg.Digest = parsed.Digest
194 cfg.InlineContent = parsed.Inline
195 cfg.RawText = content
196
197 // Sync feeds: match by URL, update/delete/add as needed
198 existingFeeds, err := w.handler.store.GetFeedsByConfig(ctx, cfg.ID)
199 if err != nil {
200 return fmt.Errorf("failed to get existing feeds: %w", err)
201 }
202
203 // Build maps for comparison
204 existingByURL := make(map[string]*store.Feed)
205 for _, f := range existingFeeds {
206 existingByURL[f.URL] = f
207 }
208
209 newByURL := make(map[string]struct{ URL, Name string })
210 for _, f := range parsed.Feeds {
211 newByURL[f.URL] = struct{ URL, Name string }{URL: f.URL, Name: f.Name}
212 }
213
214 // Update existing feeds that are still present
215 for _, newFeed := range parsed.Feeds {
216 if existingFeed, exists := existingByURL[newFeed.URL]; exists {
217 // Feed still exists - update name if changed
218 if err := w.handler.store.UpdateFeed(ctx, existingFeed.ID, newFeed.Name); err != nil {
219 return fmt.Errorf("failed to update feed: %w", err)
220 }
221 } else {
222 // New feed - create it and mark existing items as seen
223 newFeedRecord, err := w.handler.store.CreateFeed(ctx, cfg.ID, newFeed.URL, newFeed.Name)
224 if err != nil {
225 return fmt.Errorf("failed to create feed: %w", err)
226 }
227 // Pre-seed seen items so we don't send old posts
228 if err := w.preseedSeenItems(ctx, newFeedRecord); err != nil {
229 w.handler.logger.Warn("failed to preseed seen items", "feed_url", newFeed.URL, "err", err)
230 }
231 }
232 }
233
234 // Delete feeds that are no longer present
235 for _, existingFeed := range existingFeeds {
236 if _, stillExists := newByURL[existingFeed.URL]; !stillExists {
237 if err := w.handler.store.DeleteFeed(ctx, existingFeed.ID); err != nil {
238 return fmt.Errorf("failed to delete feed: %w", err)
239 }
240 }
241 }
242
243 w.handler.logger.Debug("updated existing config via SFTP", "filename", w.filename)
244 } else {
245 // Config doesn't exist - create new one
246 cfg, err = w.handler.store.CreateConfig(ctx, w.handler.user.ID, w.filename, parsed.Email, parsed.CronExpr, parsed.Digest, parsed.Inline, content, nextRun)
247 if err != nil {
248 return fmt.Errorf("failed to create config: %w", err)
249 }
250
251 for _, feed := range parsed.Feeds {
252 if _, err := w.handler.store.CreateFeed(ctx, cfg.ID, feed.URL, feed.Name); err != nil {
253 return fmt.Errorf("failed to create feed: %w", err)
254 }
255 }
256
257 w.handler.logger.Debug("created new config via SFTP", "filename", w.filename)
258 }
259
260 w.handler.logger.Info("config uploaded via SFTP", "user_id", w.handler.user.ID, "filename", w.filename, "feeds", len(parsed.Feeds))
261 return nil
262}
263
264// preseedSeenItems fetches the feed and marks all current items as seen,
265// so that adding a new feed doesn't trigger emails for old posts.
266func (w *configWriter) preseedSeenItems(ctx context.Context, feed *store.Feed) error {
267 result := scheduler.FetchFeed(ctx, feed)
268 if result.Error != nil {
269 return result.Error
270 }
271
272 for _, item := range result.Items {
273 if err := w.handler.store.MarkItemSeen(ctx, feed.ID, item.GUID, item.Title, item.Link); err != nil {
274 return err
275 }
276 }
277
278 w.handler.logger.Debug("preseeded seen items for new feed", "feed_url", feed.URL, "count", len(result.Items))
279 return nil
280}
281
282type bytesReaderAt struct {
283 data []byte
284}
285
286func (r *bytesReaderAt) ReadAt(p []byte, off int64) (int, error) {
287 if off >= int64(len(r.data)) {
288 return 0, io.EOF
289 }
290 n := copy(p, r.data[off:])
291 if n < len(p) {
292 return n, io.EOF
293 }
294 return n, nil
295}
296
297type listerAt []fs.FileInfo
298
299func (l listerAt) ListAt(ls []fs.FileInfo, offset int64) (int, error) {
300 if offset >= int64(len(l)) {
301 return 0, io.EOF
302 }
303 n := copy(ls, l[offset:])
304 if n < len(ls) {
305 return n, io.EOF
306 }
307 return n, nil
308}
309
310type dirInfo struct{}
311
312func (d *dirInfo) Name() string { return "." }
313func (d *dirInfo) Size() int64 { return 0 }
314func (d *dirInfo) Mode() fs.FileMode { return fs.ModeDir | 0755 }
315func (d *dirInfo) ModTime() time.Time { return time.Now() }
316func (d *dirInfo) IsDir() bool { return true }
317func (d *dirInfo) Sys() any { return nil }