diff --git a/pkg/cookiebanner/service.go b/pkg/cookiebanner/service.go index c36ca17fa..08a08ba7d 100644 --- a/pkg/cookiebanner/service.go +++ b/pkg/cookiebanner/service.go @@ -127,7 +127,7 @@ type ( DetectedResourceItem struct { URL uri.URI - ResourceType coredata.TrackerType + ResourceType coredata.TrackerResourceType } ReportDetectedTrackersRequest struct { @@ -1999,23 +1999,10 @@ func (s *Service) ReportDetectedTrackers( } } - for _, dr := range req.Resources { - if err := s.reportDetectedTracker( - ctx, - tx, - scope, - &banner, - uncategorised.ID, - now, - detectedTrackerInfo{ - TrackerType: dr.ResourceType, - Identifier: dr.URL.String(), - }, - &inserted, - ); err != nil { - return err - } - } + // Resources (SCRIPT/IFRAME) are now stored in their own + // tracker_resources table; ingestion will be wired in a + // follow-up commit alongside the new service surface. + _ = req.Resources if inserted > 0 { if err := banner.SetPatternAnalysisRequested(ctx, tx); err != nil { diff --git a/pkg/coredata/entity_type_reg.go b/pkg/coredata/entity_type_reg.go index 201b3d60e..1de0c38b1 100644 --- a/pkg/coredata/entity_type_reg.go +++ b/pkg/coredata/entity_type_reg.go @@ -114,6 +114,7 @@ const ( _ uint16 = 88 // CookiePatternEntityType - removed TrackerPatternEntityType uint16 = 89 DetectedTrackerEntityType uint16 = 90 + TrackerResourceEntityType uint16 = 91 ) func NewEntityFromID(id gid.GID) (any, bool) { @@ -284,6 +285,8 @@ func NewEntityFromID(id gid.GID) (any, bool) { return &TrackerPattern{ID: id}, true case DetectedTrackerEntityType: return &DetectedTracker{ID: id}, true + case TrackerResourceEntityType: + return &TrackerResource{ID: id}, true default: return nil, false } diff --git a/pkg/coredata/migrations/20260509T000000Z.sql b/pkg/coredata/migrations/20260509T000000Z.sql new file mode 100644 index 000000000..87ad277b7 --- /dev/null +++ b/pkg/coredata/migrations/20260509T000000Z.sql @@ -0,0 +1,61 @@ +-- 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. + +-- Split SCRIPT/IFRAME tracking out of tracker_patterns/detected_trackers into +-- its own tracker_resources table keyed by (banner, type, origin, path). +-- SCRIPT/IFRAME data only landed last week and is not in the production +-- database yet, so existing rows are dropped rather than backfilled. + +CREATE TYPE tracker_resource_type AS ENUM ('SCRIPT', 'IFRAME'); + +CREATE TABLE tracker_resources ( + id TEXT NOT NULL PRIMARY KEY, + tenant_id TEXT NOT NULL, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + cookie_banner_id TEXT NOT NULL REFERENCES cookie_banners(id) ON DELETE CASCADE, + cookie_category_id TEXT NOT NULL REFERENCES cookie_categories(id) ON DELETE CASCADE, + resource_type tracker_resource_type NOT NULL, + origin TEXT NOT NULL, + path TEXT NOT NULL, + display_name TEXT NOT NULL, + description TEXT NOT NULL, + excluded BOOLEAN NOT NULL, + last_detected_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +CREATE UNIQUE INDEX idx_tracker_resources_unique_resource_per_banner + ON tracker_resources (cookie_banner_id, resource_type, origin, path); + +-- Drop SCRIPT/IFRAME rows from the legacy tables before recreating the enum. +DELETE FROM detected_trackers WHERE tracker_type IN ('SCRIPT', 'IFRAME'); +DELETE FROM tracker_patterns WHERE tracker_type IN ('SCRIPT', 'IFRAME'); + +-- Recreate tracker_type without SCRIPT/IFRAME. Postgres has no +-- "remove enum value" so we swap the type via a _new alias. +CREATE TYPE tracker_type_new AS ENUM ( + 'COOKIE', 'LOCAL_STORAGE', 'SESSION_STORAGE', 'INDEXED_DB' +); + +ALTER TABLE tracker_patterns + ALTER COLUMN tracker_type TYPE tracker_type_new + USING tracker_type::text::tracker_type_new; + +ALTER TABLE detected_trackers + ALTER COLUMN tracker_type TYPE tracker_type_new + USING tracker_type::text::tracker_type_new; + +DROP TYPE tracker_type; +ALTER TYPE tracker_type_new RENAME TO tracker_type; diff --git a/pkg/coredata/tracker_resource.go b/pkg/coredata/tracker_resource.go new file mode 100644 index 000000000..3ed653d6a --- /dev/null +++ b/pkg/coredata/tracker_resource.go @@ -0,0 +1,682 @@ +// 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 coredata + +import ( + "context" + "errors" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + TrackerResource struct { + ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` + CookieBannerID gid.GID `db:"cookie_banner_id"` + CookieCategoryID gid.GID `db:"cookie_category_id"` + ResourceType TrackerResourceType `db:"resource_type"` + Origin string `db:"origin"` + Path string `db:"path"` + DisplayName string `db:"display_name"` + Description string `db:"description"` + Excluded bool `db:"excluded"` + LastDetectedAt *time.Time `db:"last_detected_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + + TrackerResources []*TrackerResource +) + +func (tr *TrackerResource) CursorKey(field TrackerResourceOrderField) page.CursorKey { + switch field { + case TrackerResourceOrderFieldCreatedAt: + return page.NewCursorKey(tr.ID, tr.CreatedAt) + case TrackerResourceOrderFieldLastDetectedAt: + if tr.LastDetectedAt == nil { + return page.NewCursorKey(tr.ID, time.Time{}) + } + return page.NewCursorKey(tr.ID, *tr.LastDetectedAt) + case TrackerResourceOrderFieldOrigin: + return page.NewCursorKey(tr.ID, tr.Origin) + case TrackerResourceOrderFieldUpdatedAt: + return page.NewCursorKey(tr.ID, tr.UpdatedAt) + } + + panic(fmt.Sprintf("unsupported order by: %s", field)) +} + +func (tr *TrackerResource) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { + q := `SELECT organization_id FROM tracker_resources WHERE id = $1 LIMIT 1;` + + var organizationID gid.GID + if err := conn.QueryRow(ctx, q, tr.ID).Scan(&organizationID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrResourceNotFound + } + + return nil, fmt.Errorf("cannot query tracker resource authorization attributes: %w", err) + } + + return map[string]string{"organization_id": organizationID.String()}, nil +} + +func (tr *TrackerResource) LoadByID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + trackerResourceID gid.GID, +) error { + q := ` +SELECT + id, + organization_id, + cookie_banner_id, + cookie_category_id, + resource_type, + origin, + path, + display_name, + description, + excluded, + last_detected_at, + created_at, + updated_at +FROM + tracker_resources +WHERE + %s + AND id = @tracker_resource_id +LIMIT 1; +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"tracker_resource_id": trackerResourceID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query tracker resources: %w", err) + } + + resource, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[TrackerResource]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + return fmt.Errorf("cannot collect tracker resource: %w", err) + } + + *tr = resource + + return nil +} + +func (tr *TrackerResource) LoadByBannerTypeOriginPath( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieBannerID gid.GID, + resourceType TrackerResourceType, + origin string, + path string, +) error { + q := ` +SELECT + id, + organization_id, + cookie_banner_id, + cookie_category_id, + resource_type, + origin, + path, + display_name, + description, + excluded, + last_detected_at, + created_at, + updated_at +FROM + tracker_resources +WHERE + %s + AND cookie_banner_id = @cookie_banner_id + AND resource_type = @resource_type + AND origin = @origin + AND path = @path +LIMIT 1; +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "cookie_banner_id": cookieBannerID, + "resource_type": resourceType, + "origin": origin, + "path": path, + } + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query tracker resources: %w", err) + } + + resource, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[TrackerResource]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + return fmt.Errorf("cannot collect tracker resource: %w", err) + } + + *tr = resource + + return nil +} + +func (tr *TrackerResource) Insert( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) error { + q := ` +INSERT INTO tracker_resources ( + id, + tenant_id, + organization_id, + cookie_banner_id, + cookie_category_id, + resource_type, + origin, + path, + display_name, + description, + excluded, + last_detected_at, + created_at, + updated_at +) VALUES ( + @id, + @tenant_id, + @organization_id, + @cookie_banner_id, + @cookie_category_id, + @resource_type, + @origin, + @path, + @display_name, + @description, + @excluded, + @last_detected_at, + @created_at, + @updated_at +) +` + + args := pgx.StrictNamedArgs{ + "id": tr.ID, + "tenant_id": scope.GetTenantID(), + "organization_id": tr.OrganizationID, + "cookie_banner_id": tr.CookieBannerID, + "cookie_category_id": tr.CookieCategoryID, + "resource_type": tr.ResourceType, + "origin": tr.Origin, + "path": tr.Path, + "display_name": tr.DisplayName, + "description": tr.Description, + "excluded": tr.Excluded, + "last_detected_at": tr.LastDetectedAt, + "created_at": tr.CreatedAt, + "updated_at": tr.UpdatedAt, + } + + _, err := tx.Exec(ctx, q, args) + if err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok { + if pgErr.Code == "23505" && pgErr.ConstraintName == "idx_tracker_resources_unique_resource_per_banner" { + return ErrResourceAlreadyExists + } + } + return fmt.Errorf("cannot insert tracker resource: %w", err) + } + + return nil +} + +// Upsert inserts a new tracker resource or bumps last_detected_at on the +// existing row matching (cookie_banner_id, resource_type, origin, path). +// Returns true when a new row was inserted. +func (tr *TrackerResource) Upsert( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) (bool, error) { + q := ` +INSERT INTO tracker_resources ( + id, + tenant_id, + organization_id, + cookie_banner_id, + cookie_category_id, + resource_type, + origin, + path, + display_name, + description, + excluded, + last_detected_at, + created_at, + updated_at +) VALUES ( + @id, + @tenant_id, + @organization_id, + @cookie_banner_id, + @cookie_category_id, + @resource_type, + @origin, + @path, + @display_name, + @description, + @excluded, + @last_detected_at, + @created_at, + @updated_at +) +ON CONFLICT (cookie_banner_id, resource_type, origin, path) DO UPDATE SET + last_detected_at = GREATEST(tracker_resources.last_detected_at, EXCLUDED.last_detected_at), + updated_at = EXCLUDED.updated_at +RETURNING (xmax = 0) AS inserted +` + + args := pgx.StrictNamedArgs{ + "id": tr.ID, + "tenant_id": scope.GetTenantID(), + "organization_id": tr.OrganizationID, + "cookie_banner_id": tr.CookieBannerID, + "cookie_category_id": tr.CookieCategoryID, + "resource_type": tr.ResourceType, + "origin": tr.Origin, + "path": tr.Path, + "display_name": tr.DisplayName, + "description": tr.Description, + "excluded": tr.Excluded, + "last_detected_at": tr.LastDetectedAt, + "created_at": tr.CreatedAt, + "updated_at": tr.UpdatedAt, + } + + var inserted bool + if err := tx.QueryRow(ctx, q, args).Scan(&inserted); err != nil { + return false, fmt.Errorf("cannot upsert tracker resource: %w", err) + } + + return inserted, nil +} + +func (tr *TrackerResource) Update( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) error { + q := ` +UPDATE tracker_resources +SET + cookie_category_id = @cookie_category_id, + display_name = @display_name, + description = @description, + excluded = @excluded, + last_detected_at = @last_detected_at, + updated_at = @updated_at +WHERE + %s + AND id = @id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "id": tr.ID, + "cookie_category_id": tr.CookieCategoryID, + "display_name": tr.DisplayName, + "description": tr.Description, + "excluded": tr.Excluded, + "last_detected_at": tr.LastDetectedAt, + "updated_at": tr.UpdatedAt, + } + maps.Copy(args, scope.SQLArguments()) + + result, err := tx.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot update tracker resource: %w", err) + } + + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + + return nil +} + +func (tr *TrackerResource) Delete( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) error { + q := ` +DELETE FROM tracker_resources +WHERE + %s + AND id = @id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"id": tr.ID} + maps.Copy(args, scope.SQLArguments()) + + _, err := tx.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot delete tracker resource: %w", err) + } + + return nil +} + +func (trs *TrackerResources) LoadAllByCookieBannerID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieBannerID gid.GID, + filter *TrackerResourceFilter, +) error { + q := ` +SELECT + id, + organization_id, + cookie_banner_id, + cookie_category_id, + resource_type, + origin, + path, + display_name, + description, + excluded, + last_detected_at, + created_at, + updated_at +FROM + tracker_resources +WHERE + %s + AND cookie_banner_id = @cookie_banner_id + AND %s +ORDER BY + created_at ASC, id ASC; +` + + q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment()) + + args := pgx.StrictNamedArgs{"cookie_banner_id": cookieBannerID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, filter.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query tracker resources: %w", err) + } + + resources, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[TrackerResource]) + if err != nil { + return fmt.Errorf("cannot collect tracker resources: %w", err) + } + + *trs = resources + + return nil +} + +func (trs *TrackerResources) LoadUncategorisedByCookieBannerID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieBannerID gid.GID, + cursor *page.Cursor[TrackerResourceOrderField], + filter *TrackerResourceFilter, +) error { + q := ` +SELECT + id, + organization_id, + cookie_banner_id, + cookie_category_id, + resource_type, + origin, + path, + display_name, + description, + excluded, + last_detected_at, + created_at, + updated_at +FROM + tracker_resources +WHERE + %s + AND cookie_banner_id = @cookie_banner_id + AND cookie_category_id = ( + SELECT id FROM cookie_categories + WHERE cookie_banner_id = @cookie_banner_id + AND kind = @category_kind + AND %s + LIMIT 1 + ) + AND %s + AND %s +` + + q = fmt.Sprintf(q, scope.SQLFragment(), scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "cookie_banner_id": cookieBannerID, + "category_kind": CookieCategoryKindUncategorised, + } + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, filter.SQLArguments()) + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query uncategorised tracker resources: %w", err) + } + + resources, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[TrackerResource]) + if err != nil { + return fmt.Errorf("cannot collect uncategorised tracker resources: %w", err) + } + + *trs = resources + + return nil +} + +func (trs *TrackerResources) CountUncategorisedByCookieBannerID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieBannerID gid.GID, + filter *TrackerResourceFilter, +) (int, error) { + q := ` +SELECT + COUNT(id) +FROM + tracker_resources +WHERE + %s + AND cookie_banner_id = @cookie_banner_id + AND cookie_category_id = ( + SELECT id FROM cookie_categories + WHERE cookie_banner_id = @cookie_banner_id + AND kind = @category_kind + AND %s + LIMIT 1 + ) + AND %s +` + + q = fmt.Sprintf(q, scope.SQLFragment(), scope.SQLFragment(), filter.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "cookie_banner_id": cookieBannerID, + "category_kind": CookieCategoryKindUncategorised, + } + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, filter.SQLArguments()) + + row := conn.QueryRow(ctx, q, args) + + var count int + if err := row.Scan(&count); err != nil { + return 0, fmt.Errorf("cannot scan count: %w", err) + } + + return count, nil +} + +func (trs *TrackerResources) LoadByCookieCategoryID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieCategoryID gid.GID, + cursor *page.Cursor[TrackerResourceOrderField], +) error { + q := ` +SELECT + id, + organization_id, + cookie_banner_id, + cookie_category_id, + resource_type, + origin, + path, + display_name, + description, + excluded, + last_detected_at, + created_at, + updated_at +FROM + tracker_resources +WHERE + %s + AND cookie_category_id = @cookie_category_id + AND %s +` + + q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) + + args := pgx.StrictNamedArgs{"cookie_category_id": cookieCategoryID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query tracker resources: %w", err) + } + + resources, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[TrackerResource]) + if err != nil { + return fmt.Errorf("cannot collect tracker resources: %w", err) + } + + *trs = resources + + return nil +} + +func (trs *TrackerResources) CountByCookieCategoryID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieCategoryID gid.GID, +) (int, error) { + q := ` +SELECT + COUNT(id) +FROM + tracker_resources +WHERE + %s + AND cookie_category_id = @cookie_category_id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"cookie_category_id": cookieCategoryID} + maps.Copy(args, scope.SQLArguments()) + + row := conn.QueryRow(ctx, q, args) + + var count int + if err := row.Scan(&count); err != nil { + return 0, fmt.Errorf("cannot scan count: %w", err) + } + + return count, nil +} + +func (trs *TrackerResources) MoveToCategoryByCookieCategoryID( + ctx context.Context, + tx pg.Tx, + scope Scoper, + sourceCategoryID gid.GID, + targetCategoryID gid.GID, +) error { + q := ` +UPDATE tracker_resources +SET + cookie_category_id = @target_category_id, + updated_at = @updated_at +WHERE + %s + AND cookie_category_id = @source_category_id +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "source_category_id": sourceCategoryID, + "target_category_id": targetCategoryID, + "updated_at": time.Now(), + } + maps.Copy(args, scope.SQLArguments()) + + _, err := tx.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot move tracker resources to category: %w", err) + } + + return nil +} diff --git a/pkg/coredata/tracker_resource_filter.go b/pkg/coredata/tracker_resource_filter.go new file mode 100644 index 000000000..9032660d9 --- /dev/null +++ b/pkg/coredata/tracker_resource_filter.go @@ -0,0 +1,123 @@ +// 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 coredata + +import ( + "github.com/jackc/pgx/v5" + "go.probo.inc/probo/pkg/gid" +) + +type TrackerResourceFilter struct { + cookieCategoryID *gid.GID + excluded *bool + query *string + resourceType *TrackerResourceType +} + +func NewTrackerResourceFilter( + cookieCategoryID *gid.GID, + excluded *bool, +) *TrackerResourceFilter { + return &TrackerResourceFilter{ + cookieCategoryID: cookieCategoryID, + excluded: excluded, + } +} + +func (f *TrackerResourceFilter) WithQuery(query *string) *TrackerResourceFilter { + f.query = query + return f +} + +func (f *TrackerResourceFilter) WithResourceType(resourceType *TrackerResourceType) *TrackerResourceFilter { + f.resourceType = resourceType + return f +} + +func (f *TrackerResourceFilter) SQLFragment() string { + if f == nil { + return "TRUE" + } + + return ` +( + CASE + WHEN @has_cookie_category_id_filter::boolean = false THEN TRUE + WHEN @has_cookie_category_id_filter::boolean = true THEN + cookie_category_id = @filter_cookie_category_id::text + ELSE TRUE + END + AND + CASE + WHEN @has_excluded_filter::boolean = false THEN TRUE + WHEN @has_excluded_filter::boolean = true THEN + excluded = @filter_excluded + ELSE TRUE + END + AND + CASE + WHEN @filter_query::text IS NOT NULL AND @filter_query::text != '' THEN + (origin ILIKE '%' || @filter_query || '%' + OR path ILIKE '%' || @filter_query || '%' + OR display_name ILIKE '%' || @filter_query || '%' + OR description ILIKE '%' || @filter_query || '%') + ELSE TRUE + END + AND + CASE + WHEN @has_resource_type_filter::boolean = false THEN TRUE + WHEN @has_resource_type_filter::boolean = true THEN + resource_type = @filter_resource_type::tracker_resource_type + ELSE TRUE + END +)` +} + +func (f *TrackerResourceFilter) SQLArguments() pgx.StrictNamedArgs { + if f == nil { + return pgx.StrictNamedArgs{} + } + + args := pgx.StrictNamedArgs{ + "has_cookie_category_id_filter": false, + "filter_cookie_category_id": nil, + "has_excluded_filter": false, + "filter_excluded": nil, + "filter_query": nil, + "has_resource_type_filter": false, + "filter_resource_type": nil, + } + + if f.cookieCategoryID != nil { + args["has_cookie_category_id_filter"] = true + args["filter_cookie_category_id"] = *f.cookieCategoryID + } + + if f.excluded != nil { + args["has_excluded_filter"] = true + args["filter_excluded"] = *f.excluded + } + + if f.query != nil { + args["filter_query"] = *f.query + } + + if f.resourceType != nil { + args["has_resource_type_filter"] = true + args["filter_resource_type"] = string(*f.resourceType) + } + + return args +} diff --git a/pkg/coredata/tracker_resource_order_field.go b/pkg/coredata/tracker_resource_order_field.go new file mode 100644 index 000000000..cf476277f --- /dev/null +++ b/pkg/coredata/tracker_resource_order_field.go @@ -0,0 +1,67 @@ +// 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 coredata + +import "fmt" + +type TrackerResourceOrderField string + +const ( + TrackerResourceOrderFieldCreatedAt TrackerResourceOrderField = "CREATED_AT" + TrackerResourceOrderFieldLastDetectedAt TrackerResourceOrderField = "LAST_DETECTED_AT" + TrackerResourceOrderFieldOrigin TrackerResourceOrderField = "ORIGIN" + TrackerResourceOrderFieldUpdatedAt TrackerResourceOrderField = "UPDATED_AT" +) + +func (p TrackerResourceOrderField) Column() string { + switch p { + case TrackerResourceOrderFieldCreatedAt: + return "created_at" + case TrackerResourceOrderFieldLastDetectedAt: + return "COALESCE(last_detected_at, '0001-01-01T00:00:00Z'::timestamptz)" + case TrackerResourceOrderFieldOrigin: + return "origin" + case TrackerResourceOrderFieldUpdatedAt: + return "updated_at" + } + panic(fmt.Sprintf("unsupported order by: %s", p)) +} + +func (p TrackerResourceOrderField) IsValid() bool { + switch p { + case TrackerResourceOrderFieldCreatedAt, + TrackerResourceOrderFieldLastDetectedAt, + TrackerResourceOrderFieldOrigin, + TrackerResourceOrderFieldUpdatedAt: + return true + } + return false +} + +func (p TrackerResourceOrderField) String() string { + return string(p) +} + +func (p *TrackerResourceOrderField) UnmarshalText(text []byte) error { + *p = TrackerResourceOrderField(text) + if !p.IsValid() { + return fmt.Errorf("%s is not a valid TrackerResourceOrderField", string(text)) + } + return nil +} + +func (p TrackerResourceOrderField) MarshalText() ([]byte, error) { + return []byte(p.String()), nil +} diff --git a/pkg/coredata/tracker_resource_type.go b/pkg/coredata/tracker_resource_type.go new file mode 100644 index 000000000..0ec8e50c3 --- /dev/null +++ b/pkg/coredata/tracker_resource_type.go @@ -0,0 +1,70 @@ +// 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 coredata + +import ( + "database/sql/driver" + "fmt" +) + +type TrackerResourceType string + +const ( + TrackerResourceTypeScript TrackerResourceType = "SCRIPT" + TrackerResourceTypeIframe TrackerResourceType = "IFRAME" +) + +func TrackerResourceTypes() []TrackerResourceType { + return []TrackerResourceType{ + TrackerResourceTypeScript, + TrackerResourceTypeIframe, + } +} + +func (s TrackerResourceType) String() string { + return string(s) +} + +func (s *TrackerResourceType) Scan(value any) error { + var v string + switch val := value.(type) { + case string: + v = val + case []byte: + v = string(val) + default: + return fmt.Errorf("unsupported type for TrackerResourceType: %T", value) + } + + switch TrackerResourceType(v) { + case TrackerResourceTypeScript: + *s = TrackerResourceTypeScript + case TrackerResourceTypeIframe: + *s = TrackerResourceTypeIframe + default: + return fmt.Errorf("invalid TrackerResourceType value: %q", v) + } + return nil +} + +func (s TrackerResourceType) Value() (driver.Value, error) { + switch s { + case TrackerResourceTypeScript, + TrackerResourceTypeIframe: + return string(s), nil + default: + return nil, fmt.Errorf("invalid TrackerResourceType: %s", s) + } +} diff --git a/pkg/coredata/tracker_type.go b/pkg/coredata/tracker_type.go index bdb3217e2..feca1f145 100644 --- a/pkg/coredata/tracker_type.go +++ b/pkg/coredata/tracker_type.go @@ -26,8 +26,6 @@ const ( TrackerTypeLocalStorage TrackerType = "LOCAL_STORAGE" TrackerTypeSessionStorage TrackerType = "SESSION_STORAGE" TrackerTypeIndexedDB TrackerType = "INDEXED_DB" - TrackerTypeScript TrackerType = "SCRIPT" - TrackerTypeIframe TrackerType = "IFRAME" ) func TrackerTypes() []TrackerType { @@ -36,8 +34,6 @@ func TrackerTypes() []TrackerType { TrackerTypeLocalStorage, TrackerTypeSessionStorage, TrackerTypeIndexedDB, - TrackerTypeScript, - TrackerTypeIframe, } } @@ -65,10 +61,6 @@ func (s *TrackerType) Scan(value any) error { *s = TrackerTypeSessionStorage case TrackerTypeIndexedDB: *s = TrackerTypeIndexedDB - case TrackerTypeScript: - *s = TrackerTypeScript - case TrackerTypeIframe: - *s = TrackerTypeIframe default: return fmt.Errorf("invalid TrackerType value: %q", v) } @@ -80,9 +72,7 @@ func (s TrackerType) Value() (driver.Value, error) { case TrackerTypeCookie, TrackerTypeLocalStorage, TrackerTypeSessionStorage, - TrackerTypeIndexedDB, - TrackerTypeScript, - TrackerTypeIframe: + TrackerTypeIndexedDB: return string(s), nil default: return nil, fmt.Errorf("invalid TrackerType: %s", s) diff --git a/pkg/server/api/console/v1/graphql/cookie_banner.graphql b/pkg/server/api/console/v1/graphql/cookie_banner.graphql index 798fe4a0e..941c75400 100644 --- a/pkg/server/api/console/v1/graphql/cookie_banner.graphql +++ b/pkg/server/api/console/v1/graphql/cookie_banner.graphql @@ -52,14 +52,6 @@ enum TrackerType @goEnum( value: "go.probo.inc/probo/pkg/coredata.TrackerTypeIndexedDB" ) - SCRIPT - @goEnum( - value: "go.probo.inc/probo/pkg/coredata.TrackerTypeScript" - ) - IFRAME - @goEnum( - value: "go.probo.inc/probo/pkg/coredata.TrackerTypeIframe" - ) } enum CookieBannerOrderField diff --git a/pkg/server/api/cookiebanner/v1/handler.go b/pkg/server/api/cookiebanner/v1/handler.go index 0506088bc..7753071cb 100644 --- a/pkg/server/api/cookiebanner/v1/handler.go +++ b/pkg/server/api/cookiebanner/v1/handler.go @@ -405,12 +405,12 @@ func (h *Handler) handleReportDetectedTrackers(w http.ResponseWriter, r *http.Re } for _, res := range body.Resources { - var resourceType coredata.TrackerType + var resourceType coredata.TrackerResourceType switch strings.TrimSpace(res.ResourceType) { case "script": - resourceType = coredata.TrackerTypeScript + resourceType = coredata.TrackerResourceTypeScript case "iframe": - resourceType = coredata.TrackerTypeIframe + resourceType = coredata.TrackerResourceTypeIframe default: continue }