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

Compare changes

Choose any two refs to compare.

+717 -47
+7 -1
README.md
··· 1 1 # Herald ๐ŸŽ 2 2 3 - ![email from harold](https://l4.dunkirk.sh/i/Ck271POS5n0k.webp) 3 + ![herald web interface](https://l4.dunkirk.sh/i/TuJ7meLh1JB9.webp) 4 4 5 5 This was inspired by the sunsetting of [pico.sh/feeds](https://blog.pico.sh/ann-033-moving-rss-to-email-pico-plus) being available outside of `pico+`. It is a totally understandable move from them as their email costs were skyrocketing and they needed to pay for it somehow. This was created to allow me to still get my rss feeds delivered to me each day by email which I have grown quite accustomed to. The config is completely compatible with the `pico.sh` format as of `2026-01-09` and should stay fairly stable. It is also configured over ssh with the slight addition that you can view your feeds on a website as well as I found myself wanting to hot load my feeds into my website :) 6 6 ··· 136 136 - `HERALD_SMTP_USER` 137 137 - `HERALD_SMTP_PASS` 138 138 - `HERALD_SMTP_FROM` 139 + 140 + ## Screenshots 141 + 142 + here is an example of what an email digest looks like: 143 + 144 + ![email from harold](https://l4.dunkirk.sh/i/Ck271POS5n0k.webp) 139 145 140 146 <p align="center"> 141 147 <img src="https://raw.githubusercontent.com/taciturnaxolotl/carriage/main/.github/images/line-break.svg" />
+40
crush.json
··· 1 + { 2 + "$schema": "https://charm.land/crush.json", 3 + "lsp": { 4 + "gopls": { 5 + "options": { 6 + "gofumpt": true, 7 + "codelenses": { 8 + "gc_details": true, 9 + "generate": true, 10 + "run_govulncheck": true, 11 + "test": true, 12 + "tidy": true, 13 + "upgrade_dependency": true 14 + }, 15 + "hints": { 16 + "assignVariableTypes": true, 17 + "compositeLiteralFields": true, 18 + "compositeLiteralTypes": true, 19 + "constantValues": true, 20 + "functionTypeParameters": true, 21 + "parameterNames": true, 22 + "rangeVariableTypes": true 23 + }, 24 + "analyses": { 25 + "nilness": true, 26 + "unusedparams": true, 27 + "unusedvariable": true, 28 + "unusedwrite": true, 29 + "useany": true 30 + }, 31 + "staticcheck": true, 32 + "directoryFilters": [ 33 + "-.git", 34 + "-node_modules" 35 + ], 36 + "semanticTokens": true 37 + } 38 + } 39 + } 40 + }
+72 -3
email/render.go
··· 6 6 htmltemplate "html/template" 7 7 texttemplate "text/template" 8 8 "time" 9 + 10 + "github.com/microcosm-cc/bluemonday" 9 11 ) 10 12 11 13 //go:embed templates/* ··· 30 32 Published time.Time 31 33 } 32 34 35 + // templateFeedItem is used for template rendering with sanitized HTML content 36 + type templateFeedItem struct { 37 + Title string 38 + Link string 39 + Content string // Original content for text template 40 + SanitizedContent htmltemplate.HTML // Sanitized HTML for HTML template 41 + Published time.Time 42 + } 43 + 44 + // templateFeedGroup is used for template rendering with sanitized items 45 + type templateFeedGroup struct { 46 + FeedName string 47 + FeedURL string 48 + Items []templateFeedItem 49 + } 50 + 51 + // sanitizeHTML sanitizes HTML content, allowing safe tags while stripping styles and unsafe elements 52 + func sanitizeHTML(html string) string { 53 + return policy.Sanitize(html) 54 + } 55 + 33 56 var ( 34 57 htmlTmpl *htmltemplate.Template 35 58 textTmpl *texttemplate.Template 59 + policy *bluemonday.Policy 36 60 ) 37 61 38 62 func init() { ··· 45 69 if err != nil { 46 70 panic("failed to parse text template: " + err.Error()) 47 71 } 72 + 73 + // Initialize HTML sanitization policy 74 + // UGCPolicy allows safe HTML tags but strips styles and unsafe elements 75 + // This prevents XSS attacks while allowing basic formatting 76 + policy = bluemonday.UGCPolicy() 48 77 } 49 78 50 79 func RenderDigest(data *DigestData, inline bool, daysUntilExpiry int, showUrgentBanner, showWarningBanner bool) (html string, text string, err error) { 51 - tmplData := struct { 80 + // Convert FeedGroups to templateFeedGroups with sanitized HTML content 81 + sanitizedGroups := make([]templateFeedGroup, len(data.FeedGroups)) 82 + for i, group := range data.FeedGroups { 83 + sanitizedItems := make([]templateFeedItem, len(group.Items)) 84 + for j, item := range group.Items { 85 + sanitizedItems[j] = templateFeedItem{ 86 + Title: item.Title, 87 + Link: item.Link, 88 + Content: item.Content, 89 + SanitizedContent: htmltemplate.HTML(sanitizeHTML(item.Content)), // #nosec G203 -- Content is sanitized by bluemonday before conversion 90 + Published: item.Published, 91 + } 92 + } 93 + sanitizedGroups[i] = templateFeedGroup{ 94 + FeedName: group.FeedName, 95 + FeedURL: group.FeedURL, 96 + Items: sanitizedItems, 97 + } 98 + } 99 + 100 + // Prepare template data for HTML template (with sanitized content) 101 + htmlTmplData := struct { 102 + ConfigName string 103 + TotalItems int 104 + FeedGroups []templateFeedGroup 105 + Inline bool 106 + DaysUntilExpiry int 107 + ShowUrgentBanner bool 108 + ShowWarningBanner bool 109 + }{ 110 + ConfigName: data.ConfigName, 111 + TotalItems: data.TotalItems, 112 + FeedGroups: sanitizedGroups, 113 + Inline: inline, 114 + DaysUntilExpiry: daysUntilExpiry, 115 + ShowUrgentBanner: showUrgentBanner, 116 + ShowWarningBanner: showWarningBanner, 117 + } 118 + 119 + // Prepare template data for text template (with original content) 120 + textTmplData := struct { 52 121 *DigestData 53 122 Inline bool 54 123 DaysUntilExpiry int ··· 64 133 65 134 var htmlBuf, textBuf bytes.Buffer 66 135 67 - if err = htmlTmpl.Execute(&htmlBuf, tmplData); err != nil { 136 + if err = htmlTmpl.Execute(&htmlBuf, htmlTmplData); err != nil { 68 137 return "", "", err 69 138 } 70 139 71 - if err = textTmpl.Execute(&textBuf, tmplData); err != nil { 140 + if err = textTmpl.Execute(&textBuf, textTmplData); err != nil { 72 141 return "", "", err 73 142 } 74 143
+95
email/render_test.go
··· 1 + package email 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + "time" 7 + ) 8 + 9 + func TestRenderDigest_HTMLNotEscaped(t *testing.T) { 10 + // Create test data with HTML content 11 + data := &DigestData{ 12 + ConfigName: "Test Config", 13 + TotalItems: 1, 14 + FeedGroups: []FeedGroup{ 15 + { 16 + FeedName: "Test Feed", 17 + FeedURL: "https://example.com/feed", 18 + Items: []FeedItem{ 19 + { 20 + Title: "Test Article", 21 + Link: "https://example.com/article", 22 + Content: "<p>This is a <strong>test</strong> article with <a href='https://example.com'>a link</a>.</p>", 23 + Published: time.Now(), 24 + }, 25 + }, 26 + }, 27 + }, 28 + } 29 + 30 + // Render with inline mode enabled 31 + htmlOutput, _, err := RenderDigest(data, true, 30, false, false) 32 + if err != nil { 33 + t.Fatalf("RenderDigest failed: %v", err) 34 + } 35 + 36 + // Debug: print actual output 37 + t.Logf("HTML Output:\n%s", htmlOutput) 38 + 39 + // Verify HTML is NOT escaped (should contain actual tags, not &lt; entities) 40 + if strings.Contains(htmlOutput, "&lt;p&gt;") { 41 + t.Error("HTML is being escaped - found &lt;p&gt; instead of <p>") 42 + } 43 + if strings.Contains(htmlOutput, "&lt;strong&gt;") { 44 + t.Error("HTML is being escaped - found &lt;strong&gt; instead of <strong>") 45 + } 46 + 47 + // Verify HTML tags are present (not escaped) 48 + if !strings.Contains(htmlOutput, "<p>This is a <strong>test</strong>") { 49 + t.Error("HTML tags are not being rendered - content appears to be escaped") 50 + } 51 + } 52 + 53 + func TestRenderDigest_UnsafeHTMLStripped(t *testing.T) { 54 + // Create test data with unsafe HTML content 55 + data := &DigestData{ 56 + ConfigName: "Test Config", 57 + TotalItems: 1, 58 + FeedGroups: []FeedGroup{ 59 + { 60 + FeedName: "Test Feed", 61 + FeedURL: "https://example.com/feed", 62 + Items: []FeedItem{ 63 + { 64 + Title: "Test Article", 65 + Link: "https://example.com/article", 66 + Content: "<p>Safe content</p><script>alert('xss')</script><p style='color:red'>No styles</p>", 67 + Published: time.Now(), 68 + }, 69 + }, 70 + }, 71 + }, 72 + } 73 + 74 + // Render with inline mode enabled 75 + htmlOutput, _, err := RenderDigest(data, true, 30, false, false) 76 + if err != nil { 77 + t.Fatalf("RenderDigest failed: %v", err) 78 + } 79 + 80 + // Debug: print actual output 81 + t.Logf("HTML Output:\n%s", htmlOutput) 82 + 83 + // Verify script tags are removed 84 + if strings.Contains(htmlOutput, "<script>") { 85 + t.Error("Unsafe <script> tags were not stripped") 86 + } 87 + if strings.Contains(htmlOutput, "alert('xss')") { 88 + t.Error("Script content was not removed") 89 + } 90 + 91 + // Verify safe content remains 92 + if !strings.Contains(htmlOutput, "<p>Safe content</p>") { 93 + t.Error("Safe HTML content was incorrectly removed") 94 + } 95 + }
+3 -1
email/templates/digest.html
··· 65 65 {{if $.Inline}} 66 66 <div> 67 67 {{range .Items}} 68 + {{if .SanitizedContent}} 68 69 <div> 69 70 <h1><a href="{{.Link}}">{{.Title}}</a></h1> 70 - <div>{{.Content}}</div> 71 + <div>{{.SanitizedContent}}</div> 71 72 </div> 72 73 <hr /> 74 + {{end}} 73 75 {{end}} 74 76 </div> 75 77 {{end}}
+1 -1
email/templates/digest.txt
··· 15 15 {{.Title}} 16 16 {{.Link}} 17 17 {{if and $.Inline .Content}} 18 - {{.Content}} 19 18 19 + {{.Content}} 20 20 {{end}} 21 21 {{end}} 22 22 {{end}}
+7 -2
flake.nix
··· 29 29 { 30 30 default = pkgs.buildGoModule { 31 31 pname = "herald"; 32 - version = "0.1.0"; 32 + version = "0.1.1"; 33 33 subPackages = [ "." ]; 34 34 src = self; 35 - vendorHash = "sha256-IE7JMJ4DehwYxrkh5YCgw7yWdybxtCCxWQO7M/u6bno="; 35 + vendorHash = "sha256-SjxTy/ecSUYaJJ8dpfQFLF7WgVEpnKcu5qWcqyw611Q="; 36 + proxyVendor = true; 37 + ldflags = [ 38 + "-X main.commitHash=${self.rev or self.dirtyRev or "dev"}" 39 + "-X main.version=0.1.1" 40 + ]; 36 41 }; 37 42 } 38 43 );
+3
go.mod
··· 12 12 github.com/emersion/go-msgauth v0.7.0 13 13 github.com/joho/godotenv v1.5.1 14 14 github.com/mattn/go-sqlite3 v1.14.33 15 + github.com/microcosm-cc/bluemonday v1.0.27 15 16 github.com/mmcdole/gofeed v1.3.0 16 17 github.com/pkg/sftp v1.13.10 17 18 github.com/spf13/cobra v1.10.2 ··· 27 28 github.com/andybalholm/cascadia v1.3.3 // indirect 28 29 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 29 30 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 31 + github.com/aymerick/douceur v0.2.0 // indirect 30 32 github.com/charmbracelet/bubbletea v1.3.10 // indirect 31 33 github.com/charmbracelet/colorprofile v0.4.1 // indirect 32 34 github.com/charmbracelet/keygen v0.5.4 // indirect ··· 44 46 github.com/creack/pty v1.1.24 // indirect 45 47 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 46 48 github.com/go-logfmt/logfmt v0.6.1 // indirect 49 + github.com/gorilla/css v1.0.1 // indirect 47 50 github.com/inconshreveable/mousetrap v1.1.0 // indirect 48 51 github.com/json-iterator/go v1.1.12 // indirect 49 52 github.com/kr/fs v0.1.0 // indirect
+6
go.sum
··· 12 12 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 13 13 github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= 14 14 github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= 15 + github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 16 + github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 15 17 github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= 16 18 github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= 17 19 github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= ··· 68 70 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 69 71 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 70 72 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 73 + github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 74 + github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 71 75 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 72 76 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 73 77 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= ··· 88 92 github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 89 93 github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= 90 94 github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 95 + github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= 96 + github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 91 97 github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= 92 98 github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= 93 99 github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8=
+2
main.go
··· 49 49 if err := fang.Execute( 50 50 context.Background(), 51 51 rootCmd, 52 + fang.WithVersion(version), 53 + fang.WithCommit(commitHash), 52 54 fang.WithNotifySignal(os.Interrupt, os.Kill), 53 55 ); err != nil { 54 56 os.Exit(1)
+6 -6
scheduler/scheduler.go
··· 179 179 } 180 180 }() 181 181 182 - now := time.Now() 182 + now := time.Now().UTC() 183 183 configs, err := s.store.GetDueConfigs(ctx, now) 184 184 if err != nil { 185 185 s.logger.Error("failed to get due configs", "err", err) ··· 257 257 s.logger.Debug("RunNow: feed metadata updated") 258 258 259 259 s.logger.Debug("RunNow: calculating next run") 260 - now := time.Now() 261 - nextRun, err := gronx.NextTick(cfg.CronExpr, false) 260 + now := time.Now().UTC() 261 + nextRun, err := gronx.NextTickAfter(cfg.CronExpr, now, true) 262 262 if err != nil { 263 263 return stats, fmt.Errorf("calculate next run: %w", err) 264 264 } ··· 277 277 func (s *Scheduler) collectNewItems(ctx context.Context, results []*FetchResult) ([]email.FeedGroup, int, error) { 278 278 var feedGroups []email.FeedGroup 279 279 totalNew := 0 280 - maxAge := time.Now().Add(-itemMaxAge) 280 + maxAge := time.Now().UTC().Add(-itemMaxAge) 281 281 feedErrors := 0 282 282 283 283 for _, result := range results { ··· 486 486 } 487 487 } 488 488 489 - now := time.Now() 490 - nextRun, err := gronx.NextTick(cfg.CronExpr, false) 489 + now := time.Now().UTC() 490 + nextRun, err := gronx.NextTickAfter(cfg.CronExpr, now, true) 491 491 if err != nil { 492 492 return fmt.Errorf("calculate next run: %w", err) 493 493 }
+1 -1
ssh/commands.go
··· 288 288 } 289 289 290 290 func formatRelativeTime(t time.Time) string { 291 - now := time.Now() 291 + now := time.Now().UTC() 292 292 diff := t.Sub(now) 293 293 294 294 if diff < 0 {
+95 -13
ssh/scp.go
··· 2 2 3 3 import ( 4 4 "bytes" 5 + "context" 6 + "database/sql" 5 7 "fmt" 6 8 "io" 7 9 "io/fs" ··· 156 158 } 157 159 defer func() { _ = tx.Rollback() }() 158 160 159 - if err := h.store.DeleteConfigTx(ctx, tx, user.ID, name); err != nil { 160 - h.logger.Debug("no existing config to delete", "filename", name) 161 - } else { 162 - h.logger.Debug("deleted existing config", "filename", name) 163 - } 161 + // Try to get existing config 162 + existingCfg, err := h.store.GetConfigTx(ctx, tx, user.ID, name) 163 + var cfg *store.Config 164 + 165 + if err == nil { 166 + // Config exists - update it 167 + if err := h.store.UpdateConfigTx(ctx, tx, existingCfg.ID, parsed.Email, parsed.CronExpr, parsed.Digest, parsed.Inline, string(content), nextRun); err != nil { 168 + return 0, fmt.Errorf("failed to update config: %w", err) 169 + } 170 + cfg = existingCfg 171 + cfg.Email = parsed.Email 172 + cfg.CronExpr = parsed.CronExpr 173 + cfg.Digest = parsed.Digest 174 + cfg.InlineContent = parsed.Inline 175 + cfg.RawText = string(content) 176 + 177 + // Sync feeds: match by URL, update/delete/add as needed 178 + existingFeeds, err := h.store.GetFeedsByConfigTx(ctx, tx, cfg.ID) 179 + if err != nil { 180 + return 0, fmt.Errorf("failed to get existing feeds: %w", err) 181 + } 164 182 165 - cfg, err := h.store.CreateConfigTx(ctx, tx, user.ID, name, parsed.Email, parsed.CronExpr, parsed.Digest, parsed.Inline, string(content), nextRun) 166 - if err != nil { 167 - return 0, fmt.Errorf("failed to save config: %w", err) 168 - } 183 + // Build maps for comparison 184 + existingByURL := make(map[string]*store.Feed) 185 + for _, f := range existingFeeds { 186 + existingByURL[f.URL] = f 187 + } 169 188 170 - for _, feed := range parsed.Feeds { 171 - if _, err := h.store.CreateFeedTx(ctx, tx, cfg.ID, feed.URL, feed.Name); err != nil { 172 - return 0, fmt.Errorf("failed to save feed: %w", err) 189 + newByURL := make(map[string]struct{ URL, Name string }) 190 + for _, f := range parsed.Feeds { 191 + newByURL[f.URL] = struct{ URL, Name string }{URL: f.URL, Name: f.Name} 173 192 } 193 + 194 + // Update existing feeds that are still present 195 + for _, newFeed := range parsed.Feeds { 196 + if existingFeed, exists := existingByURL[newFeed.URL]; exists { 197 + // Feed still exists - update name if changed 198 + if err := h.store.UpdateFeedTx(ctx, tx, existingFeed.ID, newFeed.Name); err != nil { 199 + return 0, fmt.Errorf("failed to update feed: %w", err) 200 + } 201 + } else { 202 + // New feed - create it and mark existing items as seen 203 + newFeedRecord, err := h.store.CreateFeedTx(ctx, tx, cfg.ID, newFeed.URL, newFeed.Name) 204 + if err != nil { 205 + return 0, fmt.Errorf("failed to create feed: %w", err) 206 + } 207 + // Pre-seed seen items so we don't send old posts 208 + if err := h.preseedSeenItems(ctx, tx, newFeedRecord); err != nil { 209 + h.logger.Warn("failed to preseed seen items", "feed_url", newFeed.URL, "err", err) 210 + } 211 + } 212 + } 213 + 214 + // Delete feeds that are no longer present 215 + for _, existingFeed := range existingFeeds { 216 + if _, stillExists := newByURL[existingFeed.URL]; !stillExists { 217 + if err := h.store.DeleteFeedTx(ctx, tx, existingFeed.ID); err != nil { 218 + return 0, fmt.Errorf("failed to delete feed: %w", err) 219 + } 220 + } 221 + } 222 + 223 + h.logger.Debug("updated existing config", "filename", name) 224 + } else { 225 + // Config doesn't exist - create new one 226 + cfg, err = h.store.CreateConfigTx(ctx, tx, user.ID, name, parsed.Email, parsed.CronExpr, parsed.Digest, parsed.Inline, string(content), nextRun) 227 + if err != nil { 228 + return 0, fmt.Errorf("failed to create config: %w", err) 229 + } 230 + 231 + for _, feed := range parsed.Feeds { 232 + if _, err := h.store.CreateFeedTx(ctx, tx, cfg.ID, feed.URL, feed.Name); err != nil { 233 + return 0, fmt.Errorf("failed to create feed: %w", err) 234 + } 235 + } 236 + 237 + h.logger.Debug("created new config", "filename", name) 174 238 } 175 239 176 240 if err := tx.Commit(); err != nil { ··· 182 246 } 183 247 184 248 func calculateNextRun(cronExpr string) (time.Time, error) { 185 - return gronx.NextTick(cronExpr, false) 249 + return gronx.NextTickAfter(cronExpr, time.Now().UTC(), true) 186 250 } 187 251 188 252 type configFileInfo struct { ··· 204 268 func (e *configDirEntry) IsDir() bool { return false } 205 269 func (e *configDirEntry) Type() fs.FileMode { return e.info.Mode() } 206 270 func (e *configDirEntry) Info() (fs.FileInfo, error) { return e.info, nil } 271 + 272 + // preseedSeenItems fetches the feed and marks all current items as seen, 273 + // so that adding a new feed doesn't trigger emails for old posts. 274 + func (h *scpHandler) preseedSeenItems(ctx context.Context, tx *sql.Tx, feed *store.Feed) error { 275 + result := scheduler.FetchFeed(ctx, feed) 276 + if result.Error != nil { 277 + return result.Error 278 + } 279 + 280 + for _, item := range result.Items { 281 + if err := h.store.MarkItemSeenTx(ctx, tx, feed.ID, item.GUID, item.Title, item.Link); err != nil { 282 + return err 283 + } 284 + } 285 + 286 + h.logger.Debug("preseeded seen items for new feed", "feed_url", feed.URL, "count", len(result.Items)) 287 + return nil 288 + }
+93 -9
ssh/sftp.go
··· 1 1 package ssh 2 2 3 3 import ( 4 + "context" 4 5 "fmt" 5 6 "io" 6 7 "io/fs" ··· 176 177 } 177 178 178 179 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) 180 + 181 + // Try to get existing config 182 + existingCfg, err := w.handler.store.GetConfig(ctx, w.handler.user.ID, w.filename) 183 + var cfg *store.Config 184 + 185 + if err == nil { 186 + // Config exists - update it 187 + if err := w.handler.store.UpdateConfig(ctx, existingCfg.ID, parsed.Email, parsed.CronExpr, parsed.Digest, parsed.Inline, content, nextRun); err != nil { 188 + return fmt.Errorf("failed to update config: %w", err) 189 + } 190 + cfg = existingCfg 191 + cfg.Email = parsed.Email 192 + cfg.CronExpr = parsed.CronExpr 193 + cfg.Digest = parsed.Digest 194 + cfg.InlineContent = parsed.Inline 195 + cfg.RawText = content 196 + 197 + // Sync feeds: match by URL, update/delete/add as needed 198 + existingFeeds, err := w.handler.store.GetFeedsByConfig(ctx, cfg.ID) 199 + if err != nil { 200 + return fmt.Errorf("failed to get existing feeds: %w", err) 201 + } 202 + 203 + // Build maps for comparison 204 + existingByURL := make(map[string]*store.Feed) 205 + for _, f := range existingFeeds { 206 + existingByURL[f.URL] = f 207 + } 208 + 209 + newByURL := make(map[string]struct{ URL, Name string }) 210 + for _, f := range parsed.Feeds { 211 + newByURL[f.URL] = struct{ URL, Name string }{URL: f.URL, Name: f.Name} 212 + } 213 + 214 + // Update existing feeds that are still present 215 + for _, newFeed := range parsed.Feeds { 216 + if existingFeed, exists := existingByURL[newFeed.URL]; exists { 217 + // Feed still exists - update name if changed 218 + if err := w.handler.store.UpdateFeed(ctx, existingFeed.ID, newFeed.Name); err != nil { 219 + return fmt.Errorf("failed to update feed: %w", err) 220 + } 221 + } else { 222 + // New feed - create it and mark existing items as seen 223 + newFeedRecord, err := w.handler.store.CreateFeed(ctx, cfg.ID, newFeed.URL, newFeed.Name) 224 + if err != nil { 225 + return fmt.Errorf("failed to create feed: %w", err) 226 + } 227 + // Pre-seed seen items so we don't send old posts 228 + if err := w.preseedSeenItems(ctx, newFeedRecord); err != nil { 229 + w.handler.logger.Warn("failed to preseed seen items", "feed_url", newFeed.URL, "err", err) 230 + } 231 + } 232 + } 233 + 234 + // Delete feeds that are no longer present 235 + for _, existingFeed := range existingFeeds { 236 + if _, stillExists := newByURL[existingFeed.URL]; !stillExists { 237 + if err := w.handler.store.DeleteFeed(ctx, existingFeed.ID); err != nil { 238 + return fmt.Errorf("failed to delete feed: %w", err) 239 + } 240 + } 241 + } 242 + 243 + w.handler.logger.Debug("updated existing config via SFTP", "filename", w.filename) 244 + } else { 245 + // Config doesn't exist - create new one 246 + cfg, err = w.handler.store.CreateConfig(ctx, w.handler.user.ID, w.filename, parsed.Email, parsed.CronExpr, parsed.Digest, parsed.Inline, content, nextRun) 247 + if err != nil { 248 + return fmt.Errorf("failed to create config: %w", err) 249 + } 250 + 251 + for _, feed := range parsed.Feeds { 252 + if _, err := w.handler.store.CreateFeed(ctx, cfg.ID, feed.URL, feed.Name); err != nil { 253 + return fmt.Errorf("failed to create feed: %w", err) 254 + } 255 + } 256 + 257 + w.handler.logger.Debug("created new config via SFTP", "filename", w.filename) 181 258 } 182 259 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) 260 + w.handler.logger.Info("config uploaded via SFTP", "user_id", w.handler.user.ID, "filename", w.filename, "feeds", len(parsed.Feeds)) 261 + return nil 262 + } 263 + 264 + // preseedSeenItems fetches the feed and marks all current items as seen, 265 + // so that adding a new feed doesn't trigger emails for old posts. 266 + func (w *configWriter) preseedSeenItems(ctx context.Context, feed *store.Feed) error { 267 + result := scheduler.FetchFeed(ctx, feed) 268 + if result.Error != nil { 269 + return result.Error 186 270 } 187 271 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) 272 + for _, item := range result.Items { 273 + if err := w.handler.store.MarkItemSeen(ctx, feed.ID, item.GUID, item.Title, item.Link); err != nil { 274 + return err 191 275 } 192 276 } 193 277 194 - w.handler.logger.Info("config uploaded via SFTP", "user_id", w.handler.user.ID, "filename", w.filename, "feeds", len(parsed.Feeds)) 278 + w.handler.logger.Debug("preseeded seen items for new feed", "feed_url", feed.URL, "count", len(result.Items)) 195 279 return nil 196 280 } 197 281
+38 -3
store/configs.go
··· 49 49 InlineContent: inline, 50 50 RawText: rawText, 51 51 NextRun: sql.NullTime{Time: nextRun, Valid: true}, 52 - CreatedAt: time.Now(), 52 + CreatedAt: time.Now().UTC(), 53 53 }, nil 54 54 } 55 55 ··· 78 78 InlineContent: inline, 79 79 RawText: rawText, 80 80 NextRun: sql.NullTime{Time: nextRun, Valid: true}, 81 - CreatedAt: time.Now(), 81 + CreatedAt: time.Now().UTC(), 82 82 }, nil 83 83 } 84 84 85 + func (db *DB) UpdateConfigTx(ctx context.Context, tx *sql.Tx, configID int64, email, cronExpr string, digest, inline bool, rawText string, nextRun time.Time) error { 86 + _, err := tx.ExecContext(ctx, 87 + `UPDATE configs SET email = ?, cron_expr = ?, digest = ?, inline_content = ?, raw_text = ?, next_run = ? WHERE id = ?`, 88 + email, cronExpr, digest, inline, rawText, nextRun, configID, 89 + ) 90 + if err != nil { 91 + return fmt.Errorf("update config: %w", err) 92 + } 93 + return nil 94 + } 95 + 85 96 func (db *DB) DeleteConfigTx(ctx context.Context, tx *sql.Tx, userID int64, filename string) error { 86 97 result, err := tx.ExecContext(ctx, 87 98 `DELETE FROM configs WHERE user_id = ? AND filename = ?`, ··· 110 121 return &cfg, nil 111 122 } 112 123 124 + func (db *DB) GetConfigTx(ctx context.Context, tx *sql.Tx, userID int64, filename string) (*Config, error) { 125 + var cfg Config 126 + err := tx.QueryRowContext(ctx, 127 + `SELECT id, user_id, filename, email, cron_expr, digest, inline_content, raw_text, last_run, next_run, created_at, last_active_at 128 + FROM configs WHERE user_id = ? AND filename = ?`, 129 + userID, filename, 130 + ).Scan(&cfg.ID, &cfg.UserID, &cfg.Filename, &cfg.Email, &cfg.CronExpr, &cfg.Digest, &cfg.InlineContent, &cfg.RawText, &cfg.LastRun, &cfg.NextRun, &cfg.CreatedAt, &cfg.LastActiveAt) 131 + if err != nil { 132 + return nil, err 133 + } 134 + return &cfg, nil 135 + } 136 + 113 137 func (db *DB) GetConfigByID(ctx context.Context, id int64) (*Config, error) { 114 138 var cfg Config 115 139 err := db.QueryRowContext(ctx, ··· 143 167 configs = append(configs, &cfg) 144 168 } 145 169 return configs, rows.Err() 170 + } 171 + 172 + func (db *DB) UpdateConfig(ctx context.Context, configID int64, email, cronExpr string, digest, inline bool, rawText string, nextRun time.Time) error { 173 + _, err := db.ExecContext(ctx, 174 + `UPDATE configs SET email = ?, cron_expr = ?, digest = ?, inline_content = ?, raw_text = ?, next_run = ? WHERE id = ?`, 175 + email, cronExpr, digest, inline, rawText, nextRun, configID, 176 + ) 177 + if err != nil { 178 + return fmt.Errorf("update config: %w", err) 179 + } 180 + return nil 146 181 } 147 182 148 183 func (db *DB) DeleteConfig(ctx context.Context, userID int64, filename string) error { ··· 219 254 return err 220 255 } 221 256 222 - nextRun, err := gronx.NextTick(cfg.CronExpr, false) 257 + nextRun, err := gronx.NextTickAfter(cfg.CronExpr, time.Now().UTC(), true) 223 258 if err != nil { 224 259 return fmt.Errorf("calculate next run: %w", err) 225 260 }
+76
store/feeds.go
··· 71 71 }, nil 72 72 } 73 73 74 + func (db *DB) UpdateFeedTx(ctx context.Context, tx *sql.Tx, feedID int64, name string) error { 75 + var nameVal sql.NullString 76 + if name != "" { 77 + nameVal = sql.NullString{String: name, Valid: true} 78 + } 79 + 80 + _, err := tx.ExecContext(ctx, 81 + `UPDATE feeds SET name = ? WHERE id = ?`, 82 + nameVal, feedID, 83 + ) 84 + if err != nil { 85 + return fmt.Errorf("update feed: %w", err) 86 + } 87 + return nil 88 + } 89 + 90 + func (db *DB) DeleteFeedTx(ctx context.Context, tx *sql.Tx, feedID int64) error { 91 + _, err := tx.ExecContext(ctx, 92 + `DELETE FROM feeds WHERE id = ?`, 93 + feedID, 94 + ) 95 + if err != nil { 96 + return fmt.Errorf("delete feed: %w", err) 97 + } 98 + return nil 99 + } 100 + 74 101 func (db *DB) GetFeedsByConfig(ctx context.Context, configID int64) ([]*Feed, error) { 75 102 rows, err := db.QueryContext(ctx, 76 103 `SELECT id, config_id, url, name, last_fetched, etag, last_modified ··· 93 120 return feeds, rows.Err() 94 121 } 95 122 123 + func (db *DB) GetFeedsByConfigTx(ctx context.Context, tx *sql.Tx, configID int64) ([]*Feed, error) { 124 + rows, err := tx.QueryContext(ctx, 125 + `SELECT id, config_id, url, name, last_fetched, etag, last_modified 126 + FROM feeds WHERE config_id = ? ORDER BY id`, 127 + configID, 128 + ) 129 + if err != nil { 130 + return nil, fmt.Errorf("query feeds: %w", err) 131 + } 132 + defer func() { _ = rows.Close() }() 133 + 134 + var feeds []*Feed 135 + for rows.Next() { 136 + var f Feed 137 + if err := rows.Scan(&f.ID, &f.ConfigID, &f.URL, &f.Name, &f.LastFetched, &f.ETag, &f.LastModified); err != nil { 138 + return nil, fmt.Errorf("scan feed: %w", err) 139 + } 140 + feeds = append(feeds, &f) 141 + } 142 + return feeds, rows.Err() 143 + } 144 + 96 145 // GetFeedsByConfigs returns a map of configID to feeds for multiple configs in a single query 97 146 func (db *DB) GetFeedsByConfigs(ctx context.Context, configIDs []int64) (map[int64][]*Feed, error) { 98 147 if len(configIDs) == 0 { ··· 131 180 } 132 181 133 182 return feedMap, rows.Err() 183 + } 184 + 185 + func (db *DB) UpdateFeed(ctx context.Context, feedID int64, name string) error { 186 + var nameVal sql.NullString 187 + if name != "" { 188 + nameVal = sql.NullString{String: name, Valid: true} 189 + } 190 + 191 + _, err := db.ExecContext(ctx, 192 + `UPDATE feeds SET name = ? WHERE id = ?`, 193 + nameVal, feedID, 194 + ) 195 + if err != nil { 196 + return fmt.Errorf("update feed: %w", err) 197 + } 198 + return nil 199 + } 200 + 201 + func (db *DB) DeleteFeed(ctx context.Context, feedID int64) error { 202 + _, err := db.ExecContext(ctx, 203 + `DELETE FROM feeds WHERE id = ?`, 204 + feedID, 205 + ) 206 + if err != nil { 207 + return fmt.Errorf("delete feed: %w", err) 208 + } 209 + return nil 134 210 } 135 211 136 212 func (db *DB) UpdateFeedFetched(ctx context.Context, feedID int64, etag, lastModified string) error {
+1 -1
store/users.go
··· 42 42 ID: id, 43 43 PubkeyFP: pubkeyFP, 44 44 Pubkey: pubkey, 45 - CreatedAt: time.Now(), 45 + CreatedAt: time.Now().UTC(), 46 46 }, nil 47 47 } 48 48
+10
web/handlers.go
··· 60 60 _, _ = w.Write(css) 61 61 } 62 62 63 + func (s *Server) handleFaviconSVG(w http.ResponseWriter, r *http.Request) { 64 + svg, err := publicFS.ReadFile("public/favicon.svg") 65 + if err != nil { 66 + http.Error(w, "Not Found", http.StatusNotFound) 67 + return 68 + } 69 + w.Header().Set("Content-Type", "image/svg+xml") 70 + _, _ = w.Write(svg) 71 + } 72 + 63 73 type userPageData struct { 64 74 Fingerprint string 65 75 ShortFingerprint string
+100
web/public/favicon.svg
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <!-- Generator: Adobe Illustrator 25.2.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> 3 + <svg version="1.1" id="Layer_2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" 4 + viewBox="0 0 128 128" style="enable-background:new 0 0 128 128;" xml:space="preserve"> 5 + <path style="fill:#FBC219;" d="M7.2,20.67l6.85-0.61l12.94,100.19c0,0-0.45,2.79-3.05,3.05c-3.05,0.3-3.81-2.59-3.81-2.59L7.2,20.67 6 + z"/> 7 + <path style="fill:#E07F16;" d="M2.67,17.47c0.63,5.16,5.25,6.72,8.34,6.51c3.43-0.23,7.88-2.88,7.31-8.45 8 + c-0.67-6.54-6.4-7.77-9.6-6.97C5.07,9.47,2.1,12.79,2.67,17.47z"/> 9 + <path style="fill:#FFC020;" d="M11.35,10.84C8.8,9.02,5.64,11.3,4.73,13.59S4.5,17.7,5.75,18.73s4,0.69,5.6-1.26 10 + C12.85,15.65,13.75,12.56,11.35,10.84z"/> 11 + <path style="fill:#016BA2;" d="M30.43,31.98c0,0,4.11-6.63,12.22-12s17.02-9.25,32.79-9.03c10.74,0.16,17.02,0.96,23.42,1.03 12 + c10.62,0.11,17.2-3.89,18.85-1.26c2.28,3.66-6.34,14-6.17,17.02c0.23,4,11.54,2.74,11.65,6.97c0.08,2.86-6.28,10.05-15.99,12.45 13 + S85.5,49,77.84,50.14c-7.65,1.14-21.14,5.5-25.53,6.9C45.9,59.09,30.43,31.98,30.43,31.98z"/> 14 + <path style="fill:#DDE2E1;" d="M26,37.96l5.03-6.95c0,0,1.36,1,3.44,1.63c2.08,0.63,11.7,2.68,16.3,9.42 15 + C55.85,49.49,52.82,57,52.82,57s-4.15,1.97-11.5,3.04c-4.76,0.69-7.79-0.17-7.79-0.17s-5.85-10.19-5.94-10.47S26,37.96,26,37.96z"/> 16 + <path style="fill:#016BA2;" d="M42.56,39.08c-1.01-0.41-2.66,0.64-2.79,3.51c-0.24,5.17,5.35,6.42,5.35,6.42s0.66,2.8,2.56,3.98 17 + c3.45,2.14,4.7,0.12,4.46-0.71c-0.24-0.83-1.43-0.77-2.5-2.2c-0.74-0.99-1.13-3.09-1.43-3.51s-2.5-0.18-4.22-3.09 18 + C42.77,41.42,43.15,39.32,42.56,39.08z"/> 19 + <path style="fill:#016BA2;" d="M27.22,37c-2.5,0.71-3.15,6.64-1.01,13.38c1.66,5.23,5.77,10.58,9.39,9.39 20 + c3.15-1.03,4.04-6.66,1.19-13.85C33.94,38.73,30.29,36.12,27.22,37z"/> 21 + <path style="fill:#DDE2E1;" d="M28.65,39.86c-1.04,0.26-0.59,6.78,0.71,9.87s3.92,7.37,5.41,6.72c0.76-0.34,0.65-5.41-0.71-9.33 22 + C32.69,43.19,29.6,39.62,28.65,39.86z"/> 23 + <path style="fill:#FFFFFF;" d="M53.64,25.39c-3.66,2.11-4.29,7.27-2.18,11.06c1.81,3.26,5.36,4.85,8.86,3.5 24 + c3.37-1.3,4.79-6.38,2.89-10.63C61.4,25.28,56.77,23.59,53.64,25.39z"/> 25 + <path style="fill:#2D2D2E;" d="M55.16,28.62c-2.21,1.31-2.27,4.03-1.14,6.03c1.24,2.19,3.99,2.19,5.46,1.28 26 + c1.73-1.06,2.12-3.31,1.24-5.32C59.67,28.24,57.01,27.53,55.16,28.62z"/> 27 + <path style="fill:#F26D31;" d="M37.9,75.09c0,0,4.35-6.95,14.41-10.58c15.3-5.52,29.65-4.43,40.23-4.67 28 + c18.46-0.41,21.88-6.64,24.68-4.15c2.36,2.09-4.46,11.92-3.94,15.76c0.29,2.15,1.24,4.04,4.15,4.87s6.4,1.25,6.74,2.8 29 + c0.41,1.87-5.08,7.88-15.55,11.92c-8.61,3.33-18.66,3.63-27.27,4.98c-7.28,1.14-15.1,3.63-19.39,4.98 30 + c-5.6,1.76-10.26,2.07-10.26,2.07s-9.54-24.68-9.85-24.99C41.53,77.79,37.9,75.09,37.9,75.09z"/> 31 + <path style="fill:#DDE2E1;" d="M38.1,74.51c0,0,6.12-0.37,11.59,3.55c5.08,3.64,10.91,10.83,10.66,17.51 32 + c-0.14,3.81-2.4,6.52-2.4,6.52s-3.67,1.52-9.45,1.18c-7.18-0.42-9.22-3.05-9.22-3.05l-5.75-15.06c0,0,0.51-4.99,1.78-6.94 33 + C36.58,76.29,38.1,74.51,38.1,74.51z"/> 34 + <path style="fill:#F26D31;" d="M32.98,91.27c1.35,5.56,5.57,10.78,8.95,9.99c3.12-0.73,4.21-5.49,2.39-12.7 35 + c-1.77-7.03-5-11.29-9.02-10.33C31.53,79.15,31.78,86.33,32.98,91.27z"/> 36 + <path style="fill:#DDE2E1;" d="M36.16,81.49c-1.41,0.42-1.1,4.77-0.1,8.9c0.73,3.02,2.55,7.86,4.22,7.44 37 + c1.67-0.42,1.09-5.67,0.31-8.69C39.8,86.12,37.89,80.97,36.16,81.49z"/> 38 + <path style="fill:#F26D31;" d="M48.7,81.95c-2.13,0-1.62,4.38-0.26,6.56c1.82,2.91,4.06,3.54,4.06,3.54s0.49,2.79,2.19,4.68 39 + c0.88,0.99,2.21,1.71,2.76,1.35c0.6-0.39-0.07-2.12-0.57-4.11c-0.68-2.65-0.78-4.27-0.78-4.27s-2.55-1.51-3.85-3.33 40 + S50.79,81.95,48.7,81.95z"/> 41 + <path style="fill:#F8FFFD;" d="M59.32,72.22c-3.5,2.13-3.64,7.13-0.94,11.09c2.71,3.96,7.08,3.8,9.42,2.45 42 + c2.34-1.35,3.85-6.09,1.2-10.67C66.6,70.95,61.97,70.61,59.32,72.22z"/> 43 + <path style="fill:#2D2D2E;" d="M61.61,74.77c-1.87,0.83-2.29,4.32-0.68,6.45c1.61,2.13,3.8,2.18,5,1.25 44 + c1.87-1.46,1.57-4.11,0.68-5.72C65.56,74.88,63.31,74.02,61.61,74.77z"/> 45 + <g> 46 + <path style="fill:#B6CED5;" d="M13.96,56.39c-2.52,0-3.81-0.94-3.97-1.05c-0.66-0.51-0.78-1.45-0.27-2.1 47 + c0.51-0.66,1.45-0.77,2.1-0.27l0,0c0,0,0.85,0.55,2.73,0.4c2.18-0.17,3.55-1.04,3.56-1.05l0.27-0.14l12.74-5.01 48 + c0.77-0.3,1.64,0.08,1.95,0.85c0.3,0.77-0.08,1.64-0.85,1.95l-12.61,4.96c-0.53,0.32-2.26,1.24-4.83,1.44 49 + C14.5,56.38,14.22,56.39,13.96,56.39z"/> 50 + </g> 51 + <g> 52 + <path style="fill:#B6CED5;" d="M20.29,98.16c-2.62,0-4.37-0.89-4.56-0.99c-0.73-0.39-1.01-1.29-0.63-2.03 53 + c0.39-0.73,1.29-1.01,2.03-0.63c0,0,1.28,0.64,3.16,0.64c1.62,0,2.87-1.4,2.88-1.42l0.27-0.31l0.4-0.14l14-4.63 54 + c0.79-0.26,1.64,0.17,1.9,0.95c0.26,0.79-0.17,1.63-0.95,1.89l-13.65,4.51C24.44,96.71,22.67,98.16,20.29,98.16z"/> 55 + </g> 56 + <path style="fill:#39BDE3;" d="M73.1,35.27c-1.38,0.83,0.52,2.61-0.06,8.01c-0.5,4.7-4.17,8.84-4.17,8.84s2.4-0.67,3.72-0.94 57 + c1.33-0.28,3.48-0.72,3.48-0.72s1.22-2.54,1.27-6.91C77.41,39.35,75.23,33.99,73.1,35.27z"/> 58 + <path style="fill:#39BDE3;" d="M75.37,21.18c-0.17,0.31,0.44,1.88,0.83,3.43c0.39,1.55,0.64,3.68,1.11,3.87 59 + c0.66,0.28,3.91-1.57,2.93-5.08C79.18,19.63,75.7,20.57,75.37,21.18z"/> 60 + <path style="fill:#39BDE3;" d="M83.77,25.49c-0.77,0.88,0.33,2.04,0.72,3.59c0.39,1.55,0.48,3.18,0.94,3.37 61 + c0.66,0.28,3.87-1.33,3.26-4.48C87.95,24.15,84.22,24.97,83.77,25.49z"/> 62 + <path style="fill:#39BDE3;" d="M81.32,15.9c-0.48,0.97,0.52,2.02,0.9,4.01c0.3,1.57,0.53,3.56,0.99,3.76 63 + c0.66,0.28,4.19-1.95,3.2-5.47C85.37,14.44,81.62,15.28,81.32,15.9z"/> 64 + <path style="fill:#39BDE3;" d="M90.93,20.21c-0.17,0.31,0.44,1.88,0.83,3.43c0.39,1.55,0.64,3.68,1.11,3.87 65 + c0.66,0.28,3.91-1.57,2.93-5.08C94.74,18.67,91.26,19.61,90.93,20.21z"/> 66 + <path style="fill:#39BDE3;" d="M96.18,15.57c-0.15,0.32,0.52,2.51,0.9,4.06c0.39,1.55,0.75,3.29,1.22,3.48 67 + c0.66,0.28,3.87-1.71,3.04-5.53C100.51,13.78,96.64,14.61,96.18,15.57z"/> 68 + <path style="fill:#39BDE3;" d="M99.05,24.6c-0.19,0.33,0.64,2.42,1.07,4.04c0.43,1.63,0.59,3.38,1.1,3.62 69 + c1,0.47,4.52-1.69,3.28-5.34C103.38,23.61,99.42,23.96,99.05,24.6z"/> 70 + <path style="fill:#39BDE3;" d="M93,29.58c-0.17,0.34,0.66,1.84,1.16,3.7c0.45,1.68,0.67,4.01,1.13,4.22 71 + c0.66,0.31,4.08-1.36,3.13-5.21C97.39,28.17,93.33,28.92,93,29.58z"/> 72 + <path style="fill:#39BDE3;" d="M77.97,30.63c-0.17,0.31,0.39,2.21,0.77,3.76c0.39,1.55,0.62,3.32,1.09,3.51 73 + c0.66,0.28,3.82-1.54,2.93-5.08C82,29.8,78.3,30.02,77.97,30.63z"/> 74 + <path style="fill:#39BDE3;" d="M86.18,35.46c-0.48,0.86,0.41,1.91,0.79,3.45c0.39,1.55,0.69,3.62,1.14,3.84 75 + c0.96,0.47,3.91-1.66,3.28-4.83C90.64,34.1,86.52,34.86,86.18,35.46z"/> 76 + <path style="fill:#39BDE3;" d="M101.24,35.08c-0.17,0.3,0.42,2.19,0.93,3.67s1.08,3.17,1.56,3.32c0.68,0.22,4.11-2.36,2.92-5.75 77 + C105.59,33.34,101.67,34.33,101.24,35.08z"/> 78 + <path style="fill:#FEC516;" d="M80.7,77.14c-0.16,0.32,0.5,1.88,0.95,3.51c0.42,1.55,0.91,3.36,1.38,3.54 79 + c0.67,0.26,3.87-1.67,2.81-5.18C84.7,75.26,81.02,76.52,80.7,77.14z"/> 80 + <path style="fill:#FEC516;" d="M86.27,71.68c-0.17,0.31,0.79,2.03,1.31,3.45c0.55,1.5,0.64,3.68,1.11,3.87 81 + c0.66,0.28,3.43-1.35,2.59-4.9C90.43,70.49,86.6,71.07,86.27,71.68z"/> 82 + <path style="fill:#FEC516;" d="M78.11,67.53c-0.18,0.34,0.54,2,1,3.64c0.47,1.64,0.72,3.07,1.15,3.42c1.13,0.9,4.38-0.71,3.36-4.71 83 + C82.59,65.82,78.46,66.87,78.11,67.53z"/> 84 + <path style="fill:#FEC516;" d="M84.16,62.1c-0.66,0.47,0.32,1.9,0.6,3.47c0.28,1.57,0.3,3.4,0.77,3.58c1.35,0.51,4.27-0.3,3.55-4.45 85 + C88.4,60.82,84.73,61.7,84.16,62.1z"/> 86 + <path style="fill:#FEC516;" d="M92.23,66.89c-0.23,1.07,0.97,1.48,1.66,3.57c0.6,1.83,0.47,3.09,0.95,3.24 87 + c1.46,0.45,4.65-2.1,3.47-5.12C96.91,64.97,92.38,66.17,92.23,66.89z"/> 88 + <path style="fill:#FEC516;" d="M98.66,62.3c-0.17,0.31,0.33,1.38,1.02,3.26c0.55,1.5,0.64,3.68,1.11,3.87 89 + c0.66,0.28,3.8-1.55,2.82-5.07C102.55,60.6,98.99,61.69,98.66,62.3z"/> 90 + <path style="fill:#FEC516;" d="M101.52,71.6c-0.17,0.31,0.51,1.78,0.9,3.06c0.46,1.53,0.41,3.18,1.12,3.66 91 + c1.07,0.73,4.17-1.48,3.04-4.96C105.54,70.13,101.86,70.99,101.52,71.6z"/> 92 + <path style="fill:#FEC516;" d="M95.6,76.05c-0.17,0.31,0.38,1.91,0.64,3.5c0.28,1.77,0.27,3.34,0.72,3.55 93 + c1.07,0.51,4.64-0.82,3.77-4.62C99.87,74.72,95.93,75.45,95.6,76.05z"/> 94 + <path style="fill:#FEC516;" d="M103.86,80.66c-0.17,0.31,0.44,1.88,0.83,3.43c0.39,1.55,0.7,3,1.1,3.3c0.9,0.68,4.06-0.9,3.22-4.56 95 + C108.13,79.02,104.19,80.05,103.86,80.66z"/> 96 + <path style="fill:#FEC516;" d="M88.88,81.06c-0.48,0.65,0.6,2.13,0.79,3.72c0.23,1.86,0.28,3.27,0.73,3.49 97 + c1.29,0.62,4.81-1.41,3.61-4.95C92.79,79.68,89.29,80.5,88.88,81.06z"/> 98 + <path style="fill:#FEC516;" d="M72.8,97.85c0,0,3.47-3.86,3.39-6.75c-0.11-4.33-1.35-6.47,0.34-7.09c1.4-0.51,2.93,1.75,3.49,4.17 99 + c1.35,5.82,0.06,8.1,0.06,8.1s-2.13,0.46-3.78,0.79C74.89,97.34,72.8,97.85,72.8,97.85z"/> 100 + </svg>
+4
web/server.go
··· 18 18 //go:embed templates/* 19 19 var templatesFS embed.FS 20 20 21 + //go:embed public/* 22 + var publicFS embed.FS 23 + 21 24 const ( 22 25 // HTTP rate limiting 23 26 httpRequestsPerSecond = 10 ··· 56 59 57 60 mux.HandleFunc("/", s.routeHandler) 58 61 mux.HandleFunc("/style.css", s.handleStyleCSS) 62 + mux.HandleFunc("/favicon.svg", s.handleFaviconSVG) 59 63 mux.HandleFunc("/health", s.handleHealth) 60 64 mux.HandleFunc("/metrics", s.handleMetrics) 61 65
+1 -1
web/templates/404.html
··· 4 4 <meta charset="utf-8"> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1"> 6 6 <title>HERALD - {{if .Title}}{{.Title}}{{else}}404{{end}}</title> 7 - <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>๐ŸŽ</text></svg>"> 7 + <link rel="icon" href="/favicon.svg" type="image/svg+xml"> 8 8 <link rel="stylesheet" href="/style.css"> 9 9 </head> 10 10 <body>
+37 -2
web/templates/index.html
··· 3 3 <head> 4 4 <meta charset="utf-8"> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1"> 6 - <title>HERALD</title> 7 - <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>๐ŸŽ</text></svg>"> 6 + <title>HERALD - RSS-to-Email via SSH</title> 7 + <meta name="description" content="Herald delivers RSS feeds to your inbox via SSH. Upload feed configs, manage subscriptions, and receive updates by email."> 8 + 9 + <!-- Open Graph --> 10 + <meta property="og:type" content="website"> 11 + <meta property="og:title" content="HERALD - RSS-to-Email via SSH"> 12 + <meta property="og:description" content="Herald delivers RSS feeds to your inbox via SSH. Upload feed configs, manage subscriptions, and receive updates by email."> 13 + <meta property="og:url" content="{{.Origin}}"> 14 + <meta property="og:image" content="https://l4.dunkirk.sh/i/AQ0z6BnQAwWu.webp"> 15 + <meta property="og:site_name" content="HERALD"> 16 + 17 + <!-- Twitter Card --> 18 + <meta name="twitter:card" content="summary_large_image"> 19 + <meta name="twitter:title" content="HERALD - RSS-to-Email via SSH"> 20 + <meta name="twitter:description" content="Herald delivers RSS feeds to your inbox via SSH. Upload feed configs, manage subscriptions, and receive updates by email."> 21 + <meta name="twitter:image" content="https://l4.dunkirk.sh/i/AQ0z6BnQAwWu.webp"> 22 + 23 + <link rel="icon" href="/favicon.svg" type="image/svg+xml"> 8 24 <link rel="stylesheet" href="/style.css"> 9 25 </head> 10 26 <body> ··· 13 29 <h2>NAME</h2> 14 30 <pre> 15 31 herald - RSS-to-Email via SSH 32 + </pre> 33 + 34 + <h2>FILE FORMAT</h2> 35 + <pre> 36 + Feed configs are plain text files with directives: 37 + 38 + =: email you@example.com Recipient email address 39 + =: cron 0 8 * * * Standard cron (5 fields) 40 + =: digest true Combine items (default: true) 41 + =: inline false Include content (default: false) 42 + =&gt; https://example.com/feed RSS/Atom feed URL 43 + =&gt; https://blog.com/rss "Blog" Feed with display name 44 + 45 + Example feeds.txt: 46 + =: email you@example.com 47 + =: cron 0 8 * * * 48 + =: digest true 49 + =&gt; https://dunkirk.sh/atom.xml 50 + =&gt; https://news.ycombinator.com/rss 16 51 </pre> 17 52 18 53 <h2>COMMANDS</h2>
+1 -1
web/templates/keepalive.html
··· 4 4 <meta charset="utf-8"> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1"> 6 6 <title>HERALD - Digest Active</title> 7 - <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>๐ŸŽ</text></svg>"> 7 + <link rel="icon" href="/favicon.svg" type="image/svg+xml"> 8 8 <link rel="stylesheet" href="/style.css"> 9 9 </head> 10 10 <body>
+1 -1
web/templates/unsubscribe.html
··· 4 4 <meta charset="utf-8"> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1"> 6 6 <title>HERALD - Unsubscribe</title> 7 - <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>๐ŸŽ</text></svg>"> 7 + <link rel="icon" href="/favicon.svg" type="image/svg+xml"> 8 8 <link rel="stylesheet" href="/style.css"> 9 9 </head> 10 10 <body>
+17 -1
web/templates/user.html
··· 4 4 <meta charset="utf-8"> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1"> 6 6 <title>HERALD - {{.ShortFingerprint}}</title> 7 - <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>๐ŸŽ</text></svg>"> 7 + <meta name="description" content="Herald dashboard for user {{.ShortFingerprint}}. View feed configs and delivery status."> 8 + 9 + <!-- Open Graph --> 10 + <meta property="og:type" content="website"> 11 + <meta property="og:title" content="HERALD - {{.ShortFingerprint}}"> 12 + <meta property="og:description" content="Herald dashboard for user {{.ShortFingerprint}}. View feed configs and delivery status."> 13 + <meta property="og:url" content="{{.Origin}}/{{.Fingerprint}}"> 14 + <meta property="og:image" content="https://l4.dunkirk.sh/i/AQ0z6BnQAwWu.webp"> 15 + <meta property="og:site_name" content="HERALD"> 16 + 17 + <!-- Twitter Card --> 18 + <meta name="twitter:card" content="summary_large_image"> 19 + <meta name="twitter:title" content="HERALD - {{.ShortFingerprint}}"> 20 + <meta name="twitter:description" content="Herald dashboard for user {{.ShortFingerprint}}. View feed configs and delivery status."> 21 + <meta name="twitter:image" content="https://l4.dunkirk.sh/i/AQ0z6BnQAwWu.webp"> 22 + 23 + <link rel="icon" href="/favicon.svg" type="image/svg+xml"> 8 24 <link rel="stylesheet" href="/style.css"> 9 25 </head> 10 26 <body>