From 5578350756fb07a29a01b2c6a07731014d030d91 Mon Sep 17 00:00:00 2001 From: Nana Date: Wed, 15 Jul 2026 22:12:52 -0700 Subject: [PATCH 01/20] feat(analytics): add consent + event-tier columns (additive migration) --- 202-config/Database/Tables/CoreTables.php | 3 ++ 202-config/Database/Tables/UserTables.php | 7 ++++ 202-config/functions-upgrade.php | 51 ++++++++++++++++++++++- 202-config/version.php | 2 +- tests/Messaging/MigrationColumnsTest.php | 26 ++++++++++++ 5 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 tests/Messaging/MigrationColumnsTest.php diff --git a/202-config/Database/Tables/CoreTables.php b/202-config/Database/Tables/CoreTables.php index 64dd684e..b90c3975 100644 --- a/202-config/Database/Tables/CoreTables.php +++ b/202-config/Database/Tables/CoreTables.php @@ -176,6 +176,8 @@ public static function messagingSync(): SchemaDefinition ); } + // 202_messaging_events: queued behavioural events; `tier` marks + // essential (always sent) vs analytics (consent-gated) events. public static function messagingEvents(): SchemaDefinition { return SchemaBuilder::fromRawSql( @@ -187,6 +189,7 @@ public static function messagingEvents(): SchemaDefinition `metadata` json DEFAULT NULL, `occurred_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `client_token` varchar(64) NOT NULL, + `tier` enum('essential','analytics') NOT NULL DEFAULT 'analytics', `delivery_status` enum('pending','sent','failed') NOT NULL DEFAULT 'pending', `sync_attempts` int(11) NOT NULL DEFAULT '0', PRIMARY KEY (`id`), diff --git a/202-config/Database/Tables/UserTables.php b/202-config/Database/Tables/UserTables.php index c7ee1be2..ca7f7ca5 100644 --- a/202-config/Database/Tables/UserTables.php +++ b/202-config/Database/Tables/UserTables.php @@ -140,6 +140,13 @@ public static function usersPref(): SchemaDefinition `lpo_status` varchar(16) NOT NULL DEFAULT '', `lpo_bridge_config` text DEFAULT NULL, `lpo_ctx_kw` tinyint(1) NOT NULL DEFAULT '1', + `analytics_consent` enum('granted','denied','unset') NOT NULL DEFAULT 'unset', + `analytics_consent_at` datetime DEFAULT NULL, + `analytics_consent_source` varchar(32) DEFAULT NULL, + `email_marketing_consent` enum('granted','denied','unset') NOT NULL DEFAULT 'unset', + `email_marketing_consent_at` datetime DEFAULT NULL, + `eu_consent_prompt_seen` tinyint(1) NOT NULL DEFAULT '0', + `analytics_geo_is_eu` tinyint(1) DEFAULT NULL, PRIMARY KEY (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci" ); diff --git a/202-config/functions-upgrade.php b/202-config/functions-upgrade.php index d9d541dc..65064492 100755 --- a/202-config/functions-upgrade.php +++ b/202-config/functions-upgrade.php @@ -3883,10 +3883,57 @@ public static function upgrade_databases($time_from) } } + if ($prosper202_version == '1.9.74') { + + // Consent + product-analytics columns (additive, idempotent): two + // independent account-holder consent flags (analytics, email + // marketing) with audit metadata, the one-time EU prompt marker, + // the persisted EU geo flag for cron use, and the + // essential/analytics tier marker on queued messaging events. + // Guarded ALTERs so a partial failure retries cleanly on the + // next run. + $consent_ok = true; + foreach ([ + ['202_users_pref', 'analytics_consent', + "ALTER TABLE `202_users_pref` ADD COLUMN `analytics_consent` enum('granted','denied','unset') NOT NULL DEFAULT 'unset'"], + ['202_users_pref', 'analytics_consent_at', + "ALTER TABLE `202_users_pref` ADD COLUMN `analytics_consent_at` datetime DEFAULT NULL"], + ['202_users_pref', 'analytics_consent_source', + "ALTER TABLE `202_users_pref` ADD COLUMN `analytics_consent_source` varchar(32) DEFAULT NULL"], + ['202_users_pref', 'email_marketing_consent', + "ALTER TABLE `202_users_pref` ADD COLUMN `email_marketing_consent` enum('granted','denied','unset') NOT NULL DEFAULT 'unset'"], + ['202_users_pref', 'email_marketing_consent_at', + "ALTER TABLE `202_users_pref` ADD COLUMN `email_marketing_consent_at` datetime DEFAULT NULL"], + ['202_users_pref', 'eu_consent_prompt_seen', + "ALTER TABLE `202_users_pref` ADD COLUMN `eu_consent_prompt_seen` tinyint(1) NOT NULL DEFAULT '0'"], + ['202_users_pref', 'analytics_geo_is_eu', + "ALTER TABLE `202_users_pref` ADD COLUMN `analytics_geo_is_eu` tinyint(1) DEFAULT NULL"], + ['202_messaging_events', 'tier', + "ALTER TABLE `202_messaging_events` ADD COLUMN `tier` enum('essential','analytics') NOT NULL DEFAULT 'analytics' AFTER `client_token`"], + ] as [$consent_table, $consent_column, $consent_sql]) { + $check = _upgrade_query("SHOW COLUMNS FROM `{$consent_table}` LIKE '{$consent_column}'"); + $exists = ($check instanceof mysqli_result) && $check->num_rows > 0; + if (!$exists && _upgrade_query($consent_sql) === false) { + $consent_ok = false; + error_log("Prosper202 upgrade: failed consent/analytics alter on {$consent_table}.{$consent_column}"); + } + } + + if ($consent_ok) { + if (_upgrade_query("UPDATE 202_version SET version='1.9.75'") !== false) { + $prosper202_version = '1.9.75'; + } else { + error_log('Prosper202 upgrade: added consent/analytics columns but failed to persist version 1.9.75; leaving version at 1.9.74 so the next run retries.'); + } + } else { + error_log('Prosper202 upgrade: consent/analytics column migration incomplete; leaving version at 1.9.74 so the next run retries.'); + } + } + //This will enable p202 to downgrade to this version if installed over a newer version - if (version_compare((string) $prosper202_version, '1.9.74', '>')) { + if (version_compare((string) $prosper202_version, '1.9.75', '>')) { - $prosper202_version = '1.9.74'; + $prosper202_version = '1.9.75'; $sql = "UPDATE 202_version SET version='" . $prosper202_version . "'"; $result = _upgrade_query($sql); } diff --git a/202-config/version.php b/202-config/version.php index dd02f0a6..a6cd5a13 100644 --- a/202-config/version.php +++ b/202-config/version.php @@ -6,7 +6,7 @@ declare(strict_types=1); // Validate version format before defining -$version_string = '1.9.74'; +$version_string = '1.9.75'; if (!preg_match('/^\d+\.\d+\.\d+(-[a-zA-Z0-9]+)?$/', $version_string)) { throw new Exception('Invalid version format: ' . $version_string); } diff --git a/tests/Messaging/MigrationColumnsTest.php b/tests/Messaging/MigrationColumnsTest.php new file mode 100644 index 00000000..7ca13eb8 --- /dev/null +++ b/tests/Messaging/MigrationColumnsTest.php @@ -0,0 +1,26 @@ +assertStringContainsString($col, $sql, "upgrade missing column $col"); + } + } + + public function test_event_table_has_tier_column(): void + { + $files = glob(__DIR__ . '/../../202-config/Database/Tables/*.php'); + $all = ''; + foreach ($files as $f) { $all .= file_get_contents($f); } + $this->assertMatchesRegularExpression('/202_messaging_events.*tier/s', $all); + } +} From e4a5841da29cdc9512765d1263b12b038738f0a2 Mon Sep 17 00:00:00 2001 From: Nana Date: Wed, 15 Jul 2026 22:21:11 -0700 Subject: [PATCH 02/20] feat(analytics): add ConsentPolicy single-chokepoint gate --- 202-config/Messaging/ConsentPolicy.class.php | 106 +++++++++++++++++++ tests/Messaging/ConsentPolicyTest.php | 39 +++++++ 2 files changed, 145 insertions(+) create mode 100644 202-config/Messaging/ConsentPolicy.class.php create mode 100644 tests/Messaging/ConsentPolicyTest.php diff --git a/202-config/Messaging/ConsentPolicy.class.php b/202-config/Messaging/ConsentPolicy.class.php new file mode 100644 index 00000000..5ff8caa5 --- /dev/null +++ b/202-config/Messaging/ConsentPolicy.class.php @@ -0,0 +1,106 @@ +prepare($sql); + if ($stmt === false) { return false; } + $stmt->bind_param('ssi', $state, $source, $userId); + } else { + $sql = "UPDATE `202_users_pref` + SET `email_marketing_consent` = ?, `email_marketing_consent_at` = NOW() + WHERE `user_id` = ?"; + $stmt = $db->prepare($sql); + if ($stmt === false) { return false; } + $stmt->bind_param('si', $state, $userId); + } + $ok = $stmt->execute(); + $stmt->close(); + return (bool) $ok; + } + + public static function rememberGeo(mysqli $db, int $userId, bool $isEu): bool + { + $stmt = $db->prepare("UPDATE `202_users_pref` SET `analytics_geo_is_eu` = ? WHERE `user_id` = ?"); + if ($stmt === false) { return false; } + $v = $isEu ? 1 : 0; + $stmt->bind_param('ii', $v, $userId); + $ok = $stmt->execute(); + $stmt->close(); + return (bool) $ok; + } + + /** @return array{analytics_consent:string,email_marketing_consent:string,is_eu:bool,prompt_seen:bool} */ + private static function loadPref(mysqli $db, int $userId): array + { + $default = [ + 'analytics_consent' => 'unset', + 'email_marketing_consent' => 'unset', + 'is_eu' => false, // unknown geo → treat as non-EU (Global Constraints) + 'prompt_seen' => false, + ]; + $stmt = $db->prepare( + "SELECT `analytics_consent`, `email_marketing_consent`, `analytics_geo_is_eu`, `eu_consent_prompt_seen` + FROM `202_users_pref` WHERE `user_id` = ? LIMIT 1" + ); + if ($stmt === false) { return $default; } + $stmt->bind_param('i', $userId); + if (!$stmt->execute()) { $stmt->close(); return $default; } + $res = $stmt->get_result(); + $row = $res ? $res->fetch_assoc() : null; + $stmt->close(); + if (!$row) { return $default; } + return [ + 'analytics_consent' => $row['analytics_consent'] ?? 'unset', + 'email_marketing_consent' => $row['email_marketing_consent'] ?? 'unset', + // NULL geo (unknown) → false per Global Constraints + 'is_eu' => ((int) ($row['analytics_geo_is_eu'] ?? 0)) === 1, + 'prompt_seen' => ((int) ($row['eu_consent_prompt_seen'] ?? 0)) === 1, + ]; + } +} diff --git a/tests/Messaging/ConsentPolicyTest.php b/tests/Messaging/ConsentPolicyTest.php new file mode 100644 index 00000000..d49c686f --- /dev/null +++ b/tests/Messaging/ConsentPolicyTest.php @@ -0,0 +1,39 @@ +assertTrue(ConsentPolicy::decide($s, $eu, 'essential')); + } + } + } + + public function test_analytics_granted_always_true(): void + { + $this->assertTrue(ConsentPolicy::decide('granted', true, 'analytics')); + $this->assertTrue(ConsentPolicy::decide('granted', false, 'analytics')); + } + + public function test_analytics_denied_always_false(): void + { + $this->assertFalse(ConsentPolicy::decide('denied', true, 'analytics')); + $this->assertFalse(ConsentPolicy::decide('denied', false, 'analytics')); + } + + public function test_analytics_unset_non_eu_true(): void + { + $this->assertTrue(ConsentPolicy::decide('unset', false, 'analytics')); + } + + public function test_analytics_unset_eu_false(): void + { + $this->assertFalse(ConsentPolicy::decide('unset', true, 'analytics')); + } +} From cf10b4817ca37cb304663d8fd6cde8681e013c65 Mon Sep 17 00:00:00 2001 From: Nana Date: Wed, 15 Jul 2026 22:26:02 -0700 Subject: [PATCH 03/20] feat(analytics): add UrlScrubber PII safety net for offer URLs --- 202-config/Messaging/UrlScrubber.class.php | 37 ++++++++++++++++++++++ tests/Messaging/UrlScrubberTest.php | 33 +++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 202-config/Messaging/UrlScrubber.class.php create mode 100644 tests/Messaging/UrlScrubberTest.php diff --git a/202-config/Messaging/UrlScrubber.class.php b/202-config/Messaging/UrlScrubber.class.php new file mode 100644 index 00000000..7b409578 --- /dev/null +++ b/202-config/Messaging/UrlScrubber.class.php @@ -0,0 +1,37 @@ + $v) { + if (!is_string($v)) { continue; } + // parse_str already urldecodes ('+' becomes a space); trim so a + // leading space from an encoded '+' doesn't defeat the anchors. + $decoded = trim(rawurldecode($v)); + if (preg_match(self::EMAIL, $decoded) || preg_match(self::PHONE, $decoded)) { + $params[$k] = '[redacted]'; + $changed = true; + } + } + if (!$changed) { return $url; } + // http_build_query urlencodes {macros}; only touch params if we actually redacted, + // and rebuild only the query portion to preserve the rest of the URL. + $rebuilt = http_build_query($params); + $base = strtok($url, '?'); + $frag = parse_url($url, PHP_URL_FRAGMENT); + return $base . '?' . $rebuilt . ($frag !== null && $frag !== false ? '#' . $frag : ''); + } +} diff --git a/tests/Messaging/UrlScrubberTest.php b/tests/Messaging/UrlScrubberTest.php new file mode 100644 index 00000000..944128f4 --- /dev/null +++ b/tests/Messaging/UrlScrubberTest.php @@ -0,0 +1,33 @@ +assertStringNotContainsString('bar.com', $out); + $this->assertStringContainsString('s2=%5Bredacted%5D', $out); // [redacted] urlencoded + $this->assertStringContainsString('c=1', $out); + } + + public function test_redacts_phone_value(): void + { + $out = UrlScrubber::scrub('https://x.com/o?p=+14155551234'); + $this->assertStringNotContainsString('4155551234', $out); + } + + public function test_leaves_macro_token_untouched(): void + { + $url = 'https://x.com/o?s1={clickid}&aff=42'; + $this->assertSame($url, UrlScrubber::scrub($url)); + } + + public function test_no_query_returned_unchanged(): void + { + $url = 'https://x.com/landing'; + $this->assertSame($url, UrlScrubber::scrub($url)); + } +} From 50984302fc795446c3f90880e514958567317fcc Mon Sep 17 00:00:00 2001 From: Nana Date: Wed, 15 Jul 2026 22:33:49 -0700 Subject: [PATCH 04/20] feat(analytics): add gated Analytics facade over MessagingService --- 202-config/Messaging/Analytics.class.php | 60 +++++++++++++++++++ .../Messaging/MessagingService.class.php | 14 +++-- tests/Messaging/AnalyticsGateTest.php | 22 +++++++ 3 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 202-config/Messaging/Analytics.class.php create mode 100644 tests/Messaging/AnalyticsGateTest.php diff --git a/202-config/Messaging/Analytics.class.php b/202-config/Messaging/Analytics.class.php new file mode 100644 index 00000000..ce62d05b --- /dev/null +++ b/202-config/Messaging/Analytics.class.php @@ -0,0 +1,60 @@ +recordEvent($name, $meta, $tier); + }); + } + + public static function attr(array $attributes, string $tier = 'analytics'): void + { + self::guarded(function (mysqli $db, int $uid) use ($attributes, $tier) { + if ($tier === 'analytics' && !ConsentPolicy::analyticsAllowed($db, $uid)) { + return; + } + $service = new MessagingService($db, $uid, []); + $service->updateAttributes($attributes); + }); + } + + /** Thin testable wrapper: would a write of this tier be recorded for this user? */ + public static function wouldRecord(mysqli $db, int $userId, string $tier): bool + { + if ($tier !== 'analytics') { + return true; + } + return ConsentPolicy::analyticsAllowed($db, $userId); + } + + private static function guarded(callable $fn): void + { + try { + $db = $GLOBALS['db'] ?? null; + $uid = (int) ($_SESSION['user_id'] ?? 0); + if (!($db instanceof mysqli) || $uid <= 0) { return; } + $fn($db, $uid); + } catch (\Throwable $e) { + // Never let tracking break a host page. Log if a logger exists; otherwise swallow. + if (function_exists('error_log')) { + error_log('[Analytics] ' . $e->getMessage()); + } + } + } +} diff --git a/202-config/Messaging/MessagingService.class.php b/202-config/Messaging/MessagingService.class.php index 7ea978ce..43d7b148 100644 --- a/202-config/Messaging/MessagingService.class.php +++ b/202-config/Messaging/MessagingService.class.php @@ -790,14 +790,20 @@ public function updateAttributes(array $attributes): void * Record a behavioural event for later delivery to the central server. * * @param array|null $metadata + * @param string $tier 'essential' or 'analytics' (202_messaging_events.tier). */ - public function recordEvent(string $name, ?array $metadata = null): void + public function recordEvent(string $name, ?array $metadata = null, string $tier = 'analytics'): void { $name = trim($name); if ($name === '') { return; } + // Guard the ENUM: an unknown tier would fail the INSERT under strict mode. + if (!in_array($tier, ['essential', 'analytics'], true)) { + $tier = 'analytics'; + } + $metaJson = null; if ($metadata !== null && $metadata !== []) { $encoded = json_encode($metadata); @@ -811,14 +817,14 @@ public function recordEvent(string $name, ?array $metadata = null): void $token = $this->generateToken(); $now = date('Y-m-d H:i:s'); $sql = "INSERT INTO 202_messaging_events - (user_id, event_name, metadata, occurred_at, client_token, delivery_status) - VALUES (?, ?, ?, ?, ?, 'pending')"; + (user_id, event_name, metadata, occurred_at, client_token, delivery_status, tier) + VALUES (?, ?, ?, ?, ?, 'pending', ?)"; $stmt = $this->db->prepare($sql); if (!$stmt) { error_log('MessagingService: prepare recordEvent failed'); return; } - $stmt->bind_param('issss', $this->userId, $name, $metaJson, $now, $token); + $stmt->bind_param('isssss', $this->userId, $name, $metaJson, $now, $token, $tier); if (!$stmt->execute()) { error_log('MessagingService: recordEvent failed'); } diff --git a/tests/Messaging/AnalyticsGateTest.php b/tests/Messaging/AnalyticsGateTest.php new file mode 100644 index 00000000..e726e9fd --- /dev/null +++ b/tests/Messaging/AnalyticsGateTest.php @@ -0,0 +1,22 @@ +assertFalse(Analytics::gate('unset', true, 'analytics')); + } + public function test_gate_essential_always_true(): void + { + $this->assertTrue(Analytics::gate('denied', true, 'essential')); + } + public function test_gate_analytics_granted(): void + { + $this->assertTrue(Analytics::gate('granted', true, 'analytics')); + } +} From 3a53b045cc3ce6f60e6bcde2dc072d0369bfef3c Mon Sep 17 00:00:00 2001 From: Nana Date: Wed, 15 Jul 2026 22:41:40 -0700 Subject: [PATCH 05/20] fix(analytics): close unknown-tier consent bypass in Analytics facade Normalize tiers in Analytics with ConsentPolicy::decide semantics (anything not 'essential' is 'analytics' and consent-gated), route event()/attr() gating through wouldRecord(), and make MessagingService::recordEvent() reject unknown tiers with a log instead of silently coercing them to 'analytics'. --- 202-config/Messaging/Analytics.class.php | 18 +++++++++++++++--- .../Messaging/MessagingService.class.php | 7 +++++-- tests/Messaging/AnalyticsGateTest.php | 17 +++++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/202-config/Messaging/Analytics.class.php b/202-config/Messaging/Analytics.class.php index ce62d05b..ef4be3af 100644 --- a/202-config/Messaging/Analytics.class.php +++ b/202-config/Messaging/Analytics.class.php @@ -11,10 +11,22 @@ public static function gate(string $stored, bool $isEu, string $tier): bool return ConsentPolicy::decide($stored, $isEu, $tier); } + /** + * Normalize a tier with the same semantics as ConsentPolicy::decide: + * anything that is not exactly 'essential' is treated as 'analytics' + * and therefore consent-gated. Never fail open on a typo or a future + * tier name. Exposed for testing. + */ + public static function normalizeTier(string $tier): string + { + return $tier === 'essential' ? 'essential' : 'analytics'; + } + public static function event(string $name, array $meta = [], string $tier = 'analytics'): void { self::guarded(function (mysqli $db, int $uid) use ($name, $meta, $tier) { - if ($tier === 'analytics' && !ConsentPolicy::analyticsAllowed($db, $uid)) { + $tier = self::normalizeTier($tier); + if (!self::wouldRecord($db, $uid, $tier)) { return; } $service = new MessagingService($db, $uid, []); @@ -26,7 +38,7 @@ public static function event(string $name, array $meta = [], string $tier = 'ana public static function attr(array $attributes, string $tier = 'analytics'): void { self::guarded(function (mysqli $db, int $uid) use ($attributes, $tier) { - if ($tier === 'analytics' && !ConsentPolicy::analyticsAllowed($db, $uid)) { + if (!self::wouldRecord($db, $uid, self::normalizeTier($tier))) { return; } $service = new MessagingService($db, $uid, []); @@ -37,7 +49,7 @@ public static function attr(array $attributes, string $tier = 'analytics'): void /** Thin testable wrapper: would a write of this tier be recorded for this user? */ public static function wouldRecord(mysqli $db, int $userId, string $tier): bool { - if ($tier !== 'analytics') { + if (self::normalizeTier($tier) === 'essential') { return true; } return ConsentPolicy::analyticsAllowed($db, $userId); diff --git a/202-config/Messaging/MessagingService.class.php b/202-config/Messaging/MessagingService.class.php index 43d7b148..b9ebccac 100644 --- a/202-config/Messaging/MessagingService.class.php +++ b/202-config/Messaging/MessagingService.class.php @@ -799,9 +799,12 @@ public function recordEvent(string $name, ?array $metadata = null, string $tier return; } - // Guard the ENUM: an unknown tier would fail the INSERT under strict mode. + // Reject unknown tiers loudly: silently coercing would persist data + // as consent-gated 'analytics' without ConsentPolicy ever being + // consulted. Callers must normalize (see Analytics::normalizeTier). if (!in_array($tier, ['essential', 'analytics'], true)) { - $tier = 'analytics'; + error_log("MessagingService: recordEvent rejected unknown tier '{$tier}' for event '{$name}'"); + return; } $metaJson = null; diff --git a/tests/Messaging/AnalyticsGateTest.php b/tests/Messaging/AnalyticsGateTest.php index e726e9fd..f62a3c08 100644 --- a/tests/Messaging/AnalyticsGateTest.php +++ b/tests/Messaging/AnalyticsGateTest.php @@ -19,4 +19,21 @@ public function test_gate_analytics_granted(): void { $this->assertTrue(Analytics::gate('granted', true, 'analytics')); } + // Unknown tiers must not bypass the gate: anything not 'essential' + // normalizes to 'analytics' and is consent-gated (matches decide()). + public function test_normalize_tier_treats_unknown_as_analytics(): void + { + $this->assertSame('analytics', Analytics::normalizeTier('analytic')); + $this->assertSame('analytics', Analytics::normalizeTier('marketing')); + $this->assertSame('analytics', Analytics::normalizeTier('')); + $this->assertSame('analytics', Analytics::normalizeTier('Essential')); + $this->assertSame('essential', Analytics::normalizeTier('essential')); + $this->assertSame('analytics', Analytics::normalizeTier('analytics')); + } + public function test_gate_unknown_tier_is_gated_like_analytics(): void + { + $this->assertFalse(Analytics::gate('denied', false, Analytics::normalizeTier('marketing'))); + $this->assertFalse(Analytics::gate('unset', true, Analytics::normalizeTier('analytic'))); + $this->assertTrue(Analytics::gate('granted', true, Analytics::normalizeTier('marketing'))); + } } From da844726c2ae4c12e3a34f88a232422cfb5583ea Mon Sep 17 00:00:00 2001 From: Nana Date: Wed, 15 Jul 2026 22:49:19 -0700 Subject: [PATCH 06/20] fix(analytics): gate client track.php through ConsentPolicy, reject bad JSON --- 202-account/ajax/messaging/track.php | 29 +++++++++++----------- tests/Messaging/TrackEndpointGuardTest.php | 20 +++++++++++++++ 2 files changed, 34 insertions(+), 15 deletions(-) create mode 100644 tests/Messaging/TrackEndpointGuardTest.php diff --git a/202-account/ajax/messaging/track.php b/202-account/ajax/messaging/track.php index d810a663..12b75ec5 100644 --- a/202-account/ajax/messaging/track.php +++ b/202-account/ajax/messaging/track.php @@ -5,6 +5,7 @@ include_once(str_repeat('../', 3) . '202-config/connect.php'); require __DIR__ . '/_auth.php'; +require_once dirname(__DIR__, 3) . '/202-config/Messaging/Analytics.class.php'; header('Content-Type: application/json'); @@ -15,12 +16,10 @@ exit; } -$userId = $messagingUserId; -$service = MessagingService::forUser($userId); - -if ($service === null) { - http_response_code(409); - echo json_encode(['ok' => false, 'error' => 'messaging unavailable for this account']); +// --- analytics consent gate: client events are analytics-tier --- +require_once dirname(__DIR__, 3) . '/202-config/Messaging/ConsentPolicy.class.php'; +if (!ConsentPolicy::analyticsAllowed($db, $messagingUserId)) { + echo json_encode(['ok' => true, 'recorded' => false, 'reason' => 'analytics_not_consented']); exit; } @@ -28,29 +27,29 @@ // 1. Custom attributes for segmentation: Prosper202Messenger('update', {...}) if (isset($_POST['update']) && $_POST['update'] !== '') { - $attributes = json_decode((string) $_POST['update'], true); + $decoded = json_decode((string) $_POST['update'], true); // Reject malformed input explicitly rather than silently ignoring it (CLAUDE.md #4). - if (json_last_error() !== JSON_ERROR_NONE || !is_array($attributes)) { + if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) { http_response_code(400); - echo json_encode(['ok' => false, 'error' => 'update must be a JSON object']); + echo json_encode(['ok' => false, 'error' => 'invalid_update_json']); exit; } - $service->updateAttributes($attributes); + Analytics::attr($decoded, 'analytics'); $handled = true; } // 2. Behavioural event: Prosper202Messenger('trackEvent', name, metadata) if (isset($_POST['event_name']) && trim((string) $_POST['event_name']) !== '') { - $metadata = null; + $meta = []; if (isset($_POST['metadata']) && $_POST['metadata'] !== '') { - $metadata = json_decode((string) $_POST['metadata'], true); - if (json_last_error() !== JSON_ERROR_NONE || !is_array($metadata)) { + $meta = json_decode((string) $_POST['metadata'], true); + if (json_last_error() !== JSON_ERROR_NONE || !is_array($meta)) { http_response_code(400); - echo json_encode(['ok' => false, 'error' => 'metadata must be a JSON object']); + echo json_encode(['ok' => false, 'error' => 'invalid_metadata_json']); exit; } } - $service->recordEvent((string) $_POST['event_name'], $metadata); + Analytics::event((string) $_POST['event_name'], $meta, 'analytics'); $handled = true; } diff --git a/tests/Messaging/TrackEndpointGuardTest.php b/tests/Messaging/TrackEndpointGuardTest.php new file mode 100644 index 00000000..4bec5dde --- /dev/null +++ b/tests/Messaging/TrackEndpointGuardTest.php @@ -0,0 +1,20 @@ +assertStringContainsString('ConsentPolicy::analyticsAllowed', $src, + 'track.php must gate analytics writes through ConsentPolicy'); + } + + public function test_track_php_rejects_malformed_json_explicitly(): void + { + $src = file_get_contents(__DIR__ . '/../../202-account/ajax/messaging/track.php'); + // must NOT silently swallow bad JSON with "?? []" + $this->assertDoesNotMatchRegularExpression('/json_decode\([^;]*\)\s*\?\?\s*\[\]/', $src); + } +} From 52b71e8b3b5e08447f4208a15b6408f4d2bb6704 Mon Sep 17 00:00:00 2001 From: Nana Date: Wed, 15 Jul 2026 22:59:59 -0700 Subject: [PATCH 07/20] feat(analytics): server-side page_viewed + persist account EU geo at login --- 202-config/functions-auth.php | 14 ++++++++++++++ 202-config/template.php | 6 ++++++ tests/Messaging/PageViewWiringTest.php | 17 +++++++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 tests/Messaging/PageViewWiringTest.php diff --git a/202-config/functions-auth.php b/202-config/functions-auth.php index d9290b89..7aa55c55 100755 --- a/202-config/functions-auth.php +++ b/202-config/functions-auth.php @@ -266,6 +266,20 @@ public static function begin_user_session(array $user_row): void } self::$sessionHeartbeatRefreshed = true; + + // Persist the account holder's EU status once known (unknown → non-EU). + // Tracking must never break login: guard the connection and swallow failures. + try { + require_once __DIR__ . '/Messaging/ConsentPolicy.class.php'; + $db = $GLOBALS['db'] ?? null; + if (isset($db) && $db instanceof \mysqli && !empty($_SESSION['user_id'])) { + if (!ConsentPolicy::rememberGeo($db, (int) $_SESSION['user_id'], !empty($_SESSION['is_european_union']))) { + error_log('[ConsentPolicy] rememberGeo at login failed for user ' . (int) $_SESSION['user_id']); + } + } + } catch (\Throwable $e) { + error_log('[ConsentPolicy] rememberGeo at login failed: ' . $e->getMessage()); + } } /** diff --git a/202-config/template.php b/202-config/template.php index 59d2c0b0..e7830e2d 100755 --- a/202-config/template.php +++ b/202-config/template.php @@ -501,6 +501,12 @@ function template_bottom() }); + $p202_route], 'analytics'); + ?> +