Selaa lähdekoodia

Introduced server, handlers, error ret in handlerfn

Viktor Sokolov 1 kuukausi sitten
vanhempi
commit
002550715e

+ 46 - 0
handlers/health.go

@@ -0,0 +1,46 @@
+package handlers
+
+import (
+	"net/http"
+
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
+	"github.com/imgproxy/imgproxy/v3/ierrors"
+	"github.com/imgproxy/imgproxy/v3/server"
+	"github.com/imgproxy/imgproxy/v3/vips"
+)
+
+var imgproxyIsRunningMsg = []byte("imgproxy is running")
+
+// HealthHandler handles the health check requests
+func HealthHandler(reqID string, rw http.ResponseWriter, r *http.Request) error {
+	var (
+		status int
+		msg    []byte
+		ierr   *ierrors.Error
+	)
+
+	if err := vips.Health(); err == nil {
+		status = http.StatusOK
+		msg = imgproxyIsRunningMsg
+	} else {
+		status = http.StatusInternalServerError
+		msg = []byte("Error")
+		ierr = ierrors.Wrap(err, 1)
+	}
+
+	if len(msg) == 0 {
+		msg = []byte{' '}
+	}
+
+	// Log response only if something went wrong
+	if ierr != nil {
+		server.LogResponse(reqID, r, status, ierr)
+	}
+
+	rw.Header().Set(httpheaders.ContentType, "text/plain")
+	rw.Header().Set(httpheaders.CacheControl, "no-cache")
+	rw.WriteHeader(status)
+	rw.Write(msg)
+
+	return nil
+}

+ 32 - 0
handlers/health_test.go

@@ -0,0 +1,32 @@
+package handlers
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
+)
+
+func TestHealthHandler(t *testing.T) {
+	// Create a ResponseRecorder to record the response
+	rr := httptest.NewRecorder()
+
+	// Call the handler function directly (no need for actual HTTP request)
+	HealthHandler("test-req-id", rr, nil)
+
+	// Check that we get a valid response (either 200 or 500 depending on vips state)
+	assert.True(t, rr.Code == http.StatusOK || rr.Code == http.StatusInternalServerError)
+
+	// Check headers are set correctly
+	assert.Equal(t, "text/plain", rr.Header().Get(httpheaders.ContentType))
+	assert.Equal(t, "no-cache", rr.Header().Get(httpheaders.CacheControl))
+
+	// Verify response format and content
+	body := rr.Body.String()
+	assert.NotEmpty(t, body)
+
+	assert.Equal(t, "imgproxy is running", body)
+}

+ 11 - 5
landing.go → handlers/landing.go

@@ -1,6 +1,10 @@
-package main
+package handlers
 
