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:
Émile Ré
2026-04-14 14:20:01 +04:00
parent 85061884e7
commit 30a86a91f1
6 changed files with 479 additions and 1 deletions

View 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()
}

View File

@@ -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")
)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}