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

Configure Feed

Select the types of activity you want to include in your feed.

at main 184 lines 4.4 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 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}