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

feat: add unsubscribe and update user page

dunkirk.sh 25d2f4aa a0d0a9b5

verified
+26 -4
email/send.go
··· 19 19 } 20 20 21 21 type Mailer struct { 22 - cfg SMTPConfig 22 + cfg SMTPConfig 23 + unsubBaseURL string 23 24 } 24 25 25 - func NewMailer(cfg SMTPConfig) *Mailer { 26 - return &Mailer{cfg: cfg} 26 + func NewMailer(cfg SMTPConfig, unsubBaseURL string) *Mailer { 27 + return &Mailer{ 28 + cfg: cfg, 29 + unsubBaseURL: unsubBaseURL, 30 + } 27 31 } 28 32 29 - func (m *Mailer) Send(to, subject, htmlBody, textBody string) error { 33 + func (m *Mailer) Send(to, subject, htmlBody, textBody, unsubToken string) error { 30 34 addr := net.JoinHostPort(m.cfg.Host, fmt.Sprintf("%d", m.cfg.Port)) 31 35 32 36 boundary := "==herald-boundary-a1b2c3d4e5f6==" 33 37 38 + // Add unsubscribe footer if token provided 39 + if unsubToken != "" { 40 + unsubURL := m.unsubBaseURL + "/unsubscribe/" + unsubToken 41 + 42 + htmlFooter := fmt.Sprintf(`<hr><p style="font-size: 12px; color: #666;"><a href="%s">Unsubscribe</a></p>`, unsubURL) 43 + htmlBody = htmlBody + htmlFooter 44 + 45 + textFooter := fmt.Sprintf("\n\n---\nUnsubscribe: %s\n", unsubURL) 46 + textBody = textBody + textFooter 47 + } 48 + 34 49 headers := make(map[string]string) 35 50 headers["From"] = m.cfg.From 36 51 headers["To"] = to 37 52 headers["Subject"] = mime.QEncoding.Encode("utf-8", subject) 38 53 headers["MIME-Version"] = "1.0" 39 54 headers["Content-Type"] = fmt.Sprintf("multipart/alternative; boundary=%q", boundary) 55 + 56 + // Add RFC 8058 unsubscribe headers 57 + if unsubToken != "" { 58 + unsubURL := m.unsubBaseURL + "/unsubscribe/" + unsubToken 59 + headers["List-Unsubscribe"] = fmt.Sprintf("<%s>", unsubURL) 60 + headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click" 61 + } 40 62 41 63 var msg strings.Builder 42 64 for k, v := range headers {
+1 -1
main.go
··· 149 149 User: cfg.SMTP.User, 150 150 Pass: cfg.SMTP.Pass, 151 151 From: cfg.SMTP.From, 152 - }) 152 + }, cfg.Origin) 153 153 154 154 sched := scheduler.NewScheduler(db, mailer, logger, 60*time.Second) 155 155
+14 -2
scheduler/scheduler.go
··· 152 152 return 0, fmt.Errorf("render digest: %w", err) 153 153 } 154 154 155 + unsubToken, err := s.store.GetOrCreateUnsubscribeToken(ctx, cfg.ID) 156 + if err != nil { 157 + s.logger.Warn("failed to create unsubscribe token", "err", err) 158 + unsubToken = "" 159 + } 160 + 155 161 subject := "feed digest" 156 - if err := s.mailer.Send(cfg.Email, subject, htmlBody, textBody); err != nil { 162 + if err := s.mailer.Send(cfg.Email, subject, htmlBody, textBody, unsubToken); err != nil { 157 163 return 0, fmt.Errorf("send email: %w", err) 158 164 } 159 165 ··· 274 280 return fmt.Errorf("render digest: %w", err) 275 281 } 276 282 283 + unsubToken, err := s.store.GetOrCreateUnsubscribeToken(ctx, cfg.ID) 284 + if err != nil { 285 + s.logger.Warn("failed to create unsubscribe token", "err", err) 286 + unsubToken = "" 287 + } 288 + 277 289 subject := "feed digest" 278 - if err := s.mailer.Send(cfg.Email, subject, htmlBody, textBody); err != nil { 290 + if err := s.mailer.Send(cfg.Email, subject, htmlBody, textBody, unsubToken); err != nil { 279 291 return fmt.Errorf("send email: %w", err) 280 292 } 281 293
+33 -1
ssh/commands.go
··· 51 51 return 52 52 } 53 53 handleRm(ctx, sess, user, st, cmd[1]) 54 + case "activate": 55 + if len(cmd) < 2 { 56 + fmt.Fprintln(sess, errorStyle.Render("Usage: activate <filename>")) 57 + return 58 + } 59 + handleActivate(ctx, sess, user, st, cmd[1]) 60 + case "deactivate": 61 + if len(cmd) < 2 { 62 + fmt.Fprintln(sess, errorStyle.Render("Usage: deactivate <filename>")) 63 + return 64 + } 65 + handleDeactivate(ctx, sess, user, st, cmd[1]) 54 66 case "run": 55 67 if len(cmd) < 2 { 56 68 fmt.Fprintln(sess, errorStyle.Render("Usage: run <filename>")) ··· 61 73 handleLogs(ctx, sess, user, st) 62 74 default: 63 75 fmt.Fprintf(sess, errorStyle.Render("Unknown command: %s\n"), cmd[0]) 64 - fmt.Fprintln(sess, "Available commands: ls, cat, rm, run, logs") 76 + fmt.Fprintln(sess, "Available commands: ls, cat, rm, activate, deactivate, run, logs") 65 77 } 66 78 } 67 79 ··· 118 130 } 119 131 120 132 fmt.Fprintln(sess, successStyle.Render("Deleted: "+filename)) 133 + } 134 + 135 + func handleActivate(ctx context.Context, sess ssh.Session, user *store.User, st *store.DB, filename string) { 136 + err := st.ActivateConfig(ctx, user.ID, filename) 137 + if err != nil { 138 + fmt.Fprintln(sess, errorStyle.Render("Error: "+err.Error())) 139 + return 140 + } 141 + 142 + fmt.Fprintln(sess, successStyle.Render("Activated: "+filename)) 143 + } 144 + 145 + func handleDeactivate(ctx context.Context, sess ssh.Session, user *store.User, st *store.DB, filename string) { 146 + err := st.DeactivateConfigByFilename(ctx, user.ID, filename) 147 + if err != nil { 148 + fmt.Fprintln(sess, errorStyle.Render("Error: "+err.Error())) 149 + return 150 + } 151 + 152 + fmt.Fprintln(sess, successStyle.Render("Deactivated: "+filename)) 121 153 } 122 154 123 155 func handleRun(ctx context.Context, sess ssh.Session, user *store.User, st *store.DB, sched *scheduler.Scheduler, filename string) {
+43
store/configs.go
··· 5 5 "database/sql" 6 6 "fmt" 7 7 "time" 8 + 9 + "github.com/adhocore/gronx" 8 10 ) 9 11 10 12 type Config struct { ··· 149 151 } 150 152 return configs, rows.Err() 151 153 } 154 + 155 + func (db *DB) DeactivateConfig(ctx context.Context, configID int64) error { 156 + _, err := db.ExecContext(ctx, 157 + `UPDATE configs SET next_run = NULL WHERE id = ?`, 158 + configID, 159 + ) 160 + if err != nil { 161 + return fmt.Errorf("deactivate config: %w", err) 162 + } 163 + return nil 164 + } 165 + 166 + func (db *DB) DeactivateConfigByFilename(ctx context.Context, userID int64, filename string) error { 167 + cfg, err := db.GetConfig(ctx, userID, filename) 168 + if err != nil { 169 + return err 170 + } 171 + return db.DeactivateConfig(ctx, cfg.ID) 172 + } 173 + 174 + func (db *DB) ActivateConfig(ctx context.Context, userID int64, filename string) error { 175 + cfg, err := db.GetConfig(ctx, userID, filename) 176 + if err != nil { 177 + return err 178 + } 179 + 180 + nextRun, err := gronx.NextTick(cfg.CronExpr, false) 181 + if err != nil { 182 + return fmt.Errorf("calculate next run: %w", err) 183 + } 184 + 185 + _, err = db.ExecContext(ctx, 186 + `UPDATE configs SET next_run = ? WHERE id = ?`, 187 + nextRun, 188 + cfg.ID, 189 + ) 190 + if err != nil { 191 + return fmt.Errorf("activate config: %w", err) 192 + } 193 + return nil 194 + }
+8
store/db.go
··· 82 82 created_at DATETIME DEFAULT CURRENT_TIMESTAMP 83 83 ); 84 84 85 + CREATE TABLE IF NOT EXISTS unsubscribe_tokens ( 86 + id INTEGER PRIMARY KEY, 87 + token TEXT UNIQUE NOT NULL, 88 + config_id INTEGER NOT NULL REFERENCES configs(id) ON DELETE CASCADE, 89 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP 90 + ); 91 + 85 92 CREATE INDEX IF NOT EXISTS idx_configs_user_id ON configs(user_id); 86 93 CREATE INDEX IF NOT EXISTS idx_configs_next_run ON configs(next_run); 87 94 CREATE INDEX IF NOT EXISTS idx_feeds_config_id ON feeds(config_id); 88 95 CREATE INDEX IF NOT EXISTS idx_seen_items_feed_id ON seen_items(feed_id); 89 96 CREATE INDEX IF NOT EXISTS idx_logs_config_id ON logs(config_id); 90 97 CREATE INDEX IF NOT EXISTS idx_logs_created_at ON logs(created_at); 98 + CREATE INDEX IF NOT EXISTS idx_unsubscribe_tokens_token ON unsubscribe_tokens(token); 91 99 ` 92 100 93 101 _, err := db.Exec(schema)
+69
store/unsubscribe.go
··· 1 + package store 2 + 3 + import ( 4 + "context" 5 + "crypto/rand" 6 + "database/sql" 7 + "encoding/base64" 8 + "fmt" 9 + ) 10 + 11 + func (db *DB) CreateUnsubscribeToken(ctx context.Context, configID int64) (string, error) { 12 + // Generate random token 13 + tokenBytes := make([]byte, 32) 14 + if _, err := rand.Read(tokenBytes); err != nil { 15 + return "", fmt.Errorf("generate token: %w", err) 16 + } 17 + token := base64.URLEncoding.EncodeToString(tokenBytes) 18 + 19 + _, err := db.ExecContext(ctx, 20 + `INSERT INTO unsubscribe_tokens (token, config_id) VALUES (?, ?)`, 21 + token, configID, 22 + ) 23 + if err != nil { 24 + return "", fmt.Errorf("insert token: %w", err) 25 + } 26 + 27 + return token, nil 28 + } 29 + 30 + func (db *DB) GetConfigByToken(ctx context.Context, token string) (*Config, error) { 31 + var configID int64 32 + err := db.QueryRowContext(ctx, 33 + `SELECT config_id FROM unsubscribe_tokens WHERE token = ?`, 34 + token, 35 + ).Scan(&configID) 36 + if err != nil { 37 + return nil, err 38 + } 39 + 40 + return db.GetConfigByID(ctx, configID) 41 + } 42 + 43 + func (db *DB) DeleteToken(ctx context.Context, token string) error { 44 + _, err := db.ExecContext(ctx, 45 + `DELETE FROM unsubscribe_tokens WHERE token = ?`, 46 + token, 47 + ) 48 + return err 49 + } 50 + 51 + func (db *DB) GetOrCreateUnsubscribeToken(ctx context.Context, configID int64) (string, error) { 52 + // Check if token already exists 53 + var token string 54 + err := db.QueryRowContext(ctx, 55 + `SELECT token FROM unsubscribe_tokens WHERE config_id = ? LIMIT 1`, 56 + configID, 57 + ).Scan(&token) 58 + 59 + if err == nil { 60 + return token, nil 61 + } 62 + 63 + if err != sql.ErrNoRows { 64 + return "", err 65 + } 66 + 67 + // Create new token 68 + return db.CreateUnsubscribeToken(ctx, configID) 69 + }
+17
store/users.go
··· 56 56 } 57 57 return &user, nil 58 58 } 59 + 60 + func (db *DB) GetUserByID(ctx context.Context, userID int64) (*User, error) { 61 + var user User 62 + err := db.QueryRowContext(ctx, 63 + `SELECT id, pubkey_fp, pubkey, created_at FROM users WHERE id = ?`, 64 + userID, 65 + ).Scan(&user.ID, &user.PubkeyFP, &user.Pubkey, &user.CreatedAt) 66 + if err != nil { 67 + return nil, err 68 + } 69 + return &user, nil 70 + } 71 + 72 + func (db *DB) DeleteUser(ctx context.Context, userID int64) error { 73 + _, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID) 74 + return err 75 + }
+238 -65
web/handlers.go
··· 5 5 "encoding/json" 6 6 "encoding/xml" 7 7 "errors" 8 + "fmt" 8 9 "net/http" 9 10 "sort" 11 + "strings" 10 12 "time" 11 13 ) 12 14 ··· 43 45 Fingerprint string 44 46 ShortFingerprint string 45 47 Configs []configInfo 48 + Status string 46 49 NextRun string 47 - FeedXMLURL string 48 - FeedJSONURL string 49 50 Origin string 50 51 } 51 52 52 53 type configInfo struct { 53 - Filename string 54 - FeedCount int 55 - URL string 54 + Filename string 55 + FeedCount int 56 + URL string 57 + FeedXMLURL string 58 + FeedJSONURL string 59 + IsActive bool 56 60 } 57 61 58 62 func (s *Server) handleUser(w http.ResponseWriter, r *http.Request, fingerprint string) { ··· 61 65 user, err := s.store.GetUserByFingerprint(ctx, fingerprint) 62 66 if err != nil { 63 67 if errors.Is(err, sql.ErrNoRows) { 64 - http.Error(w, "User Not Found", http.StatusNotFound) 68 + s.handle404(w, r) 65 69 return 66 70 } 67 71 s.logger.Error("get user", "err", err) ··· 78 82 79 83 var configInfos []configInfo 80 84 var earliestNextRun time.Time 85 + hasAnyActive := false 81 86 82 87 for _, cfg := range configs { 83 88 feeds, err := s.store.GetFeedsByConfig(ctx, cfg.ID) ··· 86 91 continue 87 92 } 88 93 94 + isActive := cfg.NextRun.Valid 95 + if isActive { 96 + hasAnyActive = true 97 + } 98 + 99 + // Generate feed URLs - trim .txt extension for cleaner URLs 100 + feedBaseName := strings.TrimSuffix(cfg.Filename, ".txt") 101 + 89 102 configInfos = append(configInfos, configInfo{ 90 - Filename: cfg.Filename, 91 - FeedCount: len(feeds), 92 - URL: "/" + fingerprint + "/" + cfg.Filename, 103 + Filename: cfg.Filename, 104 + FeedCount: len(feeds), 105 + URL: "/" + fingerprint + "/" + cfg.Filename, 106 + FeedXMLURL: "/" + fingerprint + "/" + feedBaseName + ".xml", 107 + FeedJSONURL: "/" + fingerprint + "/" + feedBaseName + ".json", 108 + IsActive: isActive, 93 109 }) 94 110 95 111 if cfg.NextRun.Valid { ··· 100 116 } 101 117 102 118 nextRunStr := "—" 103 - if !earliestNextRun.IsZero() { 104 - nextRunStr = earliestNextRun.Format("2006-01-02 15:04 MST") 119 + status := "INACTIVE" 120 + if hasAnyActive { 121 + if !earliestNextRun.IsZero() { 122 + nextRunStr = earliestNextRun.Format("2006-01-02 15:04 MST") 123 + } 124 + status = "ACTIVE" 105 125 } 106 126 107 127 shortFP := fingerprint ··· 113 133 Fingerprint: fingerprint, 114 134 ShortFingerprint: shortFP, 115 135 Configs: configInfos, 136 + Status: status, 116 137 NextRun: nextRunStr, 117 - FeedXMLURL: "/" + fingerprint + "/feed.xml", 118 - FeedJSONURL: "/" + fingerprint + "/feed.json", 119 138 Origin: s.origin, 120 139 } 121 140 ··· 145 164 Channel rssChannel `xml:"channel"` 146 165 } 147 166 148 - func (s *Server) handleFeedXML(w http.ResponseWriter, r *http.Request, fingerprint string) { 167 + func (s *Server) handleFeedXML(w http.ResponseWriter, r *http.Request, fingerprint, configFilename string) { 149 168 ctx := r.Context() 150 169 151 170 user, err := s.store.GetUserByFingerprint(ctx, fingerprint) 152 171 if err != nil { 153 172 if errors.Is(err, sql.ErrNoRows) { 154 - http.Error(w, "User Not Found", http.StatusNotFound) 173 + s.handle404(w, r) 155 174 return 156 175 } 157 176 s.logger.Error("get user", "err", err) ··· 159 178 return 160 179 } 161 180 162 - configs, err := s.store.ListConfigs(ctx, user.ID) 181 + cfg, err := s.store.GetConfig(ctx, user.ID, configFilename) 163 182 if err != nil { 164 - s.logger.Error("list configs", "err", err) 183 + if errors.Is(err, sql.ErrNoRows) { 184 + s.handle404(w, r) 185 + return 186 + } 187 + s.logger.Error("get config", "err", err) 165 188 http.Error(w, "Internal Server Error", http.StatusInternalServerError) 166 189 return 167 190 } 168 191 169 192 var items []rssItem 170 - for _, cfg := range configs { 171 - feeds, err := s.store.GetFeedsByConfig(ctx, cfg.ID) 193 + feeds, err := s.store.GetFeedsByConfig(ctx, cfg.ID) 194 + if err != nil { 195 + s.logger.Error("get feeds", "err", err) 196 + http.Error(w, "Internal Server Error", http.StatusInternalServerError) 197 + return 198 + } 199 + 200 + for _, feed := range feeds { 201 + seenItems, err := s.store.GetSeenItems(ctx, feed.ID, 50) 172 202 if err != nil { 173 203 continue 174 204 } 175 - for _, feed := range feeds { 176 - seenItems, err := s.store.GetSeenItems(ctx, feed.ID, 50) 177 - if err != nil { 178 - continue 205 + for _, item := range seenItems { 206 + rItem := rssItem{ 207 + GUID: item.GUID, 208 + PubDate: item.SeenAt.Format(time.RFC1123Z), 179 209 } 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) 210 + if item.Title.Valid { 211 + rItem.Title = item.Title.String 212 + } 213 + if item.Link.Valid { 214 + rItem.Link = item.Link.String 192 215 } 216 + items = append(items, rItem) 193 217 } 194 218 } 195 219 ··· 206 230 feed := rssFeed{ 207 231 Version: "2.0", 208 232 Channel: rssChannel{ 209 - Title: "Herald - " + fingerprint[:12], 210 - Link: s.origin + "/" + fingerprint, 211 - Description: "Aggregated feed for " + fingerprint[:12], 233 + Title: "Herald - " + configFilename, 234 + Link: s.origin + "/" + fingerprint + "/" + configFilename, 235 + Description: "Feed for " + configFilename, 212 236 Items: items, 213 237 }, 214 238 } ··· 235 259 DatePublished string `json:"date_published"` 236 260 } 237 261 238 - func (s *Server) handleFeedJSON(w http.ResponseWriter, r *http.Request, fingerprint string) { 262 + func (s *Server) handleFeedJSON(w http.ResponseWriter, r *http.Request, fingerprint, configFilename string) { 239 263 ctx := r.Context() 240 264 241 265 user, err := s.store.GetUserByFingerprint(ctx, fingerprint) 242 266 if err != nil { 243 267 if errors.Is(err, sql.ErrNoRows) { 244 - http.Error(w, "User Not Found", http.StatusNotFound) 268 + s.handle404(w, r) 245 269 return 246 270 } 247 271 s.logger.Error("get user", "err", err) ··· 249 273 return 250 274 } 251 275 252 - configs, err := s.store.ListConfigs(ctx, user.ID) 276 + cfg, err := s.store.GetConfig(ctx, user.ID, configFilename) 253 277 if err != nil { 254 - s.logger.Error("list configs", "err", err) 278 + if errors.Is(err, sql.ErrNoRows) { 279 + s.handle404(w, r) 280 + return 281 + } 282 + s.logger.Error("get config", "err", err) 255 283 http.Error(w, "Internal Server Error", http.StatusInternalServerError) 256 284 return 257 285 } 258 286 259 287 var items []jsonFeedItem 260 - for _, cfg := range configs { 261 - feeds, err := s.store.GetFeedsByConfig(ctx, cfg.ID) 288 + feeds, err := s.store.GetFeedsByConfig(ctx, cfg.ID) 289 + if err != nil { 290 + s.logger.Error("get feeds", "err", err) 291 + http.Error(w, "Internal Server Error", http.StatusInternalServerError) 292 + return 293 + } 294 + 295 + for _, feed := range feeds { 296 + seenItems, err := s.store.GetSeenItems(ctx, feed.ID, 50) 262 297 if err != nil { 263 298 continue 264 299 } 265 - for _, feed := range feeds { 266 - seenItems, err := s.store.GetSeenItems(ctx, feed.ID, 50) 267 - if err != nil { 268 - continue 300 + for _, item := range seenItems { 301 + jItem := jsonFeedItem{ 302 + ID: item.GUID, 303 + DatePublished: item.SeenAt.Format(time.RFC3339), 269 304 } 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) 305 + if item.Title.Valid { 306 + jItem.Title = item.Title.String 282 307 } 308 + if item.Link.Valid { 309 + jItem.URL = item.Link.String 310 + } 311 + items = append(items, jItem) 283 312 } 284 313 } 285 314 ··· 295 324 296 325 feed := jsonFeed{ 297 326 Version: "https://jsonfeed.org/version/1.1", 298 - Title: "Herald - " + fingerprint[:12], 299 - HomePageURL: s.origin + "/" + fingerprint, 300 - FeedURL: s.origin + "/" + fingerprint + "/feed.json", 327 + Title: "Herald - " + configFilename, 328 + HomePageURL: s.origin + "/" + fingerprint + "/" + configFilename, 329 + FeedURL: s.origin + "/" + fingerprint + "/" + configFilename + ".json", 301 330 Items: items, 302 331 } 303 332 ··· 313 342 user, err := s.store.GetUserByFingerprint(ctx, fingerprint) 314 343 if err != nil { 315 344 if errors.Is(err, sql.ErrNoRows) { 316 - http.Error(w, "User Not Found", http.StatusNotFound) 345 + s.handle404(w, r) 317 346 return 318 347 } 319 348 s.logger.Error("get user", "err", err) ··· 324 353 cfg, err := s.store.GetConfig(ctx, user.ID, filename) 325 354 if err != nil { 326 355 if errors.Is(err, sql.ErrNoRows) { 327 - http.Error(w, "Config Not Found", http.StatusNotFound) 356 + s.handle404(w, r) 328 357 return 329 358 } 330 359 s.logger.Error("get config", "err", err) ··· 336 365 w.Write([]byte(cfg.RawText)) 337 366 } 338 367 368 + type unsubscribePageData struct { 369 + Token string 370 + ShortFingerprint string 371 + Filename string 372 + Success bool 373 + Message string 374 + Error string 375 + } 376 + 377 + func (s *Server) handleUnsubscribeGET(w http.ResponseWriter, r *http.Request, token string) { 378 + ctx := r.Context() 379 + 380 + cfg, err := s.store.GetConfigByToken(ctx, token) 381 + if err != nil { 382 + if errors.Is(err, sql.ErrNoRows) { 383 + s.handle404WithMessage(w, r, "Invalid Link", "This unsubscribe link is invalid or has expired.") 384 + return 385 + } 386 + s.logger.Error("get config by token", "err", err) 387 + http.Error(w, "Internal Server Error", http.StatusInternalServerError) 388 + return 389 + } 390 + 391 + user, err := s.store.GetUserByID(ctx, cfg.UserID) 392 + if err != nil { 393 + s.logger.Error("get user", "err", err) 394 + http.Error(w, "Internal Server Error", http.StatusInternalServerError) 395 + return 396 + } 397 + 398 + shortFP := user.PubkeyFP 399 + if len(shortFP) > 12 { 400 + shortFP = shortFP[:12] 401 + } 402 + 403 + data := unsubscribePageData{ 404 + Token: token, 405 + ShortFingerprint: shortFP, 406 + Filename: cfg.Filename, 407 + } 408 + 409 + if err := s.tmpl.ExecuteTemplate(w, "unsubscribe.html", data); err != nil { 410 + s.logger.Error("render unsubscribe", "err", err) 411 + http.Error(w, "Internal Server Error", http.StatusInternalServerError) 412 + } 413 + } 414 + 415 + func (s *Server) handleUnsubscribePOST(w http.ResponseWriter, r *http.Request, token string) { 416 + ctx := r.Context() 417 + 418 + if err := r.ParseForm(); err != nil { 419 + http.Error(w, "Bad Request", http.StatusBadRequest) 420 + return 421 + } 422 + 423 + action := r.FormValue("action") 424 + if action != "deactivate" && action != "delete" { 425 + http.Error(w, "Invalid action", http.StatusBadRequest) 426 + return 427 + } 428 + 429 + cfg, err := s.store.GetConfigByToken(ctx, token) 430 + if err != nil { 431 + if errors.Is(err, sql.ErrNoRows) { 432 + s.handle404WithMessage(w, r, "Invalid Link", "This unsubscribe link is invalid or has expired.") 433 + return 434 + } 435 + s.logger.Error("get config by token", "err", err) 436 + http.Error(w, "Internal Server Error", http.StatusInternalServerError) 437 + return 438 + } 439 + 440 + var message string 441 + 442 + if action == "deactivate" { 443 + if err := s.store.DeactivateConfig(ctx, cfg.ID); err != nil { 444 + s.logger.Error("deactivate config", "err", err) 445 + http.Error(w, "Internal Server Error", http.StatusInternalServerError) 446 + return 447 + } 448 + message = fmt.Sprintf("Config '%s' deactivated. You will no longer receive emails for this config. Other configs remain active. Files remain accessible via SSH/SCP.", cfg.Filename) 449 + s.logger.Info("config deactivated", "config_id", cfg.ID, "filename", cfg.Filename) 450 + } else { 451 + if err := s.store.DeleteUser(ctx, cfg.UserID); err != nil { 452 + s.logger.Error("delete user", "err", err) 453 + http.Error(w, "Internal Server Error", http.StatusInternalServerError) 454 + return 455 + } 456 + message = "All data deleted. You have been completely removed from Herald." 457 + s.logger.Info("user deleted", "user_id", cfg.UserID) 458 + } 459 + 460 + if err := s.store.DeleteToken(ctx, token); err != nil { 461 + s.logger.Error("delete token", "err", err) 462 + } 463 + 464 + data := unsubscribePageData{ 465 + Success: true, 466 + Message: message, 467 + } 468 + 469 + if err := s.tmpl.ExecuteTemplate(w, "unsubscribe.html", data); err != nil { 470 + s.logger.Error("render unsubscribe", "err", err) 471 + http.Error(w, "Internal Server Error", http.StatusInternalServerError) 472 + } 473 + } 474 + 475 + func (s *Server) handleUnsubscribe(w http.ResponseWriter, r *http.Request, token string) { 476 + if r.Method == http.MethodGet { 477 + s.handleUnsubscribeGET(w, r, token) 478 + } else if r.Method == http.MethodPost { 479 + s.handleUnsubscribePOST(w, r, token) 480 + } else { 481 + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) 482 + } 483 + } 484 + 339 485 func stripProtocol(origin string) string { 340 486 if len(origin) == 0 { 341 487 return origin ··· 368 514 369 515 return hostPort 370 516 } 517 + 518 + func (s *Server) handle404(w http.ResponseWriter, r *http.Request) { 519 + w.WriteHeader(http.StatusNotFound) 520 + data := struct { 521 + Title string 522 + Message string 523 + }{} 524 + if err := s.tmpl.ExecuteTemplate(w, "404.html", data); err != nil { 525 + s.logger.Error("render 404", "err", err) 526 + http.Error(w, "Not Found", http.StatusNotFound) 527 + } 528 + } 529 + 530 + func (s *Server) handle404WithMessage(w http.ResponseWriter, r *http.Request, title, message string) { 531 + w.WriteHeader(http.StatusNotFound) 532 + data := struct { 533 + Title string 534 + Message string 535 + }{ 536 + Title: title, 537 + Message: message, 538 + } 539 + if err := s.tmpl.ExecuteTemplate(w, "404.html", data); err != nil { 540 + s.logger.Error("render 404", "err", err) 541 + http.Error(w, "Not Found", http.StatusNotFound) 542 + } 543 + }
+19 -7
web/server.go
··· 69 69 70 70 parts := strings.Split(path, "/") 71 71 72 + if len(parts) == 2 && parts[0] == "unsubscribe" { 73 + s.handleUnsubscribe(w, r, parts[1]) 74 + return 75 + } 76 + 72 77 switch len(parts) { 73 78 case 1: 74 79 s.handleUser(w, r, parts[0]) 75 80 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: 81 + // Check if it's a feed file (ends with .xml or .json) 82 + if strings.HasSuffix(parts[1], ".xml") { 83 + // Extract base name by removing .xml extension, then append .txt to find config 84 + baseName := strings.TrimSuffix(parts[1], ".xml") 85 + configFile := baseName + ".txt" 86 + s.handleFeedXML(w, r, parts[0], configFile) 87 + } else if strings.HasSuffix(parts[1], ".json") { 88 + // Extract base name by removing .json extension, then append .txt to find config 89 + baseName := strings.TrimSuffix(parts[1], ".json") 90 + configFile := baseName + ".txt" 91 + s.handleFeedJSON(w, r, parts[0], configFile) 92 + } else { 93 + // Raw config file 82 94 s.handleConfig(w, r, parts[0], parts[1]) 83 95 } 84 96 default: 85 - http.NotFound(w, r) 97 + s.handle404(w, r) 86 98 } 87 99 }
+16
web/templates/404.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 - {{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>"> 8 + <link rel="stylesheet" href="/style.css"> 9 + </head> 10 + <body> 11 + <h1>HERALD</h1> 12 + <h2>{{if .Title}}{{.Title}}{{else}}404 NOT FOUND{{end}}</h2> 13 + <p>{{if .Message}}{{.Message}}{{else}}The requested resource does not exist.{{end}}</p> 14 + <p><a href="/">Return to home</a></p> 15 + </body> 16 + </html>
+7 -5
web/templates/index.html
··· 17 17 18 18 <h2>COMMANDS</h2> 19 19 <pre> 20 - ls List uploaded feed configs 21 - cat &lt;file&gt; Display config file contents 22 - rm &lt;file&gt; Delete a config file 23 - run &lt;file&gt; Manually trigger feed fetch and email 24 - logs View recent delivery logs 20 + ls List uploaded feed configs 21 + cat &lt;file&gt; Display config file contents 22 + rm &lt;file&gt; Delete a config file 23 + activate &lt;file&gt; Reactivate a deactivated config 24 + deactivate &lt;file&gt; Stop emails for a config 25 + run &lt;file&gt; Manually trigger feed fetch and email 26 + logs View recent delivery logs 25 27 </pre> 26 28 27 29 <h2>EXAMPLES</h2>
+4
web/templates/style.css
··· 51 51 strong { 52 52 font-weight: bold; 53 53 } 54 + .inactive { 55 + text-decoration: line-through; 56 + opacity: 0.6; 57 + }
+42
web/templates/unsubscribe.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 - 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>"> 8 + <link rel="stylesheet" href="/style.css"> 9 + </head> 10 + <body> 11 + <h1>HERALD</h1> 12 + 13 + {{if .Success}} 14 + <h2>SUCCESS</h2> 15 + <p>{{.Message}}</p> 16 + <p><a href="/">Return to home</a></p> 17 + {{else}} 18 + <h2>UNSUBSCRIBE</h2> 19 + <p><strong>USER:</strong> {{.ShortFingerprint}}</p> 20 + <p><strong>CONFIG:</strong> {{.Filename}}</p> 21 + 22 + <h2>OPTIONS</h2> 23 + <ul> 24 + <li> 25 + <form method="POST" style="display: inline;"> 26 + <input type="hidden" name="action" value="deactivate"> 27 + <a href="#" onclick="this.closest('form').submit(); return false;">Deactivate this config</a> 28 + </form> 29 + - Stop emails for {{.Filename}} only. Other configs remain active. Files remain accessible via SSH/SCP. 30 + </li> 31 + <li> 32 + <form method="POST" style="display: inline;"> 33 + <input type="hidden" name="action" value="delete"> 34 + <a href="#" onclick="this.closest('form').submit(); return false;">Delete Forever</a> 35 + </form> 36 + - Permanently remove ALL configs and data for this user from Herald. 37 + </li> 38 + </ul> 39 + {{end}} 40 + 41 + </body> 42 + </html>
+6 -7
web/templates/user.html
··· 10 10 <body> 11 11 <h1>HERALD</h1> 12 12 <p><strong>USER:</strong> {{.ShortFingerprint}}</p> 13 - <p><strong>STATUS:</strong> ONLINE</p> 13 + <p><strong>STATUS:</strong> {{.Status}}</p> 14 14 <p><strong>NEXT RUN:</strong> {{.NextRun}}</p> 15 15 <h2>CONFIGS</h2> 16 16 <ul> 17 17 {{range .Configs}} 18 - <li><a href="{{.URL}}">{{.Filename}}</a> ({{.FeedCount}} feeds)</li> 18 + <li{{if not .IsActive}} class="inactive"{{end}}> 19 + <a href="{{.URL}}">{{.Filename}}</a> ({{.FeedCount}} feeds) 20 + - <a href="{{.FeedXMLURL}}">RSS</a> 21 + - <a href="{{.FeedJSONURL}}">JSON</a> 22 + </li> 19 23 {{else}} 20 24 <li>No configs uploaded</li> 21 25 {{end}} 22 - </ul> 23 - <h2>FEEDS</h2> 24 - <ul> 25 - <li><a href="{{.FeedXMLURL}}">RSS 2.0</a></li> 26 - <li><a href="{{.FeedJSONURL}}">JSON Feed</a></li> 27 26 </ul> 28 27 </body> 29 28 </html>