Introduce a cookie_patterns table that groups cookies sharing a common prefix (e.g. phc_*) into a single manageable row. Every cookie now belongs to a pattern (EXACT or PREFIX match type). Category, description, and display metadata move from cookies to patterns, making patterns the unit of management and display in the console and published snapshots. Signed-off-by: Émile Ré <emile@getprobo.com>
489 lines
11 KiB
Go
489 lines
11 KiB
Go
// 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 (
|
|
CookiePattern 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"`
|
|
Pattern string `db:"pattern"`
|
|
MatchType CookiePatternMatchType `db:"match_type"`
|
|
DisplayName string `db:"display_name"`
|
|
Duration string `db:"duration"`
|
|
Description string `db:"description"`
|
|
Source CookieSource `db:"source"`
|
|
CreatedAt time.Time `db:"created_at"`
|
|
UpdatedAt time.Time `db:"updated_at"`
|
|
}
|
|
|
|
CookiePatterns []*CookiePattern
|
|
)
|
|
|
|
func (cp *CookiePattern) CursorKey(field CookiePatternOrderField) page.CursorKey {
|
|
switch field {
|
|
case CookiePatternOrderFieldCreatedAt:
|
|
return page.NewCursorKey(cp.ID, cp.CreatedAt)
|
|
}
|
|
|
|
panic(fmt.Sprintf("unsupported order by: %s", field))
|
|
}
|
|
|
|
func (cp *CookiePattern) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) {
|
|
q := `SELECT organization_id FROM cookie_patterns WHERE id = $1 LIMIT 1;`
|
|
|
|
var organizationID gid.GID
|
|
if err := conn.QueryRow(ctx, q, cp.ID).Scan(&organizationID); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrResourceNotFound
|
|
}
|
|
|
|
return nil, fmt.Errorf("cannot query cookie pattern authorization attributes: %w", err)
|
|
}
|
|
|
|
return map[string]string{"organization_id": organizationID.String()}, nil
|
|
}
|
|
|
|
func (cp *CookiePattern) LoadByID(
|
|
ctx context.Context,
|
|
conn pg.Querier,
|
|
scope Scoper,
|
|
cookiePatternID gid.GID,
|
|
) error {
|
|
q := `
|
|
SELECT
|
|
id,
|
|
organization_id,
|
|
cookie_banner_id,
|
|
cookie_category_id,
|
|
pattern,
|
|
match_type,
|
|
display_name,
|
|
duration,
|
|
description,
|
|
source,
|
|
created_at,
|
|
updated_at
|
|
FROM
|
|
cookie_patterns
|
|
WHERE
|
|
%s
|
|
AND id = @cookie_pattern_id
|
|
LIMIT 1;
|
|
`
|
|
|
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
|
|
|
args := pgx.StrictNamedArgs{"cookie_pattern_id": cookiePatternID}
|
|
maps.Copy(args, scope.SQLArguments())
|
|
|
|
rows, err := conn.Query(ctx, q, args)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot query cookie patterns: %w", err)
|
|
}
|
|
|
|
pattern, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CookiePattern])
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return ErrResourceNotFound
|
|
}
|
|
return fmt.Errorf("cannot collect cookie pattern: %w", err)
|
|
}
|
|
|
|
*cp = pattern
|
|
|
|
return nil
|
|
}
|
|
|
|
func (cp *CookiePattern) FindMatchingPattern(
|
|
ctx context.Context,
|
|
conn pg.Querier,
|
|
scope Scoper,
|
|
cookieBannerID gid.GID,
|
|
cookieName string,
|
|
) error {
|
|
q := `
|
|
SELECT
|
|
id,
|
|
organization_id,
|
|
cookie_banner_id,
|
|
cookie_category_id,
|
|
pattern,
|
|
match_type,
|
|
display_name,
|
|
duration,
|
|
description,
|
|
source,
|
|
created_at,
|
|
updated_at
|
|
FROM
|
|
cookie_patterns
|
|
WHERE
|
|
%s
|
|
AND cookie_banner_id = @cookie_banner_id
|
|
AND (
|
|
(match_type = @match_type_prefix AND starts_with(@cookie_name, pattern))
|
|
OR (match_type = @match_type_exact AND pattern = @cookie_name)
|
|
)
|
|
ORDER BY
|
|
CASE WHEN match_type = @match_type_prefix THEN 0 ELSE 1 END
|
|
LIMIT 1;
|
|
`
|
|
|
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
|
|
|
args := pgx.StrictNamedArgs{
|
|
"cookie_banner_id": cookieBannerID,
|
|
"cookie_name": cookieName,
|
|
"match_type_prefix": CookiePatternMatchTypePrefix,
|
|
"match_type_exact": CookiePatternMatchTypeExact,
|
|
}
|
|
maps.Copy(args, scope.SQLArguments())
|
|
|
|
rows, err := conn.Query(ctx, q, args)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot query cookie patterns: %w", err)
|
|
}
|
|
|
|
pattern, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CookiePattern])
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return ErrResourceNotFound
|
|
}
|
|
return fmt.Errorf("cannot collect cookie pattern: %w", err)
|
|
}
|
|
|
|
*cp = pattern
|
|
|
|
return nil
|
|
}
|
|
|
|
func (cps *CookiePatterns) LoadByCookieCategoryID(
|
|
ctx context.Context,
|
|
conn pg.Querier,
|
|
scope Scoper,
|
|
cookieCategoryID gid.GID,
|
|
cursor *page.Cursor[CookiePatternOrderField],
|
|
) error {
|
|
q := `
|
|
SELECT
|
|
id,
|
|
organization_id,
|
|
cookie_banner_id,
|
|
cookie_category_id,
|
|
pattern,
|
|
match_type,
|
|
display_name,
|
|
duration,
|
|
description,
|
|
source,
|
|
created_at,
|
|
updated_at
|
|
FROM
|
|
cookie_patterns
|
|
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 cookie patterns: %w", err)
|
|
}
|
|
|
|
patterns, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[CookiePattern])
|
|
if err != nil {
|
|
return fmt.Errorf("cannot collect cookie patterns: %w", err)
|
|
}
|
|
|
|
*cps = patterns
|
|
|
|
return nil
|
|
}
|
|
|
|
func (cps *CookiePatterns) CountByCookieCategoryID(
|
|
ctx context.Context,
|
|
conn pg.Querier,
|
|
scope Scoper,
|
|
cookieCategoryID gid.GID,
|
|
) (int, error) {
|
|
q := `
|
|
SELECT
|
|
COUNT(id)
|
|
FROM
|
|
cookie_patterns
|
|
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 (cps *CookiePatterns) LoadAllByCookieBannerID(
|
|
ctx context.Context,
|
|
conn pg.Querier,
|
|
scope Scoper,
|
|
cookieBannerID gid.GID,
|
|
) error {
|
|
q := `
|
|
SELECT
|
|
id,
|
|
organization_id,
|
|
cookie_banner_id,
|
|
cookie_category_id,
|
|
pattern,
|
|
match_type,
|
|
display_name,
|
|
duration,
|
|
description,
|
|
source,
|
|
created_at,
|
|
updated_at
|
|
FROM
|
|
cookie_patterns
|
|
WHERE
|
|
%s
|
|
AND cookie_banner_id = @cookie_banner_id
|
|
ORDER BY
|
|
created_at ASC, id ASC;
|
|
`
|
|
|
|
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 patterns: %w", err)
|
|
}
|
|
|
|
patterns, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[CookiePattern])
|
|
if err != nil {
|
|
return fmt.Errorf("cannot collect cookie patterns: %w", err)
|
|
}
|
|
|
|
*cps = patterns
|
|
|
|
return nil
|
|
}
|
|
|
|
func (cp *CookiePattern) Insert(
|
|
ctx context.Context,
|
|
tx pg.Tx,
|
|
scope Scoper,
|
|
) error {
|
|
q := `
|
|
INSERT INTO cookie_patterns (
|
|
id,
|
|
tenant_id,
|
|
organization_id,
|
|
cookie_banner_id,
|
|
cookie_category_id,
|
|
pattern,
|
|
match_type,
|
|
display_name,
|
|
duration,
|
|
description,
|
|
source,
|
|
created_at,
|
|
updated_at
|
|
) VALUES (
|
|
@id,
|
|
@tenant_id,
|
|
@organization_id,
|
|
@cookie_banner_id,
|
|
@cookie_category_id,
|
|
@pattern,
|
|
@match_type,
|
|
@display_name,
|
|
@duration,
|
|
@description,
|
|
@source,
|
|
@created_at,
|
|
@updated_at
|
|
)
|
|
`
|
|
|
|
args := pgx.StrictNamedArgs{
|
|
"id": cp.ID,
|
|
"tenant_id": scope.GetTenantID(),
|
|
"organization_id": cp.OrganizationID,
|
|
"cookie_banner_id": cp.CookieBannerID,
|
|
"cookie_category_id": cp.CookieCategoryID,
|
|
"pattern": cp.Pattern,
|
|
"match_type": cp.MatchType,
|
|
"display_name": cp.DisplayName,
|
|
"duration": cp.Duration,
|
|
"description": cp.Description,
|
|
"source": cp.Source,
|
|
"created_at": cp.CreatedAt,
|
|
"updated_at": cp.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_cookie_patterns_unique_pattern_per_banner" {
|
|
return ErrResourceAlreadyExists
|
|
}
|
|
}
|
|
return fmt.Errorf("cannot insert cookie pattern: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (cp *CookiePattern) Update(
|
|
ctx context.Context,
|
|
tx pg.Tx,
|
|
scope Scoper,
|
|
) error {
|
|
q := `
|
|
UPDATE cookie_patterns
|
|
SET
|
|
cookie_category_id = @cookie_category_id,
|
|
display_name = @display_name,
|
|
duration = @duration,
|
|
description = @description,
|
|
updated_at = @updated_at
|
|
WHERE
|
|
%s
|
|
AND id = @id
|
|
`
|
|
|
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
|
|
|
args := pgx.StrictNamedArgs{
|
|
"id": cp.ID,
|
|
"cookie_category_id": cp.CookieCategoryID,
|
|
"display_name": cp.DisplayName,
|
|
"duration": cp.Duration,
|
|
"description": cp.Description,
|
|
"updated_at": cp.UpdatedAt,
|
|
}
|
|
maps.Copy(args, scope.SQLArguments())
|
|
|
|
result, 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_cookie_patterns_unique_pattern_per_banner" {
|
|
return ErrResourceAlreadyExists
|
|
}
|
|
}
|
|
return fmt.Errorf("cannot update cookie pattern: %w", err)
|
|
}
|
|
|
|
if result.RowsAffected() == 0 {
|
|
return ErrResourceNotFound
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (cp *CookiePattern) Delete(
|
|
ctx context.Context,
|
|
tx pg.Tx,
|
|
scope Scoper,
|
|
) error {
|
|
q := `
|
|
DELETE FROM cookie_patterns
|
|
WHERE
|
|
%s
|
|
AND id = @id
|
|
`
|
|
|
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
|
|
|
args := pgx.StrictNamedArgs{"id": cp.ID}
|
|
maps.Copy(args, scope.SQLArguments())
|
|
|
|
_, err := tx.Exec(ctx, q, args)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot delete cookie pattern: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (cps *CookiePatterns) MoveToCategoryByCookieCategoryID(
|
|
ctx context.Context,
|
|
tx pg.Tx,
|
|
scope Scoper,
|
|
sourceCategoryID gid.GID,
|
|
targetCategoryID gid.GID,
|
|
) error {
|
|
q := `
|
|
UPDATE cookie_patterns
|
|
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 cookie patterns to category: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|