Add cookie banner public API endpoints
Implement config, consent retrieval, and consent recording endpoints for the JS SDK. IP addresses are anonymized (last octet zeroed for IPv4, /48 mask for IPv6) before storage. Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
35
pkg/cookiebanner/anonymize.go
Normal file
35
pkg/cookiebanner/anonymize.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user