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.

feat: init

+3842
+1
.env
··· 1 + SMTP_PASS=oYXQ2fR5hnHbROpIFkMFfGWO
+23
.gitignore
··· 1 + # Binary 2 + herald 3 + 4 + # Config 5 + config.yaml 6 + 7 + # Database 8 + *.db 9 + 10 + # SSH host keys 11 + host_key 12 + host_key.pub 13 + 14 + # IDE 15 + .vscode/ 16 + .idea/ 17 + *.swp 18 + *.swo 19 + *~ 20 + 21 + # OS 22 + .DS_Store 23 + Thumbs.db
+103
README.md
··· 4 4 5 5 The canonical repo for this is hosted on tangled over at [`dunkirk.sh/herald`](https://tangled.org/@dunkirk.sh/herald) 6 6 7 + ## Quick Start 8 + 9 + ```bash 10 + # Build 11 + go build -o herald . 12 + 13 + # Run the server 14 + ./herald serve 15 + 16 + # Or with a config file 17 + ./herald serve -c config.yaml 18 + ``` 19 + 20 + ## Usage 21 + 22 + ### Upload a config 23 + 24 + Create a `feeds.txt` file: 25 + 26 + ```text 27 + =: email you@example.com 28 + =: cron 0 8 * * * 29 + =: digest true 30 + => https://blog.example.com/rss 31 + => https://news.ycombinator.com/rss 32 + => https://lobste.rs/rss "Lobsters" 33 + ``` 34 + 35 + Upload via SCP: 36 + 37 + ```bash 38 + scp feeds.txt user@herald.example.com: 39 + ``` 40 + 41 + ### SSH Commands 42 + 43 + ```bash 44 + # List your configs 45 + ssh herald.example.com ls 46 + 47 + # Show config contents 48 + ssh herald.example.com cat feeds.txt 49 + 50 + # Delete a config 51 + ssh herald.example.com rm feeds.txt 52 + 53 + # Run immediately (don't wait for cron) 54 + ssh herald.example.com run feeds.txt 55 + 56 + # Show recent activity 57 + ssh herald.example.com logs 58 + ``` 59 + 60 + ### Web Interface 61 + 62 + Visit `http://localhost:8080` for the landing page, or `http://localhost:8080/{fingerprint}` for your user page with aggregated RSS/JSON feeds. 63 + 64 + ## Config Format 65 + 66 + ### Directives 67 + 68 + | Directive | Required | Description | 69 + | ------------------- | -------- | ------------------------------------------------ | 70 + | `=: email <addr>` | Yes | Recipient email address | 71 + | `=: cron <expr>` | Yes | Standard cron expression (5 fields) | 72 + | `=: digest <bool>` | No | Combine all items into one email (default: true) | 73 + | `=: inline <bool>` | No | Include article content in email (default: true) | 74 + | `=> <url> ["name"]` | Yes (1+) | RSS/Atom feed URL, optional display name | 75 + 76 + ## Configuration 77 + 78 + Create a `config.yaml`: 79 + 80 + ```yaml 81 + host: 0.0.0.0 82 + ssh_port: 2222 83 + http_port: 8080 84 + 85 + host_key_path: ./host_key 86 + db_path: ./herald.db 87 + 88 + smtp: 89 + host: smtp.example.com 90 + port: 587 91 + user: sender@example.com 92 + pass: ${SMTP_PASS} 93 + from: herald@example.com 94 + 95 + allow_all_keys: true 96 + ``` 97 + 98 + Environment variables can also be used: 99 + 100 + - `HERALD_HOST` 101 + - `HERALD_SSH_PORT` 102 + - `HERALD_HTTP_PORT` 103 + - `HERALD_DB_PATH` 104 + - `HERALD_SMTP_HOST` 105 + - `HERALD_SMTP_PORT` 106 + - `HERALD_SMTP_USER` 107 + - `HERALD_SMTP_PASS` 108 + - `HERALD_SMTP_FROM` 109 + 7 110 <p align="center"> 8 111 <img src="https://raw.githubusercontent.com/taciturnaxolotl/carriage/main/.github/images/line-break.svg" /> 9 112 </p>
+31
config.example.yaml
··· 1 + # Herald Configuration 2 + 3 + host: 0.0.0.0 4 + ssh_port: 2222 5 + http_port: 8080 6 + 7 + # Public URL where Herald is accessible 8 + origin: http://localhost:8080 9 + 10 + # External SSH port (defaults to ssh_port if not set) 11 + # Use this when SSH is exposed through a different port publicly 12 + # external_ssh_port: 22 13 + 14 + # SSH host keys (generated on first run if missing) 15 + host_key_path: ./host_key 16 + 17 + # Database 18 + db_path: ./herald.db 19 + 20 + # SMTP 21 + smtp: 22 + host: smtp.example.com 23 + port: 587 24 + user: sender@example.com 25 + pass: ${SMTP_PASS} # Env var substitution 26 + from: herald@example.com 27 + 28 + # Auth 29 + allow_all_keys: true 30 + # allowed_keys: 31 + # - "ssh-ed25519 AAAA... user@host"
+152
config/app.go
··· 1 + package config 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + "strconv" 8 + "strings" 9 + 10 + "github.com/joho/godotenv" 11 + "gopkg.in/yaml.v3" 12 + ) 13 + 14 + type AppConfig struct { 15 + Host string `yaml:"host"` 16 + SSHPort int `yaml:"ssh_port"` 17 + ExternalSSHPort int `yaml:"external_ssh_port"` 18 + HTTPPort int `yaml:"http_port"` 19 + HostKeyPath string `yaml:"host_key_path"` 20 + DBPath string `yaml:"db_path"` 21 + Origin string `yaml:"origin"` 22 + SMTP SMTPConfig `yaml:"smtp"` 23 + AllowAllKeys bool `yaml:"allow_all_keys"` 24 + AllowedKeys []string `yaml:"allowed_keys"` 25 + } 26 + 27 + type SMTPConfig struct { 28 + Host string `yaml:"host"` 29 + Port int `yaml:"port"` 30 + User string `yaml:"user"` 31 + Pass string `yaml:"pass"` 32 + From string `yaml:"from"` 33 + } 34 + 35 + func DefaultAppConfig() *AppConfig { 36 + return &AppConfig{ 37 + Host: "0.0.0.0", 38 + SSHPort: 2222, 39 + HTTPPort: 8080, 40 + HostKeyPath: "./host_key", 41 + DBPath: "./herald.db", 42 + Origin: "http://localhost:8080", 43 + SMTP: SMTPConfig{ 44 + Host: "localhost", 45 + Port: 587, 46 + From: "herald@localhost", 47 + }, 48 + AllowAllKeys: true, 49 + } 50 + } 51 + 52 + func LoadAppConfig(path string) (*AppConfig, error) { 53 + cfg := DefaultAppConfig() 54 + 55 + // Load .env file if it exists (silently ignore if not found) 56 + if envPath := findEnvFile(path); envPath != "" { 57 + _ = godotenv.Load(envPath) 58 + } 59 + 60 + if path != "" { 61 + data, err := os.ReadFile(path) 62 + if err != nil { 63 + return nil, fmt.Errorf("failed to read config file: %w", err) 64 + } 65 + 66 + expanded := os.Expand(string(data), func(key string) string { 67 + return os.Getenv(key) 68 + }) 69 + 70 + if err := yaml.Unmarshal([]byte(expanded), cfg); err != nil { 71 + return nil, fmt.Errorf("failed to parse config file: %w", err) 72 + } 73 + } 74 + 75 + applyEnvOverrides(cfg) 76 + 77 + // Default external_ssh_port to ssh_port if not set 78 + if cfg.ExternalSSHPort == 0 { 79 + cfg.ExternalSSHPort = cfg.SSHPort 80 + } 81 + 82 + return cfg, nil 83 + } 84 + 85 + // findEnvFile looks for .env file in the config file's directory or current directory 86 + func findEnvFile(configPath string) string { 87 + // If config path provided, look in its directory 88 + if configPath != "" { 89 + dir := filepath.Dir(configPath) 90 + envPath := filepath.Join(dir, ".env") 91 + if _, err := os.Stat(envPath); err == nil { 92 + return envPath 93 + } 94 + } 95 + 96 + // Otherwise check current directory 97 + if _, err := os.Stat(".env"); err == nil { 98 + return ".env" 99 + } 100 + 101 + return "" 102 + } 103 + 104 + func applyEnvOverrides(cfg *AppConfig) { 105 + if v := os.Getenv("HERALD_HOST"); v != "" { 106 + cfg.Host = v 107 + } 108 + if v := os.Getenv("HERALD_SSH_PORT"); v != "" { 109 + if port, err := strconv.Atoi(v); err == nil { 110 + cfg.SSHPort = port 111 + } 112 + } 113 + if v := os.Getenv("HERALD_EXTERNAL_SSH_PORT"); v != "" { 114 + if port, err := strconv.Atoi(v); err == nil { 115 + cfg.ExternalSSHPort = port 116 + } 117 + } 118 + if v := os.Getenv("HERALD_HTTP_PORT"); v != "" { 119 + if port, err := strconv.Atoi(v); err == nil { 120 + cfg.HTTPPort = port 121 + } 122 + } 123 + if v := os.Getenv("HERALD_HOST_KEY_PATH"); v != "" { 124 + cfg.HostKeyPath = v 125 + } 126 + if v := os.Getenv("HERALD_DB_PATH"); v != "" { 127 + cfg.DBPath = v 128 + } 129 + if v := os.Getenv("HERALD_SMTP_HOST"); v != "" { 130 + cfg.SMTP.Host = v 131 + } 132 + if v := os.Getenv("HERALD_SMTP_PORT"); v != "" { 133 + if port, err := strconv.Atoi(v); err == nil { 134 + cfg.SMTP.Port = port 135 + } 136 + } 137 + if v := os.Getenv("HERALD_SMTP_USER"); v != "" { 138 + cfg.SMTP.User = v 139 + } 140 + if v := os.Getenv("HERALD_SMTP_PASS"); v != "" { 141 + cfg.SMTP.Pass = v 142 + } 143 + if v := os.Getenv("HERALD_SMTP_FROM"); v != "" { 144 + cfg.SMTP.From = v 145 + } 146 + if v := os.Getenv("HERALD_ALLOW_ALL_KEYS"); v != "" { 147 + cfg.AllowAllKeys = strings.ToLower(v) == "true" 148 + } 149 + if v := os.Getenv("HERALD_ORIGIN"); v != "" { 150 + cfg.Origin = v 151 + } 152 + }
+99
config/parse.go
··· 1 + package config 2 + 3 + import ( 4 + "regexp" 5 + "strconv" 6 + "strings" 7 + ) 8 + 9 + type FeedEntry struct { 10 + URL string 11 + Name string 12 + } 13 + 14 + type ParsedConfig struct { 15 + Email string 16 + CronExpr string 17 + Digest bool 18 + Inline bool 19 + Feeds []FeedEntry 20 + } 21 + 22 + var feedLineRegex = regexp.MustCompile(`^=>\s+(\S+)(?:\s+"([^"]*)")?$`) 23 + 24 + func Parse(text string) (*ParsedConfig, error) { 25 + cfg := &ParsedConfig{ 26 + Digest: true, 27 + Inline: false, 28 + Feeds: []FeedEntry{}, 29 + } 30 + 31 + lines := strings.Split(text, "\n") 32 + for _, line := range lines { 33 + line = strings.TrimSpace(line) 34 + if line == "" || strings.HasPrefix(line, "#") { 35 + continue 36 + } 37 + 38 + if strings.HasPrefix(line, "=:") { 39 + if err := parseDirective(cfg, line); err != nil { 40 + return nil, err 41 + } 42 + } else if strings.HasPrefix(line, "=>") { 43 + if err := parseFeed(cfg, line); err != nil { 44 + return nil, err 45 + } 46 + } 47 + } 48 + 49 + return cfg, nil 50 + } 51 + 52 + func parseDirective(cfg *ParsedConfig, line string) error { 53 + content := strings.TrimPrefix(line, "=:") 54 + content = strings.TrimSpace(content) 55 + 56 + parts := strings.SplitN(content, " ", 2) 57 + if len(parts) < 2 { 58 + return nil 59 + } 60 + 61 + key := strings.ToLower(parts[0]) 62 + value := strings.TrimSpace(parts[1]) 63 + 64 + switch key { 65 + case "email": 66 + cfg.Email = value 67 + case "cron": 68 + cfg.CronExpr = value 69 + case "digest": 70 + cfg.Digest = parseBool(value, true) 71 + case "inline": 72 + cfg.Inline = parseBool(value, false) 73 + } 74 + 75 + return nil 76 + } 77 + 78 + func parseFeed(cfg *ParsedConfig, line string) error { 79 + matches := feedLineRegex.FindStringSubmatch(line) 80 + if matches == nil { 81 + return nil 82 + } 83 + 84 + entry := FeedEntry{ 85 + URL: matches[1], 86 + Name: matches[2], 87 + } 88 + cfg.Feeds = append(cfg.Feeds, entry) 89 + 90 + return nil 91 + } 92 + 93 + func parseBool(s string, defaultVal bool) bool { 94 + b, err := strconv.ParseBool(s) 95 + if err != nil { 96 + return defaultVal 97 + } 98 + return b 99 + }
+48
config/validate.go
··· 1 + package config 2 + 3 + import ( 4 + "errors" 5 + "net/mail" 6 + "net/url" 7 + 8 + "github.com/adhocore/gronx" 9 + ) 10 + 11 + var ( 12 + ErrNoEmail = errors.New("email is required") 13 + ErrBadEmail = errors.New("invalid email format") 14 + ErrNoCron = errors.New("cron expression is required") 15 + ErrBadCron = errors.New("invalid cron expression") 16 + ErrNoFeeds = errors.New("at least one feed URL is required") 17 + ErrBadFeedURL = errors.New("invalid feed URL") 18 + ) 19 + 20 + func Validate(cfg *ParsedConfig) error { 21 + if cfg.Email == "" { 22 + return ErrNoEmail 23 + } 24 + if _, err := mail.ParseAddress(cfg.Email); err != nil { 25 + return ErrBadEmail 26 + } 27 + 28 + if cfg.CronExpr == "" { 29 + return ErrNoCron 30 + } 31 + gron := gronx.New() 32 + if !gron.IsValid(cfg.CronExpr) { 33 + return ErrBadCron 34 + } 35 + 36 + if len(cfg.Feeds) == 0 { 37 + return ErrNoFeeds 38 + } 39 + 40 + for _, feed := range cfg.Feeds { 41 + u, err := url.Parse(feed.URL) 42 + if err != nil || u.Scheme == "" || u.Host == "" { 43 + return ErrBadFeedURL 44 + } 45 + } 46 + 47 + return nil 48 + }
+70
email/render.go
··· 1 + package email 2 + 3 + import ( 4 + "bytes" 5 + "embed" 6 + htmltemplate "html/template" 7 + texttemplate "text/template" 8 + "time" 9 + ) 10 + 11 + //go:embed templates/* 12 + var templateFS embed.FS 13 + 14 + type DigestData struct { 15 + ConfigName string 16 + TotalItems int 17 + FeedGroups []FeedGroup 18 + } 19 + 20 + type FeedGroup struct { 21 + FeedName string 22 + FeedURL string 23 + Items []FeedItem 24 + } 25 + 26 + type FeedItem struct { 27 + Title string 28 + Link string 29 + Content string 30 + Published time.Time 31 + } 32 + 33 + var ( 34 + htmlTmpl *htmltemplate.Template 35 + textTmpl *texttemplate.Template 36 + ) 37 + 38 + func init() { 39 + var err error 40 + htmlTmpl, err = htmltemplate.ParseFS(templateFS, "templates/digest.html") 41 + if err != nil { 42 + panic("failed to parse HTML template: " + err.Error()) 43 + } 44 + textTmpl, err = texttemplate.ParseFS(templateFS, "templates/digest.txt") 45 + if err != nil { 46 + panic("failed to parse text template: " + err.Error()) 47 + } 48 + } 49 + 50 + func RenderDigest(data *DigestData, inline bool) (html string, text string, err error) { 51 + tmplData := struct { 52 + *DigestData 53 + Inline bool 54 + }{ 55 + DigestData: data, 56 + Inline: inline, 57 + } 58 + 59 + var htmlBuf, textBuf bytes.Buffer 60 + 61 + if err = htmlTmpl.Execute(&htmlBuf, tmplData); err != nil { 62 + return "", "", err 63 + } 64 + 65 + if err = textTmpl.Execute(&textBuf, tmplData); err != nil { 66 + return "", "", err 67 + } 68 + 69 + return htmlBuf.String(), textBuf.String(), nil 70 + }
+117
email/send.go
··· 1 + package email 2 + 3 + import ( 4 + "crypto/tls" 5 + "fmt" 6 + "mime" 7 + "net" 8 + "net/smtp" 9 + "strings" 10 + ) 11 + 12 + type SMTPConfig struct { 13 + Host string 14 + Port int 15 + User string 16 + Pass string 17 + From string 18 + } 19 + 20 + type Mailer struct { 21 + cfg SMTPConfig 22 + } 23 + 24 + func NewMailer(cfg SMTPConfig) *Mailer { 25 + return &Mailer{cfg: cfg} 26 + } 27 + 28 + func (m *Mailer) Send(to, subject, htmlBody, textBody string) error { 29 + addr := net.JoinHostPort(m.cfg.Host, fmt.Sprintf("%d", m.cfg.Port)) 30 + 31 + boundary := "==herald-boundary-a1b2c3d4e5f6==" 32 + 33 + headers := make(map[string]string) 34 + headers["From"] = m.cfg.From 35 + headers["To"] = to 36 + headers["Subject"] = mime.QEncoding.Encode("utf-8", subject) 37 + headers["MIME-Version"] = "1.0" 38 + headers["Content-Type"] = fmt.Sprintf("multipart/alternative; boundary=%q", boundary) 39 + 40 + var msg strings.Builder 41 + for k, v := range headers { 42 + msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v)) 43 + } 44 + msg.WriteString("\r\n") 45 + 46 + msg.WriteString(fmt.Sprintf("--%s\r\n", boundary)) 47 + msg.WriteString("Content-Type: text/plain; charset=utf-8\r\n") 48 + msg.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") 49 + msg.WriteString(textBody) 50 + msg.WriteString("\r\n") 51 + 52 + msg.WriteString(fmt.Sprintf("--%s\r\n", boundary)) 53 + msg.WriteString("Content-Type: text/html; charset=utf-8\r\n") 54 + msg.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") 55 + msg.WriteString(htmlBody) 56 + msg.WriteString("\r\n") 57 + 58 + msg.WriteString(fmt.Sprintf("--%s--\r\n", boundary)) 59 + 60 + var auth smtp.Auth 61 + if m.cfg.User != "" && m.cfg.Pass != "" { 62 + auth = smtp.PlainAuth("", m.cfg.User, m.cfg.Pass, m.cfg.Host) 63 + } 64 + 65 + if m.cfg.Port == 465 { 66 + return m.sendWithTLS(addr, auth, to, msg.String()) 67 + } 68 + 69 + return smtp.SendMail(addr, auth, m.cfg.From, []string{to}, []byte(msg.String())) 70 + } 71 + 72 + func (m *Mailer) sendWithTLS(addr string, auth smtp.Auth, to, msg string) error { 73 + tlsConfig := &tls.Config{ 74 + ServerName: m.cfg.Host, 75 + } 76 + 77 + conn, err := tls.Dial("tcp", addr, tlsConfig) 78 + if err != nil { 79 + return fmt.Errorf("TLS dial: %w", err) 80 + } 81 + defer conn.Close() 82 + 83 + client, err := smtp.NewClient(conn, m.cfg.Host) 84 + if err != nil { 85 + return fmt.Errorf("SMTP client: %w", err) 86 + } 87 + defer client.Close() 88 + 89 + if auth != nil { 90 + if err = client.Auth(auth); err != nil { 91 + return fmt.Errorf("auth: %w", err) 92 + } 93 + } 94 + 95 + if err = client.Mail(m.cfg.From); err != nil { 96 + return fmt.Errorf("mail from: %w", err) 97 + } 98 + 99 + if err = client.Rcpt(to); err != nil { 100 + return fmt.Errorf("rcpt to: %w", err) 101 + } 102 + 103 + w, err := client.Data() 104 + if err != nil { 105 + return fmt.Errorf("data: %w", err) 106 + } 107 + 108 + if _, err = w.Write([]byte(msg)); err != nil { 109 + return fmt.Errorf("write: %w", err) 110 + } 111 + 112 + if err = w.Close(); err != nil { 113 + return fmt.Errorf("close data: %w", err) 114 + } 115 + 116 + return client.Quit() 117 + }
+66
email/templates/digest.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <style> 7 + body { 8 + font-family: monospace; 9 + background-color: #fff; 10 + color: #000; 11 + margin: 0; 12 + padding: 20px; 13 + line-height: 1.6; 14 + max-width: 600px; 15 + } 16 + a { 17 + color: #000; 18 + word-wrap: break-word; 19 + } 20 + h2 { 21 + font-size: 16px; 22 + font-weight: bold; 23 + margin: 20px 0 5px 0; 24 + } 25 + h3 { 26 + font-size: 14px; 27 + font-weight: bold; 28 + margin: 15px 0 10px 0; 29 + } 30 + .feed-url { 31 + font-size: 14px; 32 + margin-bottom: 10px; 33 + } 34 + .item { 35 + margin-bottom: 20px; 36 + } 37 + .item-title { 38 + font-weight: bold; 39 + margin-bottom: 5px; 40 + } 41 + .item-content { 42 + margin-top: 10px; 43 + white-space: pre-wrap; 44 + } 45 + </style> 46 + </head> 47 + <body> 48 + {{range .FeedGroups}} 49 + <h2>{{.FeedName}}</h2> 50 + <div class="feed-url"><a href="{{.FeedURL}}">{{.FeedURL}}</a></div> 51 + 52 + <h3>Summary</h3> 53 + 54 + {{range .Items}} 55 + <div class="item"> 56 + <div class="item-title">{{.Title}}</div> 57 + <div><a href="{{.Link}}">{{.Link}}</a></div> 58 + {{if and $.Inline .Content}} 59 + <div class="item-content">{{.Content}}</div> 60 + {{end}} 61 + </div> 62 + {{end}} 63 + 64 + {{end}} 65 + </body> 66 + </html>
+15
email/templates/digest.txt
··· 1 + {{range .FeedGroups}} 2 + {{.FeedName}} 3 + {{.FeedURL}} 4 + 5 + Summary 6 + 7 + {{range .Items}} 8 + {{.Title}} 9 + {{.Link}} 10 + {{if and $.Inline .Content}} 11 + {{.Content}} 12 + 13 + {{end}} 14 + {{end}} 15 + {{end}}
+69
go.mod
··· 1 + module github.com/kierank/herald 2 + 3 + go 1.24.2 4 + 5 + require ( 6 + github.com/adhocore/gronx v1.19.5 7 + github.com/charmbracelet/fang v0.4.4 8 + github.com/charmbracelet/lipgloss v0.13.1 9 + github.com/charmbracelet/log v0.4.0 10 + github.com/charmbracelet/ssh v0.0.0-20240725163421-eb71b85b27aa 11 + github.com/charmbracelet/wish v1.4.3 12 + github.com/mattn/go-sqlite3 v1.14.24 13 + github.com/mmcdole/gofeed v1.3.0 14 + github.com/spf13/cobra v1.9.1 15 + golang.org/x/crypto v0.41.0 16 + golang.org/x/sync v0.17.0 17 + gopkg.in/yaml.v3 v3.0.1 18 + ) 19 + 20 + require ( 21 + charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 // indirect 22 + github.com/PuerkitoBio/goquery v1.8.0 // indirect 23 + github.com/andybalholm/cascadia v1.3.1 // indirect 24 + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 25 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 26 + github.com/charmbracelet/bubbletea v1.0.0 // indirect 27 + github.com/charmbracelet/colorprofile v0.3.3 // indirect 28 + github.com/charmbracelet/keygen v0.5.1 // indirect 29 + github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 // indirect 30 + github.com/charmbracelet/x/ansi v0.11.0 // indirect 31 + github.com/charmbracelet/x/conpty v0.1.0 // indirect 32 + github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect 33 + github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect 34 + github.com/charmbracelet/x/term v0.2.2 // indirect 35 + github.com/charmbracelet/x/termios v0.1.1 // indirect 36 + github.com/charmbracelet/x/windows v0.2.2 // indirect 37 + github.com/clipperhouse/displaywidth v0.4.1 // indirect 38 + github.com/clipperhouse/stringish v0.1.1 // indirect 39 + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect 40 + github.com/creack/pty v1.1.21 // indirect 41 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 42 + github.com/go-logfmt/logfmt v0.6.0 // indirect 43 + github.com/inconshreveable/mousetrap v1.1.0 // indirect 44 + github.com/joho/godotenv v1.5.1 // indirect 45 + github.com/json-iterator/go v1.1.12 // indirect 46 + github.com/kr/fs v0.1.0 // indirect 47 + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect 48 + github.com/mattn/go-isatty v0.0.20 // indirect 49 + github.com/mattn/go-localereader v0.0.1 // indirect 50 + github.com/mattn/go-runewidth v0.0.19 // indirect 51 + github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect 52 + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 53 + github.com/modern-go/reflect2 v1.0.2 // indirect 54 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 55 + github.com/muesli/cancelreader v0.2.2 // indirect 56 + github.com/muesli/mango v0.1.0 // indirect 57 + github.com/muesli/mango-cobra v1.2.0 // indirect 58 + github.com/muesli/mango-pflag v0.1.0 // indirect 59 + github.com/muesli/roff v0.1.0 // indirect 60 + github.com/muesli/termenv v0.15.3-0.20240509142007-81b8f94111d5 // indirect 61 + github.com/pkg/sftp v1.13.10 // indirect 62 + github.com/rivo/uniseg v0.4.7 // indirect 63 + github.com/spf13/pflag v1.0.6 // indirect 64 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 65 + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect 66 + golang.org/x/net v0.42.0 // indirect 67 + golang.org/x/sys v0.37.0 // indirect 68 + golang.org/x/text v0.28.0 // indirect 69 + )
+160
go.sum
··· 1 + charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k= 2 + charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU= 3 + github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= 4 + github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= 5 + github.com/adhocore/gronx v1.19.5 h1:cwIG4nT1v9DvadxtHBe6MzE+FZ1JDvAUC45U2fl4eSQ= 6 + github.com/adhocore/gronx v1.19.5/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg= 7 + github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= 8 + github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= 9 + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 10 + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 11 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 12 + github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 13 + github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= 14 + github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= 15 + github.com/charmbracelet/bubbletea v1.0.0 h1:BlNvkVed3DADQlV+W79eioNUOrnMUY25EEVdFUoDoGA= 16 + github.com/charmbracelet/bubbletea v1.0.0/go.mod h1:xc4gm5yv+7tbniEvQ0naiG9P3fzYhk16cTgDZQQW6YE= 17 + github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI= 18 + github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4= 19 + github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY= 20 + github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= 21 + github.com/charmbracelet/keygen v0.5.1 h1:zBkkYPtmKDVTw+cwUyY6ZwGDhRxXkEp0Oxs9sqMLqxI= 22 + github.com/charmbracelet/keygen v0.5.1/go.mod h1:zznJVmK/GWB6dAtjluqn2qsttiCBhA5MZSiwb80fcHw= 23 + github.com/charmbracelet/lipgloss v0.13.1 h1:Oik/oqDTMVA01GetT4JdEC033dNzWoQHdWnHnQmXE2A= 24 + github.com/charmbracelet/lipgloss v0.13.1/go.mod h1:zaYVJ2xKSKEnTEEbX6uAHabh2d975RJ+0yfkFpRBz5U= 25 + github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= 26 + github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= 27 + github.com/charmbracelet/ssh v0.0.0-20240725163421-eb71b85b27aa h1:6rePgmsJguB6Z7Y55stsEVDlWFJoUpQvOX4mdnBjgx4= 28 + github.com/charmbracelet/ssh v0.0.0-20240725163421-eb71b85b27aa/go.mod h1:LmMZag2g7ILMmWtDmU7dIlctUopwmb73KpPzj0ip1uk= 29 + github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 h1:r/3jQZ1LjWW6ybp8HHfhrKrwHIWiJhUuY7wwYIWZulQ= 30 + github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692/go.mod h1:Y8B4DzWeTb0ama8l3+KyopZtkE8fZjwRQ3aEAPEXHE0= 31 + github.com/charmbracelet/wish v1.4.3 h1:7FvNLoPGqiT7EdjQP4+XuvM1Hrnx9DyknilbD+Okx1s= 32 + github.com/charmbracelet/wish v1.4.3/go.mod h1:hVgmhwhd52fLmO6m5AkREUMZYqQ0qmIJQDMe3HsNPmU= 33 + github.com/charmbracelet/x/ansi v0.11.0 h1:uuIVK7GIplwX6UBIz8S2TF8nkr7xRlygSsBRjSJqIvA= 34 + github.com/charmbracelet/x/ansi v0.11.0/go.mod h1:uQt8bOrq/xgXjlGcFMc8U2WYbnxyjrKhnvTQluvfCaE= 35 + github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= 36 + github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= 37 + github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= 38 + github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= 39 + github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= 40 + github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= 41 + github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= 42 + github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= 43 + github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= 44 + github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= 45 + github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= 46 + github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= 47 + github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= 48 + github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= 49 + github.com/clipperhouse/displaywidth v0.4.1 h1:uVw9V8UDfnggg3K2U84VWY1YLQ/x2aKSCtkRyYozfoU= 50 + github.com/clipperhouse/displaywidth v0.4.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= 51 + github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= 52 + github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= 53 + github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= 54 + github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= 55 + github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 56 + github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= 57 + github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 58 + github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 59 + github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 60 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 61 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 62 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 63 + github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 64 + github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 65 + github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 66 + github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 67 + github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 68 + github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 69 + github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 70 + github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 71 + github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 72 + github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 73 + github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 74 + github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= 75 + github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= 76 + github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= 77 + github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 78 + github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= 79 + github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= 80 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 81 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 82 + github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 83 + github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 84 + github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= 85 + github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 86 + github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 87 + github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 88 + github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= 89 + github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= 90 + github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk= 91 + github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8= 92 + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 93 + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 94 + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 95 + github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 96 + github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 97 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 98 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 99 + github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 100 + github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 101 + github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= 102 + github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= 103 + github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg= 104 + github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= 105 + github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= 106 + github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= 107 + github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= 108 + github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= 109 + github.com/muesli/termenv v0.15.3-0.20240509142007-81b8f94111d5 h1:NiONcKK0EV5gUZcnCiPMORaZA0eBDc+Fgepl9xl4lZ8= 110 + github.com/muesli/termenv v0.15.3-0.20240509142007-81b8f94111d5/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ= 111 + github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= 112 + github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= 113 + github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 114 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 115 + github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 116 + github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 117 + github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 118 + github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 119 + github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 120 + github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 121 + github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 122 + github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 123 + github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 124 + github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 125 + github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 126 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 127 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 128 + golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 129 + golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 130 + golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= 131 + golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= 132 + golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 133 + golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 134 + golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 135 + golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= 136 + golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 137 + golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= 138 + golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= 139 + golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= 140 + golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 141 + golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 142 + golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 143 + golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 144 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 145 + golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= 146 + golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 147 + golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 148 + golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= 149 + golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 150 + golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= 151 + golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 152 + golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 153 + golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 154 + golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= 155 + golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 156 + golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 157 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 158 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 159 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 160 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+182
main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + "time" 8 + 9 + "github.com/charmbracelet/fang" 10 + "github.com/charmbracelet/log" 11 + "github.com/kierank/herald/config" 12 + "github.com/kierank/herald/email" 13 + "github.com/kierank/herald/scheduler" 14 + "github.com/kierank/herald/ssh" 15 + "github.com/kierank/herald/store" 16 + "github.com/kierank/herald/web" 17 + "github.com/spf13/cobra" 18 + "golang.org/x/sync/errgroup" 19 + ) 20 + 21 + var ( 22 + version = "dev" 23 + cfgFile string 24 + logger *log.Logger 25 + ) 26 + 27 + func main() { 28 + logger = log.NewWithOptions(os.Stderr, log.Options{ 29 + ReportTimestamp: true, 30 + Level: log.InfoLevel, 31 + }) 32 + 33 + rootCmd := &cobra.Command{ 34 + Use: "herald", 35 + Short: "RSS-to-Email via SSH", 36 + Long: `Herald is a minimal, SSH-powered RSS to email service. 37 + Upload a feed config via SCP, get email digests on a schedule.`, 38 + Version: version, 39 + } 40 + 41 + rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file path") 42 + 43 + rootCmd.AddCommand(serveCmd()) 44 + rootCmd.AddCommand(initCmd()) 45 + 46 + if err := fang.Execute( 47 + context.Background(), 48 + rootCmd, 49 + fang.WithNotifySignal(os.Interrupt, os.Kill), 50 + ); err != nil { 51 + os.Exit(1) 52 + } 53 + } 54 + 55 + func serveCmd() *cobra.Command { 56 + return &cobra.Command{ 57 + Use: "serve", 58 + Short: "Start the Herald server", 59 + RunE: func(cmd *cobra.Command, args []string) error { 60 + return runServer(cmd.Context()) 61 + }, 62 + } 63 + } 64 + 65 + func initCmd() *cobra.Command { 66 + return &cobra.Command{ 67 + Use: "init [config_path]", 68 + Short: "Generate a sample configuration file", 69 + Long: "Create a config.yaml file with default values. If no path is provided, uses config.yaml", 70 + Args: cobra.MaximumNArgs(1), 71 + RunE: func(cmd *cobra.Command, args []string) error { 72 + path := "config.yaml" 73 + if len(args) > 0 { 74 + path = args[0] 75 + } 76 + 77 + if _, err := os.Stat(path); err == nil { 78 + return fmt.Errorf("config file already exists at %s", path) 79 + } 80 + 81 + sampleConfig := `# Herald Configuration 82 + 83 + host: 0.0.0.0 84 + ssh_port: 2222 85 + http_port: 8080 86 + 87 + # Public URL where Herald is accessible 88 + origin: http://localhost:8080 89 + 90 + # External SSH port (defaults to ssh_port if not set) 91 + # Use this when SSH is exposed through a different port publicly 92 + # external_ssh_port: 22 93 + 94 + # SSH host keys (generated on first run if missing) 95 + host_key_path: ./host_key 96 + 97 + # Database 98 + db_path: ./herald.db 99 + 100 + # SMTP 101 + smtp: 102 + host: smtp.example.com 103 + port: 587 104 + user: sender@example.com 105 + pass: ${SMTP_PASS} # Env var substitution 106 + from: herald@example.com 107 + 108 + # Auth 109 + allow_all_keys: true 110 + # allowed_keys: 111 + # - "ssh-ed25519 AAAA... user@host" 112 + ` 113 + 114 + if err := os.WriteFile(path, []byte(sampleConfig), 0644); err != nil { 115 + return fmt.Errorf("failed to write config file: %w", err) 116 + } 117 + 118 + logger.Info("created config file", "path", path) 119 + return nil 120 + }, 121 + } 122 + } 123 + 124 + func runServer(ctx context.Context) error { 125 + cfg, err := config.LoadAppConfig(cfgFile) 126 + if err != nil { 127 + return fmt.Errorf("failed to load config: %w", err) 128 + } 129 + 130 + logger.Info("starting herald", 131 + "ssh_port", cfg.SSHPort, 132 + "http_port", cfg.HTTPPort, 133 + "db_path", cfg.DBPath, 134 + ) 135 + 136 + db, err := store.Open(cfg.DBPath) 137 + if err != nil { 138 + return fmt.Errorf("failed to open database: %w", err) 139 + } 140 + defer db.Close() 141 + 142 + if err := db.Migrate(ctx); err != nil { 143 + return fmt.Errorf("failed to migrate database: %w", err) 144 + } 145 + 146 + mailer := email.NewMailer(email.SMTPConfig{ 147 + Host: cfg.SMTP.Host, 148 + Port: cfg.SMTP.Port, 149 + User: cfg.SMTP.User, 150 + Pass: cfg.SMTP.Pass, 151 + From: cfg.SMTP.From, 152 + }) 153 + 154 + sched := scheduler.NewScheduler(db, mailer, logger, 60*time.Second) 155 + 156 + sshServer := ssh.NewServer(ssh.Config{ 157 + Host: cfg.Host, 158 + Port: cfg.SSHPort, 159 + HostKeyPath: cfg.HostKeyPath, 160 + AllowAllKeys: cfg.AllowAllKeys, 161 + AllowedKeys: cfg.AllowedKeys, 162 + }, db, sched, logger) 163 + 164 + webServer := web.NewServer(db, fmt.Sprintf("%s:%d", cfg.Host, cfg.HTTPPort), cfg.Origin, cfg.ExternalSSHPort, logger) 165 + 166 + g, ctx := errgroup.WithContext(ctx) 167 + 168 + g.Go(func() error { 169 + return sshServer.ListenAndServe(ctx) 170 + }) 171 + 172 + g.Go(func() error { 173 + return webServer.ListenAndServe(ctx) 174 + }) 175 + 176 + g.Go(func() error { 177 + sched.Start(ctx) 178 + return nil 179 + }) 180 + 181 + return g.Wait() 182 + }
+144
scheduler/fetch.go
··· 1 + package scheduler 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + "sync" 7 + "time" 8 + 9 + "github.com/kierank/herald/store" 10 + "github.com/mmcdole/gofeed" 11 + ) 12 + 13 + type FetchResult struct { 14 + FeedID int64 15 + FeedName string 16 + FeedURL string 17 + Items []FetchedItem 18 + ETag string 19 + LastModified string 20 + Error error 21 + } 22 + 23 + type FetchedItem struct { 24 + GUID string 25 + Title string 26 + Link string 27 + Content string 28 + Published time.Time 29 + } 30 + 31 + func FetchFeed(ctx context.Context, feed *store.Feed) *FetchResult { 32 + result := &FetchResult{ 33 + FeedID: feed.ID, 34 + FeedURL: feed.URL, 35 + } 36 + 37 + if feed.Name.Valid { 38 + result.FeedName = feed.Name.String 39 + } 40 + 41 + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) 42 + defer cancel() 43 + 44 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, feed.URL, nil) 45 + if err != nil { 46 + result.Error = err 47 + return result 48 + } 49 + 50 + req.Header.Set("User-Agent", "Herald/1.0 (RSS Aggregator)") 51 + 52 + if feed.ETag.Valid && feed.ETag.String != "" { 53 + req.Header.Set("If-None-Match", feed.ETag.String) 54 + } 55 + if feed.LastModified.Valid && feed.LastModified.String != "" { 56 + req.Header.Set("If-Modified-Since", feed.LastModified.String) 57 + } 58 + 59 + client := &http.Client{ 60 + Timeout: 30 * time.Second, 61 + } 62 + 63 + resp, err := client.Do(req) 64 + if err != nil { 65 + result.Error = err 66 + return result 67 + } 68 + defer resp.Body.Close() 69 + 70 + if resp.StatusCode == http.StatusNotModified { 71 + return result 72 + } 73 + 74 + if resp.StatusCode != http.StatusOK { 75 + result.Error = &httpError{StatusCode: resp.StatusCode} 76 + return result 77 + } 78 + 79 + result.ETag = resp.Header.Get("ETag") 80 + result.LastModified = resp.Header.Get("Last-Modified") 81 + 82 + parser := gofeed.NewParser() 83 + parsedFeed, err := parser.Parse(resp.Body) 84 + if err != nil { 85 + result.Error = err 86 + return result 87 + } 88 + 89 + if result.FeedName == "" && parsedFeed.Title != "" { 90 + result.FeedName = parsedFeed.Title 91 + } 92 + 93 + for _, item := range parsedFeed.Items { 94 + fetchedItem := FetchedItem{ 95 + GUID: item.GUID, 96 + Title: item.Title, 97 + Link: item.Link, 98 + } 99 + 100 + if fetchedItem.GUID == "" { 101 + fetchedItem.GUID = item.Link 102 + } 103 + 104 + if item.Content != "" { 105 + fetchedItem.Content = item.Content 106 + } else if item.Description != "" { 107 + fetchedItem.Content = item.Description 108 + } 109 + 110 + if item.PublishedParsed != nil { 111 + fetchedItem.Published = *item.PublishedParsed 112 + } else if item.UpdatedParsed != nil { 113 + fetchedItem.Published = *item.UpdatedParsed 114 + } 115 + 116 + result.Items = append(result.Items, fetchedItem) 117 + } 118 + 119 + return result 120 + } 121 + 122 + func FetchFeeds(ctx context.Context, feeds []*store.Feed) []*FetchResult { 123 + results := make([]*FetchResult, len(feeds)) 124 + var wg sync.WaitGroup 125 + 126 + for i, feed := range feeds { 127 + wg.Add(1) 128 + go func(idx int, f *store.Feed) { 129 + defer wg.Done() 130 + results[idx] = FetchFeed(ctx, f) 131 + }(i, feed) 132 + } 133 + 134 + wg.Wait() 135 + return results 136 + } 137 + 138 + type httpError struct { 139 + StatusCode int 140 + } 141 + 142 + func (e *httpError) Error() string { 143 + return http.StatusText(e.StatusCode) 144 + }
+191
scheduler/scheduler.go
··· 1 + package scheduler 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "time" 7 + 8 + "github.com/adhocore/gronx" 9 + "github.com/charmbracelet/log" 10 + "github.com/kierank/herald/email" 11 + "github.com/kierank/herald/store" 12 + ) 13 + 14 + type Scheduler struct { 15 + store *store.DB 16 + mailer *email.Mailer 17 + logger *log.Logger 18 + interval time.Duration 19 + } 20 + 21 + func NewScheduler(st *store.DB, mailer *email.Mailer, logger *log.Logger, interval time.Duration) *Scheduler { 22 + return &Scheduler{ 23 + store: st, 24 + mailer: mailer, 25 + logger: logger, 26 + interval: interval, 27 + } 28 + } 29 + 30 + func (s *Scheduler) Start(ctx context.Context) { 31 + ticker := time.NewTicker(s.interval) 32 + defer ticker.Stop() 33 + 34 + s.logger.Info("scheduler started", "interval", s.interval) 35 + 36 + for { 37 + select { 38 + case <-ctx.Done(): 39 + s.logger.Info("scheduler stopped") 40 + return 41 + case <-ticker.C: 42 + s.tick(ctx) 43 + } 44 + } 45 + } 46 + 47 + func (s *Scheduler) tick(ctx context.Context) { 48 + now := time.Now() 49 + configs, err := s.store.GetDueConfigs(ctx, now) 50 + if err != nil { 51 + s.logger.Error("failed to get due configs", "err", err) 52 + return 53 + } 54 + 55 + for _, cfg := range configs { 56 + if err := s.processConfig(ctx, cfg); err != nil { 57 + s.logger.Error("failed to process config", "config_id", cfg.ID, "err", err) 58 + _ = s.store.AddLog(ctx, cfg.ID, "error", fmt.Sprintf("Failed: %v", err)) 59 + } 60 + } 61 + } 62 + 63 + func (s *Scheduler) RunNow(ctx context.Context, configID int64) error { 64 + cfg, err := s.store.GetConfigByID(ctx, configID) 65 + if err != nil { 66 + return fmt.Errorf("get config: %w", err) 67 + } 68 + return s.processConfig(ctx, cfg) 69 + } 70 + 71 + func (s *Scheduler) processConfig(ctx context.Context, cfg *store.Config) error { 72 + s.logger.Info("processing config", "config_id", cfg.ID, "filename", cfg.Filename) 73 + 74 + feeds, err := s.store.GetFeedsByConfig(ctx, cfg.ID) 75 + if err != nil { 76 + return fmt.Errorf("get feeds: %w", err) 77 + } 78 + 79 + if len(feeds) == 0 { 80 + s.logger.Warn("no feeds for config", "config_id", cfg.ID) 81 + return nil 82 + } 83 + 84 + results := FetchFeeds(ctx, feeds) 85 + 86 + var feedGroups []email.FeedGroup 87 + totalNew := 0 88 + threeMonthsAgo := time.Now().AddDate(0, -3, 0) 89 + 90 + for _, result := range results { 91 + if result.Error != nil { 92 + s.logger.Warn("feed fetch error", "feed_id", result.FeedID, "url", result.FeedURL, "err", result.Error) 93 + continue 94 + } 95 + 96 + var newItems []email.FeedItem 97 + for _, item := range result.Items { 98 + // Skip items older than 3 months 99 + if !item.Published.IsZero() && item.Published.Before(threeMonthsAgo) { 100 + continue 101 + } 102 + 103 + seen, err := s.store.IsItemSeen(ctx, result.FeedID, item.GUID) 104 + if err != nil { 105 + s.logger.Warn("failed to check if item seen", "err", err) 106 + continue 107 + } 108 + 109 + if !seen { 110 + newItems = append(newItems, email.FeedItem{ 111 + Title: item.Title, 112 + Link: item.Link, 113 + Content: item.Content, 114 + Published: item.Published, 115 + }) 116 + } 117 + } 118 + 119 + if len(newItems) > 0 { 120 + feedName := result.FeedName 121 + if feedName == "" { 122 + feedName = result.FeedURL 123 + } 124 + feedGroups = append(feedGroups, email.FeedGroup{ 125 + FeedName: feedName, 126 + FeedURL: result.FeedURL, 127 + Items: newItems, 128 + }) 129 + totalNew += len(newItems) 130 + } 131 + 132 + if result.ETag != "" || result.LastModified != "" { 133 + if err := s.store.UpdateFeedFetched(ctx, result.FeedID, result.ETag, result.LastModified); err != nil { 134 + s.logger.Warn("failed to update feed fetched", "err", err) 135 + } 136 + } 137 + } 138 + 139 + if totalNew == 0 { 140 + s.logger.Info("no new items", "config_id", cfg.ID) 141 + } else { 142 + digestData := &email.DigestData{ 143 + ConfigName: cfg.Filename, 144 + TotalItems: totalNew, 145 + FeedGroups: feedGroups, 146 + } 147 + 148 + // Auto-disable inline content if more than 5 items 149 + inline := cfg.InlineContent 150 + if totalNew > 5 { 151 + inline = false 152 + } 153 + 154 + htmlBody, textBody, err := email.RenderDigest(digestData, inline) 155 + if err != nil { 156 + return fmt.Errorf("render digest: %w", err) 157 + } 158 + 159 + subject := "feed digest" 160 + if err := s.mailer.Send(cfg.Email, subject, htmlBody, textBody); err != nil { 161 + return fmt.Errorf("send email: %w", err) 162 + } 163 + 164 + s.logger.Info("email sent", "to", cfg.Email, "items", totalNew) 165 + 166 + for _, result := range results { 167 + if result.Error != nil { 168 + continue 169 + } 170 + for _, item := range result.Items { 171 + if err := s.store.MarkItemSeen(ctx, result.FeedID, item.GUID, item.Title, item.Link); err != nil { 172 + s.logger.Warn("failed to mark item seen", "err", err) 173 + } 174 + } 175 + } 176 + } 177 + 178 + now := time.Now() 179 + nextRun, err := gronx.NextTick(cfg.CronExpr, false) 180 + if err != nil { 181 + return fmt.Errorf("calculate next run: %w", err) 182 + } 183 + 184 + if err := s.store.UpdateLastRun(ctx, cfg.ID, now, nextRun); err != nil { 185 + return fmt.Errorf("update last run: %w", err) 186 + } 187 + 188 + _ = s.store.AddLog(ctx, cfg.ID, "info", fmt.Sprintf("Processed: %d new items, next run: %s", totalNew, nextRun.Format(time.RFC3339))) 189 + 190 + return nil 191 + }
+434
spec.md
··· 1 + # rss2email-ssh 2 + 3 + A minimal, SSH-powered RSS to email service. Upload a feed config via SCP, get email digests on a schedule. 4 + 5 + ## Goals 6 + 7 + - **Simple**: Single binary, SQLite storage, no external dependencies 8 + - **SSH-native**: Auth via SSH keys, configure via SCP or interactive TUI 9 + - **Pico-compatible**: Same config format as pico.sh/feeds 10 + - **Charm-powered**: Built with the Charm ecosystem 11 + 12 + ## Architecture 13 + 14 + ``` 15 + ┌─────────────────────────────────────────┐ 16 + │ rss2email-ssh │ 17 + ├─────────────────────────────────────────┤ 18 + │ │ 19 + SSH key auth │ ┌─────────────┐ ┌──────────────┐ │ 20 + ─────────────────┼─▶│ wish │───▶│ bubbletea │ │ 21 + │ │ (SSH srv) │ │ (TUI) │ │ 22 + │ └─────────────┘ └──────────────┘ │ 23 + │ │ │ 24 + SCP upload │ ▼ │ 25 + ─────────────────┼─▶┌─────────────┐ ┌──────────────┐ │ 26 + │ │ wish/scp │───▶│ SQLite │ │ 27 + │ │ (files) │ │ (store) │ │ 28 + │ └─────────────┘ └──────┬───────┘ │ 29 + │ │ │ 30 + │ ▼ │ 31 + │ ┌──────────────┐ │ 32 + │ │ Scheduler │ │ 33 + │ │ (cron loop) │ │ 34 + │ └──────┬───────┘ │ 35 + │ │ │ 36 + │ ▼ │ 37 + │ ┌──────────────┐ │ 38 + │ │ SMTP out │──────▶ Email 39 + │ └──────────────┘ │ 40 + └─────────────────────────────────────────┘ 41 + ``` 42 + 43 + ## Charm Libraries 44 + 45 + | Library | Purpose | 46 + |---------|---------| 47 + | [wish](https://github.com/charmbracelet/wish) | SSH server, middleware, SCP handling | 48 + | [lipgloss](https://github.com/charmbracelet/lipgloss) | Styling CLI output | 49 + | [log](https://github.com/charmbracelet/log) | Structured logging | 50 + 51 + ## Config Format 52 + 53 + Pico-compatible plaintext format. Users upload as `feeds.txt` (or any `.txt` file): 54 + 55 + ```text 56 + =: email you@example.com 57 + =: cron 0 8 * * * 58 + =: digest true 59 + =: inline false 60 + => https://blog.example.com/rss 61 + => https://news.ycombinator.com/rss 62 + => https://lobste.rs/rss "Lobsters" 63 + ``` 64 + 65 + ### Directives 66 + 67 + | Directive | Required | Description | 68 + |-----------|----------|-------------| 69 + | `=: email <addr>` | Yes | Recipient email address | 70 + | `=: cron <expr>` | Yes | Standard cron expression (5 fields) | 71 + | `=: digest <bool>` | No | Combine all items into one email (default: true) | 72 + | `=: inline <bool>` | No | Include article content in email (default: false) | 73 + | `=> <url> ["name"]` | Yes (1+) | RSS/Atom feed URL, optional display name | 74 + 75 + Note: Items are filtered to only include those published within the last 3 months. 76 + 77 + ## Data Model 78 + 79 + ### SQLite Schema 80 + 81 + ```sql 82 + -- Users identified by SSH public key fingerprint 83 + CREATE TABLE users ( 84 + id INTEGER PRIMARY KEY, 85 + pubkey_fp TEXT UNIQUE NOT NULL, -- SHA256 fingerprint 86 + pubkey TEXT NOT NULL, -- Full public key 87 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP 88 + ); 89 + 90 + -- Feed configurations (one per uploaded file) 91 + CREATE TABLE configs ( 92 + id INTEGER PRIMARY KEY, 93 + user_id INTEGER NOT NULL REFERENCES users(id), 94 + filename TEXT NOT NULL, 95 + email TEXT NOT NULL, 96 + cron_expr TEXT NOT NULL, 97 + digest BOOLEAN DEFAULT TRUE, 98 + inline_content BOOLEAN DEFAULT FALSE, 99 + raw_text TEXT NOT NULL, -- Original file contents 100 + last_run DATETIME, 101 + next_run DATETIME, 102 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 103 + UNIQUE(user_id, filename) 104 + ); 105 + 106 + -- Individual feeds within a config 107 + CREATE TABLE feeds ( 108 + id INTEGER PRIMARY KEY, 109 + config_id INTEGER NOT NULL REFERENCES configs(id) ON DELETE CASCADE, 110 + url TEXT NOT NULL, 111 + name TEXT, -- Optional display name 112 + last_fetched DATETIME, 113 + etag TEXT, -- For conditional requests 114 + last_modified TEXT 115 + ); 116 + 117 + -- Seen items for deduplication 118 + CREATE TABLE seen_items ( 119 + id INTEGER PRIMARY KEY, 120 + feed_id INTEGER NOT NULL REFERENCES feeds(id) ON DELETE CASCADE, 121 + guid TEXT NOT NULL, -- Item GUID or link hash 122 + title TEXT, 123 + link TEXT, 124 + seen_at DATETIME DEFAULT CURRENT_TIMESTAMP, 125 + UNIQUE(feed_id, guid) 126 + ); 127 + ``` 128 + 129 + ## SSH Interface 130 + 131 + ### Authentication 132 + 133 + - Public key auth only (no passwords) 134 + - First connection auto-registers user by key fingerprint 135 + - Config file for allowed keys (optional, default: allow all) 136 + 137 + ```yaml 138 + # config.yaml 139 + allow_all_keys: true # or false to require explicit registration 140 + allowed_keys: 141 + - "ssh-ed25519 AAAA... user@host" 142 + ``` 143 + 144 + ### SCP Upload 145 + 146 + ```bash 147 + # Upload a feed config 148 + scp feeds.txt user@rss.example.com: 149 + 150 + # Upload with custom name 151 + scp feeds.txt user@rss.example.com:work-feeds.txt 152 + 153 + # Download existing config 154 + scp user@rss.example.com:feeds.txt . 155 + ``` 156 + 157 + ### CLI Commands 158 + 159 + Via SSH command execution: 160 + 161 + ```bash 162 + # List configs 163 + ssh rss.example.com ls 164 + 165 + # Show config contents 166 + ssh rss.example.com cat feeds.txt 167 + 168 + # Delete a config 169 + ssh rss.example.com rm feeds.txt 170 + 171 + # Run immediately (don't wait for cron) 172 + ssh rss.example.com run feeds.txt 173 + 174 + # Show recent activity 175 + ssh rss.example.com logs 176 + ``` 177 + 178 + ## Web Interface 179 + 180 + Minimal brutalist web UI (à la pierre.computer). Serves two purposes: 181 + 182 + 1. **Public RSS feed** - Aggregated feed of all items for a user 183 + 2. **Config view** - Shows the raw config file 184 + 185 + ### Routes 186 + 187 + ``` 188 + GET / # Landing page 189 + GET /{fingerprint} # User's public page 190 + GET /{fingerprint}/feed.xml # Aggregated RSS feed 191 + GET /{fingerprint}/feed.json # JSON Feed format 192 + GET /{fingerprint}/{config} # Raw config file 193 + ``` 194 + 195 + ### User Page 196 + 197 + ``` 198 + /{fingerprint} 199 + ``` 200 + 201 + ``` 202 + RSS2EMAIL █ 203 + 204 + ~~~ 205 + 206 + USER: a]D+3xKL... 207 + STATUS: ONLINE 208 + NEXT RUN: 2025-01-09 08:00 UTC 209 + 210 + ~~~ 211 + 212 + CONFIGS: 213 + - [feeds.txt](/abc123/feeds.txt) (3 feeds) 214 + - [work.txt](/abc123/work.txt) (5 feeds) 215 + 216 + FEEDS: 217 + - [RSS](/abc123/feed.xml) 218 + - [JSON](/abc123/feed.json) 219 + ``` 220 + 221 + ### Aggregated Feed 222 + 223 + `/{fingerprint}/feed.xml` returns a standard RSS 2.0 feed containing all items from all the user's subscribed feeds - essentially a "river of news" feed they can subscribe to elsewhere. 224 + 225 + ### Styling 226 + 227 + ```css 228 + * { 229 + font-family: monospace; 230 + background: #000; 231 + color: #fff; 232 + } 233 + 234 + a { 235 + color: #fff; 236 + text-decoration: underline; 237 + } 238 + 239 + pre { 240 + white-space: pre-wrap; 241 + } 242 + ``` 243 + 244 + Single HTML template, no JS, ~20 lines of CSS. 245 + 246 + ## Scheduler 247 + 248 + Background goroutine that: 249 + 250 + 1. Every 60 seconds, queries `configs` where `next_run <= now()` 251 + 2. For each due config: 252 + - Fetch all feeds (parallel, with timeout) 253 + - Filter to unseen items (check `seen_items` table) 254 + - If new items exist, render and send email 255 + - Update `last_run`, calculate and set `next_run` 256 + - Insert new items into `seen_items` 257 + 3. Handle errors gracefully (log, increment retry counter) 258 + 259 + ### Cron Parsing 260 + 261 + Use [adhocore/gronx](https://github.com/adhocore/gronx) for cron expression parsing (same as pico). 262 + 263 + ## Email Rendering 264 + 265 + ### Digest Mode (default) 266 + 267 + One email per config run containing all new items: 268 + 269 + ``` 270 + Subject: RSS Digest: 5 new items 271 + From: rss@example.com 272 + To: you@example.com 273 + Content-Type: multipart/alternative 274 + 275 + ────────────────────────────────────── 276 + Lobsters (2 new) 277 + ────────────────────────────────────── 278 + 279 + ▸ Show HN: I built a thing 280 + https://example.com/article1 281 + 282 + ▸ Why Rust is great 283 + https://example.com/article2 284 + 285 + ────────────────────────────────────── 286 + Example Blog (3 new) 287 + ────────────────────────────────────── 288 + 289 + ▸ My latest post 290 + https://blog.example.com/post1 291 + 292 + ... 293 + ``` 294 + 295 + ### Individual Mode 296 + 297 + One email per item (when `digest: false`). 298 + 299 + ### Templates 300 + 301 + Use Go `html/template` and `text/template` for HTML and plaintext versions. 302 + 303 + ## Project Structure 304 + 305 + ``` 306 + rss2email-ssh/ 307 + ├── main.go # Entry point, CLI flags 308 + ├── ssh/ 309 + │ ├── server.go # wish server setup, middleware 310 + │ ├── scp.go # SCP upload/download handlers 311 + │ └── commands.go # ls, rm, cat, run, logs 312 + ├── web/ 313 + │ ├── server.go # HTTP server 314 + │ ├── handlers.go # Route handlers 315 + │ └── templates/ 316 + │ ├── index.html 317 + │ ├── user.html 318 + │ └── style.css 319 + ├── config/ 320 + │ ├── parse.go # Parse pico-format config files 321 + │ └── validate.go # Validation logic 322 + ├── store/ 323 + │ ├── db.go # SQLite connection, migrations 324 + │ ├── users.go # User CRUD 325 + │ ├── configs.go # Config CRUD 326 + │ └── items.go # Seen items tracking 327 + ├── scheduler/ 328 + │ ├── scheduler.go # Main loop 329 + │ └── fetch.go # RSS fetching with gofeed 330 + ├── email/ 331 + │ ├── render.go # Template rendering 332 + │ ├── send.go # SMTP sending 333 + │ └── templates/ 334 + │ ├── digest.html 335 + │ └── digest.txt 336 + ├── go.mod 337 + ├── go.sum 338 + └── config.example.yaml 339 + ``` 340 + 341 + ## Configuration 342 + 343 + Server configuration via YAML or environment variables: 344 + 345 + ```yaml 346 + # config.yaml 347 + host: 0.0.0.0 348 + port: 2222 349 + 350 + # SSH host keys (generated on first run if missing) 351 + host_key_path: ./host_key 352 + 353 + # Database 354 + db_path: ./rss2email.db 355 + 356 + # SMTP 357 + smtp: 358 + host: smtp.example.com 359 + port: 587 360 + user: sender@example.com 361 + pass: ${SMTP_PASS} # Env var substitution 362 + from: rss@example.com 363 + 364 + # Auth 365 + allow_all_keys: true 366 + ``` 367 + 368 + ## Dependencies 369 + 370 + ```go 371 + require ( 372 + github.com/charmbracelet/wish v1.4.0 373 + github.com/charmbracelet/lipgloss v1.0.0 374 + github.com/charmbracelet/log v0.4.0 375 + github.com/mmcdole/gofeed v1.3.0 376 + github.com/adhocore/gronx v1.19.0 377 + github.com/mattn/go-sqlite3 v1.14.24 378 + gopkg.in/yaml.v3 v3.0.1 379 + ) 380 + ``` 381 + 382 + ## Implementation Phases 383 + 384 + ### Phase 1: Core (MVP) 385 + 386 + - [ ] SSH server with key auth (wish) 387 + - [ ] SCP upload/download 388 + - [ ] Config parsing (pico format) 389 + - [ ] SQLite storage 390 + - [ ] Basic scheduler 391 + - [ ] Plaintext email sending 392 + 393 + ### Phase 2: Polish 394 + 395 + - [ ] Web UI (brutalist style) 396 + - [ ] Aggregated RSS/JSON feeds 397 + - [ ] HTML emails 398 + - [ ] Conditional fetching (ETag/Last-Modified) 399 + - [ ] `logs` command 400 + 401 + ### Phase 3: Nice-to-have 402 + 403 + - [ ] OPML import/export 404 + - [ ] Feed discovery (find RSS from URL) 405 + - [ ] Webhook notifications 406 + - [ ] Metrics endpoint 407 + 408 + ## Example Session 409 + 410 + ```bash 411 + # First time setup - just SSH in 412 + $ ssh rss.example.com 413 + Welcome! Your account has been created. 414 + Upload a config with: scp feeds.txt rss.example.com: 415 + 416 + # Upload a config 417 + $ cat feeds.txt 418 + =: email me@example.com 419 + =: cron 0 8 * * * 420 + => https://lobste.rs/rss 421 + 422 + $ scp feeds.txt rss.example.com: 423 + feeds.txt 100% 89 12.3KB/s 00:00 424 + Config saved! Next run: tomorrow at 8:00 AM 425 + 426 + # Check status 427 + $ ssh rss.example.com ls 428 + feeds.txt 1 feed next: 8:00 AM 429 + 430 + # Run immediately 431 + $ ssh rss.example.com run feeds.txt 432 + Fetched 25 items, 25 new 433 + Email sent to me@example.com 434 + ```
+194
ssh/commands.go
··· 1 + package ssh 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "strings" 7 + "time" 8 + 9 + "github.com/charmbracelet/lipgloss" 10 + "github.com/charmbracelet/log" 11 + "github.com/charmbracelet/ssh" 12 + "github.com/kierank/herald/scheduler" 13 + "github.com/kierank/herald/store" 14 + ) 15 + 16 + var ( 17 + titleStyle = lipgloss.NewStyle(). 18 + Bold(true). 19 + Foreground(lipgloss.Color("12")) 20 + 21 + dimStyle = lipgloss.NewStyle(). 22 + Foreground(lipgloss.Color("8")) 23 + 24 + successStyle = lipgloss.NewStyle(). 25 + Foreground(lipgloss.Color("10")) 26 + 27 + errorStyle = lipgloss.NewStyle(). 28 + Foreground(lipgloss.Color("9")) 29 + ) 30 + 31 + func HandleCommand(sess ssh.Session, user *store.User, st *store.DB, sched *scheduler.Scheduler, logger *log.Logger) { 32 + cmd := sess.Command() 33 + if len(cmd) == 0 { 34 + return 35 + } 36 + 37 + ctx := context.Background() 38 + 39 + switch cmd[0] { 40 + case "ls": 41 + handleLs(ctx, sess, user, st) 42 + case "cat": 43 + if len(cmd) < 2 { 44 + fmt.Fprintln(sess, errorStyle.Render("Usage: cat <filename>")) 45 + return 46 + } 47 + handleCat(ctx, sess, user, st, cmd[1]) 48 + case "rm": 49 + if len(cmd) < 2 { 50 + fmt.Fprintln(sess, errorStyle.Render("Usage: rm <filename>")) 51 + return 52 + } 53 + handleRm(ctx, sess, user, st, cmd[1]) 54 + case "run": 55 + if len(cmd) < 2 { 56 + fmt.Fprintln(sess, errorStyle.Render("Usage: run <filename>")) 57 + return 58 + } 59 + handleRun(ctx, sess, user, st, sched, cmd[1]) 60 + case "logs": 61 + handleLogs(ctx, sess, user, st) 62 + default: 63 + fmt.Fprintf(sess, errorStyle.Render("Unknown command: %s\n"), cmd[0]) 64 + fmt.Fprintln(sess, "Available commands: ls, cat, rm, run, logs") 65 + } 66 + } 67 + 68 + func handleLs(ctx context.Context, sess ssh.Session, user *store.User, st *store.DB) { 69 + configs, err := st.ListConfigs(ctx, user.ID) 70 + if err != nil { 71 + fmt.Fprintln(sess, errorStyle.Render("Error: "+err.Error())) 72 + return 73 + } 74 + 75 + if len(configs) == 0 { 76 + fmt.Fprintln(sess, dimStyle.Render("No configs found. Upload one with: scp feeds.txt <host>:")) 77 + return 78 + } 79 + 80 + fmt.Fprintln(sess, titleStyle.Render("Your configs:")) 81 + 82 + for _, cfg := range configs { 83 + feeds, err := st.GetFeedsByConfig(ctx, cfg.ID) 84 + feedCount := 0 85 + if err == nil { 86 + feedCount = len(feeds) 87 + } 88 + 89 + nextRunStr := "never" 90 + if cfg.NextRun.Valid { 91 + nextRunStr = formatRelativeTime(cfg.NextRun.Time) 92 + } 93 + 94 + fmt.Fprintf(sess, " %-20s %s next: %s\n", 95 + cfg.Filename, 96 + dimStyle.Render(fmt.Sprintf("%d feed(s)", feedCount)), 97 + nextRunStr, 98 + ) 99 + } 100 + } 101 + 102 + func handleCat(ctx context.Context, sess ssh.Session, user *store.User, st *store.DB, filename string) { 103 + cfg, err := st.GetConfig(ctx, user.ID, filename) 104 + if err != nil { 105 + fmt.Fprintln(sess, errorStyle.Render("Config not found: "+filename)) 106 + return 107 + } 108 + 109 + fmt.Fprintln(sess, titleStyle.Render("# "+filename)) 110 + fmt.Fprintln(sess, cfg.RawText) 111 + } 112 + 113 + func handleRm(ctx context.Context, sess ssh.Session, user *store.User, st *store.DB, filename string) { 114 + err := st.DeleteConfig(ctx, user.ID, filename) 115 + if err != nil { 116 + fmt.Fprintln(sess, errorStyle.Render("Error: "+err.Error())) 117 + return 118 + } 119 + 120 + fmt.Fprintln(sess, successStyle.Render("Deleted: "+filename)) 121 + } 122 + 123 + func handleRun(ctx context.Context, sess ssh.Session, user *store.User, st *store.DB, sched *scheduler.Scheduler, filename string) { 124 + cfg, err := st.GetConfig(ctx, user.ID, filename) 125 + if err != nil { 126 + fmt.Fprintln(sess, errorStyle.Render("Config not found: "+filename)) 127 + return 128 + } 129 + 130 + fmt.Fprintln(sess, "Running "+filename+"...") 131 + 132 + if err := sched.RunNow(ctx, cfg.ID); err != nil { 133 + fmt.Fprintln(sess, errorStyle.Render("Error: "+err.Error())) 134 + return 135 + } 136 + 137 + fmt.Fprintln(sess, successStyle.Render("Done! Check your email.")) 138 + } 139 + 140 + func handleLogs(ctx context.Context, sess ssh.Session, user *store.User, st *store.DB) { 141 + logs, err := st.GetRecentLogs(ctx, user.ID, 20) 142 + if err != nil { 143 + fmt.Fprintln(sess, errorStyle.Render("Error: "+err.Error())) 144 + return 145 + } 146 + 147 + if len(logs) == 0 { 148 + fmt.Fprintln(sess, dimStyle.Render("No logs yet.")) 149 + return 150 + } 151 + 152 + fmt.Fprintln(sess, titleStyle.Render("Recent activity:")) 153 + 154 + for _, l := range logs { 155 + levelStyle := dimStyle 156 + switch strings.ToLower(l.Level) { 157 + case "error": 158 + levelStyle = errorStyle 159 + case "info": 160 + levelStyle = successStyle 161 + } 162 + 163 + timestamp := l.CreatedAt.Format("Jan 02 15:04") 164 + fmt.Fprintf(sess, " %s %s %s\n", 165 + dimStyle.Render(timestamp), 166 + levelStyle.Render(fmt.Sprintf("[%s]", l.Level)), 167 + l.Message, 168 + ) 169 + } 170 + } 171 + 172 + func formatRelativeTime(t time.Time) string { 173 + now := time.Now() 174 + diff := t.Sub(now) 175 + 176 + if diff < 0 { 177 + return "overdue" 178 + } 179 + 180 + if diff < time.Minute { 181 + return "< 1 min" 182 + } 183 + if diff < time.Hour { 184 + mins := int(diff.Minutes()) 185 + return fmt.Sprintf("%d min", mins) 186 + } 187 + if diff < 24*time.Hour { 188 + hours := int(diff.Hours()) 189 + return fmt.Sprintf("%d hr", hours) 190 + } 191 + 192 + days := int(diff.Hours() / 24) 193 + return fmt.Sprintf("%d day(s)", days) 194 + }
+177
ssh/scp.go
··· 1 + package ssh 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "io" 7 + "io/fs" 8 + "path/filepath" 9 + "strings" 10 + "time" 11 + 12 + "github.com/adhocore/gronx" 13 + "github.com/charmbracelet/log" 14 + "github.com/charmbracelet/ssh" 15 + "github.com/charmbracelet/wish/scp" 16 + "github.com/kierank/herald/config" 17 + "github.com/kierank/herald/scheduler" 18 + "github.com/kierank/herald/store" 19 + ) 20 + 21 + type scpHandler struct { 22 + store *store.DB 23 + scheduler *scheduler.Scheduler 24 + logger *log.Logger 25 + } 26 + 27 + func (h *scpHandler) Glob(s ssh.Session, pattern string) ([]string, error) { 28 + user, ok := s.Context().Value("user").(*store.User) 29 + if !ok { 30 + return nil, fmt.Errorf("no user in context") 31 + } 32 + 33 + configs, err := h.store.ListConfigs(s.Context(), user.ID) 34 + if err != nil { 35 + return nil, err 36 + } 37 + 38 + var matches []string 39 + for _, cfg := range configs { 40 + matched, _ := filepath.Match(pattern, cfg.Filename) 41 + if matched || pattern == "*" || pattern == cfg.Filename { 42 + matches = append(matches, cfg.Filename) 43 + } 44 + } 45 + return matches, nil 46 + } 47 + 48 + func (h *scpHandler) WalkDir(s ssh.Session, path string, fn fs.WalkDirFunc) error { 49 + user, ok := s.Context().Value("user").(*store.User) 50 + if !ok { 51 + return fmt.Errorf("no user in context") 52 + } 53 + 54 + configs, err := h.store.ListConfigs(s.Context(), user.ID) 55 + if err != nil { 56 + return err 57 + } 58 + 59 + for _, cfg := range configs { 60 + info := &configFileInfo{cfg: cfg} 61 + if err := fn(cfg.Filename, &configDirEntry{info: info}, nil); err != nil { 62 + return err 63 + } 64 + } 65 + return nil 66 + } 67 + 68 + func (h *scpHandler) NewDirEntry(s ssh.Session, name string) (*scp.DirEntry, error) { 69 + return nil, fmt.Errorf("directories not supported") 70 + } 71 + 72 + func (h *scpHandler) NewFileEntry(s ssh.Session, name string) (*scp.FileEntry, func() error, error) { 73 + user, ok := s.Context().Value("user").(*store.User) 74 + if !ok { 75 + return nil, nil, fmt.Errorf("no user in context") 76 + } 77 + 78 + cfg, err := h.store.GetConfig(s.Context(), user.ID, name) 79 + if err != nil { 80 + return nil, nil, fmt.Errorf("config not found: %w", err) 81 + } 82 + 83 + content := []byte(cfg.RawText) 84 + entry := &scp.FileEntry{ 85 + Name: cfg.Filename, 86 + Mode: 0644, 87 + Size: int64(len(content)), 88 + Mtime: cfg.CreatedAt.Unix(), 89 + Atime: cfg.CreatedAt.Unix(), 90 + Reader: bytes.NewReader(content), 91 + Filepath: cfg.Filename, 92 + } 93 + 94 + return entry, nil, nil 95 + } 96 + 97 + func (h *scpHandler) Mkdir(s ssh.Session, entry *scp.DirEntry) error { 98 + return fmt.Errorf("directories not supported") 99 + } 100 + 101 + func (h *scpHandler) Write(s ssh.Session, entry *scp.FileEntry) (int64, error) { 102 + h.logger.Debug("SCP Write called", "name", entry.Name, "size", entry.Size) 103 + 104 + user, ok := s.Context().Value("user").(*store.User) 105 + if !ok { 106 + return 0, fmt.Errorf("no user in context") 107 + } 108 + 109 + name := entry.Name 110 + if !strings.HasSuffix(name, ".txt") { 111 + return 0, fmt.Errorf("only .txt files are supported") 112 + } 113 + 114 + content, err := io.ReadAll(entry.Reader) 115 + if err != nil { 116 + return 0, fmt.Errorf("failed to read file: %w", err) 117 + } 118 + 119 + parsed, err := config.Parse(string(content)) 120 + if err != nil { 121 + return 0, fmt.Errorf("failed to parse config: %w", err) 122 + } 123 + 124 + if err := config.Validate(parsed); err != nil { 125 + return 0, fmt.Errorf("invalid config: %w", err) 126 + } 127 + 128 + nextRun, err := calculateNextRun(parsed.CronExpr) 129 + if err != nil { 130 + return 0, fmt.Errorf("failed to calculate next run: %w", err) 131 + } 132 + 133 + ctx := s.Context() 134 + if err := h.store.DeleteConfig(ctx, user.ID, name); err != nil { 135 + h.logger.Debug("no existing config to delete", "filename", name) 136 + } else { 137 + h.logger.Debug("deleted existing config", "filename", name) 138 + } 139 + 140 + cfg, err := h.store.CreateConfig(ctx, user.ID, name, parsed.Email, parsed.CronExpr, parsed.Digest, parsed.Inline, string(content), nextRun) 141 + if err != nil { 142 + return 0, fmt.Errorf("failed to save config: %w", err) 143 + } 144 + 145 + for _, feed := range parsed.Feeds { 146 + if _, err := h.store.CreateFeed(ctx, cfg.ID, feed.URL, feed.Name); err != nil { 147 + return 0, fmt.Errorf("failed to save feed: %w", err) 148 + } 149 + } 150 + 151 + h.logger.Info("config uploaded", "user_id", user.ID, "filename", name, "feeds", len(parsed.Feeds), "next_run", nextRun) 152 + return int64(len(content)), nil 153 + } 154 + 155 + func calculateNextRun(cronExpr string) (time.Time, error) { 156 + return gronx.NextTick(cronExpr, false) 157 + } 158 + 159 + type configFileInfo struct { 160 + cfg *store.Config 161 + } 162 + 163 + func (i *configFileInfo) Name() string { return i.cfg.Filename } 164 + func (i *configFileInfo) Size() int64 { return int64(len(i.cfg.RawText)) } 165 + func (i *configFileInfo) Mode() fs.FileMode { return 0644 } 166 + func (i *configFileInfo) ModTime() time.Time { return i.cfg.CreatedAt } 167 + func (i *configFileInfo) IsDir() bool { return false } 168 + func (i *configFileInfo) Sys() any { return nil } 169 + 170 + type configDirEntry struct { 171 + info *configFileInfo 172 + } 173 + 174 + func (e *configDirEntry) Name() string { return e.info.Name() } 175 + func (e *configDirEntry) IsDir() bool { return false } 176 + func (e *configDirEntry) Type() fs.FileMode { return e.info.Mode() } 177 + func (e *configDirEntry) Info() (fs.FileInfo, error) { return e.info, nil }
+193
ssh/server.go
··· 1 + package ssh 2 + 3 + import ( 4 + "context" 5 + "crypto/ed25519" 6 + "crypto/rand" 7 + "encoding/pem" 8 + "errors" 9 + "fmt" 10 + "os" 11 + "time" 12 + 13 + "github.com/charmbracelet/log" 14 + "github.com/charmbracelet/ssh" 15 + "github.com/charmbracelet/wish" 16 + "github.com/charmbracelet/wish/scp" 17 + "github.com/kierank/herald/scheduler" 18 + "github.com/kierank/herald/store" 19 + gossh "golang.org/x/crypto/ssh" 20 + ) 21 + 22 + type Config struct { 23 + Host string 24 + Port int 25 + HostKeyPath string 26 + AllowAllKeys bool 27 + AllowedKeys []string 28 + } 29 + 30 + type Server struct { 31 + cfg Config 32 + store *store.DB 33 + scheduler *scheduler.Scheduler 34 + logger *log.Logger 35 + } 36 + 37 + func NewServer(cfg Config, st *store.DB, sched *scheduler.Scheduler, logger *log.Logger) *Server { 38 + return &Server{ 39 + cfg: cfg, 40 + store: st, 41 + scheduler: sched, 42 + logger: logger, 43 + } 44 + } 45 + 46 + func (s *Server) ListenAndServe(ctx context.Context) error { 47 + if err := s.ensureHostKey(); err != nil { 48 + return fmt.Errorf("failed to ensure host key: %w", err) 49 + } 50 + 51 + handler := &scpHandler{ 52 + store: s.store, 53 + scheduler: s.scheduler, 54 + logger: s.logger, 55 + } 56 + 57 + srv, err := wish.NewServer( 58 + wish.WithAddress(fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)), 59 + wish.WithHostKeyPath(s.cfg.HostKeyPath), 60 + wish.WithPublicKeyAuth(s.publicKeyHandler), 61 + wish.WithSubsystem("sftp", SFTPHandler(s.store, s.scheduler, s.logger)), 62 + wish.WithMiddleware( 63 + scp.Middleware(handler, handler), 64 + s.commandMiddleware, 65 + ), 66 + ) 67 + if err != nil { 68 + return fmt.Errorf("failed to create SSH server: %w", err) 69 + } 70 + 71 + s.logger.Info("SSH server starting", "addr", fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)) 72 + 73 + errCh := make(chan error, 1) 74 + go func() { 75 + errCh <- srv.ListenAndServe() 76 + }() 77 + 78 + select { 79 + case <-ctx.Done(): 80 + s.logger.Info("shutting down SSH server") 81 + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 82 + defer cancel() 83 + if err := srv.Shutdown(shutdownCtx); err != nil && !errors.Is(err, ssh.ErrServerClosed) { 84 + return err 85 + } 86 + return nil 87 + case err := <-errCh: 88 + if errors.Is(err, ssh.ErrServerClosed) { 89 + return nil 90 + } 91 + return err 92 + } 93 + } 94 + 95 + func (s *Server) publicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool { 96 + fp := gossh.FingerprintSHA256(key) 97 + pubkeyStr := string(gossh.MarshalAuthorizedKey(key)) 98 + 99 + if !s.cfg.AllowAllKeys { 100 + allowed := false 101 + for _, k := range s.cfg.AllowedKeys { 102 + if k == pubkeyStr { 103 + allowed = true 104 + break 105 + } 106 + } 107 + if !allowed { 108 + s.logger.Warn("rejected key", "fingerprint", fp) 109 + return false 110 + } 111 + } 112 + 113 + user, err := s.store.GetOrCreateUser(ctx, fp, pubkeyStr) 114 + if err != nil { 115 + s.logger.Error("failed to get/create user", "err", err) 116 + return false 117 + } 118 + 119 + ctx.SetValue("user", user) 120 + ctx.SetValue("fingerprint", fp) 121 + s.logger.Debug("authenticated user", "fingerprint", fp, "user_id", user.ID) 122 + return true 123 + } 124 + 125 + func (s *Server) commandMiddleware(next ssh.Handler) ssh.Handler { 126 + return func(sess ssh.Session) { 127 + cmd := sess.Command() 128 + s.logger.Debug("commandMiddleware", "cmd", cmd, "len", len(cmd)) 129 + 130 + user, ok := sess.Context().Value("user").(*store.User) 131 + if !ok { 132 + fmt.Fprintln(sess, "Authentication error") 133 + return 134 + } 135 + 136 + // No command = interactive session (welcome message) 137 + if len(cmd) == 0 { 138 + s.handleWelcome(sess, user) 139 + return 140 + } 141 + 142 + // Check if it's an SCP command - let SCP middleware handle it 143 + if len(cmd) > 0 && cmd[0] == "scp" { 144 + s.logger.Debug("passing to SCP middleware") 145 + next(sess) 146 + return 147 + } 148 + 149 + // Handle our custom commands (ls, cat, rm, run, logs) 150 + HandleCommand(sess, user, s.store, s.scheduler, s.logger) 151 + } 152 + } 153 + 154 + func (s *Server) handleWelcome(sess ssh.Session, user *store.User) { 155 + fp := sess.Context().Value("fingerprint").(string) 156 + fmt.Fprintf(sess, "Welcome to Herald!\n\n") 157 + fmt.Fprintf(sess, "Your fingerprint: %s\n\n", fp) 158 + fmt.Fprintf(sess, "Upload a config with:\n") 159 + fmt.Fprintf(sess, " scp feeds.txt %s:\n\n", sess.User()) 160 + fmt.Fprintf(sess, "Commands:\n") 161 + fmt.Fprintf(sess, " ls List your configs\n") 162 + fmt.Fprintf(sess, " cat <file> Show config contents\n") 163 + fmt.Fprintf(sess, " rm <file> Delete a config\n") 164 + fmt.Fprintf(sess, " run <file> Run a config now\n") 165 + fmt.Fprintf(sess, " logs Show recent activity\n") 166 + } 167 + 168 + func (s *Server) ensureHostKey() error { 169 + if _, err := os.Stat(s.cfg.HostKeyPath); err == nil { 170 + return nil 171 + } else if !errors.Is(err, os.ErrNotExist) { 172 + return err 173 + } 174 + 175 + s.logger.Info("generating new host key", "path", s.cfg.HostKeyPath) 176 + 177 + _, priv, err := ed25519.GenerateKey(rand.Reader) 178 + if err != nil { 179 + return fmt.Errorf("failed to generate key: %w", err) 180 + } 181 + 182 + privBytes, err := gossh.MarshalPrivateKey(priv, "") 183 + if err != nil { 184 + return fmt.Errorf("failed to marshal private key: %w", err) 185 + } 186 + 187 + pemBlock := pem.EncodeToMemory(privBytes) 188 + if err := os.WriteFile(s.cfg.HostKeyPath, pemBlock, 0600); err != nil { 189 + return fmt.Errorf("failed to write host key: %w", err) 190 + } 191 + 192 + return nil 193 + }
+233
ssh/sftp.go
··· 1 + package ssh 2 + 3 + import ( 4 + "fmt" 5 + "io" 6 + "io/fs" 7 + "strings" 8 + "time" 9 + 10 + "github.com/charmbracelet/log" 11 + "github.com/charmbracelet/ssh" 12 + "github.com/kierank/herald/config" 13 + "github.com/kierank/herald/scheduler" 14 + "github.com/kierank/herald/store" 15 + "github.com/pkg/sftp" 16 + ) 17 + 18 + func SFTPHandler(st *store.DB, sched *scheduler.Scheduler, logger *log.Logger) func(ssh.Session) { 19 + return func(s ssh.Session) { 20 + user, ok := s.Context().Value("user").(*store.User) 21 + if !ok { 22 + logger.Error("SFTP: no user in context") 23 + return 24 + } 25 + 26 + handler := &sftpHandler{ 27 + store: st, 28 + scheduler: sched, 29 + logger: logger, 30 + user: user, 31 + session: s, 32 + } 33 + 34 + server := sftp.NewRequestServer(s, sftp.Handlers{ 35 + FileGet: handler, 36 + FilePut: handler, 37 + FileCmd: handler, 38 + FileList: handler, 39 + }) 40 + 41 + if err := server.Serve(); err == io.EOF { 42 + server.Close() 43 + } else if err != nil { 44 + logger.Error("SFTP server error", "err", err) 45 + } 46 + } 47 + } 48 + 49 + type sftpHandler struct { 50 + store *store.DB 51 + scheduler *scheduler.Scheduler 52 + logger *log.Logger 53 + user *store.User 54 + session ssh.Session 55 + } 56 + 57 + // Fileread for downloads 58 + func (h *sftpHandler) Fileread(r *sftp.Request) (io.ReaderAt, error) { 59 + filename := strings.TrimPrefix(r.Filepath, "/") 60 + if filename == "" || filename == "." { 61 + return nil, fmt.Errorf("invalid path") 62 + } 63 + 64 + cfg, err := h.store.GetConfig(h.session.Context(), h.user.ID, filename) 65 + if err != nil { 66 + return nil, fmt.Errorf("config not found: %w", err) 67 + } 68 + 69 + return &bytesReaderAt{data: []byte(cfg.RawText)}, nil 70 + } 71 + 72 + // Filewrite for uploads 73 + func (h *sftpHandler) Filewrite(r *sftp.Request) (io.WriterAt, error) { 74 + filename := strings.TrimPrefix(r.Filepath, "/") 75 + if filename == "" || filename == "." { 76 + return nil, fmt.Errorf("invalid filename") 77 + } 78 + 79 + if !strings.HasSuffix(filename, ".txt") { 80 + return nil, fmt.Errorf("only .txt files are supported") 81 + } 82 + 83 + h.logger.Debug("SFTP write", "filename", filename, "user_id", h.user.ID) 84 + 85 + return &configWriter{ 86 + handler: h, 87 + filename: filename, 88 + buffer: []byte{}, 89 + }, nil 90 + } 91 + 92 + // Filecmd handles file operations 93 + func (h *sftpHandler) Filecmd(r *sftp.Request) error { 94 + filename := strings.TrimPrefix(r.Filepath, "/") 95 + 96 + switch r.Method { 97 + case "Setstat": 98 + // Allow setstat (used by scp) 99 + return nil 100 + case "Remove": 101 + if filename == "" || filename == "." { 102 + return fmt.Errorf("invalid filename") 103 + } 104 + return h.store.DeleteConfig(h.session.Context(), h.user.ID, filename) 105 + case "Rename": 106 + return fmt.Errorf("rename not supported") 107 + case "Mkdir", "Rmdir": 108 + return fmt.Errorf("directories not supported") 109 + default: 110 + return sftp.ErrSSHFxOpUnsupported 111 + } 112 + } 113 + 114 + // Filelist for directory listings 115 + func (h *sftpHandler) Filelist(r *sftp.Request) (sftp.ListerAt, error) { 116 + switch r.Method { 117 + case "List": 118 + configs, err := h.store.ListConfigs(h.session.Context(), h.user.ID) 119 + if err != nil { 120 + return nil, err 121 + } 122 + infos := make([]fs.FileInfo, len(configs)) 123 + for i, cfg := range configs { 124 + infos[i] = &configFileInfo{cfg: cfg} 125 + } 126 + return listerAt(infos), nil 127 + case "Stat": 128 + filename := strings.TrimPrefix(r.Filepath, "/") 129 + if filename == "" || filename == "." || filename == "/" { 130 + // Return root directory info 131 + return listerAt{&dirInfo{}}, nil 132 + } 133 + cfg, err := h.store.GetConfig(h.session.Context(), h.user.ID, filename) 134 + if err != nil { 135 + return nil, err 136 + } 137 + return listerAt{&configFileInfo{cfg: cfg}}, nil 138 + default: 139 + return nil, sftp.ErrSSHFxOpUnsupported 140 + } 141 + } 142 + 143 + type configWriter struct { 144 + handler *sftpHandler 145 + filename string 146 + buffer []byte 147 + } 148 + 149 + func (w *configWriter) WriteAt(p []byte, off int64) (int, error) { 150 + // Expand buffer if needed 151 + needed := int(off) + len(p) 152 + if needed > len(w.buffer) { 153 + newBuf := make([]byte, needed) 154 + copy(newBuf, w.buffer) 155 + w.buffer = newBuf 156 + } 157 + copy(w.buffer[off:], p) 158 + return len(p), nil 159 + } 160 + 161 + func (w *configWriter) Close() error { 162 + content := string(w.buffer) 163 + 164 + parsed, err := config.Parse(content) 165 + if err != nil { 166 + return fmt.Errorf("failed to parse config: %w", err) 167 + } 168 + 169 + if err := config.Validate(parsed); err != nil { 170 + return fmt.Errorf("invalid config: %w", err) 171 + } 172 + 173 + nextRun, err := calculateNextRun(parsed.CronExpr) 174 + if err != nil { 175 + return fmt.Errorf("failed to calculate next run: %w", err) 176 + } 177 + 178 + ctx := w.handler.session.Context() 179 + if err := w.handler.store.DeleteConfig(ctx, w.handler.user.ID, w.filename); err != nil { 180 + w.handler.logger.Debug("no existing config to delete", "filename", w.filename) 181 + } 182 + 183 + cfg, err := w.handler.store.CreateConfig(ctx, w.handler.user.ID, w.filename, parsed.Email, parsed.CronExpr, parsed.Digest, parsed.Inline, content, nextRun) 184 + if err != nil { 185 + return fmt.Errorf("failed to save config: %w", err) 186 + } 187 + 188 + for _, feed := range parsed.Feeds { 189 + if _, err := w.handler.store.CreateFeed(ctx, cfg.ID, feed.URL, feed.Name); err != nil { 190 + return fmt.Errorf("failed to save feed: %w", err) 191 + } 192 + } 193 + 194 + w.handler.logger.Info("config uploaded via SFTP", "user_id", w.handler.user.ID, "filename", w.filename, "feeds", len(parsed.Feeds)) 195 + return nil 196 + } 197 + 198 + type bytesReaderAt struct { 199 + data []byte 200 + } 201 + 202 + func (r *bytesReaderAt) ReadAt(p []byte, off int64) (int, error) { 203 + if off >= int64(len(r.data)) { 204 + return 0, io.EOF 205 + } 206 + n := copy(p, r.data[off:]) 207 + if n < len(p) { 208 + return n, io.EOF 209 + } 210 + return n, nil 211 + } 212 + 213 + type listerAt []fs.FileInfo 214 + 215 + func (l listerAt) ListAt(ls []fs.FileInfo, offset int64) (int, error) { 216 + if offset >= int64(len(l)) { 217 + return 0, io.EOF 218 + } 219 + n := copy(ls, l[offset:]) 220 + if n < len(ls) { 221 + return n, io.EOF 222 + } 223 + return n, nil 224 + } 225 + 226 + type dirInfo struct{} 227 + 228 + func (d *dirInfo) Name() string { return "." } 229 + func (d *dirInfo) Size() int64 { return 0 } 230 + func (d *dirInfo) Mode() fs.FileMode { return fs.ModeDir | 0755 } 231 + func (d *dirInfo) ModTime() time.Time { return time.Now() } 232 + func (d *dirInfo) IsDir() bool { return true } 233 + func (d *dirInfo) Sys() any { return nil }
+151
store/configs.go
··· 1 + package store 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + "time" 8 + ) 9 + 10 + type Config struct { 11 + ID int64 12 + UserID int64 13 + Filename string 14 + Email string 15 + CronExpr string 16 + Digest bool 17 + InlineContent bool 18 + RawText string 19 + LastRun sql.NullTime 20 + NextRun sql.NullTime 21 + CreatedAt time.Time 22 + } 23 + 24 + func (db *DB) CreateConfig(ctx context.Context, userID int64, filename, email, cronExpr string, digest, inline bool, rawText string, nextRun time.Time) (*Config, error) { 25 + result, err := db.ExecContext(ctx, 26 + `INSERT INTO configs (user_id, filename, email, cron_expr, digest, inline_content, raw_text, next_run) 27 + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, 28 + userID, filename, email, cronExpr, digest, inline, rawText, nextRun, 29 + ) 30 + if err != nil { 31 + return nil, fmt.Errorf("insert config: %w", err) 32 + } 33 + 34 + id, err := result.LastInsertId() 35 + if err != nil { 36 + return nil, fmt.Errorf("get last insert id: %w", err) 37 + } 38 + 39 + return &Config{ 40 + ID: id, 41 + UserID: userID, 42 + Filename: filename, 43 + Email: email, 44 + CronExpr: cronExpr, 45 + Digest: digest, 46 + InlineContent: inline, 47 + RawText: rawText, 48 + NextRun: sql.NullTime{Time: nextRun, Valid: true}, 49 + CreatedAt: time.Now(), 50 + }, nil 51 + } 52 + 53 + func (db *DB) GetConfig(ctx context.Context, userID int64, filename string) (*Config, error) { 54 + var cfg Config 55 + err := db.QueryRowContext(ctx, 56 + `SELECT id, user_id, filename, email, cron_expr, digest, inline_content, raw_text, last_run, next_run, created_at 57 + FROM configs WHERE user_id = ? AND filename = ?`, 58 + userID, filename, 59 + ).Scan(&cfg.ID, &cfg.UserID, &cfg.Filename, &cfg.Email, &cfg.CronExpr, &cfg.Digest, &cfg.InlineContent, &cfg.RawText, &cfg.LastRun, &cfg.NextRun, &cfg.CreatedAt) 60 + if err != nil { 61 + return nil, err 62 + } 63 + return &cfg, nil 64 + } 65 + 66 + func (db *DB) GetConfigByID(ctx context.Context, id int64) (*Config, error) { 67 + var cfg Config 68 + err := db.QueryRowContext(ctx, 69 + `SELECT id, user_id, filename, email, cron_expr, digest, inline_content, raw_text, last_run, next_run, created_at 70 + FROM configs WHERE id = ?`, 71 + id, 72 + ).Scan(&cfg.ID, &cfg.UserID, &cfg.Filename, &cfg.Email, &cfg.CronExpr, &cfg.Digest, &cfg.InlineContent, &cfg.RawText, &cfg.LastRun, &cfg.NextRun, &cfg.CreatedAt) 73 + if err != nil { 74 + return nil, err 75 + } 76 + return &cfg, nil 77 + } 78 + 79 + func (db *DB) ListConfigs(ctx context.Context, userID int64) ([]*Config, error) { 80 + rows, err := db.QueryContext(ctx, 81 + `SELECT id, user_id, filename, email, cron_expr, digest, inline_content, raw_text, last_run, next_run, created_at 82 + FROM configs WHERE user_id = ? ORDER BY filename`, 83 + userID, 84 + ) 85 + if err != nil { 86 + return nil, fmt.Errorf("query configs: %w", err) 87 + } 88 + defer rows.Close() 89 + 90 + var configs []*Config 91 + for rows.Next() { 92 + var cfg Config 93 + if err := rows.Scan(&cfg.ID, &cfg.UserID, &cfg.Filename, &cfg.Email, &cfg.CronExpr, &cfg.Digest, &cfg.InlineContent, &cfg.RawText, &cfg.LastRun, &cfg.NextRun, &cfg.CreatedAt); err != nil { 94 + return nil, fmt.Errorf("scan config: %w", err) 95 + } 96 + configs = append(configs, &cfg) 97 + } 98 + return configs, rows.Err() 99 + } 100 + 101 + func (db *DB) DeleteConfig(ctx context.Context, userID int64, filename string) error { 102 + result, err := db.ExecContext(ctx, 103 + `DELETE FROM configs WHERE user_id = ? AND filename = ?`, 104 + userID, filename, 105 + ) 106 + if err != nil { 107 + return fmt.Errorf("delete config: %w", err) 108 + } 109 + 110 + n, err := result.RowsAffected() 111 + if err != nil { 112 + return fmt.Errorf("rows affected: %w", err) 113 + } 114 + if n == 0 { 115 + return sql.ErrNoRows 116 + } 117 + return nil 118 + } 119 + 120 + func (db *DB) UpdateLastRun(ctx context.Context, configID int64, lastRun, nextRun time.Time) error { 121 + _, err := db.ExecContext(ctx, 122 + `UPDATE configs SET last_run = ?, next_run = ? WHERE id = ?`, 123 + lastRun, nextRun, configID, 124 + ) 125 + if err != nil { 126 + return fmt.Errorf("update last run: %w", err) 127 + } 128 + return nil 129 + } 130 + 131 + func (db *DB) GetDueConfigs(ctx context.Context, now time.Time) ([]*Config, error) { 132 + rows, err := db.QueryContext(ctx, 133 + `SELECT id, user_id, filename, email, cron_expr, digest, inline_content, raw_text, last_run, next_run, created_at 134 + FROM configs WHERE next_run IS NOT NULL AND next_run <= ? ORDER BY next_run`, 135 + now, 136 + ) 137 + if err != nil { 138 + return nil, fmt.Errorf("query due configs: %w", err) 139 + } 140 + defer rows.Close() 141 + 142 + var configs []*Config 143 + for rows.Next() { 144 + var cfg Config 145 + if err := rows.Scan(&cfg.ID, &cfg.UserID, &cfg.Filename, &cfg.Email, &cfg.CronExpr, &cfg.Digest, &cfg.InlineContent, &cfg.RawText, &cfg.LastRun, &cfg.NextRun, &cfg.CreatedAt); err != nil { 146 + return nil, fmt.Errorf("scan config: %w", err) 147 + } 148 + configs = append(configs, &cfg) 149 + } 150 + return configs, rows.Err() 151 + }
+107
store/db.go
··· 1 + package store 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + 8 + _ "github.com/mattn/go-sqlite3" 9 + ) 10 + 11 + type DB struct { 12 + *sql.DB 13 + } 14 + 15 + func Open(path string) (*DB, error) { 16 + db, err := sql.Open("sqlite3", path+"?_foreign_keys=on") 17 + if err != nil { 18 + return nil, fmt.Errorf("open database: %w", err) 19 + } 20 + 21 + if err := db.Ping(); err != nil { 22 + return nil, fmt.Errorf("ping database: %w", err) 23 + } 24 + 25 + store := &DB{db} 26 + if err := store.migrate(); err != nil { 27 + return nil, fmt.Errorf("migrate database: %w", err) 28 + } 29 + 30 + return store, nil 31 + } 32 + 33 + func (db *DB) migrate() error { 34 + schema := ` 35 + CREATE TABLE IF NOT EXISTS users ( 36 + id INTEGER PRIMARY KEY, 37 + pubkey_fp TEXT UNIQUE NOT NULL, 38 + pubkey TEXT NOT NULL, 39 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP 40 + ); 41 + 42 + CREATE TABLE IF NOT EXISTS configs ( 43 + id INTEGER PRIMARY KEY, 44 + user_id INTEGER NOT NULL REFERENCES users(id), 45 + filename TEXT NOT NULL, 46 + email TEXT NOT NULL, 47 + cron_expr TEXT NOT NULL, 48 + digest BOOLEAN DEFAULT TRUE, 49 + inline_content BOOLEAN DEFAULT FALSE, 50 + raw_text TEXT NOT NULL, 51 + last_run DATETIME, 52 + next_run DATETIME, 53 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 54 + UNIQUE(user_id, filename) 55 + ); 56 + 57 + CREATE TABLE IF NOT EXISTS feeds ( 58 + id INTEGER PRIMARY KEY, 59 + config_id INTEGER NOT NULL REFERENCES configs(id) ON DELETE CASCADE, 60 + url TEXT NOT NULL, 61 + name TEXT, 62 + last_fetched DATETIME, 63 + etag TEXT, 64 + last_modified TEXT 65 + ); 66 + 67 + CREATE TABLE IF NOT EXISTS seen_items ( 68 + id INTEGER PRIMARY KEY, 69 + feed_id INTEGER NOT NULL REFERENCES feeds(id) ON DELETE CASCADE, 70 + guid TEXT NOT NULL, 71 + title TEXT, 72 + link TEXT, 73 + seen_at DATETIME DEFAULT CURRENT_TIMESTAMP, 74 + UNIQUE(feed_id, guid) 75 + ); 76 + 77 + CREATE TABLE IF NOT EXISTS logs ( 78 + id INTEGER PRIMARY KEY, 79 + config_id INTEGER NOT NULL REFERENCES configs(id) ON DELETE CASCADE, 80 + message TEXT NOT NULL, 81 + level TEXT NOT NULL, 82 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP 83 + ); 84 + 85 + CREATE INDEX IF NOT EXISTS idx_configs_user_id ON configs(user_id); 86 + CREATE INDEX IF NOT EXISTS idx_configs_next_run ON configs(next_run); 87 + CREATE INDEX IF NOT EXISTS idx_feeds_config_id ON feeds(config_id); 88 + CREATE INDEX IF NOT EXISTS idx_seen_items_feed_id ON seen_items(feed_id); 89 + CREATE INDEX IF NOT EXISTS idx_logs_config_id ON logs(config_id); 90 + CREATE INDEX IF NOT EXISTS idx_logs_created_at ON logs(created_at); 91 + ` 92 + 93 + _, err := db.Exec(schema) 94 + return err 95 + } 96 + 97 + func (db *DB) Close() error { 98 + return db.DB.Close() 99 + } 100 + 101 + func (db *DB) Migrate(ctx context.Context) error { 102 + return db.migrate() 103 + } 104 + 105 + func (db *DB) BeginTx(ctx context.Context) (*sql.Tx, error) { 106 + return db.DB.BeginTx(ctx, nil) 107 + }
+97
store/feeds.go
··· 1 + package store 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + "time" 8 + ) 9 + 10 + type Feed struct { 11 + ID int64 12 + ConfigID int64 13 + URL string 14 + Name sql.NullString 15 + LastFetched sql.NullTime 16 + ETag sql.NullString 17 + LastModified sql.NullString 18 + } 19 + 20 + func (db *DB) CreateFeed(ctx context.Context, configID int64, url, name string) (*Feed, error) { 21 + var nameVal sql.NullString 22 + if name != "" { 23 + nameVal = sql.NullString{String: name, Valid: true} 24 + } 25 + 26 + result, err := db.ExecContext(ctx, 27 + `INSERT INTO feeds (config_id, url, name) VALUES (?, ?, ?)`, 28 + configID, url, nameVal, 29 + ) 30 + if err != nil { 31 + return nil, fmt.Errorf("insert feed: %w", err) 32 + } 33 + 34 + id, err := result.LastInsertId() 35 + if err != nil { 36 + return nil, fmt.Errorf("get last insert id: %w", err) 37 + } 38 + 39 + return &Feed{ 40 + ID: id, 41 + ConfigID: configID, 42 + URL: url, 43 + Name: nameVal, 44 + }, nil 45 + } 46 + 47 + func (db *DB) GetFeedsByConfig(ctx context.Context, configID int64) ([]*Feed, error) { 48 + rows, err := db.QueryContext(ctx, 49 + `SELECT id, config_id, url, name, last_fetched, etag, last_modified 50 + FROM feeds WHERE config_id = ? ORDER BY id`, 51 + configID, 52 + ) 53 + if err != nil { 54 + return nil, fmt.Errorf("query feeds: %w", err) 55 + } 56 + defer rows.Close() 57 + 58 + var feeds []*Feed 59 + for rows.Next() { 60 + var f Feed 61 + if err := rows.Scan(&f.ID, &f.ConfigID, &f.URL, &f.Name, &f.LastFetched, &f.ETag, &f.LastModified); err != nil { 62 + return nil, fmt.Errorf("scan feed: %w", err) 63 + } 64 + feeds = append(feeds, &f) 65 + } 66 + return feeds, rows.Err() 67 + } 68 + 69 + func (db *DB) UpdateFeedFetched(ctx context.Context, feedID int64, etag, lastModified string) error { 70 + var etagVal, lmVal sql.NullString 71 + if etag != "" { 72 + etagVal = sql.NullString{String: etag, Valid: true} 73 + } 74 + if lastModified != "" { 75 + lmVal = sql.NullString{String: lastModified, Valid: true} 76 + } 77 + 78 + _, err := db.ExecContext(ctx, 79 + `UPDATE feeds SET last_fetched = ?, etag = ?, last_modified = ? WHERE id = ?`, 80 + time.Now(), etagVal, lmVal, feedID, 81 + ) 82 + if err != nil { 83 + return fmt.Errorf("update feed fetched: %w", err) 84 + } 85 + return nil 86 + } 87 + 88 + func (db *DB) DeleteFeedsByConfig(ctx context.Context, configID int64) error { 89 + _, err := db.ExecContext(ctx, 90 + `DELETE FROM feeds WHERE config_id = ?`, 91 + configID, 92 + ) 93 + if err != nil { 94 + return fmt.Errorf("delete feeds: %w", err) 95 + } 96 + return nil 97 + }
+75
store/items.go
··· 1 + package store 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "errors" 7 + "fmt" 8 + "time" 9 + ) 10 + 11 + type SeenItem struct { 12 + ID int64 13 + FeedID int64 14 + GUID string 15 + Title sql.NullString 16 + Link sql.NullString 17 + SeenAt time.Time 18 + } 19 + 20 + func (db *DB) MarkItemSeen(ctx context.Context, feedID int64, guid, title, link string) error { 21 + var titleVal, linkVal sql.NullString 22 + if title != "" { 23 + titleVal = sql.NullString{String: title, Valid: true} 24 + } 25 + if link != "" { 26 + linkVal = sql.NullString{String: link, Valid: true} 27 + } 28 + 29 + _, err := db.ExecContext(ctx, 30 + `INSERT INTO seen_items (feed_id, guid, title, link) VALUES (?, ?, ?, ?) 31 + ON CONFLICT(feed_id, guid) DO UPDATE SET title = excluded.title, link = excluded.link`, 32 + feedID, guid, titleVal, linkVal, 33 + ) 34 + if err != nil { 35 + return fmt.Errorf("mark item seen: %w", err) 36 + } 37 + return nil 38 + } 39 + 40 + func (db *DB) IsItemSeen(ctx context.Context, feedID int64, guid string) (bool, error) { 41 + var id int64 42 + err := db.QueryRowContext(ctx, 43 + `SELECT id FROM seen_items WHERE feed_id = ? AND guid = ?`, 44 + feedID, guid, 45 + ).Scan(&id) 46 + if err != nil { 47 + if errors.Is(err, sql.ErrNoRows) { 48 + return false, nil 49 + } 50 + return false, fmt.Errorf("check item seen: %w", err) 51 + } 52 + return true, nil 53 + } 54 + 55 + func (db *DB) GetSeenItems(ctx context.Context, feedID int64, limit int) ([]*SeenItem, error) { 56 + rows, err := db.QueryContext(ctx, 57 + `SELECT id, feed_id, guid, title, link, seen_at 58 + FROM seen_items WHERE feed_id = ? ORDER BY seen_at DESC LIMIT ?`, 59 + feedID, limit, 60 + ) 61 + if err != nil { 62 + return nil, fmt.Errorf("query seen items: %w", err) 63 + } 64 + defer rows.Close() 65 + 66 + var items []*SeenItem 67 + for rows.Next() { 68 + var item SeenItem 69 + if err := rows.Scan(&item.ID, &item.FeedID, &item.GUID, &item.Title, &item.Link, &item.SeenAt); err != nil { 70 + return nil, fmt.Errorf("scan seen item: %w", err) 71 + } 72 + items = append(items, &item) 73 + } 74 + return items, rows.Err() 75 + }
+73
store/logs.go
··· 1 + package store 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "time" 7 + ) 8 + 9 + type Log struct { 10 + ID int64 11 + ConfigID int64 12 + Message string 13 + Level string 14 + CreatedAt time.Time 15 + } 16 + 17 + func (db *DB) AddLog(ctx context.Context, configID int64, level, message string) error { 18 + _, err := db.ExecContext(ctx, 19 + `INSERT INTO logs (config_id, level, message) VALUES (?, ?, ?)`, 20 + configID, level, message, 21 + ) 22 + if err != nil { 23 + return fmt.Errorf("add log: %w", err) 24 + } 25 + return nil 26 + } 27 + 28 + func (db *DB) GetLogs(ctx context.Context, configID int64, limit int) ([]*Log, error) { 29 + rows, err := db.QueryContext(ctx, 30 + `SELECT id, config_id, message, level, created_at 31 + FROM logs WHERE config_id = ? ORDER BY created_at DESC LIMIT ?`, 32 + configID, limit, 33 + ) 34 + if err != nil { 35 + return nil, fmt.Errorf("query logs: %w", err) 36 + } 37 + defer rows.Close() 38 + 39 + var logs []*Log 40 + for rows.Next() { 41 + var log Log 42 + if err := rows.Scan(&log.ID, &log.ConfigID, &log.Message, &log.Level, &log.CreatedAt); err != nil { 43 + return nil, fmt.Errorf("scan log: %w", err) 44 + } 45 + logs = append(logs, &log) 46 + } 47 + return logs, rows.Err() 48 + } 49 + 50 + func (db *DB) GetRecentLogs(ctx context.Context, userID int64, limit int) ([]*Log, error) { 51 + rows, err := db.QueryContext(ctx, 52 + `SELECT l.id, l.config_id, l.message, l.level, l.created_at 53 + FROM logs l 54 + JOIN configs c ON l.config_id = c.id 55 + WHERE c.user_id = ? 56 + ORDER BY l.created_at DESC LIMIT ?`, 57 + userID, limit, 58 + ) 59 + if err != nil { 60 + return nil, fmt.Errorf("query recent logs: %w", err) 61 + } 62 + defer rows.Close() 63 + 64 + var logs []*Log 65 + for rows.Next() { 66 + var log Log 67 + if err := rows.Scan(&log.ID, &log.ConfigID, &log.Message, &log.Level, &log.CreatedAt); err != nil { 68 + return nil, fmt.Errorf("scan log: %w", err) 69 + } 70 + logs = append(logs, &log) 71 + } 72 + return logs, rows.Err() 73 + }
+58
store/users.go
··· 1 + package store 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "errors" 7 + "fmt" 8 + "time" 9 + ) 10 + 11 + type User struct { 12 + ID int64 13 + PubkeyFP string 14 + Pubkey string 15 + CreatedAt time.Time 16 + } 17 + 18 + func (db *DB) GetOrCreateUser(ctx context.Context, pubkeyFP, pubkey string) (*User, error) { 19 + user, err := db.GetUserByFingerprint(ctx, pubkeyFP) 20 + if err == nil { 21 + return user, nil 22 + } 23 + if !errors.Is(err, sql.ErrNoRows) { 24 + return nil, err 25 + } 26 + 27 + result, err := db.ExecContext(ctx, 28 + `INSERT INTO users (pubkey_fp, pubkey) VALUES (?, ?)`, 29 + pubkeyFP, pubkey, 30 + ) 31 + if err != nil { 32 + return nil, fmt.Errorf("insert user: %w", err) 33 + } 34 + 35 + id, err := result.LastInsertId() 36 + if err != nil { 37 + return nil, fmt.Errorf("get last insert id: %w", err) 38 + } 39 + 40 + return &User{ 41 + ID: id, 42 + PubkeyFP: pubkeyFP, 43 + Pubkey: pubkey, 44 + CreatedAt: time.Now(), 45 + }, nil 46 + } 47 + 48 + func (db *DB) GetUserByFingerprint(ctx context.Context, fp string) (*User, error) { 49 + var user User 50 + err := db.QueryRowContext(ctx, 51 + `SELECT id, pubkey_fp, pubkey, created_at FROM users WHERE pubkey_fp = ?`, 52 + fp, 53 + ).Scan(&user.ID, &user.PubkeyFP, &user.Pubkey, &user.CreatedAt) 54 + if err != nil { 55 + return nil, err 56 + } 57 + return &user, nil 58 + }
+370
web/handlers.go
··· 1 + package web 2 + 3 + import ( 4 + "database/sql" 5 + "encoding/json" 6 + "encoding/xml" 7 + "errors" 8 + "net/http" 9 + "sort" 10 + "time" 11 + ) 12 + 13 + func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { 14 + host := parseOriginHost(s.origin) 15 + data := struct { 16 + Origin string 17 + OriginHost string 18 + SSHHost string 19 + SSHPort int 20 + }{ 21 + Origin: s.origin, 22 + OriginHost: stripProtocol(s.origin), 23 + SSHHost: host, 24 + SSHPort: s.sshPort, 25 + } 26 + if err := s.tmpl.ExecuteTemplate(w, "index.html", data); err != nil { 27 + s.logger.Error("render index", "err", err) 28 + http.Error(w, "Internal Server Error", http.StatusInternalServerError) 29 + } 30 + } 31 + 32 + func (s *Server) handleStyleCSS(w http.ResponseWriter, r *http.Request) { 33 + css, err := templatesFS.ReadFile("templates/style.css") 34 + if err != nil { 35 + http.Error(w, "Not Found", http.StatusNotFound) 36 + return 37 + } 38 + w.Header().Set("Content-Type", "text/css; charset=utf-8") 39 + w.Write(css) 40 + } 41 + 42 + type userPageData struct { 43 + Fingerprint string 44 + ShortFingerprint string 45 + Configs []configInfo 46 + NextRun string 47 + FeedXMLURL string 48 + FeedJSONURL string 49 + Origin string 50 + } 51 + 52 + type configInfo struct { 53 + Filename string 54 + FeedCount int 55 + URL string 56 + } 57 + 58 + func (s *Server) handleUser(w http.ResponseWriter, r *http.Request, fingerprint string) { 59 + ctx := r.Context() 60 + 61 + user, err := s.store.GetUserByFingerprint(ctx, fingerprint) 62 + if err != nil { 63 + if errors.Is(err, sql.ErrNoRows) { 64 + http.Error(w, "User Not Found", http.StatusNotFound) 65 + return 66 + } 67 + s.logger.Error("get user", "err", err) 68 + http.Error(w, "Internal Server Error", http.StatusInternalServerError) 69 + return 70 + } 71 + 72 + configs, err := s.store.ListConfigs(ctx, user.ID) 73 + if err != nil { 74 + s.logger.Error("list configs", "err", err) 75 + http.Error(w, "Internal Server Error", http.StatusInternalServerError) 76 + return 77 + } 78 + 79 + var configInfos []configInfo 80 + var earliestNextRun time.Time 81 + 82 + for _, cfg := range configs { 83 + feeds, err := s.store.GetFeedsByConfig(ctx, cfg.ID) 84 + if err != nil { 85 + s.logger.Error("get feeds", "err", err) 86 + continue 87 + } 88 + 89 + configInfos = append(configInfos, configInfo{ 90 + Filename: cfg.Filename, 91 + FeedCount: len(feeds), 92 + URL: "/" + fingerprint + "/" + cfg.Filename, 93 + }) 94 + 95 + if cfg.NextRun.Valid { 96 + if earliestNextRun.IsZero() || cfg.NextRun.Time.Before(earliestNextRun) { 97 + earliestNextRun = cfg.NextRun.Time 98 + } 99 + } 100 + } 101 + 102 + nextRunStr := "—" 103 + if !earliestNextRun.IsZero() { 104 + nextRunStr = earliestNextRun.Format("2006-01-02 15:04 MST") 105 + } 106 + 107 + shortFP := fingerprint 108 + if len(shortFP) > 12 { 109 + shortFP = shortFP[:12] 110 + } 111 + 112 + data := userPageData{ 113 + Fingerprint: fingerprint, 114 + ShortFingerprint: shortFP, 115 + Configs: configInfos, 116 + NextRun: nextRunStr, 117 + FeedXMLURL: "/" + fingerprint + "/feed.xml", 118 + FeedJSONURL: "/" + fingerprint + "/feed.json", 119 + Origin: s.origin, 120 + } 121 + 122 + if err := s.tmpl.ExecuteTemplate(w, "user.html", data); err != nil { 123 + s.logger.Error("render user", "err", err) 124 + http.Error(w, "Internal Server Error", http.StatusInternalServerError) 125 + } 126 + } 127 + 128 + type rssItem struct { 129 + Title string `xml:"title"` 130 + Link string `xml:"link"` 131 + GUID string `xml:"guid"` 132 + PubDate string `xml:"pubDate"` 133 + } 134 + 135 + type rssChannel struct { 136 + Title string `xml:"title"` 137 + Link string `xml:"link"` 138 + Description string `xml:"description"` 139 + Items []rssItem `xml:"item"` 140 + } 141 + 142 + type rssFeed struct { 143 + XMLName xml.Name `xml:"rss"` 144 + Version string `xml:"version,attr"` 145 + Channel rssChannel `xml:"channel"` 146 + } 147 + 148 + func (s *Server) handleFeedXML(w http.ResponseWriter, r *http.Request, fingerprint string) { 149 + ctx := r.Context() 150 + 151 + user, err := s.store.GetUserByFingerprint(ctx, fingerprint) 152 + if err != nil { 153 + if errors.Is(err, sql.ErrNoRows) { 154 + http.Error(w, "User Not Found", http.StatusNotFound) 155 + return 156 + } 157 + s.logger.Error("get user", "err", err) 158 + http.Error(w, "Internal Server Error", http.StatusInternalServerError) 159 + return 160 + } 161 + 162 + configs, err := s.store.ListConfigs(ctx, user.ID) 163 + if err != nil { 164 + s.logger.Error("list configs", "err", err) 165 + http.Error(w, "Internal Server Error", http.StatusInternalServerError) 166 + return 167 + } 168 + 169 + var items []rssItem 170 + for _, cfg := range configs { 171 + feeds, err := s.store.GetFeedsByConfig(ctx, cfg.ID) 172 + if err != nil { 173 + continue 174 + } 175 + for _, feed := range feeds { 176 + seenItems, err := s.store.GetSeenItems(ctx, feed.ID, 50) 177 + if err != nil { 178 + continue 179 + } 180 + for _, item := range seenItems { 181 + rItem := rssItem{ 182 + GUID: item.GUID, 183 + PubDate: item.SeenAt.Format(time.RFC1123Z), 184 + } 185 + if item.Title.Valid { 186 + rItem.Title = item.Title.String 187 + } 188 + if item.Link.Valid { 189 + rItem.Link = item.Link.String 190 + } 191 + items = append(items, rItem) 192 + } 193 + } 194 + } 195 + 196 + sort.Slice(items, func(i, j int) bool { 197 + ti, _ := time.Parse(time.RFC1123Z, items[i].PubDate) 198 + tj, _ := time.Parse(time.RFC1123Z, items[j].PubDate) 199 + return ti.After(tj) 200 + }) 201 + 202 + if len(items) > 100 { 203 + items = items[:100] 204 + } 205 + 206 + feed := rssFeed{ 207 + Version: "2.0", 208 + Channel: rssChannel{ 209 + Title: "Herald - " + fingerprint[:12], 210 + Link: s.origin + "/" + fingerprint, 211 + Description: "Aggregated feed for " + fingerprint[:12], 212 + Items: items, 213 + }, 214 + } 215 + 216 + w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8") 217 + w.Write([]byte(xml.Header)) 218 + enc := xml.NewEncoder(w) 219 + enc.Indent("", " ") 220 + enc.Encode(feed) 221 + } 222 + 223 + type jsonFeed struct { 224 + Version string `json:"version"` 225 + Title string `json:"title"` 226 + HomePageURL string `json:"home_page_url"` 227 + FeedURL string `json:"feed_url"` 228 + Items []jsonFeedItem `json:"items"` 229 + } 230 + 231 + type jsonFeedItem struct { 232 + ID string `json:"id"` 233 + URL string `json:"url,omitempty"` 234 + Title string `json:"title,omitempty"` 235 + DatePublished string `json:"date_published"` 236 + } 237 + 238 + func (s *Server) handleFeedJSON(w http.ResponseWriter, r *http.Request, fingerprint string) { 239 + ctx := r.Context() 240 + 241 + user, err := s.store.GetUserByFingerprint(ctx, fingerprint) 242 + if err != nil { 243 + if errors.Is(err, sql.ErrNoRows) { 244 + http.Error(w, "User Not Found", http.StatusNotFound) 245 + return 246 + } 247 + s.logger.Error("get user", "err", err) 248 + http.Error(w, "Internal Server Error", http.StatusInternalServerError) 249 + return 250 + } 251 + 252 + configs, err := s.store.ListConfigs(ctx, user.ID) 253 + if err != nil { 254 + s.logger.Error("list configs", "err", err) 255 + http.Error(w, "Internal Server Error", http.StatusInternalServerError) 256 + return 257 + } 258 + 259 + var items []jsonFeedItem 260 + for _, cfg := range configs { 261 + feeds, err := s.store.GetFeedsByConfig(ctx, cfg.ID) 262 + if err != nil { 263 + continue 264 + } 265 + for _, feed := range feeds { 266 + seenItems, err := s.store.GetSeenItems(ctx, feed.ID, 50) 267 + if err != nil { 268 + continue 269 + } 270 + for _, item := range seenItems { 271 + jItem := jsonFeedItem{ 272 + ID: item.GUID, 273 + DatePublished: item.SeenAt.Format(time.RFC3339), 274 + } 275 + if item.Title.Valid { 276 + jItem.Title = item.Title.String 277 + } 278 + if item.Link.Valid { 279 + jItem.URL = item.Link.String 280 + } 281 + items = append(items, jItem) 282 + } 283 + } 284 + } 285 + 286 + sort.Slice(items, func(i, j int) bool { 287 + ti, _ := time.Parse(time.RFC3339, items[i].DatePublished) 288 + tj, _ := time.Parse(time.RFC3339, items[j].DatePublished) 289 + return ti.After(tj) 290 + }) 291 + 292 + if len(items) > 100 { 293 + items = items[:100] 294 + } 295 + 296 + feed := jsonFeed{ 297 + Version: "https://jsonfeed.org/version/1.1", 298 + Title: "Herald - " + fingerprint[:12], 299 + HomePageURL: s.origin + "/" + fingerprint, 300 + FeedURL: s.origin + "/" + fingerprint + "/feed.json", 301 + Items: items, 302 + } 303 + 304 + w.Header().Set("Content-Type", "application/feed+json; charset=utf-8") 305 + enc := json.NewEncoder(w) 306 + enc.SetIndent("", " ") 307 + enc.Encode(feed) 308 + } 309 + 310 + func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request, fingerprint, filename string) { 311 + ctx := r.Context() 312 + 313 + user, err := s.store.GetUserByFingerprint(ctx, fingerprint) 314 + if err != nil { 315 + if errors.Is(err, sql.ErrNoRows) { 316 + http.Error(w, "User Not Found", http.StatusNotFound) 317 + return 318 + } 319 + s.logger.Error("get user", "err", err) 320 + http.Error(w, "Internal Server Error", http.StatusInternalServerError) 321 + return 322 + } 323 + 324 + cfg, err := s.store.GetConfig(ctx, user.ID, filename) 325 + if err != nil { 326 + if errors.Is(err, sql.ErrNoRows) { 327 + http.Error(w, "Config Not Found", http.StatusNotFound) 328 + return 329 + } 330 + s.logger.Error("get config", "err", err) 331 + http.Error(w, "Internal Server Error", http.StatusInternalServerError) 332 + return 333 + } 334 + 335 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 336 + w.Write([]byte(cfg.RawText)) 337 + } 338 + 339 + func stripProtocol(origin string) string { 340 + if len(origin) == 0 { 341 + return origin 342 + } 343 + 344 + // Remove http:// or https:// 345 + if len(origin) > 7 && origin[:7] == "http://" { 346 + return origin[7:] 347 + } 348 + if len(origin) > 8 && origin[:8] == "https://" { 349 + return origin[8:] 350 + } 351 + 352 + return origin 353 + } 354 + 355 + func parseOriginHost(origin string) string { 356 + // Strip protocol 357 + hostPort := stripProtocol(origin) 358 + if hostPort == "" { 359 + return "localhost" 360 + } 361 + 362 + // Strip port if present 363 + for i := len(hostPort) - 1; i >= 0; i-- { 364 + if hostPort[i] == ':' { 365 + return hostPort[:i] 366 + } 367 + } 368 + 369 + return hostPort 370 + }
+87
web/server.go
··· 1 + package web 2 + 3 + import ( 4 + "context" 5 + "embed" 6 + "html/template" 7 + "net/http" 8 + "strings" 9 + 10 + "github.com/charmbracelet/log" 11 + "github.com/kierank/herald/store" 12 + ) 13 + 14 + //go:embed templates/* 15 + var templatesFS embed.FS 16 + 17 + type Server struct { 18 + store *store.DB 19 + addr string 20 + origin string 21 + sshPort int 22 + logger *log.Logger 23 + tmpl *template.Template 24 + } 25 + 26 + func NewServer(st *store.DB, addr string, origin string, sshPort int, logger *log.Logger) *Server { 27 + tmpl := template.Must(template.ParseFS(templatesFS, "templates/*.html")) 28 + return &Server{ 29 + store: st, 30 + addr: addr, 31 + origin: origin, 32 + sshPort: sshPort, 33 + logger: logger, 34 + tmpl: tmpl, 35 + } 36 + } 37 + 38 + func (s *Server) ListenAndServe(ctx context.Context) error { 39 + mux := http.NewServeMux() 40 + 41 + mux.HandleFunc("/", s.routeHandler) 42 + mux.HandleFunc("/style.css", s.handleStyleCSS) 43 + 44 + srv := &http.Server{ 45 + Addr: s.addr, 46 + Handler: mux, 47 + } 48 + 49 + go func() { 50 + <-ctx.Done() 51 + srv.Shutdown(context.Background()) 52 + }() 53 + 54 + s.logger.Info("web server listening", "addr", s.addr) 55 + err := srv.ListenAndServe() 56 + if err == http.ErrServerClosed { 57 + return nil 58 + } 59 + return err 60 + } 61 + 62 + func (s *Server) routeHandler(w http.ResponseWriter, r *http.Request) { 63 + path := strings.Trim(r.URL.Path, "/") 64 + 65 + if path == "" { 66 + s.handleIndex(w, r) 67 + return 68 + } 69 + 70 + parts := strings.Split(path, "/") 71 + 72 + switch len(parts) { 73 + case 1: 74 + s.handleUser(w, r, parts[0]) 75 + case 2: 76 + switch parts[1] { 77 + case "feed.xml": 78 + s.handleFeedXML(w, r, parts[0]) 79 + case "feed.json": 80 + s.handleFeedJSON(w, r, parts[0]) 81 + default: 82 + s.handleConfig(w, r, parts[0], parts[1]) 83 + } 84 + default: 85 + http.NotFound(w, r) 86 + } 87 + }
+41
web/templates/index.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <title>HERALD</title> 7 + <link rel="stylesheet" href="/style.css"> 8 + </head> 9 + <body> 10 + <h1>HERALD</h1> 11 + 12 + <h2>NAME</h2> 13 + <pre> 14 + herald - RSS-to-Email via SSH 15 + </pre> 16 + 17 + <h2>COMMANDS</h2> 18 + <pre> 19 + ls List uploaded feed configs 20 + cat &lt;file&gt; Display config file contents 21 + rm &lt;file&gt; Delete a config file 22 + run &lt;file&gt; Manually trigger feed fetch and email 23 + logs View recent delivery logs 24 + </pre> 25 + 26 + <h2>EXAMPLES</h2> 27 + <pre> 28 + # Upload a config 29 + scp {{if ne .SSHPort 22}}-P {{.SSHPort}} {{end}}feeds.txt herald@{{.SSHHost}}: 30 + 31 + # Check status 32 + ssh {{if ne .SSHPort 22}}-p {{.SSHPort}} {{end}}herald@{{.SSHHost}} ls 33 + 34 + # View config 35 + ssh {{if ne .SSHPort 22}}-p {{.SSHPort}} {{end}}herald@{{.SSHHost}} cat feeds.txt 36 + 37 + # Manual run 38 + ssh {{if ne .SSHPort 22}}-p {{.SSHPort}} {{end}}herald@{{.SSHHost}} run feeds.txt 39 + </pre> 40 + </body> 41 + </html>
+53
web/templates/style.css
··· 1 + * { 2 + margin: 0; 3 + padding: 0; 4 + box-sizing: border-box; 5 + } 6 + html, body { 7 + font-family: monospace; 8 + background: #0a0a0a; 9 + color: #e0e0e0; 10 + } 11 + body { 12 + max-width: 80ch; 13 + margin: 2rem auto; 14 + padding: 1rem; 15 + line-height: 1.5; 16 + } 17 + a { 18 + color: #8ab4f8; 19 + text-decoration: underline; 20 + } 21 + a:hover { 22 + color: #fff; 23 + } 24 + pre { 25 + white-space: pre-wrap; 26 + margin: 1rem 0; 27 + } 28 + h1 { 29 + font-weight: bold; 30 + font-size: 1rem; 31 + border-bottom: 2px solid #333; 32 + padding-bottom: 0.5rem; 33 + margin-bottom: 1rem; 34 + } 35 + h2 { 36 + font-weight: bold; 37 + font-size: 1rem; 38 + margin-top: 1.5rem; 39 + margin-bottom: 0.5rem; 40 + } 41 + p { 42 + margin: 0.5rem 0; 43 + } 44 + ul { 45 + list-style: none; 46 + margin: 0.5rem 0 1rem 2rem; 47 + } 48 + li { 49 + margin: 0.25rem 0; 50 + } 51 + strong { 52 + font-weight: bold; 53 + }
+28
web/templates/user.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <title>HERALD - {{.ShortFingerprint}}</title> 7 + <link rel="stylesheet" href="/style.css"> 8 + </head> 9 + <body> 10 + <h1>HERALD</h1> 11 + <p><strong>USER:</strong> {{.ShortFingerprint}}</p> 12 + <p><strong>STATUS:</strong> ONLINE</p> 13 + <p><strong>NEXT RUN:</strong> {{.NextRun}}</p> 14 + <h2>CONFIGS</h2> 15 + <ul> 16 + {{range .Configs}} 17 + <li><a href="{{.URL}}">{{.Filename}}</a> ({{.FeedCount}} feeds)</li> 18 + {{else}} 19 + <li>No configs uploaded</li> 20 + {{end}} 21 + </ul> 22 + <h2>FEEDS</h2> 23 + <ul> 24 + <li><a href="{{.FeedXMLURL}}">RSS 2.0</a></li> 25 + <li><a href="{{.FeedJSONURL}}">JSON Feed</a></li> 26 + </ul> 27 + </body> 28 + </html>