rss email digests over ssh because you're a cool kid herald.dunkirk.sh
go rss rss-reader ssh charm
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 317 lines 8.8 kB view raw
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 }