rss email digests over ssh because you're a cool kid herald.dunkirk.sh
go rss rss-reader ssh charm
at main 4.3 kB view raw
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}