diff --git a/pkg/cookiebanner/anonymize.go b/pkg/cookiebanner/anonymize.go new file mode 100644 index 000000000..de8a107e3 --- /dev/null +++ b/pkg/cookiebanner/anonymize.go @@ -0,0 +1,35 @@ +// 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 cookiebanner + +import "net" + +// AnonymizeIP truncates an IP address for GDPR compliance. +// IPv4: zeroes the last octet (e.g. 192.168.1.123 -> 192.168.1.0). +// IPv6: zeroes the last 80 bits (/48 mask, e.g. 2001:db8:1:2:3:4:5:6 -> 2001:db8:1::). +func AnonymizeIP(raw string) string { + ip := net.ParseIP(raw) + if ip == nil { + return raw + } + + if v4 := ip.To4(); v4 != nil { + v4[3] = 0 + return v4.String() + } + + mask := net.CIDRMask(48, 128) + return ip.Mask(mask).String() +} diff --git a/pkg/cookiebanner/errors.go b/pkg/cookiebanner/errors.go index e7ee753c7..e3c902f5d 100644 --- a/pkg/cookiebanner/errors.go +++ b/pkg/cookiebanner/errors.go @@ -23,7 +23,9 @@ var ( ErrBannerAlreadyActive = errors.New("cookie banner is already active") ErrBannerAlreadyInactive = errors.New("cookie banner is already inactive") ErrVersionNotPublished = errors.New("cookie banner version is not published") + ErrNoPublishedVersion = errors.New("no published cookie banner version") ErrNoDraftVersion = errors.New("no draft cookie banner version to publish") ErrCannotDeleteRequiredCategory = errors.New("cannot delete required cookie category") ErrOriginAlreadyInUse = errors.New("origin is already used by another active cookie banner") + ErrConsentNotFound = errors.New("consent record not found") ) diff --git a/pkg/cookiebanner/service.go b/pkg/cookiebanner/service.go index a7a046918..ec57695d7 100644 --- a/pkg/cookiebanner/service.go +++ b/pkg/cookiebanner/service.go @@ -95,6 +95,32 @@ type ( ConsentData json.RawMessage Action coredata.CookieConsentAction } + + RecordConsentRequest struct { + Version int + VisitorID string + IPAddress *string + UserAgent *string + ConsentData json.RawMessage + Action coredata.CookieConsentAction + } + + BannerConfig struct { + BannerID gid.GID `json:"banner_id"` + Version int `json:"version"` + PrivacyPolicyURL string `json:"privacy_policy_url"` + ConsentExpiryDays int `json:"consent_expiry_days"` + ConsentMode string `json:"consent_mode"` + Categories []coredata.CookieBannerVersionSnapshotCategory `json:"categories"` + } + + VisitorConsent struct { + VisitorID string `json:"visitor_id"` + Version int `json:"version"` + Action coredata.CookieConsentAction `json:"action"` + ConsentData json.RawMessage `json:"consent_data"` + CreatedAt time.Time `json:"created_at"` + } ) func (r *CreateCookieBannerRequest) Validate() error { @@ -156,6 +182,16 @@ func (r *CreateCookieConsentRecordRequest) Validate() error { return v.Error() } +func (r *RecordConsentRequest) Validate() error { + v := validator.New() + + v.Check(r.Version, "version", validator.Required(), validator.Min(1)) + v.Check(r.VisitorID, "visitor_id", validator.Required(), validator.NotEmpty()) + v.Check(r.Action, "action", validator.Required(), validator.OneOfSlice(coredata.CookieConsentActions())) + + return v.Error() +} + func CanonicalizeOrigin(raw string) string { u, err := url.Parse(raw) if err != nil { @@ -1083,3 +1119,173 @@ func (s *Service) CountCookieConsentRecordsForBanner( return count, nil } + +func (s *Service) GetActiveBannerConfig( + ctx context.Context, + bannerID gid.GID, +) (*BannerConfig, error) { + var config *BannerConfig + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + var banner coredata.CookieBanner + if err := banner.LoadActiveByID(ctx, conn, bannerID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrBannerNotFound + } + return fmt.Errorf("cannot load active cookie banner: %w", err) + } + + scope := coredata.NewScopeFromObjectID(banner.ID) + + var version coredata.CookieBannerVersion + if err := version.LoadLatestPublishedByCookieBannerID(ctx, conn, scope, banner.ID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrNoPublishedVersion + } + return fmt.Errorf("cannot load latest published version: %w", err) + } + + snapshot, err := version.GetSnapshot() + if err != nil { + return fmt.Errorf("cannot get version snapshot: %w", err) + } + + config = &BannerConfig{ + BannerID: banner.ID, + Version: version.Version, + PrivacyPolicyURL: snapshot.PrivacyPolicyURL, + ConsentExpiryDays: snapshot.ConsentExpiryDays, + ConsentMode: snapshot.ConsentMode, + Categories: snapshot.Categories, + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return config, nil +} + +func (s *Service) GetVisitorConsent( + ctx context.Context, + bannerID gid.GID, + visitorID string, +) (*VisitorConsent, error) { + var consent *VisitorConsent + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + var banner coredata.CookieBanner + if err := banner.LoadActiveByID(ctx, conn, bannerID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrBannerNotFound + } + return fmt.Errorf("cannot load active cookie banner: %w", err) + } + + scope := coredata.NewScopeFromObjectID(banner.ID) + + var record coredata.CookieConsentRecord + if err := record.LoadLatestByVisitorAndBannerID(ctx, conn, scope, banner.ID, visitorID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrConsentNotFound + } + return fmt.Errorf("cannot load consent record: %w", err) + } + + var version coredata.CookieBannerVersion + if err := version.LoadByID(ctx, conn, scope, record.CookieBannerVersionID); err != nil { + return fmt.Errorf("cannot load cookie banner version: %w", err) + } + + consent = &VisitorConsent{ + VisitorID: record.VisitorID, + Version: version.Version, + Action: record.Action, + ConsentData: record.ConsentData, + CreatedAt: record.CreatedAt, + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return consent, nil +} + +func (s *Service) RecordConsent( + ctx context.Context, + bannerID gid.GID, + req RecordConsentRequest, +) (*coredata.CookieConsentRecord, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + if req.IPAddress != nil { + anonymized := AnonymizeIP(*req.IPAddress) + req.IPAddress = &anonymized + } + + var record *coredata.CookieConsentRecord + + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + var banner coredata.CookieBanner + if err := banner.LoadActiveByID(ctx, tx, bannerID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrBannerNotFound + } + return fmt.Errorf("cannot load active cookie banner: %w", err) + } + + scope := coredata.NewScopeFromObjectID(banner.ID) + + var publishedVersion coredata.CookieBannerVersion + if err := publishedVersion.LoadByCookieBannerIDAndVersion(ctx, tx, scope, banner.ID, req.Version); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrVersionNotFound + } + return fmt.Errorf("cannot load cookie banner version: %w", err) + } + + if publishedVersion.State != coredata.CookieBannerVersionStatePublished { + return ErrVersionNotPublished + } + + record = &coredata.CookieConsentRecord{ + ID: gid.New(scope.GetTenantID(), coredata.CookieConsentRecordEntityType), + OrganizationID: banner.OrganizationID, + CookieBannerID: banner.ID, + CookieBannerVersionID: publishedVersion.ID, + VisitorID: req.VisitorID, + IPAddress: req.IPAddress, + UserAgent: req.UserAgent, + ConsentData: req.ConsentData, + Action: req.Action, + CreatedAt: time.Now(), + } + + if err := record.Insert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot insert consent record: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return record, nil +} diff --git a/pkg/coredata/cookie_banner_version.go b/pkg/coredata/cookie_banner_version.go index 411bc90ea..15707b806 100644 --- a/pkg/coredata/cookie_banner_version.go +++ b/pkg/coredata/cookie_banner_version.go @@ -428,3 +428,52 @@ WHERE return nil } + +func (v *CookieBannerVersion) LoadLatestPublishedByCookieBannerID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieBannerID gid.GID, +) error { + q := ` +SELECT + id, + organization_id, + cookie_banner_id, + version, + state, + snapshot, + created_at, + updated_at +FROM + cookie_banner_versions +WHERE + %s + AND cookie_banner_id = @cookie_banner_id + AND state = 'PUBLISHED' +ORDER BY version DESC +LIMIT 1; +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"cookie_banner_id": cookieBannerID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query cookie banner versions: %w", err) + } + + ver, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CookieBannerVersion]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + return fmt.Errorf("cannot collect cookie banner version: %w", err) + } + + *v = ver + + return nil +} diff --git a/pkg/coredata/cookie_consent_record.go b/pkg/coredata/cookie_consent_record.go index 1cb3fcf9b..6b7fa8827 100644 --- a/pkg/coredata/cookie_consent_record.go +++ b/pkg/coredata/cookie_consent_record.go @@ -17,6 +17,7 @@ package coredata import ( "context" "encoding/json" + "errors" "fmt" "maps" "time" @@ -196,3 +197,58 @@ INSERT INTO cookie_consent_records ( return nil } + +func (r *CookieConsentRecord) LoadLatestByVisitorAndBannerID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieBannerID gid.GID, + visitorID string, +) error { + q := ` +SELECT + id, + organization_id, + cookie_banner_id, + cookie_banner_version_id, + visitor_id, + ip_address, + user_agent, + consent_data, + action, + created_at +FROM + cookie_consent_records +WHERE + %s + AND cookie_banner_id = @cookie_banner_id + AND visitor_id = @visitor_id +ORDER BY created_at DESC +LIMIT 1; +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "cookie_banner_id": cookieBannerID, + "visitor_id": visitorID, + } + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query consent records: %w", err) + } + + record, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CookieConsentRecord]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + return fmt.Errorf("cannot collect consent record: %w", err) + } + + *r = record + + return nil +} diff --git a/pkg/server/api/cookiebanner/v1/handler.go b/pkg/server/api/cookiebanner/v1/handler.go index e67bae0b9..b154a4454 100644 --- a/pkg/server/api/cookiebanner/v1/handler.go +++ b/pkg/server/api/cookiebanner/v1/handler.go @@ -15,12 +15,17 @@ package cookiebanner_v1 import ( + "encoding/json" + "errors" + "net" "net/http" "github.com/go-chi/chi/v5" "go.gearno.de/kit/httpserver" "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/cookiebanner" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" ) type Handler struct { @@ -40,10 +45,135 @@ func NewMux( r := chi.NewMux() r.Use(newCORSMiddleware(logger, cookieBannerSvc)) r.Get("/{bannerID}/config", h.handleGetConfig) + r.Get("/{bannerID}/consents/{visitorID}", h.handleGetConsent) + r.Post("/{bannerID}/consents", h.handlePostConsent) return r } func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) { - httpserver.RenderJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID")) + if err != nil { + httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{"error": "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"}) + return + } + if errors.Is(err, cookiebanner.ErrNoPublishedVersion) { + httpserver.RenderJSON(w, http.StatusNotFound, map[string]string{"error": "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"}) + return + } + + httpserver.RenderJSON(w, http.StatusOK, config) +} + +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"}) + return + } + + visitorID := chi.URLParam(r, "visitorID") + if visitorID == "" { + httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{"error": "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"}) + return + } + if errors.Is(err, cookiebanner.ErrConsentNotFound) { + httpserver.RenderJSON(w, http.StatusNotFound, map[string]string{"error": "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"}) + 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"` +} + +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"}) + 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"}) + return + } + + ip := clientIP(r) + ua := r.UserAgent() + + req := cookiebanner.RecordConsentRequest{ + Version: body.Version, + VisitorID: body.VisitorID, + IPAddress: &ip, + UserAgent: &ua, + ConsentData: body.ConsentData, + Action: body.Action, + } + + 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"}) + return + } + if errors.Is(err, cookiebanner.ErrVersionNotFound) || errors.Is(err, cookiebanner.ErrVersionNotPublished) { + httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{"error": "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"}) + 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"), + }) +} + +func clientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); 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 }