From 7a50537bf444fc083fe401afd7a35ee5b82d0757 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Tue, 14 Apr 2026 17:02:08 +0400 Subject: [PATCH] Extract clientip middleware and add jsonutil helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move clientIP extraction into a reusable pkg/server/api/clientip package with RFC 7239 Forwarded header support. Add pkg/server/jsonutil with helpers for common HTTP error responses (RenderForbidden, RenderInternalServerError, RenderNotFound, RenderBadRequest) and use them in the cookie banner handlers. Signed-off-by: Émile Ré --- pkg/coredata/cookie_banner.go | 14 ++- pkg/coredata/cookie_banner_version.go | 7 +- pkg/server/api/clientip/clientip.go | 106 ++++++++++++++++++ .../api/cookiebanner/v1/cors_middleware.go | 11 +- pkg/server/api/cookiebanner/v1/handler.go | 91 +++++++-------- pkg/server/jsonutil/errors.go | 38 +++++++ 6 files changed, 205 insertions(+), 62 deletions(-) create mode 100644 pkg/server/api/clientip/clientip.go create mode 100644 pkg/server/jsonutil/errors.go diff --git a/pkg/coredata/cookie_banner.go b/pkg/coredata/cookie_banner.go index 5c2ce14f2..e0fb7c68b 100644 --- a/pkg/coredata/cookie_banner.go +++ b/pkg/coredata/cookie_banner.go @@ -140,11 +140,14 @@ FROM cookie_banners WHERE id = @banner_id - AND state = 'ACTIVE' + AND state = @state LIMIT 1; ` - args := pgx.StrictNamedArgs{"banner_id": bannerID} + args := pgx.StrictNamedArgs{ + "banner_id": bannerID, + "state": CookieBannerStateActive, + } rows, err := conn.Query(ctx, q, args) if err != nil { @@ -188,13 +191,16 @@ FROM WHERE %s AND origin = @origin - AND state = 'ACTIVE' + AND state = @state LIMIT 1; ` q = fmt.Sprintf(q, scope.SQLFragment()) - args := pgx.StrictNamedArgs{"origin": origin} + args := pgx.StrictNamedArgs{ + "origin": origin, + "state": CookieBannerStateActive, + } maps.Copy(args, scope.SQLArguments()) rows, err := conn.Query(ctx, q, args) diff --git a/pkg/coredata/cookie_banner_version.go b/pkg/coredata/cookie_banner_version.go index 15707b806..960de1f8e 100644 --- a/pkg/coredata/cookie_banner_version.go +++ b/pkg/coredata/cookie_banner_version.go @@ -450,14 +450,17 @@ FROM WHERE %s AND cookie_banner_id = @cookie_banner_id - AND state = 'PUBLISHED' + AND state = @state ORDER BY version DESC LIMIT 1; ` q = fmt.Sprintf(q, scope.SQLFragment()) - args := pgx.StrictNamedArgs{"cookie_banner_id": cookieBannerID} + args := pgx.StrictNamedArgs{ + "cookie_banner_id": cookieBannerID, + "state": CookieBannerVersionStatePublished, + } maps.Copy(args, scope.SQLArguments()) rows, err := conn.Query(ctx, q, args) diff --git a/pkg/server/api/clientip/clientip.go b/pkg/server/api/clientip/clientip.go new file mode 100644 index 000000000..d561e5be9 --- /dev/null +++ b/pkg/server/api/clientip/clientip.go @@ -0,0 +1,106 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package clientip + +import ( + "context" + "net" + "net/http" + "strings" +) + +type ctxKey struct{} + +// NewMiddleware returns an HTTP middleware that extracts the client IP +// from standard proxy headers and stores it in the request context. +func NewMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := Extract(r) + ctx := context.WithValue(r.Context(), ctxKey{}, ip) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// FromContext returns the client IP stored by the middleware, or an +// empty string if the middleware has not run. +func FromContext(ctx context.Context) string { + if ip, ok := ctx.Value(ctxKey{}).(string); ok { + return ip + } + return "" +} + +// Extract resolves the client IP address from standard proxy headers +// in priority order: RFC 7239 Forwarded, then X-Forwarded-For, then +// the connection's remote address. +func Extract(r *http.Request) string { + if fwd := r.Header.Get("Forwarded"); fwd != "" { + if ip := parseForwardedFor(fwd); ip != "" { + return ip + } + } + + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if i := strings.IndexByte(xff, ','); i != -1 { + xff = xff[:i] + } + xff = strings.TrimSpace(xff) + + if ip, _, err := net.SplitHostPort(xff); err == nil { + return ip + } + return xff + } + + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + + return ip +} + +// parseForwardedFor extracts the client IP from the first "for=" directive +// of an RFC 7239 Forwarded header value. +func parseForwardedFor(header string) string { + if i := strings.IndexByte(header, ','); i != -1 { + header = header[:i] + } + + for _, part := range strings.Split(header, ";") { + part = strings.TrimSpace(part) + if !strings.HasPrefix(strings.ToLower(part), "for=") { + continue + } + + val := part[4:] + val = strings.Trim(val, "\"") + + if strings.HasPrefix(val, "[") { + if end := strings.IndexByte(val, ']'); end != -1 { + return val[1:end] + } + } + + if ip, _, err := net.SplitHostPort(val); err == nil { + return ip + } + return val + } + + return "" +} diff --git a/pkg/server/api/cookiebanner/v1/cors_middleware.go b/pkg/server/api/cookiebanner/v1/cors_middleware.go index 00a14a185..a147e8bb3 100644 --- a/pkg/server/api/cookiebanner/v1/cors_middleware.go +++ b/pkg/server/api/cookiebanner/v1/cors_middleware.go @@ -22,6 +22,7 @@ import ( "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/cookiebanner" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/server/jsonutil" ) func newCORSMiddleware(logger *log.Logger, cookieBannerSvc *cookiebanner.Service) func(http.Handler) http.Handler { @@ -36,30 +37,30 @@ func newCORSMiddleware(logger *log.Logger, cookieBannerSvc *cookiebanner.Service bannerIDStr := chi.URLParam(r, "bannerID") if bannerIDStr == "" { - http.Error(w, "forbidden", http.StatusForbidden) + jsonutil.RenderForbidden(w) return } bannerID, err := gid.ParseGID(bannerIDStr) if err != nil { - http.Error(w, "forbidden", http.StatusForbidden) + jsonutil.RenderForbidden(w) return } banner, err := cookieBannerSvc.GetActiveCookieBanner(r.Context(), bannerID) if err != nil { if errors.Is(err, cookiebanner.ErrBannerNotFound) { - http.Error(w, "forbidden", http.StatusForbidden) + jsonutil.RenderForbidden(w) return } logger.ErrorCtx(r.Context(), "cannot load cookie banner for CORS check", log.Error(err)) - http.Error(w, "internal server error", http.StatusInternalServerError) + jsonutil.RenderInternalServerError(w) return } canonicalOrigin := cookiebanner.CanonicalizeOrigin(origin) if banner.Origin != canonicalOrigin { - http.Error(w, "forbidden", http.StatusForbidden) + jsonutil.RenderForbidden(w) return } diff --git a/pkg/server/api/cookiebanner/v1/handler.go b/pkg/server/api/cookiebanner/v1/handler.go index 883eb2aec..f72d34736 100644 --- a/pkg/server/api/cookiebanner/v1/handler.go +++ b/pkg/server/api/cookiebanner/v1/handler.go @@ -17,9 +17,9 @@ package cookiebanner_v1 import ( "encoding/json" "errors" - "net" + "fmt" "net/http" - "strings" + "time" "github.com/go-chi/chi/v5" "go.gearno.de/kit/httpserver" @@ -27,6 +27,8 @@ import ( "go.probo.inc/probo/pkg/cookiebanner" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/server/api/clientip" + "go.probo.inc/probo/pkg/server/jsonutil" ) type Handler struct { @@ -45,6 +47,7 @@ func NewMux( r := chi.NewMux() r.Use(newCORSMiddleware(logger, cookieBannerSvc)) + r.Use(clientip.NewMiddleware()) r.Get("/{bannerID}/config", h.handleGetConfig) r.Get("/{bannerID}/consents/{visitorID}", h.handleGetConsent) r.Post("/{bannerID}/consents", h.handlePostConsent) @@ -55,22 +58,22 @@ func NewMux( func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) { bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID")) if err != nil { - httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid banner id"}) + jsonutil.RenderBadRequest(w, fmt.Errorf("invalid banner id")) return } config, err := h.cookieBannerSvc.GetActiveBannerConfig(r.Context(), bannerID) if err != nil { if errors.Is(err, cookiebanner.ErrBannerNotFound) { - httpserver.RenderJSON(w, http.StatusNotFound, map[string]string{"error": "banner not found"}) + jsonutil.RenderNotFound(w, fmt.Errorf("banner not found")) return } if errors.Is(err, cookiebanner.ErrNoPublishedVersion) { - httpserver.RenderJSON(w, http.StatusNotFound, map[string]string{"error": "no published version"}) + jsonutil.RenderNotFound(w, fmt.Errorf("no published version")) return } h.logger.ErrorCtx(r.Context(), "cannot get banner config", log.Error(err)) - httpserver.RenderJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + jsonutil.RenderInternalServerError(w) return } @@ -80,55 +83,64 @@ func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleGetConsent(w http.ResponseWriter, r *http.Request) { bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID")) if err != nil { - httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid banner id"}) + jsonutil.RenderBadRequest(w, fmt.Errorf("invalid banner id")) return } visitorID := chi.URLParam(r, "visitorID") if visitorID == "" { - httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{"error": "missing visitor id"}) + jsonutil.RenderBadRequest(w, fmt.Errorf("missing visitor id")) return } consent, err := h.cookieBannerSvc.GetVisitorConsent(r.Context(), bannerID, visitorID) if err != nil { if errors.Is(err, cookiebanner.ErrBannerNotFound) { - httpserver.RenderJSON(w, http.StatusNotFound, map[string]string{"error": "banner not found"}) + jsonutil.RenderNotFound(w, fmt.Errorf("banner not found")) return } if errors.Is(err, cookiebanner.ErrConsentNotFound) { - httpserver.RenderJSON(w, http.StatusNotFound, map[string]string{"error": "consent not found"}) + jsonutil.RenderNotFound(w, fmt.Errorf("consent not found")) return } h.logger.ErrorCtx(r.Context(), "cannot get visitor consent", log.Error(err)) - httpserver.RenderJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + jsonutil.RenderInternalServerError(w) return } httpserver.RenderJSON(w, http.StatusOK, consent) } -type postConsentBody struct { - VisitorID string `json:"visitor_id"` - Version int `json:"version"` - Action coredata.CookieConsentAction `json:"action"` - ConsentData json.RawMessage `json:"consent_data"` -} +type ( + postConsentBody struct { + VisitorID string `json:"visitor_id"` + Version int `json:"version"` + Action coredata.CookieConsentAction `json:"action"` + ConsentData json.RawMessage `json:"consent_data"` + } + + postConsentResponse struct { + ID string `json:"id"` + VisitorID string `json:"visitor_id"` + Action string `json:"action"` + CreatedAt time.Time `json:"created_at"` + } +) func (h *Handler) handlePostConsent(w http.ResponseWriter, r *http.Request) { bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID")) if err != nil { - httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid banner id"}) + jsonutil.RenderBadRequest(w, fmt.Errorf("invalid banner id")) return } var body postConsentBody if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + jsonutil.RenderBadRequest(w, fmt.Errorf("invalid request body")) return } - ip := clientIP(r) + ip := clientip.FromContext(r.Context()) ua := r.UserAgent() req := cookiebanner.RecordConsentRequest{ @@ -143,45 +155,22 @@ func (h *Handler) handlePostConsent(w http.ResponseWriter, r *http.Request) { record, err := h.cookieBannerSvc.RecordConsent(r.Context(), bannerID, req) if err != nil { if errors.Is(err, cookiebanner.ErrBannerNotFound) { - httpserver.RenderJSON(w, http.StatusNotFound, map[string]string{"error": "banner not found"}) + jsonutil.RenderNotFound(w, fmt.Errorf("banner not found")) return } if errors.Is(err, cookiebanner.ErrVersionNotFound) || errors.Is(err, cookiebanner.ErrVersionNotPublished) { - httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid version"}) + jsonutil.RenderBadRequest(w, fmt.Errorf("invalid version")) return } h.logger.ErrorCtx(r.Context(), "cannot record consent", log.Error(err)) - httpserver.RenderJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + jsonutil.RenderInternalServerError(w) return } - httpserver.RenderJSON(w, http.StatusCreated, map[string]string{ - "id": record.ID.String(), - "visitor_id": record.VisitorID, - "action": string(record.Action), - "created_at": record.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + httpserver.RenderJSON(w, http.StatusCreated, postConsentResponse{ + ID: record.ID.String(), + VisitorID: record.VisitorID, + Action: string(record.Action), + CreatedAt: record.CreatedAt, }) } - -func clientIP(r *http.Request) string { - if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - // X-Forwarded-For may contain a comma-separated chain; use only the - // leftmost (client) entry. - if i := strings.IndexByte(xff, ','); i != -1 { - xff = xff[:i] - } - xff = strings.TrimSpace(xff) - - if ip, _, err := net.SplitHostPort(xff); err == nil { - return ip - } - return xff - } - - ip, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - return r.RemoteAddr - } - - return ip -} diff --git a/pkg/server/jsonutil/errors.go b/pkg/server/jsonutil/errors.go new file mode 100644 index 000000000..0dad50c06 --- /dev/null +++ b/pkg/server/jsonutil/errors.go @@ -0,0 +1,38 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package jsonutil + +import ( + "fmt" + "net/http" + + "go.gearno.de/kit/httpserver" +) + +func RenderForbidden(w http.ResponseWriter) { + httpserver.RenderError(w, http.StatusForbidden, fmt.Errorf("forbidden")) +} + +func RenderInternalServerError(w http.ResponseWriter) { + httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("internal server error")) +} + +func RenderNotFound(w http.ResponseWriter, err error) { + httpserver.RenderError(w, http.StatusNotFound, err) +} + +func RenderBadRequest(w http.ResponseWriter, err error) { + httpserver.RenderError(w, http.StatusBadRequest, err) +}