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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user