+26
-4
email/send.go
+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
+1
-1
main.go
+14
-2
scheduler/scheduler.go
+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
+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
+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
+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
+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
+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
+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
+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
+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
+7
-5
web/templates/index.html
···
17
17
18
18
<h2>COMMANDS</h2>
19
19
<pre>
20
-
ls List uploaded feed configs
21
-
cat <file> Display config file contents
22
-
rm <file> Delete a config file
23
-
run <file> Manually trigger feed fetch and email
24
-
logs View recent delivery logs
20
+
ls List uploaded feed configs
21
+
cat <file> Display config file contents
22
+
rm <file> Delete a config file
23
+
activate <file> Reactivate a deactivated config
24
+
deactivate <file> Stop emails for a config
25
+
run <file> 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
+4
web/templates/style.css
+42
web/templates/unsubscribe.html
+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
+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>