package server import ( "encoding/json" "net/http" "strconv" "time" sentryhttp "github.com/getsentry/sentry-go/http" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/prometheus/client_golang/prometheus/promhttp" "sneak.berlin/go/pixa/internal/handlers" "sneak.berlin/go/pixa/internal/static" ) // LoginAttemptsPerMinute is how many login attempts (POST /) one client may // make per minute; the next is refused with 429 Too Many Requests. const LoginAttemptsPerMinute = 5 // MaintenanceRetryAfterSeconds is the Retry-After, in seconds, sent with // the 503 that the image routes answer while maintenance mode is on. const MaintenanceRetryAfterSeconds = 300 // SetupRoutes configures all HTTP routes. func (s *Server) SetupRoutes() { s.router = chi.NewRouter() s.router.Use(middleware.Recoverer) s.router.Use(middleware.RequestID) s.router.Use(s.mw.ClientIP()) s.router.Use(s.mw.SecurityHeaders()) s.router.Use(s.mw.Logging()) // Add metrics middleware only if credentials are configured if s.config.MetricsUsername != "" { s.router.Use(s.mw.Metrics()) } s.router.Use(s.mw.CORS()) s.router.Use(middleware.Timeout(HTTPWriteTimeout)) if s.sentryEnabled { sentryHandler := sentryhttp.New(sentryhttp.Options{ Repanic: true, }) s.router.Use(sentryHandler.Handle) } // Health check endpoint s.router.Get("/.well-known/healthcheck.json", s.h.HandleHealthCheck()) // Robots.txt s.router.Get("/robots.txt", s.h.HandleRobotsTxt()) // Static files (Tailwind CSS, etc.) s.router.Handle("/static/*", http.StripPrefix("/static/", static.Handler())) // Login/generator UI. The form routes carry CSRF protection; the // token cookie is independent of the session cookie, so it also // covers the login POST, where no session exists yet. LimitBody caps // the POST body ahead of CSRF, which reads its token from that body. // The login POST is rate limited per client after both, so every // attempt that reaches the signing key comparison is counted. s.router.Group(func(r chi.Router) { r.Use(s.h.LimitBody(handlers.MaxFormBytes)) r.Use(s.h.CSRF()) r.Get("/", s.h.HandleRoot()) r.With(s.mw.RateLimit(LoginAttemptsPerMinute, time.Minute)). Post("/", s.h.HandleRoot()) r.Post("/generate", s.h.HandleGenerateURL()) }) s.router.Get("/logout", s.h.HandleLogout()) // Image routes, refused while maintenance mode is on. Only these: the // image's Docker HEALTHCHECK requests the health check, a 503 there // would make the container unhealthy, and upaas marks a deploy failed // when its container is unhealthy. s.router.Group(func(r chi.Router) { r.Use(s.refuseDuringMaintenance) // Main image proxy route // /v1/image///x. r.Get("/v1/image/*", s.h.HandleImage()) r.Head("/v1/image/*", s.h.HandleImage()) // Encrypted image URL route // The trailing filename (e.g., /img.jpg) is ignored but helps // browsers with content type r.Get("/v1/e/{token}/*", s.h.HandleImageEnc()) }) // Metrics endpoint with auth if s.config.MetricsUsername != "" { s.router.Group(func(r chi.Router) { r.Use(s.mw.MetricsAuth()) r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP)) }) } } // refuseDuringMaintenance answers a request with 503 Service Unavailable, // a Retry-After header and a JSON error body while maintenance mode is on, // and passes it on otherwise. The body has the fields of the JSON errors // the image handlers send. func (s *Server) refuseDuringMaintenance(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !s.MaintenanceMode() { next.ServeHTTP(w, r) return } w.Header().Set("Retry-After", strconv.Itoa(MaintenanceRetryAfterSeconds)) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusServiceUnavailable) err := json.NewEncoder(w).Encode(map[string]any{ "error": "down for maintenance, try again later", "status": http.StatusServiceUnavailable, "timestamp": time.Now().UTC().Format(time.RFC3339), }) if err != nil { s.log.Error("json encode error", "error", err) } }) }