rss email digests over ssh because you're a cool kid
herald.dunkirk.sh
go
rss
rss-reader
ssh
charm
1// Package web provides functionality for Herald.
2package web
3
4import (
5 "context"
6 "embed"
7 "html/template"
8 "net"
9 "net/http"
10 "strings"
11 "time"
12
13 "github.com/charmbracelet/log"
14 "github.com/kierank/herald/ratelimit"
15 "github.com/kierank/herald/store"
16)
17
18//go:embed templates/*
19var templatesFS embed.FS
20
21const (
22 // HTTP rate limiting
23 httpRequestsPerSecond = 10
24 httpRateLimiterBurst = 20
25)
26
27type Server struct {
28 store *store.DB
29 addr string
30 origin string
31 sshPort int
32 logger *log.Logger
33 tmpl *template.Template
34 commitHash string
35 rateLimiter *ratelimit.Limiter
36 metrics *Metrics
37}
38
39func NewServer(st *store.DB, addr string, origin string, sshPort int, logger *log.Logger, commitHash string) *Server {
40 tmpl := template.Must(template.ParseFS(templatesFS, "templates/*.html"))
41 return &Server{
42 store: st,
43 addr: addr,
44 origin: origin,
45 sshPort: sshPort,
46 logger: logger,
47 tmpl: tmpl,
48 commitHash: commitHash,
49 rateLimiter: ratelimit.New(httpRequestsPerSecond, httpRateLimiterBurst),
50 metrics: NewMetrics(),
51 }
52}
53
54func (s *Server) ListenAndServe(ctx context.Context) error {
55 mux := http.NewServeMux()
56
57 mux.HandleFunc("/", s.routeHandler)
58 mux.HandleFunc("/style.css", s.handleStyleCSS)
59 mux.HandleFunc("/health", s.handleHealth)
60 mux.HandleFunc("/metrics", s.handleMetrics)
61
62 srv := &http.Server{
63 Addr: s.addr,
64 Handler: s.loggingMiddleware(s.rateLimitMiddleware(mux)),
65 ReadHeaderTimeout: 10 * time.Second,
66 }
67
68 go func() {
69 <-ctx.Done()
70 _ = srv.Shutdown(context.Background())
71 }()
72
73 s.logger.Info("web server listening", "addr", s.addr)
74 err := srv.ListenAndServe()
75 if err == http.ErrServerClosed {
76 return nil
77 }
78 return err
79}
80
81func (s *Server) rateLimitMiddleware(next http.Handler) http.Handler {
82 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
83 ip, _, err := net.SplitHostPort(r.RemoteAddr)
84 if err != nil {
85 ip = r.RemoteAddr
86 }
87
88 if !s.rateLimiter.Allow(ip) {
89 s.metrics.RateLimitHits.Add(1)
90 s.logger.Warn("rate limit exceeded", "ip", ip, "path", r.URL.Path)
91 http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
92 return
93 }
94
95 next.ServeHTTP(w, r)
96 })
97}
98
99func (s *Server) loggingMiddleware(next http.Handler) http.Handler {
100 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
101 start := time.Now()
102
103 s.metrics.RequestsTotal.Add(1)
104 s.metrics.RequestsActive.Add(1)
105 defer s.metrics.RequestsActive.Add(-1)
106
107 // Wrap response writer to capture status code
108 lrw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
109
110 next.ServeHTTP(lrw, r)
111
112 duration := time.Since(start)
113
114 s.logger.Info("http request",
115 "method", r.Method,
116 "path", r.URL.Path,
117 "status", lrw.statusCode,
118 "duration_ms", duration.Milliseconds(),
119 "remote_addr", r.RemoteAddr,
120 )
121
122 if lrw.statusCode >= 500 {
123 s.metrics.ErrorsTotal.Add(1)
124 }
125 })
126}
127
128type loggingResponseWriter struct {
129 http.ResponseWriter
130 statusCode int
131}
132
133func (lrw *loggingResponseWriter) WriteHeader(code int) {
134 lrw.statusCode = code
135 lrw.ResponseWriter.WriteHeader(code)
136}
137
138func (s *Server) routeHandler(w http.ResponseWriter, r *http.Request) {
139 path := strings.Trim(r.URL.Path, "/")
140
141 if path == "" {
142 s.handleIndex(w, r)
143 return
144 }
145
146 parts := strings.Split(path, "/")
147
148 if len(parts) == 2 && parts[0] == "unsubscribe" {
149 s.handleUnsubscribe(w, r, parts[1])
150 return
151 }
152
153 if len(parts) == 2 && parts[0] == "keep-alive" {
154 s.handleKeepAlive(w, r, parts[1])
155 return
156 }
157
158 switch len(parts) {
159 case 1:
160 s.handleUser(w, r, parts[0])
161 case 2:
162 // Check if it's a feed file (ends with .xml or .json)
163 if strings.HasSuffix(parts[1], ".xml") {
164 // Extract base name by removing .xml extension, then append .txt to find config
165 baseName := strings.TrimSuffix(parts[1], ".xml")
166 configFile := baseName + ".txt"
167 s.handleFeedXML(w, r, parts[0], configFile)
168 } else if strings.HasSuffix(parts[1], ".json") {
169 // Extract base name by removing .json extension, then append .txt to find config
170 baseName := strings.TrimSuffix(parts[1], ".json")
171 configFile := baseName + ".txt"
172 s.handleFeedJSON(w, r, parts[0], configFile)
173 } else {
174 // Raw config file
175 s.handleConfig(w, r, parts[0], parts[1])
176 }
177 default:
178 s.handle404(w, r)
179 }
180}