coredata: split SCRIPT/IFRAME into tracker_resources table

Move resource tracking (scripts, iframes) out of the pattern-based
tracker_patterns/detected_trackers machinery into its own
tracker_resources table keyed by (banner, type, origin, path).

- Add migration that creates the tracker_resource_type enum, the
  tracker_resources table with a unique index, drops existing
  SCRIPT/IFRAME rows (not yet in production), and recreates the
  tracker_type enum without those values.
- Add TrackerResource coredata model with full CRUD, Upsert (bumps
  last_detected_at on conflict), list/count/move operations, filter,
  and order field support.
- Register TrackerResourceEntityType (91) in the entity type registry.
- Drop TrackerTypeScript/TrackerTypeIframe from TrackerType enum.
- Update handler to use TrackerResourceType for resource detection.
- Temporarily stub out resource ingestion in ReportDetectedTrackers
  pending the service-layer wiring in the next commit.
- Drop SCRIPT/IFRAME from the GraphQL TrackerType enum.

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-05-08 18:36:07 +04:00
parent 923488b2b0
commit 93407f6431
10 changed files with 1015 additions and 40 deletions

View File

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

View File

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

View File

@@ -0,0 +1,61 @@
-- 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.
-- 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;

View File

@@ -0,0 +1,682 @@
// 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 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
}

View File

@@ -0,0 +1,123 @@
// 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 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
}

View File

@@ -0,0 +1,67 @@
// 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 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
}

View File

@@ -0,0 +1,70 @@
// 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 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)
}
}

View File

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

View File

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

View File

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