Files
probo/pkg/probo/tracker_policy_document.go
Sacha Al Himdani 4c57d201a4 Make license declarations consistently MIT
The source headers, LICENSE files, and license metadata had drifted
apart. Align the entire project to MIT:

- Convert every source-file header to the MIT text across all comment
  styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including
  SPDX-License-Identifier tags
- Set the root and cookie-banner LICENSE files to the MIT text with a
  "MIT License" title line
- Switch the package.json license fields, Docker image label, and
  cookie-banner README to MIT
- Update docs and the genmodels header generator accordingly
- Normalize copyright lines to a single format
  (Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the
  hello@getprobo.com and hello@probo.inc emails to hello@probo.com and
  the comma-separated years to a hyphenated range

Genuine third-party references are intentionally left untouched: the
Lucide icon attributions (Lucide is ISC) and the trivy dependency
license allowlist.

Signed-off-by: Sacha Al Himdani <sacha@probo.com>
2026-07-13 16:21:14 +02:00

429 lines
14 KiB
Go

// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package probo
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"text/template"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/docgen"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/prosemirror"
)
var trackerPolicyTemplate = template.Must(
template.New("tracker_policy.md.tmpl").
Funcs(template.FuncMap{
"formatDate": func(t time.Time) string {
return t.Format("January 2, 2006")
},
"add": func(a, b int) int {
return a + b
},
}).
ParseFS(Templates, "templates/tracker_policy.md.tmpl"),
)
// BuildTrackerPolicyDocument renders the tracker policy markdown template for
// the given data and converts it into the ProseMirror JSON expected by
// DocumentVersion.Content.
func BuildTrackerPolicyDocument(data docgen.TrackerPolicyData) (string, error) {
var buf bytes.Buffer
if err := trackerPolicyTemplate.Execute(&buf, data); err != nil {
return "", fmt.Errorf("cannot execute tracker policy template: %w", err)
}
node, err := prosemirror.ParseMarkdown(buf.String())
if err != nil {
return "", fmt.Errorf("cannot convert tracker policy markdown: %w", err)
}
out, err := json.Marshal(node)
if err != nil {
return "", fmt.Errorf("cannot marshal tracker policy prosemirror node: %w", err)
}
return string(out), nil
}
// PublishTrackerPolicy generates (or regenerates) the cookie and tracking
// technologies policy document for a banner from its latest published version
// snapshot. The document is stored as a GENERATED document that is PRIVATE in
// the trust center by default, and is linked to the banner through
// cookie_banners.policy_document_id.
func (s *GeneratedDocumentService) PublishTrackerPolicy(
ctx context.Context,
scope coredata.Scoper,
cookieBannerID gid.GID,
) error {
// Phase 1: collect data and render the prosemirror document outside any
// write transaction. Template rendering, markdown parsing, and JSON
// marshaling are CPU work that should not hold a write transaction open.
var (
organizationID gid.GID
bannerOrigin string
documentData docgen.TrackerPolicyData
)
err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
banner := &coredata.CookieBanner{}
if err := banner.LoadByID(ctx, conn, scope, cookieBannerID); err != nil {
return fmt.Errorf("cannot load cookie banner: %w", err)
}
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, conn, scope, banner.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
var err error
documentData, err = s.buildTrackerPolicyDocumentData(ctx, scope, conn, organization, banner)
if err != nil {
return fmt.Errorf("cannot build document data: %w", err)
}
organizationID = banner.OrganizationID
bannerOrigin = banner.Origin
return nil
})
if err != nil {
return err
}
prosemirrorJSON, err := BuildTrackerPolicyDocument(documentData)
if err != nil {
return fmt.Errorf("cannot build prosemirror document: %w", err)
}
// Phase 2: persist the document and version in a write transaction.
return s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
banner := &coredata.CookieBanner{}
if err := banner.LoadByID(ctx, tx, scope, cookieBannerID); err != nil {
return fmt.Errorf("cannot reload cookie banner: %w", err)
}
now := time.Now()
var (
document *coredata.Document
existingDoc *coredata.Document
)
if banner.PolicyDocumentID != nil {
doc := &coredata.Document{}
err = doc.LoadByID(ctx, tx, scope, *banner.PolicyDocumentID)
if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot load tracker policy document: %w", err)
}
if err == nil && doc.ArchivedAt == nil {
existingDoc = doc
} else {
banner.PolicyDocumentID = nil
banner.UpdatedAt = now
if err := banner.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot clear tracker policy document reference: %w", err)
}
}
}
if existingDoc == nil {
documentID := gid.New(scope.GetTenantID(), coredata.DocumentEntityType)
document = &coredata.Document{
ID: documentID,
OrganizationID: organizationID,
WriteMode: coredata.DocumentWriteModeGenerated,
TrustCenterVisibility: coredata.TrustCenterVisibilityPrivate,
Status: coredata.DocumentStatusActive,
CreatedAt: now,
UpdatedAt: now,
}
if err := document.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert document: %w", err)
}
banner.PolicyDocumentID = &documentID
banner.UpdatedAt = now
if err := banner.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update tracker policy document reference: %w", err)
}
} else {
document = existingDoc
}
documentVersionID := gid.New(scope.GetTenantID(), coredata.DocumentVersionEntityType)
documentVersion := &coredata.DocumentVersion{
ID: documentVersionID,
OrganizationID: organizationID,
DocumentID: document.ID,
Title: fmt.Sprintf("Cookie and Tracking Technologies Policy — %s", bannerOrigin),
Content: prosemirrorJSON,
Classification: coredata.DocumentClassificationPublic,
DocumentType: coredata.DocumentTypePolicy,
Orientation: coredata.DocumentVersionOrientationPortrait,
CreatedAt: now,
UpdatedAt: now,
}
return s.publishOrRequestApproval(ctx, scope, tx, document, documentVersion, organizationID, nil, false, now)
},
)
}
func (s *GeneratedDocumentService) buildTrackerPolicyDocumentData(
ctx context.Context, scope coredata.Scoper,
conn pg.Querier,
organization *coredata.Organization,
banner *coredata.CookieBanner,
) (docgen.TrackerPolicyData, error) {
version := &coredata.CookieBannerVersion{}
if err := version.LoadLatestPublishedByCookieBannerID(ctx, conn, scope, banner.ID); err != nil {
return docgen.TrackerPolicyData{}, fmt.Errorf("cannot load latest published version: %w", err)
}
snapshot, err := version.GetSnapshot()
if err != nil {
return docgen.TrackerPolicyData{}, fmt.Errorf("cannot decode snapshot: %w", err)
}
categories := make([]docgen.TrackerPolicyCategory, 0, len(snapshot.Categories))
for _, c := range snapshot.Categories {
trackers := make([]docgen.TrackerPolicyTracker, 0, len(c.Cookies))
for _, cookie := range c.Cookies {
trackers = append(trackers, docgen.TrackerPolicyTracker{
Name: sanitizeTrackerCell(cookie.Name),
Type: cookie.TrackerType.Label(),
Purpose: trackerPurpose(cookie.Description),
Duration: cookie.HumanizedDuration(),
})
}
categories = append(categories, docgen.TrackerPolicyCategory{
Name: strings.TrimSpace(c.Name),
Description: strings.TrimSpace(c.Description),
Necessary: c.Kind == coredata.CookieCategoryKindNecessary,
Trackers: trackers,
})
}
thirdParties, err := s.buildTrackerPolicyThirdParties(ctx, scope, conn, banner.ID)
if err != nil {
return docgen.TrackerPolicyData{}, err
}
privacyPolicyURL := ""
if snapshot.PrivacyPolicyURL != nil {
privacyPolicyURL = strings.TrimSpace(*snapshot.PrivacyPolicyURL)
}
return docgen.TrackerPolicyData{
OrganizationName: organization.Name,
WebsiteOrigin: banner.Origin,
GeneratedAt: time.Now(),
PrivacyPolicyURL: privacyPolicyURL,
ConsentExpiryDays: snapshot.ConsentExpiryDays,
Categories: categories,
ThirdParties: thirdParties,
}, nil
}
func (s *GeneratedDocumentService) buildTrackerPolicyThirdParties(
ctx context.Context, scope coredata.Scoper,
conn pg.Querier,
cookieBannerID gid.GID,
) ([]docgen.TrackerPolicyThirdParty, error) {
var patterns coredata.TrackerPatterns
thirdPartyIDs, err := patterns.LoadDistinctThirdPartyIDsByCookieBannerID(ctx, conn, scope, cookieBannerID)
if err != nil {
return nil, fmt.Errorf("cannot load distinct third party ids: %w", err)
}
var thirdParties coredata.ThirdParties
if len(thirdPartyIDs) > 0 {
if err := thirdParties.LoadByIDs(ctx, conn, scope, thirdPartyIDs); err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
return nil, fmt.Errorf("cannot load third parties: %w", err)
}
}
// Patterns not linked to an org ThirdParty still surface their catalog
// (CommonThirdParty) vendor, mirroring the banner's linkedThirdParties
// union, so the policy stays complete whether or not the vendor was
// imported into the org register.
commonPatternIDs, err := patterns.LoadDistinctCommonTrackerPatternIDsByCookieBannerID(ctx, conn, scope, cookieBannerID)
if err != nil {
return nil, fmt.Errorf("cannot load distinct common tracker pattern ids: %w", err)
}
var commonParties coredata.CommonThirdParties
if len(commonPatternIDs) > 0 {
var commonPatterns coredata.CommonTrackerPatterns
if err := commonPatterns.LoadByIDs(ctx, conn, commonPatternIDs); err != nil {
return nil, fmt.Errorf("cannot load common tracker patterns: %w", err)
}
seen := make(map[gid.GID]struct{}, len(commonPatterns))
commonThirdPartyIDs := make([]gid.GID, 0, len(commonPatterns))
for _, cp := range commonPatterns {
if cp.CommonThirdPartyID == nil {
continue
}
if _, ok := seen[*cp.CommonThirdPartyID]; ok {
continue
}
seen[*cp.CommonThirdPartyID] = struct{}{}
commonThirdPartyIDs = append(commonThirdPartyIDs, *cp.CommonThirdPartyID)
}
if len(commonThirdPartyIDs) > 0 {
if err := commonParties.LoadByIDs(ctx, conn, commonThirdPartyIDs); err != nil {
return nil, fmt.Errorf("cannot load common third parties: %w", err)
}
}
}
if len(thirdParties) == 0 && len(commonParties) == 0 {
return nil, nil
}
rows := make([]docgen.TrackerPolicyThirdParty, 0, len(thirdParties)+len(commonParties))
// Dedupe by name so a vendor present both as an org third party (one
// pattern) and as a catalog entry (an unlinked pattern) is listed
// once. Org third parties are appended first, so their richer
// (user-editable) data wins for a shared name. When the kept row left a
// field empty (e.g. an org third party with no privacy policy URL), a
// later duplicate backfills it from the catalog so common-vendor
// metadata is not dropped.
rowIndexByName := make(map[string]int, len(thirdParties)+len(commonParties))
addRow := func(name, description, privacyPolicyURL string) {
name = strings.TrimSpace(name)
description = collapseWhitespace(description)
privacyPolicyURL = strings.TrimSpace(privacyPolicyURL)
key := strings.ToLower(name)
if idx, ok := rowIndexByName[key]; ok {
if rows[idx].Description == "" {
rows[idx].Description = description
}
if rows[idx].PrivacyPolicyURL == "" {
rows[idx].PrivacyPolicyURL = privacyPolicyURL
}
return
}
rowIndexByName[key] = len(rows)
rows = append(rows, docgen.TrackerPolicyThirdParty{
Name: name,
Description: description,
PrivacyPolicyURL: privacyPolicyURL,
})
}
for _, tp := range thirdParties {
description := ""
if tp.Description != nil {
description = *tp.Description
}
privacyPolicyURL := ""
if tp.PrivacyPolicyURL != nil {
privacyPolicyURL = *tp.PrivacyPolicyURL
}
addRow(tp.Name, description, privacyPolicyURL)
}
for _, cp := range commonParties {
privacyPolicyURL := ""
if cp.PrivacyPolicyURL != nil {
privacyPolicyURL = *cp.PrivacyPolicyURL
}
addRow(cp.Name, "", privacyPolicyURL)
}
// LoadByIDs returns rows in an unspecified order (no ORDER BY), so sort
// here to keep the generated policy document deterministic across
// regenerations.
slices.SortFunc(rows, func(a, b docgen.TrackerPolicyThirdParty) int {
if c := strings.Compare(a.Name, b.Name); c != 0 {
return c
}
if c := strings.Compare(a.Description, b.Description); c != 0 {
return c
}
return strings.Compare(a.PrivacyPolicyURL, b.PrivacyPolicyURL)
})
return rows, nil
}
// trackerPurpose returns a table-safe purpose string for a tracker, falling
// back to a neutral label when no enriched description is available.
func trackerPurpose(description string) string {
cell := sanitizeTrackerCell(description)
if cell == "" {
return "Not specified"
}
return cell
}
// sanitizeTrackerCell makes free-form text safe to embed in a markdown table
// cell: it collapses whitespace (including newlines) and escapes pipe
// characters so they do not break the column layout.
func sanitizeTrackerCell(s string) string {
return strings.ReplaceAll(collapseWhitespace(s), "|", "\\|")
}
func collapseWhitespace(s string) string {
return strings.Join(strings.Fields(s), " ")
}