check / check (push) Failing after 2s
pixa's own RequestID middleware replaces chi's and the response-header middleware. It keeps a request's own X-Request-Id only when it is at most 64 letters, digits, '-', '_' or '.', so an over-long or odd value is never sent back or upstream, and otherwise makes a random ID with crypto/rand, which tells nothing about the host or how many requests pixa served. It stores the ID under chi's RequestIDKey, where the logging middleware, the handlers and the fetcher read it. Model: opus-5-5
139 lines
4.5 KiB
Go
139 lines
4.5 KiB
Go
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(s.mw.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(middleware.Timeout(s.config.DownstreamTimeout))
|
|
|
|
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, the only ones that send CORS headers, as pages on other
|
|
// sites read them. They are a subrouter rather than a group: a group's
|
|
// middleware runs only for a request that matches one of its routes,
|
|
// and a browser's preflight OPTIONS request matches none, so the CORS
|
|
// middleware could not answer it.
|
|
s.router.Route("/v1", func(r chi.Router) {
|
|
r.Use(s.mw.CORS())
|
|
|
|
// 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.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(s.refuseDuringMaintenance)
|
|
|
|
// Main image proxy route
|
|
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
|
r.Get("/image/*", s.h.HandleImage())
|
|
r.Head("/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("/e/{token}/*", s.h.HandleImageEnc())
|
|
r.Head("/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)
|
|
}
|
|
})
|
|
}
|