-import "net/http"
+import (
+	"net/http"
+
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
+)
 
 var landingTmpl = []byte(`
 <!doctype html>
@@ -39,8 +43,10 @@ var landingTmpl = []byte(`
 </html>
 `)
 
-func handleLanding(reqID string, rw http.ResponseWriter, r *http.Request) {
-	rw.Header().Set("Content-Type", "text/html")
-	rw.WriteHeader(200)
+// LandingHandler handles the landing page requests
+func LandingHandler(reqID string, rw http.ResponseWriter, r *http.Request) error {
+	rw.Header().Set(httpheaders.ContentType, "text/html")
+	rw.WriteHeader(http.StatusOK)
 	rw.Write(landingTmpl)
+	return nil
 }

+ 4 - 0
httpheaders/headers.go

@@ -17,6 +17,7 @@ const (
 	AltSvc                          = "Alt-Svc"
 	Authorization                   = "Authorization"
 	CacheControl                    = "Cache-Control"
+	CFConnectingIP                  = "CF-Connecting-IP"
 	Connection                      = "Connection"
 	ContentDisposition              = "Content-Disposition"
 	ContentEncoding                 = "Content-Encoding"
@@ -56,6 +57,7 @@ const (
 	Vary                            = "Vary"
 	Via                             = "Via"
 	WwwAuthenticate                 = "Www-Authenticate"
+	XAmznRequestContextHeader       = "x-amzn-request-context"
 	XContentTypeOptions             = "X-Content-Type-Options"
 	XForwardedFor                   = "X-Forwarded-For"
 	XForwardedHost                  = "X-Forwarded-Host"
@@ -63,6 +65,8 @@ const (
 	XFrameOptions                   = "X-Frame-Options"
 	XOriginWidth                    = "X-Origin-Width"
 	XOriginHeight                   = "X-Origin-Height"
+	XRealIP                         = "X-Real-IP"
+	XRequestID                      = "X-Request-ID"
 	XResultWidth                    = "X-Result-Width"
 	XResultHeight                   = "X-Result-Height"
 	XOriginContentLength            = "X-Origin-Content-Length"

+ 19 - 0
ierrors/errors.go

@@ -7,6 +7,10 @@ import (
 	"strings"
 )
 
+const (
+	defaultCategory = "default"
+)
+
 type Option func(*Error)
 
 type Error struct {
@@ -16,6 +20,7 @@ type Error struct {
 	statusCode    int
 	publicMessage string
 	shouldReport  bool
+	category      string
 
 	stack []uintptr
 }
@@ -64,6 +69,14 @@ func (e *Error) Callers() []uintptr {
 	return e.stack
 }
 
+func (e *Error) Category() string {
+	if e.category == "" {
+		return defaultCategory
+	}
+
+	return e.category
+}
+
 func (e *Error) FormatStackLines() []string {
 	lines := make([]string, len(e.stack))
 
@@ -141,6 +154,12 @@ func WithShouldReport(report bool) Option {
 	}
 }
 
+func WithCategory(category string) Option {
+	return func(e *Error) {
+		e.category = category
+	}
+}
+
 func callers(skip int) []uintptr {
 	stack := make([]uintptr, 10)
 	n := runtime.Callers(skip+2, stack)

+ 2 - 2
processing/pipeline.go

@@ -6,7 +6,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/imagedata"
 	"github.com/imgproxy/imgproxy/v3/imagetype"
 	"github.com/imgproxy/imgproxy/v3/options"
-	"github.com/imgproxy/imgproxy/v3/router"
+	"github.com/imgproxy/imgproxy/v3/server"
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
@@ -89,7 +89,7 @@ func (p pipeline) Run(ctx context.Context, img *vips.Image, po *options.Processi
 			return err
 		}
 
-		if err := router.CheckTimeout(ctx); err != nil {
+		if err := server.CheckTimeout(ctx); err != nil {
 			return err
 		}
 	}

+ 3 - 3
processing/processing.go

@@ -12,8 +12,8 @@ import (
 	"github.com/imgproxy/imgproxy/v3/imagedata"
 	"github.com/imgproxy/imgproxy/v3/imagetype"
 	"github.com/imgproxy/imgproxy/v3/options"
-	"github.com/imgproxy/imgproxy/v3/router"
 	"github.com/imgproxy/imgproxy/v3/security"
+	"github.com/imgproxy/imgproxy/v3/server"
 	"github.com/imgproxy/imgproxy/v3/svg"
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
@@ -173,7 +173,7 @@ func transformAnimated(ctx context.Context, img *vips.Image, po *options.Process
 				return err
 			}
 
-			if err = router.CheckTimeout(ctx); err != nil {
+			if err = server.CheckTimeout(ctx); err != nil {
 				return err
 			}
 		}
@@ -240,7 +240,7 @@ func saveImageToFitBytes(ctx context.Context, po *options.ProcessingOptions, img
 		}
 		imgdata.Close()
 
-		if err := router.CheckTimeout(ctx); err != nil {
+		if err := server.CheckTimeout(ctx); err != nil {
 			return nil, err
 		}
 

+ 14 - 12
processing_handler.go

@@ -27,8 +27,8 @@ import (
 	"github.com/imgproxy/imgproxy/v3/metrics/stats"
 	"github.com/imgproxy/imgproxy/v3/options"
 	"github.com/imgproxy/imgproxy/v3/processing"
-	"github.com/imgproxy/imgproxy/v3/router"
 	"github.com/imgproxy/imgproxy/v3/security"
+	"github.com/imgproxy/imgproxy/v3/server"
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
@@ -188,7 +188,7 @@ func respondWithImage(reqID string, r *http.Request, rw http.ResponseWriter, sta
 		}
 	}
 
-	router.LogResponse(
+	server.LogResponse(
 		reqID, r, statusCode, ierr,
 		log.Fields{
 			"image_url":          originURL,
@@ -202,7 +202,7 @@ func respondWithNotModified(reqID string, r *http.Request, rw http.ResponseWrite
 	setVary(rw)
 
 	rw.WriteHeader(304)
-	router.LogResponse(
+	server.LogResponse(
 		reqID, r, 304, nil,
 		log.Fields{
 			"image_url":          originURL,
@@ -241,7 +241,7 @@ func checkErr(ctx context.Context, errType string, err error) {
 	sendErrAndPanic(ctx, errType, err)
 }
 
-func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
+func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) error {
 	stats.IncRequestsInProgress()
 	defer stats.DecRequestsInProgress()
 
@@ -299,7 +299,7 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 
 	if po.Raw {
 		streamOriginImage(ctx, reqID, r, rw, po, imageURL)
-		return
+		return nil
 	}
 
 	// SVG is a special case. Though saving to svg is not supported, SVG->SVG is.
@@ -347,7 +347,7 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 			// We don't actually need to check timeout here,
 			// but it's an easy way to check if this is an actual timeout
 			// or the request was canceled
-			checkErr(ctx, "queue", router.CheckTimeout(ctx))
+			checkErr(ctx, "queue", server.CheckTimeout(ctx))
 			// We should never reach this line as err could be only ctx.Err()
 			// and we've already checked for it. But beter safe than sorry
 			sendErrAndPanic(ctx, "queue", err)
@@ -393,12 +393,12 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 		}
 
 		respondWithNotModified(reqID, r, rw, po, imageURL, nmErr.Headers())
-		return
+		return nil
 
 	default:
 		// This may be a request timeout error or a request cancelled error.
 		// Check it before moving further
-		checkErr(ctx, "timeout", router.CheckTimeout(ctx))
+		checkErr(ctx, "timeout", server.CheckTimeout(ctx))
 
 		ierr := ierrors.Wrap(err, 0)
 		if config.ReportDownloadingErrors {
@@ -433,7 +433,7 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 		}
 	}
 
-	checkErr(ctx, "timeout", router.CheckTimeout(ctx))
+	checkErr(ctx, "timeout", server.CheckTimeout(ctx))
 
 	if config.ETagEnabled && statusCode == http.StatusOK {
 		imgDataMatch, terr := etagHandler.SetActualImageData(originData, originHeaders)
@@ -442,12 +442,12 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 
 			if imgDataMatch && etagHandler.ProcessingOptionsMatch() {
 				respondWithNotModified(reqID, r, rw, po, imageURL, originHeaders)
-				return
+				return nil
 			}
 		}
 	}
 
-	checkErr(ctx, "timeout", router.CheckTimeout(ctx))
+	checkErr(ctx, "timeout", server.CheckTimeout(ctx))
 
 	if !vips.SupportsLoad(originData.Format()) {
 		sendErrAndPanic(ctx, "processing", newInvalidURLErrorf(
@@ -476,10 +476,12 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 		sendErrAndPanic(ctx, "processing", err)
 	}
 
-	checkErr(ctx, "timeout", router.CheckTimeout(ctx))
+	checkErr(ctx, "timeout", server.CheckTimeout(ctx))
 
 	writeDebugHeaders(rw, result)
 	writeOriginContentLengthDebugHeader(ctx, rw, originData)
 
 	respondWithImage(reqID, r, rw, statusCode, result.OutData, po, imageURL, originData, originHeaders)
+
+	return nil
 }

+ 2 - 2
processing_handler_test.go

@@ -22,7 +22,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/imagedata"
 	"github.com/imgproxy/imgproxy/v3/imagetype"
 	"github.com/imgproxy/imgproxy/v3/options"
-	"github.com/imgproxy/imgproxy/v3/router"
+	"github.com/imgproxy/imgproxy/v3/server"
 	"github.com/imgproxy/imgproxy/v3/svg"
 	"github.com/imgproxy/imgproxy/v3/testutil"
 	"github.com/imgproxy/imgproxy/v3/vips"
@@ -31,7 +31,7 @@ import (
 type ProcessingHandlerTestSuite struct {
 	suite.Suite
 
-	router *router.Router
+	router *server.Router
 }
 
 func (s *ProcessingHandlerTestSuite) SetupSuite() {

+ 0 - 174
router/router.go

@@ -1,174 +0,0 @@
-package router
-
-import (
-	"encoding/json"
-	"net"
-	"net/http"
-	"regexp"
-	"strings"
-
-	nanoid "github.com/matoous/go-nanoid/v2"
-
-	"github.com/imgproxy/imgproxy/v3/config"
-)
-
-const (
-	xRequestIDHeader          = "X-Request-ID"
-	xAmznRequestContextHeader = "x-amzn-request-context"
-)
-
-var (
-	requestIDRe = regexp.MustCompile(`^[A-Za-z0-9_\-]+$`)
-)
-
-type RouteHandler func(string, http.ResponseWriter, *http.Request)
-
-type route struct {
-	Method  string
-	Prefix  string
-	Handler RouteHandler
-	Exact   bool
-}
-
-type Router struct {
-	prefix       string
-	healthRoutes []string
-	faviconRoute string
-
-	Routes        []*route
-	HealthHandler RouteHandler
-}
-
-func (r *route) isMatch(req *http.Request) bool {
-	if r.Method != req.Method {
-		return false
-	}
-
-	if r.Exact {
-		return req.URL.Path == r.Prefix
-	}
-
-	return strings.HasPrefix(req.URL.Path, r.Prefix)
-}
-
-func New(prefix string) *Router {
-	healthRoutes := []string{prefix + "/health"}
-	if len(config.HealthCheckPath) > 0 {
-		healthRoutes = append(healthRoutes, prefix+config.HealthCheckPath)
-	}
-
-	return &Router{
-		prefix:       prefix,
-		healthRoutes: healthRoutes,
-		faviconRoute: prefix + "/favicon.ico",
-		Routes:       make([]*route, 0),
-	}
-}
-
-func (r *Router) Add(method, prefix string, handler RouteHandler, exact bool) {
-	// Don't add routes with empty prefix
-	if len(r.prefix+prefix) == 0 {
-		return
-	}
-
-	r.Routes = append(
-		r.Routes,
-		&route{Method: method, Prefix: r.prefix + prefix, Handler: handler, Exact: exact},
-	)
-}
-
-func (r *Router) GET(prefix string, handler RouteHandler, exact bool) {
-	r.Add(http.MethodGet, prefix, handler, exact)
-}
-
-func (r *Router) OPTIONS(prefix string, handler RouteHandler, exact bool) {
-	r.Add(http.MethodOptions, prefix, handler, exact)
-}
-
-func (r *Router) HEAD(prefix string, handler RouteHandler, exact bool) {
-	r.Add(http.MethodHead, prefix, handler, exact)
-}
-
-func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
-	req, timeoutCancel := startRequestTimer(req)
-	defer timeoutCancel()
-
-	rw = newTimeoutResponse(rw)
-
-	reqID := req.Header.Get(xRequestIDHeader)
-
-	if len(reqID) == 0 || !requestIDRe.MatchString(reqID) {
-		if lambdaContextVal := req.Header.Get(xAmznRequestContextHeader); len(lambdaContextVal) > 0 {
-			var lambdaContext struct {
-				RequestID string `json:"requestId"`
-			}
-
-			err := json.Unmarshal([]byte(lambdaContextVal), &lambdaContext)
-			if err == nil && len(lambdaContext.RequestID) > 0 {
-				reqID = lambdaContext.RequestID
-			}
-		}
-	}
-
-	if len(reqID) == 0 || !requestIDRe.MatchString(reqID) {
-		reqID, _ = nanoid.New()
-	}
-
-	rw.Header().Set("Server", "imgproxy")
-	rw.Header().Set(xRequestIDHeader, reqID)
-
-	if req.Method == http.MethodGet {
-		if r.HealthHandler != nil {
-			for _, healthRoute := range r.healthRoutes {
-				if req.URL.Path == healthRoute {
-					r.HealthHandler(reqID, rw, req)
-					return
-				}
-			}
-		}
-
-		if req.URL.Path == r.faviconRoute {
-			// TODO: Add a real favicon maybe?
-			rw.Header().Set("Content-Type", "text/plain")
-			rw.WriteHeader(404)
-			// Write a single byte to make AWS Lambda happy
-			rw.Write([]byte{' '})
-			return
-		}
-	}
-
-	if ip := req.Header.Get("CF-Connecting-IP"); len(ip) != 0 {
-		replaceRemoteAddr(req, ip)
-	} else if ip := req.Header.Get("X-Forwarded-For"); len(ip) != 0 {
-		if index := strings.Index(ip, ","); index > 0 {
-			ip = ip[:index]
-		}
-		replaceRemoteAddr(req, ip)
-	} else if ip := req.Header.Get("X-Real-IP"); len(ip) != 0 {
-		replaceRemoteAddr(req, ip)
-	}
-
-	LogRequest(reqID, req)
-
-	for _, rr := range r.Routes {
-		if rr.isMatch(req) {
-			rr.Handler(reqID, rw, req)
-			return
-		}
-	}
-
-	LogResponse(reqID, req, 404, newRouteNotDefinedError(req.URL.Path))
-
-	rw.Header().Set("Content-Type", "text/plain")
-	rw.WriteHeader(404)
-	rw.Write([]byte{' '})
-}
-
-func replaceRemoteAddr(req *http.Request, ip string) {
-	_, port, err := net.SplitHostPort(req.RemoteAddr)
-	if err != nil {
-		port = "80"
-	}
-
-	req.RemoteAddr = net.JoinHostPort(strings.TrimSpace(ip), port)
-}

+ 0 - 43
router/timeout_response.go

@@ -1,43 +0,0 @@
-package router
-
-import (
-	"net/http"
-	"time"
-
-	"github.com/imgproxy/imgproxy/v3/config"
-)
-
-type timeoutResponse struct {
-	http.ResponseWriter
-	controller *http.ResponseController
-}
-
-func newTimeoutResponse(rw http.ResponseWriter) http.ResponseWriter {
-	return &timeoutResponse{
-		ResponseWriter: rw,
-		controller:     http.NewResponseController(rw),
-	}
-}
-
-func (rw *timeoutResponse) WriteHeader(statusCode int) {
-	rw.withWriteDeadline(func() {
-		rw.ResponseWriter.WriteHeader(statusCode)
-	})
-}
-
-func (rw *timeoutResponse) Write(b []byte) (int, error) {
-	var (
-		n   int
-		err error
-	)
-	rw.withWriteDeadline(func() {
-		n, err = rw.ResponseWriter.Write(b)
-	})
-	return n, err
-}
-
-func (rw *timeoutResponse) withWriteDeadline(f func()) {
-	rw.controller.SetWriteDeadline(time.Now().Add(time.Duration(config.WriteResponseTimeout) * time.Second))
-	defer rw.controller.SetWriteDeadline(time.Time{})
-	f()
-}

+ 34 - 55
server.go

@@ -13,27 +13,34 @@ import (
 
 	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/errorreport"
+	"github.com/imgproxy/imgproxy/v3/handlers"
 	"github.com/imgproxy/imgproxy/v3/ierrors"
 	"github.com/imgproxy/imgproxy/v3/metrics"
 	"github.com/imgproxy/imgproxy/v3/reuseport"
-	"github.com/imgproxy/imgproxy/v3/router"
-	"github.com/imgproxy/imgproxy/v3/vips"
+	"github.com/imgproxy/imgproxy/v3/server"
 )
 
-var imgproxyIsRunningMsg = []byte("imgproxy is running")
+const (
+	faviconPath = "/favicon.ico"
+	healthPath  = "/health"
+)
 
-func buildRouter() *router.Router {
-	r := router.New(config.PathPrefix)
+func buildRouter() *server.Router {
+	r := server.NewRouter(config.PathPrefix)
 
-	r.GET("/", handleLanding, true)
-	r.GET("", handleLanding, true)
+	r.GET("/", true, handlers.LandingHandler)
+	r.GET("", true, handlers.LandingHandler)
 
-	r.GET("/", withMetrics(withPanicHandler(withCORS(withSecret(handleProcessing)))), false)
+	r.GET("/", false, handleProcessing, withMetrics, withPanicHandler, withCORS, withSecret)
 
-	r.HEAD("/", withCORS(handleHead), false)
-	r.OPTIONS("/", withCORS(handleHead), false)
+	r.HEAD("/", false, r.OkHandler, withCORS)
+	r.OPTIONS("/", false, r.OkHandler, withCORS)
 
-	r.HealthHandler = handleHealth
+	r.GET(faviconPath, true, r.NotFoundHandler).Silent()
+	r.GET(healthPath, true, handlers.HealthHandler).Silent()
+	if config.HealthCheckPath != "" {
+		r.GET(config.HealthCheckPath, true, handlers.HealthHandler).Silent()
+	}
 
 	return r
 }
@@ -86,48 +93,54 @@ func shutdownServer(s *http.Server) {
 	s.Shutdown(ctx)
 }
 
-func withMetrics(h router.RouteHandler) router.RouteHandler {
+func withMetrics(h server.RouteHandler) server.RouteHandler {
 	if !metrics.Enabled() {
 		return h
 	}
 
-	return func(reqID string, rw http.ResponseWriter, r *http.Request) {
+	return func(reqID string, rw http.ResponseWriter, r *http.Request) error {
 		ctx, metricsCancel, rw := metrics.StartRequest(r.Context(), rw, r)
 		defer metricsCancel()
 
 		h(reqID, rw, r.WithContext(ctx))
+
+		return nil
 	}
 }
 
-func withCORS(h router.RouteHandler) router.RouteHandler {
-	return func(reqID string, rw http.ResponseWriter, r *http.Request) {
+func withCORS(h server.RouteHandler) server.RouteHandler {
+	return func(reqID string, rw http.ResponseWriter, r *http.Request) error {
 		if len(config.AllowOrigin) > 0 {
 			rw.Header().Set("Access-Control-Allow-Origin", config.AllowOrigin)
 			rw.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
 		}
 
 		h(reqID, rw, r)
+
+		return nil
 	}
 }
 
-func withSecret(h router.RouteHandler) router.RouteHandler {
+func withSecret(h server.RouteHandler) server.RouteHandler {
 	if len(config.Secret) == 0 {
 		return h
 	}
 
 	authHeader := []byte(fmt.Sprintf("Bearer %s", config.Secret))
 
-	return func(reqID string, rw http.ResponseWriter, r *http.Request) {
+	return func(reqID string, rw http.ResponseWriter, r *http.Request) error {
 		if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), authHeader) == 1 {
 			h(reqID, rw, r)
 		} else {
 			panic(newInvalidSecretError())
 		}
+
+		return nil
 	}
 }
 
-func withPanicHandler(h router.RouteHandler) router.RouteHandler {
-	return func(reqID string, rw http.ResponseWriter, r *http.Request) {
+func withPanicHandler(h server.RouteHandler) server.RouteHandler {
+	return func(reqID string, rw http.ResponseWriter, r *http.Request) error {
 		ctx := errorreport.StartRequest(r)
 		r = r.WithContext(ctx)
 
@@ -150,7 +163,7 @@ func withPanicHandler(h router.RouteHandler) router.RouteHandler {
 					errorreport.Report(err, r)
 				}
 
-				router.LogResponse(reqID, r, ierr.StatusCode(), ierr)
+				server.LogResponse(reqID, r, ierr.StatusCode(), ierr)
 
 				rw.Header().Set("Content-Type", "text/plain")
 				rw.WriteHeader(ierr.StatusCode())
@@ -164,41 +177,7 @@ func withPanicHandler(h router.RouteHandler) router.RouteHandler {
 		}()
 
 		h(reqID, rw, r)
-	}
-}
-
-func handleHealth(reqID string, rw http.ResponseWriter, r *http.Request) {
-	var (
-		status int
-		msg    []byte
-		ierr   *ierrors.Error
-	)
 
-	if err := vips.Health(); err == nil {
-		status = http.StatusOK
-		msg = imgproxyIsRunningMsg
-	} else {
-		status = http.StatusInternalServerError
-		msg = []byte("Error")
-		ierr = ierrors.Wrap(err, 1)
-	}
-
-	if len(msg) == 0 {
-		msg = []byte{' '}
-	}
-
-	// Log response only if something went wrong
-	if ierr != nil {
-		router.LogResponse(reqID, r, status, ierr)
+		return nil
 	}
-
-	rw.Header().Set("Content-Type", "text/plain")
-	rw.Header().Set("Cache-Control", "no-cache")
-	rw.WriteHeader(status)
-	rw.Write(msg)
-}
-
-func handleHead(reqID string, rw http.ResponseWriter, r *http.Request) {
-	router.LogResponse(reqID, r, 200, nil)
-	rw.WriteHeader(200)
 }

+ 1 - 1
router/errors.go → server/errors.go

@@ -1,4 +1,4 @@
-package router
+package server
 
 import (
 	"fmt"

+ 2 - 2
router/logging.go → server/logging.go

@@ -1,4 +1,4 @@
-package router
+package server
 
 import (
 	"net"
@@ -59,6 +59,6 @@ func LogResponse(reqID string, r *http.Request, status int, err *ierrors.Error,
 
 	log.WithFields(fields).Logf(
 		level,
-		"Completed in %s %s", ctxTime(r.Context()), r.RequestURI,
+		"Completed in %s %s", requestStartedAt(r.Context()), r.RequestURI,
 	)
 }

+ 209 - 0
server/router.go

@@ -0,0 +1,209 @@
+package server
+
+import (
+	"encoding/json"
+	"net"
+	"net/http"
+	"regexp"
+	"strings"
+
+	nanoid "github.com/matoous/go-nanoid/v2"
+
+	"github.com/imgproxy/imgproxy/v3/config"
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
+)
+
+const (
+	// defaultServerName is the default name of the server
+	defaultServerName = "imgproxy"
+)
+
+var (
+	// requestIDRe is a regular expression for validating request IDs
+	requestIDRe = regexp.MustCompile(`^[A-Za-z0-9_\-]+$`)
+)
+
+// RouteHandler is a function that handles HTTP requests.
+type RouteHandler func(string, http.ResponseWriter, *http.Request) error
+
+// Middleware is a function that wraps a RouteHandler with additional functionality.
+type Middleware func(next RouteHandler) RouteHandler
+
+// route represents a single route in the router.
+type route struct {
+	method  string       // method is the HTTP method for a route
+	path    string       // path represents a route path
+	exact   bool         // exact means that path must match exactly, otherwise any prefixed matches
+	handler RouteHandler // handler is the function that handles the route
+	silent  bool         // Silent route (no logs)
+}
+
+// Router is responsible for routing HTTP requests
+type Router struct {
+	// prefix represents global path prefix for all routes
+	prefix string
+
+	// routes is the collection of all routes
+	routes []*route
+}
+
+// NewRouter creates a new Router instance
+func NewRouter(prefix string) *Router {
+	return &Router{prefix: prefix}
+}
+
+// add adds an abitary route to the router
+func (r *Router) add(method, prefix string, exact bool, handler RouteHandler, middlewares ...Middleware) *route {
+	for i := len(middlewares) - 1; i >= 0; i-- {
+		handler = middlewares[i](handler)
+	}
+
+	route := &route{method: method, path: r.prefix + prefix, handler: handler, exact: exact}
+
+	r.routes = append(
+		r.routes,
+		route,
+	)
+
+	return route
+}
+
+// GET adds GET route
+func (r *Router) GET(prefix string, exact bool, handler RouteHandler, middlewares ...Middleware) *route {
+	return r.add(http.MethodGet, prefix, exact, handler, middlewares...)
+}
+
+// OPTIONS adds OPTIONS route
+func (r *Router) OPTIONS(prefix string, exact bool, handler RouteHandler, middlewares ...Middleware) *route {
+	return r.add(http.MethodOptions, prefix, exact, handler, middlewares...)
+}
+
+// HEAD adds HEAD route
+func (r *Router) HEAD(prefix string, exact bool, handler RouteHandler, middlewares ...Middleware) *route {
+	return r.add(http.MethodHead, prefix, exact, handler, middlewares...)
+}
+
+// ServeHTTP serves routes
+func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
+	// Attach timer to the context
+	req, timeoutCancel := startRequestTimer(req)
+	defer timeoutCancel()
+
+	// Create the response writer which times out on write
+	rw = newTimeoutResponse(rw, config.WriteResponseTimeout)
+
+	// Get/create request ID
+	reqID := r.getRequestID(req)
+
+	// Replace request IP from headers
+	r.replaceRemoteAddr(req)
+
+	rw.Header().Set(httpheaders.Server, defaultServerName)
+	rw.Header().Set(httpheaders.XRequestID, reqID)
+
+	for _, rr := range r.routes {
+		if rr.isMatch(req) {
+			if !rr.silent {
+				LogRequest(reqID, req)
+			}
+
+			rr.handler(reqID, rw, req)
+			return
+		}
+	}
+
+	// Means that we have not found matching route
+	LogRequest(reqID, req)
+	LogResponse(reqID, req, http.StatusNotFound, newRouteNotDefinedError(req.URL.Path))
+	r.NotFoundHandler(reqID, rw, req)
+}
+
+// NotFoundHandler is default 404 handler
+func (r *Router) NotFoundHandler(reqID string, rw http.ResponseWriter, req *http.Request) error {
+	rw.Header().Set(httpheaders.ContentType, "text/plain")
+	rw.WriteHeader(http.StatusNotFound)
+	rw.Write([]byte{' '}) // Write a single byte to make AWS Lambda happy
+
+	return nil
+}
+
+// OkHandler is a default 200 OK handler
+func (r *Router) OkHandler(reqID string, rw http.ResponseWriter, req *http.Request) error {
+	rw.Header().Set(httpheaders.ContentType, "text/plain")
+	rw.WriteHeader(http.StatusOK)
+	rw.Write([]byte{' '}) // Write a single byte to make AWS Lambda happy
+
+	return nil
+}
+
+// getRequestID tries to read request id from headers or from lambda
+// context or generates a new one if nothing found.
+func (rw *Router) getRequestID(req *http.Request) string {
+	// Get request ID from headers (if any)
+	reqID := req.Header.Get(httpheaders.XRequestID)
+
+	if len(reqID) == 0 || !requestIDRe.MatchString(reqID) {
+		lambdaContextVal := req.Header.Get(httpheaders.XAmznRequestContextHeader)
+
+		if len(lambdaContextVal) > 0 {
+			var lambdaContext struct {
+				RequestID string `json:"requestId"`
+			}
+
+			err := json.Unmarshal([]byte(lambdaContextVal), &lambdaContext)
+			if err == nil && len(lambdaContext.RequestID) > 0 {
+				reqID = lambdaContext.RequestID
+			}
+		}
+	}
+
+	if len(reqID) == 0 || !requestIDRe.MatchString(reqID) {
+		reqID, _ = nanoid.New()
+	}
+
+	return reqID
+}
+
+// replaceRemoteAddr rewrites the req.RemoteAddr property from request headers
+func (rw *Router) replaceRemoteAddr(req *http.Request) {
+	cfConnectingIP := req.Header.Get(httpheaders.CFConnectingIP)
+	xForwardedFor := req.Header.Get(httpheaders.XForwardedFor)
+	xRealIP := req.Header.Get(httpheaders.XRealIP)
+
+	switch {
+	case len(cfConnectingIP) > 0:
+		replaceRemoteAddr(req, cfConnectingIP)
+	case len(xForwardedFor) > 0:
+		if index := strings.Index(xForwardedFor, ","); index > 0 {
+			xForwardedFor = xForwardedFor[:index]
+		}
+		replaceRemoteAddr(req, xForwardedFor)
+	case len(xRealIP) > 0:
+		replaceRemoteAddr(req, xRealIP)
+	}
+}
+
+// replaceRemoteAddr sets the req.RemoteAddr for request
+func replaceRemoteAddr(req *http.Request, ip string) {
+	_, port, err := net.SplitHostPort(req.RemoteAddr)
+	if err != nil {
+		port = "80"
+	}
+
+	req.RemoteAddr = net.JoinHostPort(strings.TrimSpace(ip), port)
+}
+
+// isMatch checks that a request matches route
+func (r *route) isMatch(req *http.Request) bool {
+	methodMatches := r.method == req.Method
+	notExactPathMathes := !r.exact && strings.HasPrefix(req.URL.Path, r.path)
+	exactPathMatches := r.exact && req.URL.Path == r.path
+
+	return methodMatches && (notExactPathMathes || exactPathMatches)
+}
+
+// Silent sets Silent flag which supresses logs to true
+func (r *route) Silent() *route {
+	r.silent = true
+	return r
+}

+ 294 - 0
server/router_test.go

@@ -0,0 +1,294 @@
+package server
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/suite"
+
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
+)
+
+type RouterTestSuite struct {
+	suite.Suite
+	router *Router
+}
+
+func (s *RouterTestSuite) SetupTest() {
+	s.router = NewRouter("/api")
+}
+
+func TestRouterSuite(t *testing.T) {
+	suite.Run(t, new(RouterTestSuite))
+}
+
+// TestHTTPMethods tests route methods registration and HTTP requests
+func (s *RouterTestSuite) TestHTTPMethods() {
+	var capturedMethod string
+	var capturedPath string
+
+	getHandler := func(reqID string, rw http.ResponseWriter, req *http.Request) error {
+		capturedMethod = req.Method
+		capturedPath = req.URL.Path
+		rw.WriteHeader(200)
+		rw.Write([]byte("GET response"))
+		return nil
+	}
+
+	optionsHandler := func(reqID string, rw http.ResponseWriter, req *http.Request) error {
+		capturedMethod = req.Method
+		capturedPath = req.URL.Path
+		rw.WriteHeader(200)
+		rw.Write([]byte("OPTIONS response"))
+		return nil
+	}
+
+	headHandler := func(reqID string, rw http.ResponseWriter, req *http.Request) error {
+		capturedMethod = req.Method
+		capturedPath = req.URL.Path
+		rw.WriteHeader(200)
+		return nil
+	}
+
+	// Register routes with different configurations
+	s.router.GET("/get-test", true, getHandler)              // exact match
+	s.router.OPTIONS("/options-test", false, optionsHandler) // prefix match
+	s.router.HEAD("/head-test", true, headHandler)           // exact match
+
+	tests := []struct {
+		name          string
+		requestMethod string
+		requestPath   string
+		expectedBody  string
+		expectedPath  string
+	}{
+		{
+			name:          "GET",
+			requestMethod: http.MethodGet,
+			requestPath:   "/api/get-test",
+			expectedBody:  "GET response",
+			expectedPath:  "/api/get-test",
+		},
+		{
+			name:          "OPTIONS",
+			requestMethod: http.MethodOptions,
+			requestPath:   "/api/options-test",
+			expectedBody:  "OPTIONS response",
+			expectedPath:  "/api/options-test",
+		},
+		{
+			name:          "OPTIONSPrefixed",
+			requestMethod: http.MethodOptions,
+			requestPath:   "/api/options-test/sub",
+			expectedBody:  "OPTIONS response",
+			expectedPath:  "/api/options-test/sub",
+		},
+		{
+			name:          "HEAD",
+			requestMethod: http.MethodHead,
+			requestPath:   "/api/head-test",
+			expectedBody:  "",
+			expectedPath:  "/api/head-test",
+		},
+	}
+
+	for _, tt := range tests {
+		s.Run(tt.name, func() {
+			req := httptest.NewRequest(tt.requestMethod, tt.requestPath, nil)
+			rw := httptest.NewRecorder()
+
+			s.router.ServeHTTP(rw, req)
+
+			s.Require().Equal(tt.expectedBody, rw.Body.String())
+			s.Require().Equal(tt.requestMethod, capturedMethod)
+			s.Require().Equal(tt.expectedPath, capturedPath)
+		})
+	}
+}
+
+// TestMiddlewareOrder checks middleware ordering and functionality
+func (s *RouterTestSuite) TestMiddlewareOrder() {
+	var order []string
+
+	middleware1 := func(next RouteHandler) RouteHandler {
+		return func(reqID string, rw http.ResponseWriter, req *http.Request) error {
+			order = append(order, "middleware1")
+			return next(reqID, rw, req)
+		}
+	}
+
+	middleware2 := func(next RouteHandler) RouteHandler {
+		return func(reqID string, rw http.ResponseWriter, req *http.Request) error {
+			order = append(order, "middleware2")
+			return next(reqID, rw, req)
+		}
+	}
+
+	handler := func(reqID string, rw http.ResponseWriter, req *http.Request) error {
+		order = append(order, "handler")
+		rw.WriteHeader(200)
+		return nil
+	}
+
+	s.router.GET("/test", true, handler, middleware1, middleware2)
+
+	req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
+	rw := httptest.NewRecorder()
+
+	s.router.ServeHTTP(rw, req)
+
+	// Middleware should execute in the order they are passed (first added first)
+	s.Require().Equal([]string{"middleware1", "middleware2", "handler"}, order)
+}
+
+// TestServeHTTP tests ServeHTTP method
+func (s *RouterTestSuite) TestServeHTTP() {
+	handler := func(reqID string, rw http.ResponseWriter, req *http.Request) error {
+		rw.Header().Set("Custom-Header", "test-value")
+		rw.WriteHeader(200)
+		rw.Write([]byte("success"))
+		return nil
+	}
+
+	s.router.GET("/test", true, handler)
+
+	req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
+	rw := httptest.NewRecorder()
+
+	s.router.ServeHTTP(rw, req)
+
+	s.Require().Equal(200, rw.Code)
+	s.Require().Equal("success", rw.Body.String())
+	s.Require().Equal("test-value", rw.Header().Get("Custom-Header"))
+	s.Require().Equal(defaultServerName, rw.Header().Get(httpheaders.Server))
+	s.Require().NotEmpty(rw.Header().Get(httpheaders.XRequestID))
+}
+
+// TestRequestID checks request ID generation and validation
+func (s *RouterTestSuite) TestRequestID() {
+	handler := func(reqID string, rw http.ResponseWriter, req *http.Request) error {
+		rw.WriteHeader(200)
+		return nil
+	}
+
+	s.router.GET("/test", true, handler)
+
+	// Test request ID passthrough (if present)
+	req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
+	req.Header.Set(httpheaders.XRequestID, "valid-id-123")
+	rw := httptest.NewRecorder()
+
+	s.router.ServeHTTP(rw, req)
+
+	s.Require().Equal("valid-id-123", rw.Header().Get(httpheaders.XRequestID))
+
+	// Test invalid request ID (should generate a new one)
+	req = httptest.NewRequest(http.MethodGet, "/api/test", nil)
+	req.Header.Set(httpheaders.XRequestID, "invalid id with spaces!")
+	rw = httptest.NewRecorder()
+
+	s.router.ServeHTTP(rw, req)
+
+	generatedID := rw.Header().Get(httpheaders.XRequestID)
+	s.Require().NotEqual("invalid id with spaces!", generatedID)
+	s.Require().NotEmpty(generatedID)
+
+	// Test no request ID (should generate a new one)
+	req = httptest.NewRequest(http.MethodGet, "/api/test", nil)
+	rw = httptest.NewRecorder()
+
+	s.router.ServeHTTP(rw, req)
+
+	generatedID = rw.Header().Get(httpheaders.XRequestID)
+	s.Require().NotEmpty(generatedID)
+	s.Require().Regexp(`^[A-Za-z0-9_\-]+$`, generatedID)
+}
+
+// TestLambdaRequestIDExtraction checks AWS lambda request id extraction
+func (s *RouterTestSuite) TestLambdaRequestIDExtraction() {
+	handler := func(reqID string, rw http.ResponseWriter, req *http.Request) error {
+		rw.WriteHeader(200)
+		return nil
+	}
+
+	s.router.GET("/test", true, handler)
+
+	// Test with valid Lambda context
+	req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
+	req.Header.Set(httpheaders.XAmznRequestContextHeader, `{"requestId":"lambda-req-123"}`)
+	rw := httptest.NewRecorder()
+
+	s.router.ServeHTTP(rw, req)
+
+	s.Require().Equal("lambda-req-123", rw.Header().Get(httpheaders.XRequestID))
+}
+
+// Test IP address handling
+func (s *RouterTestSuite) TestReplaceIP() {
+	var capturedRemoteAddr string
+	handler := func(reqID string, rw http.ResponseWriter, req *http.Request) error {
+		capturedRemoteAddr = req.RemoteAddr
+		rw.WriteHeader(200)
+		return nil
+	}
+
+	s.router.GET("/test", true, handler)
+
+	tests := []struct {
+		name         string
+		originalAddr string
+		headers      map[string]string
+		expectedAddr string
+	}{
+		{
+			name:         "CFConnectingIP",
+			originalAddr: "original:8080",
+			headers: map[string]string{
+				httpheaders.CFConnectingIP: "1.2.3.4",
+			},
+			expectedAddr: "1.2.3.4:8080",
+		},
+		{
+			name:         "XForwardedForMulti",
+			originalAddr: "original:8080",
+			headers: map[string]string{
+				httpheaders.XForwardedFor: "5.6.7.8, 9.10.11.12",
+			},
+			expectedAddr: "5.6.7.8:8080",
+		},
+		{
+			name:         "XForwardedForSingle",
+			originalAddr: "original:8080",
+			headers: map[string]string{
+				httpheaders.XForwardedFor: "13.14.15.16",
+			},
+			expectedAddr: "13.14.15.16:8080",
+		},
+		{
+			name:         "XRealIP",
+			originalAddr: "original:8080",
+			headers: map[string]string{
+				httpheaders.XRealIP: "17.18.19.20",
+			},
+			expectedAddr: "17.18.19.20:8080",
+		},
+	}
+
+	for _, tt := range tests {
+		s.Run(tt.name, func() {
+			req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
+			req.RemoteAddr = tt.originalAddr
+
+			for header, value := range tt.headers {
+				req.Header.Set(header, value)
+			}
+
+			rw := httptest.NewRecorder()
+
+			s.router.ServeHTTP(rw, req)
+
+			s.Require().Equal(tt.expectedAddr, capturedRemoteAddr)
+		})
+	}
+}

+ 59 - 0
server/timeout_response.go

@@ -0,0 +1,59 @@
+package server
+
+import (
+	"net/http"
+	"time"
+)
+
+// timeoutResponse manages response writer with timeout. It has
+// timeout on all write methods.
+type timeoutResponse struct {
+	http.ResponseWriter
+	controller *http.ResponseController
+	timeout    int
+}
+
+// newTimeoutResponse creates a new timeoutResponse
+func newTimeoutResponse(rw http.ResponseWriter, timeout int) http.ResponseWriter {
+	return &timeoutResponse{
+		ResponseWriter: rw,
+		controller:     http.NewResponseController(rw),
+		timeout:        timeout,
+	}
+}
+
+// WriteHeader implements http.ResponseWriter.WriteHeader
+func (rw *timeoutResponse) WriteHeader(statusCode int) {
+	rw.withWriteDeadline(func() {
+		rw.ResponseWriter.WriteHeader(statusCode)
+	})
+}
+
+// Write implements http.ResponseWriter.Write
+func (rw *timeoutResponse) Write(b []byte) (int, error) {
+	var (
+		n   int
+		err error
+	)
+	rw.withWriteDeadline(func() {
+		n, err = rw.ResponseWriter.Write(b)
+	})
+	return n, err
+}
+
+// Header returns current HTTP headers
+func (rw *timeoutResponse) Header() http.Header {
+	return rw.ResponseWriter.Header()
+}
+
+// withWriteDeadline executes a Write* function with a deadline
+func (rw *timeoutResponse) withWriteDeadline(f func()) {
+	deadline := time.Now().Add(time.Duration(rw.timeout) * time.Second)
+
+	// Set write deadline
+	rw.controller.SetWriteDeadline(deadline)
+
+	// Reset write deadline after method has finished
+	defer rw.controller.SetWriteDeadline(time.Time{})
+	f()
+}

+ 10 - 3
router/timer.go → server/timer.go

@@ -1,4 +1,6 @@
-package router
+// timer.go contains methods for storing, retrieving and checking
+// timer in a request context.
+package server
 
 import (
 	"context"
@@ -9,8 +11,10 @@ import (
 	"github.com/imgproxy/imgproxy/v3/ierrors"
 )
 
+// timerSinceCtxKey represents a context key for start time.
 type timerSinceCtxKey struct{}
 
+// startRequestTimer starts a new request timer.
 func startRequestTimer(r *http.Request) (*http.Request, context.CancelFunc) {
 	ctx := r.Context()
 	ctx = context.WithValue(ctx, timerSinceCtxKey{}, time.Now())
@@ -18,17 +22,20 @@ func startRequestTimer(r *http.Request) (*http.Request, context.CancelFunc) {
 	return r.WithContext(ctx), cancel
 }
 
-func ctxTime(ctx context.Context) time.Duration {
+// requestStartedAt returns the duration since the timer started in the context.
+func requestStartedAt(ctx context.Context) time.Duration {
 	if t, ok := ctx.Value(timerSinceCtxKey{}).(time.Time); ok {
 		return time.Since(t)
 	}
 	return 0
 }
 
+// CheckTimeout checks if the request context has timed out or cancelled and returns
+// wrapped error.
 func CheckTimeout(ctx context.Context) error {
 	select {
 	case <-ctx.Done():
-		d := ctxTime(ctx)
+		d := requestStartedAt(ctx)
 
 		err := ctx.Err()
 		switch err {

+ 67 - 0
server/timer_test.go

@@ -0,0 +1,67 @@
+package server
+
+import (
+	"context"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestCheckTimeout(t *testing.T) {
+	tests := []struct {
+		name  string
+		setup func() context.Context
+		fail  bool
+	}{
+		{
+			name:  "WithoutTimeout",
+			setup: context.Background,
+			fail:  false,
+		},
+		{
+			name: "ActiveTimerContext",
+			setup: func() context.Context {
+				req := httptest.NewRequest(http.MethodGet, "/test", nil)
+				newReq, _ := startRequestTimer(req)
+				return newReq.Context()
+			},
+			fail: false,
+		},
+		{
+			name: "CancelledContext",
+			setup: func() context.Context {
+				req := httptest.NewRequest(http.MethodGet, "/test", nil)
+				newReq, cancel := startRequestTimer(req)
+				cancel() // Cancel immediately
+				return newReq.Context()
+			},
+			fail: true,
+		},
+		{
+			name: "DeadlineExceeded",
+			setup: func() context.Context {
+				ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond)
+				defer cancel()
+				time.Sleep(time.Millisecond * 10) // Ensure timeout
+				return ctx
+			},
+			fail: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ctx := tt.setup()
+			err := CheckTimeout(ctx)
+
+			if tt.fail {
+				require.Error(t, err)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}

+ 2 - 2
stream.go

@@ -16,7 +16,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/metrics"
 	"github.com/imgproxy/imgproxy/v3/metrics/stats"
 	"github.com/imgproxy/imgproxy/v3/options"
-	"github.com/imgproxy/imgproxy/v3/router"
+	"github.com/imgproxy/imgproxy/v3/server"
 )
 
 var (
@@ -116,7 +116,7 @@ func streamOriginImage(ctx context.Context, reqID string, r *http.Request, rw ht
 		copyerr = nil
 	}
 
-	router.LogResponse(
+	server.LogResponse(
 		reqID, r, res.StatusCode, nil,
 		log.Fields{
 			"image_url":          imageURL,