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