diff --git a/202-Mobile/202-login.php b/202-Mobile/202-login.php index 0bce1c97..0217a70f 100755 --- a/202-Mobile/202-login.php +++ b/202-Mobile/202-login.php @@ -78,7 +78,9 @@ $login_server_serialized, $login_session_serialized ); - $log_stmt->execute(); + if (!$log_stmt->execute()) { + prosper_log('login', 'Unable to write mobile login log row: ' . $log_stmt->error); + } $log_stmt->close(); } elseif ($should_log_attempt) { prosper_log('login', 'Unable to prepare mobile login log statement: ' . $db->error); diff --git a/202-Mobile/mini-stats/202-ministats.php b/202-Mobile/mini-stats/202-ministats.php index bad24b8e..73fbeb2f 100755 --- a/202-Mobile/mini-stats/202-ministats.php +++ b/202-Mobile/mini-stats/202-ministats.php @@ -5,8 +5,12 @@ //grab the users date range preferences $time = grab_timeframe(); $click_filtered = ''; -$mysql['to'] = $db->real_escape_string($time['to']); -$mysql['from'] = $db->real_escape_string($time['from']); +// grab_timeframe() returns int timestamps (mktime/time), and this file declares +// strict_types=1 — passing an int to real_escape_string(string) is a TypeError, +// not a coercion, so the mobile mini-stats page fataled for every account whose +// time preference resolves to a computed window (the default, 'today'). +$mysql['to'] = $db->real_escape_string((string)$time['to']); +$mysql['from'] = $db->real_escape_string((string)$time['from']); //show real or filtered clicks diff --git a/202-account/account.php b/202-account/account.php index 5c3b4071..229bf7cb 100755 --- a/202-account/account.php +++ b/202-account/account.php @@ -26,7 +26,7 @@ $mysql['user_own_id'] = $db->real_escape_string((string)$_SESSION['user_own_id']); $user_sql = "SELECT 2u.user_name as username, 2up.user_slack_incoming_webhook AS url FROM 202_users AS 2u INNER JOIN 202_users_pref AS 2up ON (2up.user_id = 1) WHERE 2u.user_id = '" . $mysql['user_own_id'] . "'"; $user_results = $db->query($user_sql); -$user_row = $user_results->fetch_assoc(); +$user_row = $user_results ? ($user_results->fetch_assoc() ?: []) : []; $username = $user_row['username']; if (!empty($user_row['url'])) @@ -89,16 +89,20 @@ die(); } +// Inbound handoff from my.tracking202.com, which redirects here with the key +// base64'd in the query string. A GET must NOT change state: the vendor cannot +// carry our session token, so this used to be an unauthenticated-origin write — +// any site could and silently +// rewrite the account's customer API key. Decode it, hold it, and let the user +// confirm through the token-checked POST handler below (which performs the same +// validation and write, so there is no second code path to keep in sync). +$pendingCustomerApiKey = null; if (!empty($_GET['customers_api_key'])) { - $mysql['p202_customer_api_key'] = $db->real_escape_string(base64_decode((string) $_GET['customers_api_key'])); - $mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_own_id']); - $validate = validateCustomersApiKey($mysql['p202_customer_api_key']); - if ($validate['code'] != 200) { - $error['p202_customer_api_key_invalid'] = "API key is not valid. Check your key and try again!"; - } - if (!$error) { - $db->query("UPDATE 202_users SET p202_customer_api_key = '" . $mysql['p202_customer_api_key'] . "' WHERE user_id = '" . $mysql['user_id'] . "'"); - $change_p202_customer_api_key = true; + $decodedCustomerApiKey = base64_decode((string) $_GET['customers_api_key'], true); + if ($decodedCustomerApiKey === false || trim($decodedCustomerApiKey) === '') { + $error['p202_customer_api_key_invalid'] = 'That API key link was malformed. Copy your key from my.tracking202.com and paste it in the field below.'; + } else { + $pendingCustomerApiKey = trim($decodedCustomerApiKey); } } @@ -148,7 +152,7 @@ } $user_result = $db->query($user_sql); -$user_row = $user_result->fetch_assoc(); +$user_row = $user_result ? ($user_result->fetch_assoc() ?: []) : []; $currentUserEmail = isset($user_row['user_email']) ? (string)$user_row['user_email'] : ''; $html = array_map('htmlentities', $user_row); @@ -487,7 +491,12 @@ } if (!empty($_POST['change_user_stats202_app_key']) && $_POST['change_user_stats202_app_key'] == '1') { - if (!preg_match('/\*/', (string) $_POST['user_stats202_app_key'])) { + // CSRF check — every other mutation block in this file validates the token; + // this one omitted it, letting a forged form overwrite the Stats202 app key. + if (!hash_equals((string)($_SESSION['token'] ?? ''), (string)($_POST['token'] ?? ''))) { + $error['token'] = 'You must use our forms to submit data.'; + } + if (!$error && !preg_match('/\*/', (string) $_POST['user_stats202_app_key'])) { // Replace the undefined method with a more direct validation approach $app_key = $_POST['user_stats202_app_key']; $api_key = $_SESSION['user_api_key']; @@ -515,12 +524,15 @@ } if (!empty($_POST['update_p202_customer_api_key']) && $_POST['update_p202_customer_api_key'] == '1') { + // Check the token BEFORE validateCustomersApiKey(): that makes a server-side + // call out to the vendor with the submitted value, so validating first let a + // forged request drive outbound traffic even though the write was blocked. if (!hash_equals((string)($_SESSION['token'] ?? ''), (string)($_POST['token'] ?? ''))) { $error['token'] = 'You must use our forms to submit data.'; } $mysql['p202_customer_api_key'] = $db->real_escape_string((string)$_POST['p202_customer_api_key']); $mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_own_id']); - $validate = validateCustomersApiKey($_POST['p202_customer_api_key']); + $validate = $error ? ['code' => 0] : validateCustomersApiKey($_POST['p202_customer_api_key']); if ($validate['code'] != 200 && $mysql['p202_customer_api_key'] != '') { $error['p202_customer_api_key_invalid'] = "API key is not valid. Check your key and try again!"; } @@ -562,7 +574,7 @@ $verify_stmt->bind_param('i', $current_user_id); $verify_stmt->execute(); $result = $verify_stmt->get_result(); - $stored = $result ? $result->fetch_assoc() : null; + $stored = $result ? ($result->fetch_assoc() ?: []) : []; $verify_stmt->close(); if (!$stored || !verify_user_pass((string) $_POST['user_pass'], (string) ($stored['user_pass'] ?? ''))['valid']) { $error['user_pass'] .= 'Your old password was typed incorrectly.'; @@ -637,7 +649,7 @@ LEFT JOIN `202_users_pref` USING (user_id) WHERE `202_users`.`user_id`='" . $mysql['user_id'] . "'"; $user_result = $db->query($user_sql); -$user_row = $user_result->fetch_assoc(); +$user_row = $user_result ? ($user_result->fetch_assoc() ?: []) : []; $html = array_map('htmlentities', $user_row); ?> @@ -677,6 +689,28 @@
Your submission was successful. Your Prosper202 customer API key have been saved.
+ + 8 + ? substr($pendingCustomerApiKey, 0, 8) . str_repeat('*', 12) + : $pendingCustomerApiKey; + ?> +
+
+ + + + + Connect the Prosper202 customer API key + + to this account? + + +
+
+ diff --git a/202-account/administration.php b/202-account/administration.php index 437f0aff..b7756dc2 100644 --- a/202-account/administration.php +++ b/202-account/administration.php @@ -10,11 +10,20 @@ AUTH::require_user(); +// The Settings nav link is already gated on access_to_settings +// (202-config/template.php); the page itself must enforce the same +// permission, or any authenticated low-privilege user can POST directly +// to the install-wide settings and click-data deletion actions below. +if (!$userObj->hasPermission("access_to_settings")) { + header('location: ' . get_absolute_url() . '202-account/'); + exit; +} + $slack = false; $mysql['user_own_id'] = $db->real_escape_string((string) $_SESSION['user_own_id']); $user_sql = "SELECT 2u.user_name as username, 2up.user_slack_incoming_webhook AS url, maxmind_isp, user_time_register, 2up.user_auto_database_optimization_days FROM 202_users AS 2u INNER JOIN 202_users_pref AS 2up ON (2up.user_id = 1) WHERE 2u.user_id = '" . $mysql['user_own_id'] . "'"; $user_results = $db->query($user_sql); -$user_row = $user_results->fetch_assoc(); +$user_row = $user_results ? ($user_results->fetch_assoc() ?: []) : []; $username = $user_row['username']; $user_time_register = $user_row['user_time_register']; @@ -34,9 +43,9 @@ $de_query = "SELECT count(*) as total, sum(processed) as done FROM 202_dataengine_job"; $de_result = $db->query($de_query); -$de_row = $de_result->fetch_assoc(); +$de_row = $de_result ? ($de_result->fetch_assoc() ?: []) : []; -if ($de_result->num_rows && $de_row['total'] != 0) { +if ($de_result && $de_result->num_rows && $de_row['total'] != 0) { $de_total = $de_row['total']; $de_done = $de_row['done']; $de_ratio = @round(($de_done / $de_total) * 100, 2); diff --git a/202-account/ajax/validate-apikey.php b/202-account/ajax/validate-apikey.php index 23c7c358..44739c8f 100755 --- a/202-account/ajax/validate-apikey.php +++ b/202-account/ajax/validate-apikey.php @@ -1,6 +1,12 @@ real_escape_string((string)$_SESSION['user_own_id']); $user_sql = "SELECT 2u.user_name as username, 2up.user_slack_incoming_webhook AS url, 2u.install_hash, 2u.p202_customer_api_key FROM 202_users AS 2u INNER JOIN 202_users_pref AS 2up ON (2up.user_id = 1) WHERE 2u.user_id = '" . $mysql['user_own_id'] . "'"; $user_results = $db->query($user_sql); -$user_row = $user_results->fetch_assoc(); +$user_row = $user_results ? ($user_results->fetch_assoc() ?: []) : []; $username = $user_row['username']; $editing_dni_network = false; $dniNetworks = getAllDniNetworks($user_row['install_hash']); @@ -180,7 +180,7 @@ function lpo_ctx_pref_cache_bust($userId) FROM 202_users_pref WHERE user_id='" . $mysql['user_id'] . "'"; $user_results = $db->query($user_sql); - $user_row = $user_results->fetch_assoc(); + $user_row = $user_results ? ($user_results->fetch_assoc() ?: []) : []; if ($user_row['cb_verified']) { echo 'Verified'; } else { @@ -196,7 +196,7 @@ function lpo_ctx_pref_cache_bust($userId) LEFT JOIN `202_users_pref` USING (user_id) WHERE `202_users`.`user_id`='" . $mysql['user_id'] . "'"; $user_result = $db->query($user_sql); -$user_row = $user_result->fetch_assoc(); +$user_row = $user_result ? ($user_result->fetch_assoc() ?: []) : []; $html = array_map('htmlentities', $user_row); $cb_verified = $user_row['cb_verified']; @@ -467,8 +467,17 @@ function lpo_ctx_pref_cache_bust($userId) } -if (isset($_GET['delete_dni_network']) && !empty($_GET['delete_dni_network'])) { - $mysql['deleteDniNetworkId'] = $db->real_escape_string((string)$_GET['delete_dni_network']); +// Deleting is a POST: a GET carrying the CSRF token put that token into browser +// history, Referer headers and access logs, and it guards every POST mutation in +// the session. +if (isset($_POST['delete_dni_network']) && !empty($_POST['delete_dni_network'])) { + // CSRF check — this deletes a DNI network and marks the linked aff network + // deleted. + if (!hash_equals((string)($_SESSION['token'] ?? ''), (string)($_POST['token'] ?? ''))) { + http_response_code(403); + die('Invalid token.'); + } + $mysql['deleteDniNetworkId'] = $db->real_escape_string((string)$_POST['delete_dni_network']); $db->query("DELETE FROM 202_dni_networks WHERE id = '" . $mysql['deleteDniNetworkId'] . "' AND user_id = '" . $mysql['user_id'] . "'"); $sql = "UPDATE 202_aff_networks SET aff_network_deleted = '1', aff_network_time = '" . time() . "' WHERE dni_network_id = '" . $mysql['deleteDniNetworkId'] . "'"; $db->query($sql); @@ -599,7 +608,7 @@ function lpo_ctx_pref_cache_bust($userId) } ?> -   
+   
processing... @@ -611,9 +620,9 @@ function lpo_ctx_pref_cache_bust($userId)
- show + show - +
@@ -624,8 +633,8 @@ function lpo_ctx_pref_cache_bust($userId)
- - + + @@ -646,7 +655,7 @@ function lpo_ctx_pref_cache_bust($userId) echo 'col-xs-7'; } ?>" id="dni_api_key_input_group" style="padding: 0px; padding-right: 5px;"> - +

- + diff --git a/202-account/auto-upgrade.php b/202-account/auto-upgrade.php index f459593d..abd63406 100755 --- a/202-account/auto-upgrade.php +++ b/202-account/auto-upgrade.php @@ -7,6 +7,14 @@ AUTH::require_user(); +// Upgrading overwrites application files and runs DB migrations — the most +// powerful operation in the account area. Gate it like the Settings page +// instead of allowing any authenticated role to trigger it. +if (!$userObj->hasPermission("access_to_settings")) { + header('location: ' . get_absolute_url() . '202-account/'); + exit; +} + // On managed deployments (Coolify, or any Docker image built from git) the // 1-click upgrade would write into the ephemeral container filesystem and be // silently reverted on the next redeploy — refuse before touching anything. diff --git a/202-account/clickservers.php b/202-account/clickservers.php index b2b0ec7c..45aca4da 100755 --- a/202-account/clickservers.php +++ b/202-account/clickservers.php @@ -11,7 +11,7 @@ FROM `202_users` WHERE `202_users`.`user_id`='".$mysql['user_id']."'"; $user_result = $db->query($user_sql); -$user_row = $user_result->fetch_assoc(); +$user_row = $user_result ? ($user_result->fetch_assoc() ?: []) : []; if ($user_row['clickserver_api_key']) { $clickservers = clickserver_api_domain_list($user_row['clickserver_api_key']); } @@ -126,7 +126,7 @@ $(checkbox).bootstrapSwitch('toggleState'); } else { - if("" == clid){ + if( == clid){ window.location.href = "../202-account/signout.php"; } diff --git a/202-account/user-management.php b/202-account/user-management.php index 290db42e..f1e7a43e 100755 --- a/202-account/user-management.php +++ b/202-account/user-management.php @@ -24,7 +24,7 @@ $mysql['user_own_id'] = $db->real_escape_string((string)$_SESSION['user_own_id']); $user_sql = "SELECT 2u.user_name as username, 2up.user_slack_incoming_webhook AS url FROM 202_users AS 2u INNER JOIN 202_users_pref AS 2up ON (2up.user_id = 1) WHERE 2u.user_id = '" . $mysql['user_own_id'] . "'"; $user_results = $db->query($user_sql); -$user_row = $user_results->fetch_assoc(); +$user_row = $user_results ? ($user_results->fetch_assoc() ?: []) : []; $username = $user_row['username']; if (!empty($user_row['url'])) @@ -37,7 +37,11 @@ $editing = true; } -if (!empty($_GET['delete_user_id'])) { +// Deleting is a POST: it is a state change, and routing it through GET meant +// the CSRF token had to ride in the query string, where it lands in browser +// history, Referer headers and access logs. That token guards every POST +// mutation in the session, so leaking it is worse than the hole it closed. +if (!empty($_POST['delete_user_id'])) { $deleting = true; } @@ -51,7 +55,7 @@ $user_result2 = _mysqli_query($user_sql); $user_row2 = $user_result2->fetch_assoc(); -if ($_SERVER['REQUEST_METHOD'] == 'POST') { +if ($_SERVER['REQUEST_METHOD'] == 'POST' && empty($_POST['delete_user_id'])) { // validate token if (!hash_equals((string)($_SESSION['token'] ?? ''), (string)($_POST['token'] ?? ''))) { header('location: ' . get_absolute_url() . '202-account/user-management.php'); @@ -274,7 +278,13 @@ if ($deleting == true) { - $mysql['user_id'] = $db->real_escape_string(trim(filter_input(INPUT_GET, 'delete_user_id', FILTER_SANITIZE_NUMBER_INT))); + // CSRF check — this soft-deletes a user and purges their attribution data. + if (!hash_equals((string)($_SESSION['token'] ?? ''), (string)($_POST['token'] ?? ''))) { + http_response_code(403); + die('Invalid token.'); + } + + $mysql['user_id'] = $db->real_escape_string(trim((string) filter_input(INPUT_POST, 'delete_user_id', FILTER_SANITIZE_NUMBER_INT))); if (!$userObj->hasPermission("add_edit_delete_admin")) { header('location: ' . get_absolute_url() . '202-account/user-management.php'); @@ -284,8 +294,12 @@ $user_sql_delete = "UPDATE 202_users SET user_deleted = '1' WHERE user_id = " . $mysql['user_id']; $user_result_delete = _mysqli_query($user_sql_delete); - // Purge attribution data for the deleted user + // Revoke API keys and purge attribution data for the deleted user. + // The key deletion mirrors the v3 API's delete endpoint + // (UsersController::delete) — without it, a user deleted through this page + // kept working REST access on every API version. $attributionCleanupQueries = [ + "DELETE FROM 202_api_keys WHERE user_id = " . $mysql['user_id'], "DELETE FROM 202_attribution_touchpoints WHERE snapshot_id IN (SELECT snapshot_id FROM 202_attribution_snapshots WHERE user_id = " . $mysql['user_id'] . ")", "DELETE FROM 202_attribution_snapshots WHERE user_id = " . $mysql['user_id'], "DELETE FROM 202_attribution_settings WHERE user_id = " . $mysql['user_id'], @@ -496,10 +510,10 @@ if (!$userObj->hasPermission("add_edit_delete_admin")) { printf('
  • %s
  • ', $html['user_display_name']); } else { - printf('
  • %s - edit - remove
  • ', $html['user_display_name'], $url['user_id'], $url['user_id']); + printf('
  • %s - edit -
  • ', $html['user_display_name'], $url['user_id'], htmlspecialchars((string) ($_SESSION['token'] ?? ''), ENT_QUOTES, 'UTF-8'), $url['user_id']); } } else { - printf('
  • %s - edit - remove
  • ', $html['user_display_name'], $url['user_id'], $url['user_id']); + printf('
  • %s - edit -
  • ', $html['user_display_name'], $url['user_id'], htmlspecialchars((string) ($_SESSION['token'] ?? ''), ENT_QUOTES, 'UTF-8'), $url['user_id']); } ?> diff --git a/202-appstore/index.php b/202-appstore/index.php index 1c5865c3..8c49f26b 100755 --- a/202-appstore/index.php +++ b/202-appstore/index.php @@ -6,7 +6,7 @@ template_top('Prosper202 ClickServer App Store'); - if ($_POST['update_clickserver_api_key'] == '1') { + if (isset($_POST['update_clickserver_api_key']) && $_POST['update_clickserver_api_key'] == '1') { // validate token if (!hash_equals((string)($_SESSION['token'] ?? ''), (string)($_POST['token'] ?? ''))) { $error['token'] = 'You must use our forms to submit data.'; } @@ -43,6 +43,7 @@ //make it hide most of the api keys $hideChars = 22; + $hiddenPart = ''; for ($x = 0; $x < $hideChars; $x++) $hiddenPart .= '*'; if ($html['clickserver_api_key']) $html['clickserver_api_key'] = $hiddenPart . substr($html['clickserver_api_key'], $hideChars, 99); diff --git a/202-config/Attribution/AttributionJobRunner.php b/202-config/Attribution/AttributionJobRunner.php index d2167aec..c9934d32 100644 --- a/202-config/Attribution/AttributionJobRunner.php +++ b/202-config/Attribution/AttributionJobRunner.php @@ -83,6 +83,13 @@ public function runForUser(int $userId, int $startTime, int $endTime): void $lastConversionId = $lastRecord?->conversionId; } while ($lastConversionId !== null); + // Break the by-reference binding from the batch loop above. While it is + // still bound, the by-value `$state = $modelsState[...]` in the finalise + // loop below writes into the LAST model's slot instead of reading its + // own — so with >= 2 models the final model finalises another model's + // state (wrong totals, and its snapshot rows re-pointed at that model). + unset($state); + if (!$processed) { return; } diff --git a/202-config/Attribution/AttributionRepositoryInterface.php b/202-config/Attribution/AttributionRepositoryInterface.php index 125e5bec..b9f14521 100644 --- a/202-config/Attribution/AttributionRepositoryInterface.php +++ b/202-config/Attribution/AttributionRepositoryInterface.php @@ -48,9 +48,4 @@ public function listSnapshots(int $modelId, int $userId, array $filters, int $of * @return list> */ public function listExports(int $modelId, int $userId): array; - - /** - * @param array $data scope_type, scope_id, start_hour, end_hour, format, webhook_url - */ - public function scheduleExport(int $modelId, int $userId, array $data): int; } diff --git a/202-config/Attribution/AttributionService.php b/202-config/Attribution/AttributionService.php index 6edf3d55..e7b98916 100644 --- a/202-config/Attribution/AttributionService.php +++ b/202-config/Attribution/AttributionService.php @@ -153,6 +153,8 @@ public function scheduleSnapshotExport(int $userId, int $modelId, array $payload if (isset($payload['webhook']) && is_array($payload['webhook'])) { $webhookPayload = array_filter($payload['webhook'], static fn ($value) => $value !== null && $value !== ''); if (!empty($webhookPayload)) { + // fromArray() runs the write-boundary SSRF check and throws + // InvalidArgumentException like the other payload validation here. $webhook = ExportWebhook::fromArray($webhookPayload); } } diff --git a/202-config/Attribution/Export/ExportProcessor.php b/202-config/Attribution/Export/ExportProcessor.php deleted file mode 100644 index 8d2a83e9..00000000 --- a/202-config/Attribution/Export/ExportProcessor.php +++ /dev/null @@ -1,72 +0,0 @@ -> - */ - public function processPending(int $limit = 5): array - { - $jobs = $this->exportRepository->claimPending($limit); - $results = []; - - foreach ($jobs as $job) { - try { - $model = $this->modelRepository->findById($job->modelId); - if ($model === null || $model->userId !== $job->userId) { - throw new \RuntimeException('Attribution model no longer available.'); - } - - $snapshots = $this->snapshotRepository->findForRange( - $job->modelId, - $job->scopeType, - $job->scopeId, - $job->startHour, - $job->endHour, - 1000, - 0 - ); - - $filePath = $this->snapshotExporter->export($job, $snapshots); - $webhookResult = $this->webhookDispatcher->dispatch($job, $filePath); - $job->markCompleted($filePath, time(), $webhookResult->statusCode, $webhookResult->responseBody, $webhookResult->errorMessage); - $this->exportRepository->update($job); - - $results[] = [ - 'export_id' => $job->exportId, - 'status' => 'completed', - 'webhook_status_code' => $webhookResult->statusCode, - 'webhook_error' => $webhookResult->errorMessage, - ]; - } catch (\Throwable $exception) { - $job->markFailed($exception->getMessage(), time()); - $this->exportRepository->update($job); - - $results[] = [ - 'export_id' => $job->exportId, - 'status' => 'failed', - 'error' => $exception->getMessage(), - ]; - } - } - - return $results; - } -} diff --git a/202-config/Attribution/Export/SnapshotExporter.php b/202-config/Attribution/Export/SnapshotExporter.php deleted file mode 100644 index eca28479..00000000 --- a/202-config/Attribution/Export/SnapshotExporter.php +++ /dev/null @@ -1,142 +0,0 @@ -basePath = rtrim($basePath ?? dirname(__DIR__, 2) . '/storage/attribution-exports', DIRECTORY_SEPARATOR); - } - - /** - * @param Snapshot[] $snapshots - */ - public function export(ExportJob $job, array $snapshots): string - { - $directory = $this->basePath; - if (!is_dir($directory) && !mkdir($directory, 0755, true) && !is_dir($directory)) { - throw new \RuntimeException('Unable to initialise export directory: ' . $directory); - } - - $timestamp = time(); - // Use exportId if available, otherwise use a hash of userId, modelId, and timestamp - if (!empty($job->exportId)) { - $fileBase = sprintf('attribution-export-%s-%d', $job->exportId, $timestamp); - } else { - $hash = sha1($job->userId . '-' . $job->modelId . '-' . $timestamp); - $fileBase = sprintf('attribution-export-%s-%d', $hash, $timestamp); - } - - return match ($job->format) { - ExportFormat::CSV => $this->writeCsv($directory, $fileBase, $snapshots), - ExportFormat::XLS => $this->writeXls($directory, $fileBase, $snapshots), - }; - } - - /** - * @param Snapshot[] $snapshots - */ - private function writeCsv(string $directory, string $fileBase, array $snapshots): string - { - $path = $directory . DIRECTORY_SEPARATOR . $fileBase . '.csv'; - $handle = fopen($path, 'w'); - if ($handle === false) { - throw new \RuntimeException('Unable to open export file for writing: ' . $path); - } - - // Pass $escape explicitly: its default is deprecated as of PHP 8.4. '\\' - // preserves the historical behaviour and matches the rest of the codebase - // (see tracking202/update/upload.php). - fputcsv($handle, ['Date (UTC)', 'Attributed Clicks', 'Attributed Conversions', 'Attributed Revenue', 'Attributed Cost', 'ROI %', 'Profit'], escape: '\\'); - - foreach ($snapshots as $snapshot) { - $row = $this->normaliseSnapshot($snapshot); - fputcsv($handle, [ - gmdate('Y-m-d H:i', $row['date_hour']), - $row['attributed_clicks'], - $row['attributed_conversions'], - number_format($row['attributed_revenue'], 2, '.', ''), - number_format($row['attributed_cost'], 2, '.', ''), - $row['roi'] !== null ? number_format($row['roi'], 2, '.', '') : '', - number_format($row['profit'], 2, '.', ''), - ], escape: '\\'); - } - - fclose($handle); - - return $path; - } - - /** - * @param Snapshot[] $snapshots - */ - private function writeXls(string $directory, string $fileBase, array $snapshots): string - { - $path = $directory . DIRECTORY_SEPARATOR . $fileBase . '.xls'; - $rows = array_map($this->normaliseSnapshot(...), $snapshots); - - $escape = static fn (string $value): string => htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); - - $html = ''; - $html .= '' - . '' - . '' - . '' - . '' - . '' - . '' - . '' - . ''; - - foreach ($rows as $row) { - $html .= '' - . '' - . '' - . '' - . '' - . '' - . '' - . '' - . ''; - } - - $html .= '
    Date (UTC)Attributed ClicksAttributed ConversionsAttributed RevenueAttributed CostROI %Profit
    ' . $escape(gmdate('Y-m-d H:i', $row['date_hour'])) . '' . $escape((string) $row['attributed_clicks']) . '' . $escape((string) $row['attributed_conversions']) . '' . $escape(number_format($row['attributed_revenue'], 2)) . '' . $escape(number_format($row['attributed_cost'], 2)) . '' . ($row['roi'] !== null ? $escape(number_format($row['roi'], 2)) : '') . '' . $escape(number_format($row['profit'], 2)) . '
    '; - - if (file_put_contents($path, $html) === false) { - throw new \RuntimeException('Unable to write export spreadsheet: ' . $path); - } - - return $path; - } - - /** - * @return array{date_hour:int,attributed_clicks:int,attributed_conversions:int,attributed_revenue:float,attributed_cost:float,roi:float|null,profit:float} - */ - private function normaliseSnapshot(Snapshot $snapshot): array - { - $revenue = $snapshot->attributedRevenue; - $cost = $snapshot->attributedCost; - $profit = $revenue - $cost; - $roi = null; - if ($cost > 0.0) { - $roi = (($revenue - $cost) / $cost) * 100.0; - } - - return [ - 'date_hour' => $snapshot->dateHour, - 'attributed_clicks' => $snapshot->attributedClicks, - 'attributed_conversions' => $snapshot->attributedConversions, - 'attributed_revenue' => $revenue, - 'attributed_cost' => $cost, - 'roi' => $roi, - 'profit' => $profit, - ]; - } -} diff --git a/202-config/Attribution/Export/WebhookDispatcher.php b/202-config/Attribution/Export/WebhookDispatcher.php deleted file mode 100644 index d96830dd..00000000 --- a/202-config/Attribution/Export/WebhookDispatcher.php +++ /dev/null @@ -1,273 +0,0 @@ -webhookUrl === null || $job->webhookUrl === '') { - return new WebhookResult(null, null, null); - } - - // Validate webhook URL to prevent SSRF attacks - $validationError = $this->validateWebhookUrl($job->webhookUrl); - if ($validationError !== null) { - return new WebhookResult(null, null, $validationError); - } - - if (!is_file($filePath) || !is_readable($filePath)) { - return new WebhookResult(null, null, 'Export file is not readable.'); - } - - $fileSize = filesize($filePath); - if ($fileSize === false || $fileSize > self::MAX_FILE_SIZE_BYTES) { - $maxSizeMB = self::MAX_FILE_SIZE_BYTES / (1024 * 1024); - return new WebhookResult( - null, - null, - sprintf('Export file exceeds maximum size limit of %d MB for webhook dispatch.', $maxSizeMB) - ); - } - - $base64Content = $this->encodeFileBase64($filePath); - if ($base64Content === null) { - return new WebhookResult(null, null, 'Failed to encode export file.'); - } - - $body = [ - 'export_id' => $job->exportId, - 'model_id' => $job->modelId, - 'format' => $job->format->value, - 'status' => $job->status->value, - 'generated_at' => time(), - 'file_name' => basename($filePath), - 'file_mime' => $job->format === ExportFormat::CSV ? 'text/csv' : 'application/vnd.ms-excel', - 'file_content' => $base64Content, - ]; - - $headers = array_merge(['Content-Type' => 'application/json'], $job->webhookHeaders); - $method = strtoupper($job->webhookMethod ?: 'POST'); - - if (function_exists('curl_init')) { - return $this->dispatchWithCurl($job->webhookUrl, $method, $headers, json_encode($body, JSON_THROW_ON_ERROR)); - } - - return $this->dispatchWithStream($job->webhookUrl, $method, $headers, json_encode($body, JSON_THROW_ON_ERROR)); - } - - /** - * Encode file content to base64 using chunked reading to minimize memory usage. - */ - private function encodeFileBase64(string $filePath): ?string - { - $handle = fopen($filePath, 'rb'); - if ($handle === false) { - return null; - } - - $base64 = ''; - $remainder = ''; - - while (!feof($handle)) { - $chunk = fread($handle, self::CHUNK_SIZE_BYTES); - if ($chunk === false) { - fclose($handle); - return null; - } - - // Combine with any remainder from previous iteration - $data = $remainder . $chunk; - - // Base64 encoding works best with data length divisible by 3 - // to avoid padding issues between chunks - $dataLength = strlen($data); - $encodeLength = $dataLength - ($dataLength % 3); - - if ($encodeLength > 0) { - $base64 .= base64_encode(substr($data, 0, $encodeLength)); - $remainder = substr($data, $encodeLength); - } else { - $remainder = $data; - } - } - - // Encode any remaining data - if ($remainder !== '') { - $base64 .= base64_encode($remainder); - } - - fclose($handle); - return $base64; - } - - private function dispatchWithCurl(string $url, string $method, array $headers, string $payload): WebhookResult - { - $curl = curl_init($url); - if ($curl === false) { - return new WebhookResult(null, null, 'Unable to initialise cURL.'); - } - - $headerList = []; - foreach ($headers as $key => $value) { - $headerList[] = $key . ': ' . $value; - } - - curl_setopt_array($curl, [ - CURLOPT_CUSTOMREQUEST => $method, - CURLOPT_HTTPHEADER => $headerList, - CURLOPT_POSTFIELDS => $payload, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 15, - ]); - - $response = curl_exec($curl); - $error = curl_error($curl); - $statusCode = curl_getinfo($curl, CURLINFO_RESPONSE_CODE) ?: null; - curl_close($curl); - - if ($response === false) { - return new WebhookResult($statusCode, null, $error ?: 'Unknown cURL error.'); - } - - return new WebhookResult($statusCode, $response, null); - } - - private function dispatchWithStream(string $url, string $method, array $headers, string $payload): WebhookResult - { - $headerLines = []; - foreach ($headers as $key => $value) { - $headerLines[] = $key . ': ' . $value; - } - - $context = stream_context_create([ - 'http' => [ - 'method' => $method, - 'header' => implode("\r\n", $headerLines), - 'content' => $payload, - 'timeout' => 15, - ], - ]); - - $response = file_get_contents($url, false, $context); - $error = $response === false ? error_get_last()['message'] ?? 'Unknown stream error.' : null; - $statusCode = null; - - // Read the response headers without the magic $http_response_header variable, - // which is deprecated as of PHP 8.5. http_get_last_response_headers() is the - // replacement but only exists on PHP 8.4+, so fall back to the variable on - // older runtimes (where reading it is not deprecated). - if (function_exists('http_get_last_response_headers')) { - $responseHeaders = http_get_last_response_headers(); - } else { - // @phpstan-ignore-next-line -- magic var may be unset if no response was received - $responseHeaders = $http_response_header ?? null; - } - if (!empty($responseHeaders)) { - foreach ($responseHeaders as $line) { - if (preg_match('#HTTP/\S+\s+(\d{3})#', $line, $matches)) { - $statusCode = (int) $matches[1]; - break; - } - } - } - - return new WebhookResult($statusCode, $response ?: null, $error); - } - - /** - * Validates webhook URL to prevent SSRF attacks. - * - * @param string $url The URL to validate - * @return string|null Error message if validation fails, null if valid - */ - private function validateWebhookUrl(string $url): ?string - { - // Parse the URL - $parsed = parse_url($url); - if ($parsed === false || !isset($parsed['scheme']) || !isset($parsed['host'])) { - return 'Invalid webhook URL format.'; - } - - // Only allow http and https schemes - $scheme = strtolower($parsed['scheme']); - if ($scheme !== 'http' && $scheme !== 'https') { - return 'Webhook URL must use http or https scheme.'; - } - - $host = $parsed['host']; - - // Resolve hostname to IP address if it's not already an IP - $ip = $host; - if (!filter_var($host, FILTER_VALIDATE_IP)) { - // It's a hostname, resolve it (supports both IPv4 and IPv6) - $records = @dns_get_record($host, DNS_A | DNS_AAAA); - if ($records === false || empty($records)) { - // DNS resolution failed - reject to prevent bypassing IP-based restrictions - return 'Webhook URL hostname could not be resolved.'; - } - // Use the first resolved IP address - $ip = $records[0]['ip'] ?? $records[0]['ipv6'] ?? null; - if ($ip === null) { - return 'Webhook URL hostname could not be resolved.'; - } - } - - // Validate the IP address - if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { - return $this->validateIPv4Address($ip); - } - - if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { - return $this->validateIPv6Address($ip); - } - - return 'Webhook URL resolves to an invalid IP address.'; - } - - /** - * Validates an IPv4 address to ensure it's not in private or reserved ranges. - * - * @param string $ip The IPv4 address to validate - * @return string|null Error message if validation fails, null if valid - */ - private function validateIPv4Address(string $ip): ?string - { - // Use filter_var for validation which is reliable across different architectures - if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { - return 'Webhook URL cannot target private or reserved IP addresses.'; - } - - return null; - } - - /** - * Validates an IPv6 address to ensure it's not in private or reserved ranges. - * - * @param string $ip The IPv6 address to validate - * @return string|null Error message if validation fails, null if valid - */ - private function validateIPv6Address(string $ip): ?string - { - // Use filter_var for validation - if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { - return 'Webhook URL cannot target private or reserved IPv6 addresses.'; - } - - return null; - } -} diff --git a/202-config/Attribution/Export/WebhookResult.php b/202-config/Attribution/Export/WebhookResult.php deleted file mode 100644 index 96ecca7a..00000000 --- a/202-config/Attribution/Export/WebhookResult.php +++ /dev/null @@ -1,15 +0,0 @@ -headers as $key => $value) { if (!is_string($key) || $key === '' || !is_string($value)) { throw new InvalidArgumentException('Webhook headers must be an associative array of strings.'); @@ -39,6 +52,14 @@ public static function fromArray(array $data): self if ($url === '') { throw new InvalidArgumentException('Webhook URL is required when webhook settings are provided.'); } + // Write-boundary SSRF check: shape only, no DNS (see OutboundUrlGuard). + // This factory is reached only from a request payload, never from a + // stored row, so a rejection here fails exactly the request that sent it. + try { + OutboundUrlGuard::assertWellFormed($url, 'webhook.url'); + } catch (OutboundUrlException $e) { + throw new InvalidArgumentException($e->getMessage(), 0, $e); + } $secret = isset($data['secret']) && $data['secret'] !== '' ? (string) $data['secret'] : null; $headers = []; diff --git a/202-config/Attribution/InMemoryAttributionRepository.php b/202-config/Attribution/InMemoryAttributionRepository.php index bdcee3da..8cbb0d7e 100644 --- a/202-config/Attribution/InMemoryAttributionRepository.php +++ b/202-config/Attribution/InMemoryAttributionRepository.php @@ -152,26 +152,38 @@ public function listExports(int $modelId, int $userId): array return $filtered; } - public function scheduleExport(int $modelId, int $userId, array $data): int + /** + * Test fixture: put an export row in place for listExports() to find. + * + * This is NOT a scheduling API. The repository trio used to expose + * scheduleExport(), which nothing in production called -- both API + * controllers write exports themselves -- and whose MySQL version stored + * status 'queued' (not a valid ExportStatus; the export cron only claims + * 'pending') and the webhook_url unguarded. Dead code with two latent + * defects is not kept for a test's convenience; the test seeds directly. + * + * @param array $row overrides for the seeded export + */ + public function seedExport(int $modelId, int $userId, array $row = []): int { $id = $this->nextExportId++; $now = time(); - $this->exports[$id] = [ + $this->exports[$id] = $row + [ 'export_id' => $id, 'user_id' => $userId, 'model_id' => $modelId, - 'scope_type' => (string) ($data['scope_type'] ?? 'global'), - 'scope_id' => (int) ($data['scope_id'] ?? 0), - 'start_hour' => (int) ($data['start_hour'] ?? 0), - 'end_hour' => (int) ($data['end_hour'] ?? time()), - 'requested_format' => (string) ($data['format'] ?? 'csv'), - 'status' => 'queued', + 'scope_type' => 'global', + 'scope_id' => 0, + 'start_hour' => 0, + 'end_hour' => $now, + 'requested_format' => 'csv', + 'status' => 'pending', 'queued_at' => $now, 'started_at' => null, 'completed_at' => null, 'file_path' => null, - 'webhook_url' => (string) ($data['webhook_url'] ?? ''), + 'webhook_url' => '', 'created_at' => $now, 'updated_at' => $now, ]; diff --git a/202-config/Attribution/MysqlAttributionRepository.php b/202-config/Attribution/MysqlAttributionRepository.php index f9506e54..122d2231 100644 --- a/202-config/Attribution/MysqlAttributionRepository.php +++ b/202-config/Attribution/MysqlAttributionRepository.php @@ -190,27 +190,4 @@ public function listExports(int $modelId, int $userId): array return $this->conn->fetchAll($stmt); } - - public function scheduleExport(int $modelId, int $userId, array $data): int - { - $now = time(); - - $stmt = $this->conn->prepareWrite( - 'INSERT INTO 202_attribution_exports - (user_id, model_id, scope_type, scope_id, start_hour, end_hour, requested_format, status, queued_at, created_at, updated_at, webhook_url) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' - ); - $this->conn->bind($stmt, 'iisiiissiiis', [ - $userId, $modelId, - (string) ($data['scope_type'] ?? 'global'), - (int) ($data['scope_id'] ?? 0), - (int) ($data['start_hour'] ?? 0), - (int) ($data['end_hour'] ?? time()), - (string) ($data['format'] ?? 'csv'), - 'queued', - $now, $now, $now, - (string) ($data['webhook_url'] ?? ''), - ]); - return $this->conn->executeInsert($stmt); - } } diff --git a/202-config/Database/Connection.php b/202-config/Database/Connection.php index 24ebf733..cbd4d54b 100644 --- a/202-config/Database/Connection.php +++ b/202-config/Database/Connection.php @@ -16,8 +16,13 @@ * * Consolidates the prepare/bind/execute boilerplate that was duplicated * across every Attribution repository implementation. + * + * Not final: statementError()/statementErrno() are protected so a test can + * stand in for mysqli_stmt::$error/$errno, which throw on every + * constructor-skipping fake. Those two readers are the only sanctioned + * override point; everything else is an implementation detail. */ -final class Connection +class Connection { private readonly mysqli $read; @@ -125,16 +130,8 @@ public function execute(object $stmt): void { // @phpstan-ignore-next-line -- this IS the centralized execute wrapper; self-routing would recurse if (!$stmt->execute()) { - try { - $error = $stmt->error; - } catch (\Error) { - $error = '(unknown)'; - } - try { - $errno = (int) $stmt->errno; - } catch (\Error) { - $errno = 0; - } + $error = $this->statementError($stmt); + $errno = $this->statementErrno($stmt); unset($this->boundValues[spl_object_id($stmt)]); $stmt->close(); // The errno tag makes error-class detection (deadlock, duplicate @@ -147,6 +144,70 @@ public function execute(object $stmt): void unset($this->boundValues[spl_object_id($stmt)]); } + /** + * A false from get_result() after a successful execute() means one of two + * very different things: the statement produced no result set (an INSERT + * or UPDATE -- not an error), or the fetch failed (a lost connection, a + * server that went away mid-query). Reading them both as "no rows" is + * CLAUDE.md #1's silent-failure tell -- publicIdIsFree() would report a + * taken id as free, a batch loop would end early and report success. The + * statement's errno tells them apart. + * + * @param mysqli_stmt $stmt + * @throws QueryException when the fetch failed + */ + private function assertResultRetrieved(object $stmt, mysqli_result|false $result): void + { + if ($result !== false) { + return; + } + $errno = $this->statementErrno($stmt); + if ($errno === 0) { + return; // no result set, and MySQL reports no error: a legitimate empty answer + } + $error = $this->statementError($stmt); + $stmt->close(); + throw new QueryException('MySQL get_result failed: ' . $error . ' [errno ' . $errno . ']'); + } + + /** + * mysqli_stmt::$error, or '(unknown)' where the property cannot be read. + * Native mysqli_stmt properties throw on a statement whose constructor was + * skipped (test fakes) and on a closed one; the guard keeps the diagnostic + * from replacing the failure it was meant to describe. Protected so a test + * can stand in for the value a fake physically cannot carry. + * + * @param mysqli_stmt $stmt + */ + protected function statementError(object $stmt): string + { + try { + // `??` rather than a bare read: a plain-object test fake without the + // property must not raise "Undefined property" (a warning PHPUnit + // promotes to a failure), and on a constructor-skipping native fake + // isset() answers false instead of throwing. + return (string) ($stmt->error ?? '(unknown)'); + } catch (\Error) { + return '(unknown)'; + } + } + + /** + * mysqli_stmt::$errno, or 0 where the property cannot be read (see + * statementError()). 0 is deliberately "no error": a fake that cannot + * report an errno must not make every fetch look failed. + * + * @param mysqli_stmt $stmt + */ + protected function statementErrno(object $stmt): int + { + try { + return (int) ($stmt->errno ?? 0); // see statementError() on `??` + } catch (\Error) { + return 0; + } + } + /** * True when the throwable (or anything in its previous-chain) is a MySQL * deadlock (1213) or lock-wait timeout (1205) — the retryable lock @@ -196,6 +257,7 @@ public function fetchOne(object $stmt): ?array { $this->execute($stmt); $result = $stmt->get_result(); + $this->assertResultRetrieved($stmt, $result); $row = ($result instanceof mysqli_result) ? $result->fetch_assoc() : null; if ($result instanceof mysqli_result) { $result->free(); @@ -215,6 +277,7 @@ public function fetchAll(object $stmt): array { $this->execute($stmt); $result = $stmt->get_result(); + $this->assertResultRetrieved($stmt, $result); $rows = []; if ($result instanceof mysqli_result) { while ($row = $result->fetch_assoc()) { diff --git a/202-config/Database/DataSeeder.php b/202-config/Database/DataSeeder.php index 6faca18b..fe31575d 100644 --- a/202-config/Database/DataSeeder.php +++ b/202-config/Database/DataSeeder.php @@ -15,6 +15,24 @@ public function __construct(private mysqli $connection) { } + /** + * Run a seed statement on THIS connection and fail loudly. + * + * Every statement previously used the one-arg _mysqli_query() (which + * resolves `global $db`, not the installer's connection) and discarded the + * result. Under MYSQLI_REPORT_STRICT a query error is a false return, and + * seed()/seedVersion() are void, so INSTALL::install_databases() could not + * see a failure — an install with half-seeded roles reported success and + * locked the new super-user out. + */ + private function run(string $sql): void + { + $result = _mysqli_query($this->connection, $sql); + if ($result === false) { + throw new \RuntimeException('Seeding failed: ' . $this->connection->error); + } + } + /** * Seed all initial data. */ @@ -42,7 +60,7 @@ public function seedPixelTypes(): void ('Postback'), ('Raw'), ('Bot202 Facebook Pixel Assistant')"; - _mysqli_query($sql); + $this->run($sql); } /** @@ -55,7 +73,7 @@ public function seedDeviceTypes(): void (2, 'Mobile'), (3, 'Tablet'), (4, 'Bot')"; - _mysqli_query($sql); + $this->run($sql); } /** @@ -70,7 +88,7 @@ public function seedRoles(): void (4, 'Campaign optimizer'), (5, 'Campaign viewer'), (6, 'Publisher')"; - _mysqli_query($sql); + $this->run($sql); } /** @@ -102,7 +120,7 @@ public function seedPermissions(): void (21, 'remove_tracker'), (22, 'view_attribution_reports'), (23, 'manage_attribution_models')"; - _mysqli_query($sql); + $this->run($sql); } /** @@ -119,7 +137,7 @@ public function seedRolePermissions(): void (2, 22), (2, 23), (3, 12), (3, 14), (3, 15), (3, 22), (4, 12)"; - _mysqli_query($sql); + $this->run($sql); } /** @@ -128,7 +146,7 @@ public function seedRolePermissions(): void public function seedClicksTotal(): void { $sql = "INSERT IGNORE INTO `" . TableRegistry::CLICKS_TOTAL . "` (`click_count`) VALUES (0)"; - _mysqli_query($sql); + $this->run($sql); } /** @@ -138,13 +156,13 @@ public function seedVersion(string $version): void { // Idempotent: 202_version is read as a single row (e.g. functions-upgrade.php), // so don't add a second row when an install retry re-runs the seed step. - $existing = _mysqli_query("SELECT 1 FROM " . TableRegistry::VERSION . " LIMIT 1"); + $existing = _mysqli_query($this->connection, "SELECT 1 FROM " . TableRegistry::VERSION . " LIMIT 1"); if ($existing instanceof \mysqli_result && $existing->num_rows > 0) { return; } $escapedVersion = $this->connection->real_escape_string($version); $sql = "INSERT INTO " . TableRegistry::VERSION . " SET version='{$escapedVersion}'"; - _mysqli_query($sql); + $this->run($sql); } } diff --git a/202-config/Database/PartitionInstaller.php b/202-config/Database/PartitionInstaller.php index ef4d1799..241fdb23 100644 --- a/202-config/Database/PartitionInstaller.php +++ b/202-config/Database/PartitionInstaller.php @@ -101,7 +101,11 @@ private function executePartitionSql(string $tableName, string $sql): void */ private function disableStrictMode(): void { + // sql_mode is a SESSION variable, so it must be set on THIS connection — + // the one-arg form resolves `global $db` and silently configured a + // different session (and fatals from CLI/test contexts with no global + // $db). Matches SchemaInstaller::disableStrictMode(). $sql = "SET session sql_mode= ''"; - _mysqli_query($sql); + _mysqli_query($this->connection, $sql); } } diff --git a/202-config/Ltv/MysqlCustomerCrmRepository.php b/202-config/Ltv/MysqlCustomerCrmRepository.php index 5428fb62..0d2b126a 100644 --- a/202-config/Ltv/MysqlCustomerCrmRepository.php +++ b/202-config/Ltv/MysqlCustomerCrmRepository.php @@ -263,6 +263,25 @@ public function merge(int $userId, int $sourceId, int $targetId): void $this->conn->executeUpdate($stmt); } + // 202_offer_recommendations also carries customer_id but cannot use + // the plain repoint above: it has UNIQUE (user_id, customer_id, + // campaign_id, surface), so source and target can both hold a row + // for the same campaign+surface. Move what fits, then drop the + // leftovers — without this the source's rows were orphaned beyond + // the reach of stampRecommendationConversions() and the target's + // fatigue budget silently reset. + $stmt = $this->conn->prepareWrite( + 'UPDATE IGNORE 202_offer_recommendations SET customer_id = ? WHERE customer_id = ? AND user_id = ?' + ); + $this->conn->bind($stmt, 'iii', [$terminalTarget, $sourceId, $userId]); + $this->conn->executeUpdate($stmt); + + $stmt = $this->conn->prepareWrite( + 'DELETE FROM 202_offer_recommendations WHERE customer_id = ? AND user_id = ?' + ); + $this->conn->bind($stmt, 'ii', [$sourceId, $userId]); + $this->conn->executeUpdate($stmt); + // The target absorbs the source's acquisition and recency BEFORE // the source is zeroed: the merged revenue must attribute to the // EARLIEST acquisition click/time (breakdowns and cohorts key on diff --git a/202-config/Ltv/MysqlLtvRepository.php b/202-config/Ltv/MysqlLtvRepository.php index 7c5142e2..d4731394 100644 --- a/202-config/Ltv/MysqlLtvRepository.php +++ b/202-config/Ltv/MysqlLtvRepository.php @@ -363,6 +363,22 @@ public function predict(LtvQuery $query, ?string $breakdownType = null): array $rows = []; foreach ($this->breakdown($query, $breakdownType, 100, 0) as $row) { $customers = (int) ($row['customers'] ?? 0); + // Defensive net: every breakdown now supplies aov/repeat_rate/mrr + // (product included), but a future breakdown that doesn't must fall + // back to the account projection rather than project a bogus 0.0. + $hasProjectionInputs = array_key_exists('aov', $row) && array_key_exists('repeat_rate', $row); + if (!$hasProjectionInputs) { + $prediction = $account; + $prediction['basis'] = 'account_fallback'; + $prediction['fallback_reason'] = "'{$breakdownType}' breakdown does not supply per-cohort aov/repeat_rate"; + $rows[] = [ + 'id' => $row['id'] ?? null, + 'name' => $row['name'] ?? null, + 'customers' => $customers, + 'prediction' => $prediction, + ]; + continue; + } if ($customers >= self::MIN_COHORT_SIZE) { // Cohort projection: the COHORT's own MRR (a campaign with no // subscribers must not display the account-wide subscriber @@ -463,44 +479,91 @@ private function productBreakdown(LtvQuery $query, int $limit, int $offset): arr ? "\n INNER JOIN 202_customers c ON c.customer_id = re.customer_id AND c.user_id = li.user_id AND c.merged_into_customer_id IS NULL" . $cfJoins : ''; - $where = ['li.user_id = ?']; - $types = $cfTypes . 'i'; - $binds = array_merge($cfBinds, [$query->userId]); + $pcWhere = ['li.user_id = ?', 'li.product_id IS NOT NULL']; + $pcTypes = $cfTypes . 'i'; + $pcBinds = array_merge($cfBinds, [$query->userId]); if ($query->timeFrom !== null) { - $where[] = 're.occurred_at >= ?'; - $types .= 'i'; - $binds[] = $query->timeFrom; + $pcWhere[] = 're.occurred_at >= ?'; + $pcTypes .= 'i'; + $pcBinds[] = $query->timeFrom; } if ($query->timeTo !== null) { - $where[] = 're.occurred_at <= ?'; - $types .= 'i'; - $binds[] = $query->timeTo; + $pcWhere[] = 're.occurred_at <= ?'; + $pcTypes .= 'i'; + $pcBinds[] = $query->timeTo; } - $whereClause = 'WHERE ' . implode(' AND ', $where); - + $pcWhereClause = 'WHERE ' . implode(' AND ', $pcWhere); + + // Per-product subscriber MRR. Subscriptions carry no product_id, so a + // subscription's MRR is attributed to the product(s) it bills for (the + // products on its events' line items), split EVENLY across the distinct + // products of a multi-product (bundle) subscription. This is additive — + // summing product mrr over all products reconciles to total active MRR + // for subscriptions that have at least one product line item (a + // subscription with no product line items cannot be attributed and is + // omitted). Active subscriptions only, current-state (no report-window + // filter), matching mrr() and the acquisition breakdown's SUM(c.mrr). + // One placeholder: user_id. + $mrrSubquery = "LEFT JOIN ( + WITH sub_prod AS ( + SELECT DISTINCT s.subscription_id, s.mrr, li_s.product_id + FROM 202_subscriptions s + INNER JOIN 202_revenue_events re_s + ON re_s.subscription_id = s.subscription_id AND re_s.user_id = s.user_id + INNER JOIN 202_revenue_line_items li_s + ON li_s.event_id = re_s.event_id AND li_s.product_id IS NOT NULL + WHERE s.user_id = ? AND s.status = 'active' + ) + SELECT sp.product_id, SUM(sp.mrr / cnt.n) AS mrr + FROM sub_prod sp + INNER JOIN ( + SELECT subscription_id, COUNT(*) AS n FROM sub_prod GROUP BY subscription_id + ) cnt ON cnt.subscription_id = sp.subscription_id + GROUP BY sp.product_id + ) pm ON pm.product_id = pc.product_id"; + + // pc is one row per (product, customer): grouping the events by + // re.customer_id yields the per-customer distinct-order count that + // repeat_rate needs. A merged customer's events were repointed to the + // survivor at merge time, so the stored customer_id is already correct. $sql = "SELECT - p.product_id AS id, + pc.product_id AS id, COALESCE(p.name, p.sku, p.external_product_id) AS name, p.sku, - COUNT(DISTINCT re.customer_id) AS customers, - COUNT(DISTINCT CASE WHEN re.event_type IN ('purchase','renewal','one_time') - THEN re.event_id END) AS orders, - COALESCE(SUM(li.quantity), 0) AS units, - COALESCE(SUM(li.amount), 0) AS total_revenue, - CASE WHEN COUNT(DISTINCT re.customer_id) > 0 - THEN SUM(li.amount) / COUNT(DISTINCT re.customer_id) - ELSE 0 END AS avg_revenue_per_customer - FROM 202_revenue_line_items li - INNER JOIN 202_revenue_events re ON re.event_id = li.event_id - INNER JOIN 202_products p ON p.product_id = li.product_id{$customerJoin} - {$whereClause} - GROUP BY p.product_id, name, p.sku + COUNT(*) AS customers, + COALESCE(SUM(pc.cust_orders), 0) AS orders, + COALESCE(SUM(pc.cust_units), 0) AS units, + COALESCE(SUM(pc.cust_revenue), 0) AS total_revenue, + CASE WHEN COUNT(*) > 0 THEN SUM(pc.cust_revenue) / COUNT(*) ELSE 0 END AS avg_revenue_per_customer, + CASE WHEN SUM(pc.cust_orders) > 0 THEN SUM(pc.cust_revenue) / SUM(pc.cust_orders) ELSE 0 END AS aov, + CASE WHEN SUM(CASE WHEN pc.cust_orders >= 1 THEN 1 ELSE 0 END) > 0 + THEN SUM(CASE WHEN pc.cust_orders >= 2 THEN 1 ELSE 0 END) + / SUM(CASE WHEN pc.cust_orders >= 1 THEN 1 ELSE 0 END) + ELSE 0 END AS repeat_rate, + COALESCE(MAX(pm.mrr), 0) AS mrr + FROM ( + SELECT + li.product_id, + re.customer_id, + COUNT(DISTINCT CASE WHEN re.event_type IN ('purchase','renewal','one_time') + THEN re.event_id END) AS cust_orders, + SUM(li.quantity) AS cust_units, + SUM(li.amount) AS cust_revenue + FROM 202_revenue_line_items li + INNER JOIN 202_revenue_events re ON re.event_id = li.event_id{$customerJoin} + {$pcWhereClause} + GROUP BY li.product_id, re.customer_id + ) pc + INNER JOIN 202_products p ON p.product_id = pc.product_id + {$mrrSubquery} + GROUP BY pc.product_id, name, p.sku ORDER BY total_revenue DESC LIMIT ? OFFSET ?"; - $binds[] = $limit; - $binds[] = $offset; - $types .= 'ii'; + // Bind order follows SQL text: pc's cf-join + WHERE binds, then the MRR + // subquery's user_id, then LIMIT/OFFSET. + $binds = array_merge($pcBinds, [$query->userId, $limit, $offset]); + $types = $pcTypes . 'iii'; $stmt = $this->conn->prepareRead($sql); $this->conn->bind($stmt, $types, $binds); diff --git a/202-config/Ltv/MysqlWebhookRepository.php b/202-config/Ltv/MysqlWebhookRepository.php index c2ada7e1..08a01c8b 100644 --- a/202-config/Ltv/MysqlWebhookRepository.php +++ b/202-config/Ltv/MysqlWebhookRepository.php @@ -5,6 +5,7 @@ namespace Prosper202\Ltv; use Prosper202\Database\Connection; +use Prosper202\Validation\OutboundUrlGuard; use RuntimeException; /** @@ -40,55 +41,17 @@ public function __construct(private Connection $conn) } /** - * SSRF guard, applied at registration AND again at dispatch (DNS can - * change between the two): https only, resolvable host, and no - * private/loopback/link-local/reserved addresses. + * Dispatch-time SSRF guard: delegates to the one implementation in + * OutboundUrlGuard. Kept as a named entry point for the cron and the tests; + * the body it used to carry was a line-for-line copy that could (and did) + * drift from the attribution crons' checks. * - * Returns the VALIDATED addresses so the dispatcher can pin its - * connection to one of them (CURLOPT_RESOLVE) — without pinning, a - * DNS-rebinding host could answer the guard with a public IP and give - * curl's second lookup a private one. - * - * @return list the validated IPs for the URL's host - * @throws RuntimeException with the reason when the URL is not allowed + * @return list the validated IPs for the URL's host, for curlOptions() + * @throws \Prosper202\Validation\OutboundUrlException */ public static function assertUrlAllowed(string $url): array { - $parts = parse_url($url); - if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) { - throw new RuntimeException('webhook_url must be a valid https:// URL'); - } - if (isset($parts['port']) && !in_array((int) $parts['port'], [443, 8443], true)) { - throw new RuntimeException('webhook_url port must be 443 or 8443'); - } - - $host = (string) $parts['host']; - $ips = []; - if (filter_var($host, FILTER_VALIDATE_IP) !== false) { - $ips = [$host]; - } else { - $records = @dns_get_record($host, DNS_A + DNS_AAAA); - if (is_array($records)) { - foreach ($records as $record) { - if (!empty($record['ip'])) { - $ips[] = (string) $record['ip']; - } - if (!empty($record['ipv6'])) { - $ips[] = (string) $record['ipv6']; - } - } - } - } - if ($ips === []) { - throw new RuntimeException('webhook_url host does not resolve'); - } - foreach ($ips as $ip) { - if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { - throw new RuntimeException('webhook_url resolves to a private or reserved address'); - } - } - - return array_values($ips); + return OutboundUrlGuard::assertAllowed($url, 'webhook_url'); } /** @@ -140,7 +103,9 @@ private static function assertEventName(string $event): void */ public function create(int $userId, string $url, array $events): array { - self::assertUrlAllowed($url); + // Write boundary: syntactic check only, no DNS (see OutboundUrlGuard). + // The cron re-runs the full check and pins the connection at delivery. + OutboundUrlGuard::assertWellFormed($url, 'webhook_url'); $events = array_values(array_unique(array_map(strval(...), $events))); if ($events === []) { @@ -326,11 +291,17 @@ public function recordAttempt(int $deliveryId, int $webhookId, bool $success, ?i } $stmt = $this->conn->prepareWrite( + // `attempts = attempts + 1` MUST come last: MySQL evaluates + // single-table UPDATE assignments left to right and later + // expressions see already-updated columns. With the increment + // first, `attempts + 1` below read old+1, so the status test was + // really old+2 — abandoning delivery one attempt early (5 of the + // 6 MAX_ATTEMPTS) and marking the endpoint dead prematurely. "UPDATE 202_ltv_webhook_deliveries - SET attempts = attempts + 1, - status = IF(attempts + 1 >= ?, 'failed', 'pending'), + SET status = IF(attempts + 1 >= ?, 'failed', 'pending'), next_attempt_at = ? + (POW(2, LEAST(attempts + 1, 10)) * 60), - last_status_code = ?, last_response_body = ?, updated_at = ? + last_status_code = ?, last_response_body = ?, updated_at = ?, + attempts = attempts + 1 WHERE delivery_id = ?" ); $this->conn->bind($stmt, 'iiisii', [ diff --git a/202-config/Messaging/MessagingClient.class.php b/202-config/Messaging/MessagingClient.class.php index 59a722f4..d81a48e5 100644 --- a/202-config/Messaging/MessagingClient.class.php +++ b/202-config/Messaging/MessagingClient.class.php @@ -19,18 +19,81 @@ class MessagingClient { private readonly string $baseUrl; + private readonly int $curlProtocols; private readonly int $timeout; private readonly int $maxRetries; public function __construct() { // MESSAGING_API_URL is defined in 202-config/connect.php. - $this->baseUrl = defined('MESSAGING_API_URL') ? MESSAGING_API_URL : 'https://my.tracking202.com/api/v3/messaging'; - $this->timeout = 10; + // Every request body below carries the install's customer API key and the + // user's email, so refuse to speak cleartext even if MESSAGING_API_URL is + // misconfigured (mirrors Lpo\PairingClient's guard). + // + // Trimmed once, here. The transport decision below normalises its input, + // and storing the raw value let a whitespace-padded URL pass the check + // and then fail every request at curl with "Malformed input to a URL". + $configuredUrl = trim((string) (defined('MESSAGING_API_URL') ? MESSAGING_API_URL : 'https://my.tracking202.com/api/v3/messaging')); + $protocols = self::transportProtocols($configuredUrl); + if ($protocols === null) { + throw new \RuntimeException('MESSAGING_API_URL must be an https:// URL (http:// is allowed only for loopback); refusing to send credentials in cleartext.'); + } + $this->baseUrl = $configuredUrl; + // Decided once with the URL and reused by every request: the curl + // allowlist and the acceptance rule are the same decision, so they + // cannot disagree (they did -- see transportProtocols()). + $this->curlProtocols = $protocols; + $this->timeout = 10; // Kept low so the synchronous widget-poll path stays responsive when the // central server is slow/unreachable; a healthy server answers on the first // try. The cron path tolerates the occasional miss and catches up next run. - $this->maxRetries = 2; + $this->maxRetries = 2; + } + + /** + * The one transport decision: which curl protocols may carry requests to + * $url, or null when the URL must be refused outright. + * + * Credentials must not cross a network in cleartext, so https is required -- + * except against loopback, which never leaves the host. That carve-out + * exists because 202-config/Messaging/mock-server.php and the comment at + * connect.php:86 both document MESSAGING_API_URL=http://127.0.0.1:8787/messaging + * for local development; refusing it made the repo's own documented setup + * throw out of the constructor, which the messaging AJAX endpoints surface + * as a bare 500 instead of degrading gracefully. + * + * Acceptance and the curl allowlist used to be two functions that had to + * agree. They did not: the allowlist was pinned to CURLPROTO_HTTPS, so the + * constructor accepted the documented loopback URL and every request then + * failed with CURLE_UNSUPPORTED_PROTOCOL. One function returning both + * answers cannot drift. + * + * https://... -> CURLPROTO_HTTPS + * http://... -> CURLPROTO_HTTPS | CURLPROTO_HTTP + * anything else -> null (refused) + */ + private static function transportProtocols(string $url): ?int + { + $url = strtolower(trim($url)); + if (str_starts_with($url, 'https://')) { + return CURLPROTO_HTTPS; + } + if (!str_starts_with($url, 'http://')) { + return null; + } + + $host = (string) parse_url($url, PHP_URL_HOST); + if ($host === '') { + return null; + } + // localhost, ::1, and 127.0.0.0/8 only -- not every RFC1918 address, + // which does traverse a network. + $loopback = $host === 'localhost' + || $host === '::1' + || $host === '[::1]' + || (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false && str_starts_with($host, '127.')); + + return $loopback ? CURLPROTO_HTTPS | CURLPROTO_HTTP : null; } /** @@ -151,6 +214,8 @@ private function request(string $url, string $body): ?array CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_USERAGENT => 'Prosper202-Messaging/1.0', CURLOPT_FOLLOWLOCATION => false, + // Decided once in the constructor, alongside accepting the URL. + CURLOPT_PROTOCOLS => $this->curlProtocols, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HTTPHEADER => [ diff --git a/202-config/Messaging/MessagingService.class.php b/202-config/Messaging/MessagingService.class.php index 7ea978ce..47250f47 100644 --- a/202-config/Messaging/MessagingService.class.php +++ b/202-config/Messaging/MessagingService.class.php @@ -168,8 +168,15 @@ private function applyPull(array $response): void return; } - $this->db->begin_transaction(); try { + // Checked inside the try so the catch below is the one recovery path + // for every failure of this pull, including "could not even begin". + // An ignored false here would run the loop in autocommit, land half + // a pull permanently, and leave the rollback nothing to undo while + // recordSyncSuccess() advanced the cursor past it. + if (!$this->db->begin_transaction()) { + throw new RuntimeException('begin transaction failed'); + } foreach ($conversations as $conversation) { if (!is_array($conversation) || empty($conversation['external_id'])) { continue; @@ -183,11 +190,14 @@ private function applyPull(array $response): void $newestBody = ''; $messages = $conversation['messages'] ?? []; if (is_array($messages)) { - foreach ($messages as $message) { + // Pass the position within the conversation: it is stable + // across polls and is the only discriminator available to + // the synthetic-id fallback in upsertMessage(). + foreach (array_values($messages) as $position => $message) { if (!is_array($message)) { continue; } - $this->upsertMessage($conversationId, $message); + $this->upsertMessage($conversationId, $message, $position); $ts = $this->normalizeDate($message['created_at'] ?? null); if ($ts !== null && ($newestTs === null || $ts > $newestTs)) { $newestTs = $ts; @@ -206,7 +216,9 @@ private function applyPull(array $response): void $this->deleteConversations($response['deleted_conversation_ids']); } - $this->db->commit(); + if (!$this->db->commit()) { + throw new RuntimeException('commit pull failed'); + } } catch (Throwable $e) { $this->db->rollback(); error_log('MessagingService: applyPull failed: ' . $e->getMessage()); @@ -354,14 +366,17 @@ private function deleteConversations(array $externalIds): void * * @param array $m */ - private function upsertMessage(int $conversationId, array $m): void + private function upsertMessage(int $conversationId, array $m, int $position = 0): void { $externalId = isset($m['external_id']) ? (string) $m['external_id'] : null; $clientToken = isset($m['client_token']) ? (string) $m['client_token'] : null; $direction = ($m['direction'] ?? '') === 'outbound' ? 'outbound' : 'inbound'; $author = in_array($m['author'] ?? '', ['team', 'system', 'user'], true) ? $m['author'] : 'team'; $body = isset($m['body']) ? (string) $m['body'] : ''; - $createdAt = $this->normalizeDate($m['created_at'] ?? null) ?? date('Y-m-d H:i:s'); + // Keep the provided value separate from the substituted "now": the + // synthetic id below must not hash a timestamp that changes every poll. + $providedCreatedAt = $this->normalizeDate($m['created_at'] ?? null); + $createdAt = $providedCreatedAt ?? date('Y-m-d H:i:s'); // Reconcile a locally-queued outbound message by its client token. if ($clientToken !== null) { @@ -388,7 +403,19 @@ private function upsertMessage(int $conversationId, array $m): void // derive a stable synthetic id from its content so repeated pulls dedupe via // messageExists() below instead of inserting a fresh copy every sync. if ($externalId === null) { - $externalId = 'syn_' . md5($direction . '|' . $author . '|' . $createdAt . '|' . $body); + // Hash only fields that are stable across polls. Using the local + // $createdAt here meant a message with no created_at got a fresh id + // every sync, so messageExists() never matched and the poll inserted + // a duplicate row each time (~180/hour with a tab open) — the + // UNIQUE (conversation_id, external_id) key could not help. + // + // $position is required, not decorative: without it, a message + // lacking created_at hashed to direction|author||body alone, so two + // genuinely distinct messages with the same author and text (a + // repeated "ok") collided and the second was silently dropped as + // already-seen. Position is stable across polls and distinguishes + // them. + $externalId = 'syn_' . md5($direction . '|' . $author . '|' . ($providedCreatedAt ?? '') . '|' . $position . '|' . $body); } // Skip if we already have this message. @@ -567,8 +594,13 @@ private function pushMessage(int $messageId): bool return false; } - $this->db->begin_transaction(); try { + // See applyPull(): checked inside the try so the catch is the single + // recovery path, and because an ignored false silently downgrades + // the reconcile to autocommit with no rollback to undo it. + if (!$this->db->begin_transaction()) { + throw new RuntimeException('begin transaction failed'); + } // Adopt the server's canonical conversation identifiers. if (isset($response['conversation']) && is_array($response['conversation']) && !empty($response['conversation']['external_id'])) { @@ -597,7 +629,9 @@ private function pushMessage(int $messageId): bool } $stmt->close(); - $this->db->commit(); + if (!$this->db->commit()) { + throw new RuntimeException('commit push reconcile failed'); + } } catch (Throwable $e) { $this->db->rollback(); error_log('MessagingService: pushMessage reconcile failed: ' . $e->getMessage()); diff --git a/202-config/PHPStan/Rules/UncheckedTransactionBoundaryRule.php b/202-config/PHPStan/Rules/UncheckedTransactionBoundaryRule.php new file mode 100644 index 00000000..ca10a818 --- /dev/null +++ b/202-config/PHPStan/Rules/UncheckedTransactionBoundaryRule.php @@ -0,0 +1,87 @@ +begin_transaction(); $db->commit(); $db->autocommit(false); + * mysqli_begin_transaction($db); mysqli_commit($db); mysqli_autocommit($db, false); + * + * CLAUDE.md #1. Under connect.php's mysqli_report(MYSQLI_REPORT_STRICT) these + * return false on failure instead of throwing. An ignored false from + * begin_transaction() leaves the connection in autocommit, so every statement + * in the "transaction" lands individually and the rollback in the failure path + * undoes nothing; an ignored false from commit() reports success for work that + * was never made durable. + * + * Only whole statements are flagged -- `if (!$db->commit())`, `$ok = ...`, + * `return ...` all use the value. rollback() is deliberately not covered: it + * runs from failure paths that already hold the root cause. + * + * Method calls are checked only when the receiver's type is known to be + * mysqli, so a wrapper class that happens to expose commit() is not caught. + * tests/Api/V3/UncheckedTransactionBoundaryTest is the inference-blind + * complement for untyped legacy receivers. + * + * @implements Rule + */ +final class UncheckedTransactionBoundaryRule implements Rule +{ + private const METHODS = ['begin_transaction', 'commit', 'autocommit']; + private const FUNCTIONS = ['mysqli_begin_transaction', 'mysqli_commit', 'mysqli_autocommit']; + + public function getNodeType(): string + { + return Expression::class; + } + + /** + * @param Expression $node + */ + public function processNode(Node $node, Scope $scope): array + { + $expr = $node->expr; + + if ($expr instanceof MethodCall) { + if (!$expr->name instanceof Identifier || !in_array($expr->name->name, self::METHODS, true)) { + return []; + } + if (!(new ObjectType('mysqli'))->isSuperTypeOf($scope->getType($expr->var))->yes()) { + return []; + } + $call = '$db->' . $expr->name->name . '()'; + } elseif ($expr instanceof FuncCall) { + if (!$expr->name instanceof Name || !in_array($expr->name->toLowerString(), self::FUNCTIONS, true)) { + return []; + } + $call = $expr->name->toLowerString() . '()'; + } else { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'The result of %s is discarded. Under MYSQLI_REPORT_STRICT it returns false on failure, ' + . 'leaving the connection in autocommit (or reporting an unwritten commit as success). ' + . 'Check it, or use Connection::transaction() / StatementHelpers::transaction(). (CLAUDE.md #1)', + $call + )) + ->identifier('prosper202.uncheckedTransactionBoundary') + ->build(), + ]; + } +} diff --git a/202-config/Report/CampaignDataMask.php b/202-config/Report/CampaignDataMask.php new file mode 100644 index 00000000..c1a185f8 --- /dev/null +++ b/202-config/Report/CampaignDataMask.php @@ -0,0 +1,102 @@ +hasPermission('access_to_campaign_data') + && empty($_SESSION['publisher']) + ); + } + + /** + * Replace the sensitive metrics in one report row with '?'. + * + * Only keys already present are touched, so this never invents a column a + * template does not expect. Callers build `{$prefix}cost_wrapper` BEFORE + * applying the mask; it is masked here like any other key. + * + * @param array $row + * @return array + */ + public static function apply(array $row, string $prefix = ''): array + { + foreach ([...self::METRICS, self::COST_WRAPPER] as $key) { + $key = $prefix . $key; + if (array_key_exists($key, $row)) { + $row[$key] = '?'; + } + } + + return $row; + } + + /** + * apply() over a nested report structure, for every array node and every + * prefix given. The variable report nests network -> variable -> value rows + * and carries its totals on the last node under total_* keys; masking only + * the per-row keys there left the "Totals for report" line unmasked. + * + * @param array $data + * @param list $prefixes + * @return array + */ + public static function applyDeep(array $data, array $prefixes = ['', 'total_']): array + { + foreach ($data as $key => $item) { + if (is_array($item)) { + $data[$key] = self::applyDeep($item, $prefixes); + } + } + foreach ($prefixes as $prefix) { + $data = self::apply($data, $prefix); + } + + return $data; + } +} diff --git a/202-config/ReportSummaryForm.class.php b/202-config/ReportSummaryForm.class.php index 261a3ad7..88e59d2d 100755 --- a/202-config/ReportSummaryForm.class.php +++ b/202-config/ReportSummaryForm.class.php @@ -1287,13 +1287,10 @@ function getExportRowHeaderHtml() #[\Override] function getRowHtml($row, $tr_class = "") { - global $userObj; - - $hideDate = false; - - if ($userObj && !$userObj->hasPermission("access_to_campaign_data") && !$_SESSION['publisher']) { - $hideDate = true; - } + // Same decision as every other report surface (and empty() rather than + // a bare !$_SESSION['publisher'], which raised an undefined-index notice + // on every non-publisher render). + $hideDate = \Prosper202\Report\CampaignDataMask::hidden(); $html_val = ""; if ($this->getRollupSubTables() && ($row->getDetailId() > 1)) { @@ -1483,13 +1480,10 @@ function getPrintRowHtml($row, $tr_class = "") function getExportRowHtml($row) { - global $userObj; - - $hideDate = false; - - if ($userObj && !$userObj->hasPermission("access_to_campaign_data") && !$_SESSION['publisher']) { - $hideDate = true; - } + // Same decision as every other report surface (and empty() rather than + // a bare !$_SESSION['publisher'], which raised an undefined-index notice + // on every non-publisher render). + $hideDate = \Prosper202\Report\CampaignDataMask::hidden(); $current_detail = $this->getCurrentDetailByKey($row->getDetailId()); diff --git a/202-config/Repository/Mysql/MysqlDeviceRepository.php b/202-config/Repository/Mysql/MysqlDeviceRepository.php index b57f9bbb..467d6d30 100644 --- a/202-config/Repository/Mysql/MysqlDeviceRepository.php +++ b/202-config/Repository/Mysql/MysqlDeviceRepository.php @@ -67,8 +67,17 @@ public function findOrCreateDevice(string $name): int return 0; } + // The device catalog is 202_device_models; 202_devices is created by no + // install path, so every call here was destined for a 1146 on the click + // hot path. device_type is NOT NULL with no default, so supply it — and + // it must be one of the seeded types (1=Desktop, 2=Mobile, 3=Tablet, + // 4=Bot). A 0 joined to no row in 202_device_types and dropped the model + // out of every `device_type = N` filter for good, because rows are keyed + // on device_name. 1 matches connect2.php's fallback for an unrecognised + // device; this interface carries no type, so callers that know it should + // go through the detector in connect2.php. $stmt = $this->conn->prepareRead( - 'SELECT device_id FROM 202_devices WHERE device_name = ?' + 'SELECT device_id FROM 202_device_models WHERE device_name = ?' ); $this->conn->bind($stmt, 's', [$name]); $row = $this->conn->fetchOne($stmt); @@ -78,7 +87,7 @@ public function findOrCreateDevice(string $name): int } $stmt = $this->conn->prepareWrite( - 'INSERT INTO 202_devices SET device_name = ?' + 'INSERT INTO 202_device_models SET device_name = ?, device_type = 1' ); $this->conn->bind($stmt, 's', [$name]); diff --git a/202-config/Rotator/MysqlRotatorRepository.php b/202-config/Rotator/MysqlRotatorRepository.php index 74936b28..96bc9aad 100644 --- a/202-config/Rotator/MysqlRotatorRepository.php +++ b/202-config/Rotator/MysqlRotatorRepository.php @@ -94,7 +94,22 @@ public function findById(int $id, int $userId): ?array public function create(int $userId, array $data): int { - $publicId = (int) ($data['public_id'] ?? random_int(100_000, 9_999_999)); + // public_id is resolved by the unauthenticated redirect with no user + // scoping and has no UNIQUE key, so an ALREADY-TAKEN caller value would + // resolve to another user's rotator. The hazard is collision, not caller + // choice: honour a supplied public_id when it is free, generate one + // otherwise. Refusing it outright broke cross-install `p202 sync`, which + // matches rotators between installs by public_id. + $publicId = 0; + if (isset($data['public_id']) && $data['public_id'] !== '') { + $requested = (int) $data['public_id']; + if ($requested > 0 && $this->publicIdIsFree($requested)) { + $publicId = $requested; + } + } + if ($publicId === 0) { + $publicId = $this->generatePublicId(); + } $stmt = $this->conn->prepareWrite( 'INSERT INTO 202_rotators (public_id, user_id, name, default_url, default_campaign, default_lp) VALUES (?, ?, ?, ?, ?, ?)' @@ -233,6 +248,13 @@ public function createRule(int $rotatorId, array $data): int public function updateRule(int $ruleId, int $rotatorId, array $data): void { $this->conn->transaction(function () use ($ruleId, $rotatorId, $data): void { + // Ownership pre-check: rule ids are globally unique and the child + // criteria/redirect replacements below match on rule_id alone, so + // without this a caller could wipe another rotator's targeting and + // re-stamp the rows with their own rotator_id. Matches the guard in + // InMemoryRotatorRepository and RotatorsController::deleteRule. + $this->assertRuleBelongsToRotator($ruleId, $rotatorId); + // Update rule fields $sets = []; $values = []; @@ -324,9 +346,77 @@ public function updateRule(int $ruleId, int $rotatorId, array $data): void }); } + /** + * Is this public_id currently unused? A check-then-insert, with the limits + * that implies -- stated plainly because the comment here used to claim + * more than the code delivers: + * + * - Reading from the primary (prepareWrite) removes one duplicate source: + * a replica lagging behind an id that was just taken. + * - It does NOT remove the race. create() calls this outside any + * transaction and 202_rotators has no UNIQUE key on public_id, so two + * concurrent creates (two `p202 sync` runs, two API calls) can both see + * an id as free and both insert it. The unauthenticated redirect then + * resolves that public_id to whichever row it finds first. Closing that + * needs a UNIQUE index added by a schema upgrade, with existing + * duplicates resolved first; until then this check makes deliberate + * collisions fail and random ones vanishingly unlikely, nothing more. + * + * Connection::fetchOne() throws if the SELECT's result cannot be retrieved, + * so a failed read cannot be mistaken for "free". + */ + private function publicIdIsFree(int $candidate): bool + { + // No $stmt->close() here -- Connection::fetchOne() already closes it, and + // a second close throws "mysqli_stmt object is already closed" on PHP 8. + $stmt = $this->conn->prepareWrite('SELECT id FROM 202_rotators WHERE public_id = ? LIMIT 1'); + $this->conn->bind($stmt, 'i', [$candidate]); + $row = $this->conn->fetchOne($stmt); + + return $row === null; + } + + private function generatePublicId(): int + { + for ($attempt = 0; $attempt < 10; $attempt++) { + $candidate = random_int(100_000, 9_999_999); + if ($this->publicIdIsFree($candidate)) { + return $candidate; + } + } + + throw new RuntimeException('Unable to allocate a unique rotator public id'); + } + + /** + * Verify a rule belongs to the given rotator before touching its children, + * which are keyed on the globally-unique rule_id alone. + */ + private function assertRuleBelongsToRotator(int $ruleId, int $rotatorId): void + { + // prepareWrite, not prepareRead: both callers run this inside the write + // transaction that is about to delete or update the rule's children, so + // reading it from a replica can authorise the write against stale, + // pre-transaction state. delete() in this class uses the write + // connection with FOR UPDATE for the same reason. + // No $stmt->close() here -- Connection::fetchOne() already closes it, and + // a second close throws "mysqli_stmt object is already closed" on PHP 8. + $stmt = $this->conn->prepareWrite( + 'SELECT rotator_id FROM 202_rotator_rules WHERE id = ? FOR UPDATE' + ); + $this->conn->bind($stmt, 'i', [$ruleId]); + $row = $this->conn->fetchOne($stmt); + + if ($row === null || (int) $row['rotator_id'] !== $rotatorId) { + throw new RuntimeException("Rule $ruleId not found"); + } + } + public function deleteRule(int $ruleId, int $rotatorId): void { $this->conn->transaction(function () use ($ruleId, $rotatorId): void { + $this->assertRuleBelongsToRotator($ruleId, $rotatorId); + $stmt = $this->conn->prepareWrite( 'DELETE FROM 202_rotator_rules_criteria WHERE rule_id = ?' ); diff --git a/202-config/Validation/OutboundUrlException.php b/202-config/Validation/OutboundUrlException.php new file mode 100644 index 00000000..f7bc9113 --- /dev/null +++ b/202-config/Validation/OutboundUrlException.php @@ -0,0 +1,19 @@ + $allowedPorts + * @throws OutboundUrlException with the reason when the URL is not allowed + */ + public static function assertWellFormed(string $url, string $label = 'url', array $allowedPorts = self::DEFAULT_PORTS): void + { + $host = self::parseHost($url, $label, $allowedPorts); + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + self::assertIpAllowed($host, $label); + } + } + + /** + * The dispatch-time check: assertWellFormed() plus resolution. + * + * @param list $allowedPorts + * @return list the validated IPs for the URL's host -- pass to curlOptions() + * @throws OutboundUrlException with the reason when the URL is not allowed + */ + public static function assertAllowed(string $url, string $label = 'url', array $allowedPorts = self::DEFAULT_PORTS): array + { + $host = self::parseHost($url, $label, $allowedPorts); + + $ips = []; + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + $ips = [$host]; + } else { + $records = @dns_get_record($host, DNS_A + DNS_AAAA); + if (is_array($records)) { + foreach ($records as $record) { + if (!empty($record['ip'])) { + $ips[] = (string) $record['ip']; + } + if (!empty($record['ipv6'])) { + $ips[] = (string) $record['ipv6']; + } + } + } + } + if ($ips === []) { + throw new OutboundUrlException($label . ' host does not resolve'); + } + + foreach ($ips as $ip) { + self::assertIpAllowed($ip, $label); + } + + return array_values($ips); + } + + /** + * @throws OutboundUrlException + */ + public static function assertIpAllowed(string $ip, string $label = 'url'): void + { + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { + throw new OutboundUrlException($label . ' resolves to a private or reserved address'); + } + + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) { + foreach (self::EXTRA_DENY_V4 as [$network, $bits]) { + if (self::ipv4InCidr($ip, $network, $bits)) { + throw new OutboundUrlException($label . ' resolves to a reserved address range (' . $network . '/' . $bits . ')'); + } + } + } + } + + /** + * curl options that make a delivery to $url safe, given the addresses + * assertAllowed() just approved for it. Every dispatcher uses this same + * set, so none can drop one of them by accident: + * + * - CURLOPT_RESOLVE pins the connection to an approved address, so curl + * does not resolve the host a second time and pick up a rebound answer. + * An IPv4 literal is preferred; an IPv6 one is bracketed, the form curl + * documents -- unbracketed, its extra colons make the entry unparseable + * and curl silently drops the pin. + * - No redirects, and https only, including for any redirect curl might + * otherwise follow. + * - TLS verification stays on; SNI and certificate checks use the URL's + * hostname, which CURLOPT_RESOLVE preserves. + * + * @param list $validatedIps the return value of assertAllowed() + * @return array for curl_setopt_array() + * @throws OutboundUrlException when there is no address to pin to -- a caller bug, never a + * reason to send unpinned + */ + public static function curlOptions(string $url, array $validatedIps): array + { + return [ + CURLOPT_RESOLVE => [self::curlResolveEntry($url, $validatedIps)], + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_MAXREDIRS => 0, + CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, + CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 15, + ]; + } + + /** + * The CURLOPT_RESOLVE entry for pinning $url to one of $validatedIps. + * + * @param list $validatedIps + * @throws OutboundUrlException when nothing can be pinned; see curlOptions() + */ + public static function curlResolveEntry(string $url, array $validatedIps): string + { + $parts = parse_url($url); + if ($validatedIps === [] || !is_array($parts) || empty($parts['host'])) { + throw new OutboundUrlException('Refusing to send unpinned: no validated address for ' . $url); + } + + $v4 = array_values(array_filter( + $validatedIps, + static fn(string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false + )); + $pinned = $v4[0] ?? $validatedIps[0]; + if (str_contains($pinned, ':')) { + $pinned = '[' . $pinned . ']'; + } + + return $parts['host'] . ':' . (int) ($parts['port'] ?? 443) . ':' . $pinned; + } + + /** + * @param list $allowedPorts + * @throws OutboundUrlException + */ + private static function parseHost(string $url, string $label, array $allowedPorts): string + { + $parts = parse_url($url); + if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) { + throw new OutboundUrlException($label . ' must be a valid https:// URL'); + } + if (isset($parts['port']) && $allowedPorts !== [] && !in_array((int) $parts['port'], $allowedPorts, true)) { + throw new OutboundUrlException($label . ' port must be one of: ' . implode(', ', $allowedPorts)); + } + + return (string) $parts['host']; + } + + private static function ipv4InCidr(string $ip, string $network, int $bits): bool + { + $ipLong = ip2long($ip); + $netLong = ip2long($network); + if ($ipLong === false || $netLong === false) { + return false; + } + $mask = $bits === 0 ? 0 : (-1 << (32 - $bits)) & 0xFFFFFFFF; + return (($ipLong & $mask) === ($netLong & $mask)); + } +} diff --git a/202-config/Validation/SetupFormValidator.php b/202-config/Validation/SetupFormValidator.php index ab5b69b1..3a436d13 100644 --- a/202-config/Validation/SetupFormValidator.php +++ b/202-config/Validation/SetupFormValidator.php @@ -234,31 +234,40 @@ public function validateRecordExists(string $table, string $idColumn, int $recor /** * Validate that a slug is unique for the user */ - public function validateUniqueSlug(int $userId, string $table, string $slug, ?int $excludeId = null, string $fieldName = 'slug'): ValidationResult + public function validateUniqueSlug(int $userId, string $table, string $slug, ?int $excludeId = null, string $fieldName = 'slug', string $slugColumn = 'model_slug', string $idColumn = 'model_id'): ValidationResult { - // The table name cannot be parameterized; constrain it before use. - if (!$this->assertAllowedTable($table)) { + // Identifiers cannot be parameterized; constrain them before use. The + // column names were hardcoded to model_slug/model_id while any of the + // allowlisted tables could be passed, so every other table produced a + // 1054 that was then reported as "unique". + if (!$this->assertAllowedTable($table) || !$this->isValidIdentifier($slugColumn) || !$this->isValidIdentifier($idColumn)) { return ValidationResult::failure("$fieldName could not be validated"); } $userIdEscaped = $this->db->real_escape_string((string)$userId); $slugEscaped = $this->db->real_escape_string($slug); - $sql = "SELECT 1 FROM `$table` WHERE `user_id` = '$userIdEscaped' AND `model_slug` = '$slugEscaped'"; + $sql = "SELECT 1 FROM `$table` WHERE `user_id` = '$userIdEscaped' AND `$slugColumn` = '$slugEscaped'"; if ($excludeId !== null) { $excludeIdEscaped = $this->db->real_escape_string((string)$excludeId); - $sql .= " AND `model_id` != '$excludeIdEscaped'"; + $sql .= " AND `$idColumn` != '$excludeIdEscaped'"; } $sql .= " LIMIT 1"; $result = $this->db->query($sql); - - if ($result && $result->num_rows > 0) { + + // Fail CLOSED like validateRecordExists/validateOwnership above: a + // failed query previously fell through and reported the slug as unique. + if (!$result) { + return ValidationResult::failure("$fieldName could not be validated"); + } + + if ($result->num_rows > 0) { return ValidationResult::failure("$fieldName must be unique"); } - + return ValidationResult::success($slug); } diff --git a/202-config/class-dataengine.php b/202-config/class-dataengine.php index f0410f85..539294e7 100644 --- a/202-config/class-dataengine.php +++ b/202-config/class-dataengine.php @@ -1236,6 +1236,11 @@ class DisplayData private static function labelStyle($value): string { $number = self::convertToNumber($value); + if (!is_numeric($number)) { + // A masked '?' reaches here. PHP 8 compares '?' > 0 as true, which + // styled every masked total as a positive figure. + return 'default'; + } if ($number > 0) { return 'primary'; } @@ -1297,8 +1302,6 @@ private static function featureKey(string $reportType, array $html): string public function displayReport($reportType, $theData, $foundRows = '') { - global $userObj; - $paginateReport = !in_array($reportType, self::UNPAGINATED, true); $downloadUrl = self::DOWNLOAD_URLS[$reportType] ?? ''; $featureLabel = self::FEATURE_LABELS[$reportType] ?? 'Item'; @@ -1351,6 +1354,10 @@ public function displayReport($reportType, $theData, $foundRows = '') return; } + // Decided once per report, not once per row: the viewer cannot change + // mid-render. + $masked = \Prosper202\Report\CampaignDataMask::hidden(); + for ($i = 0; $i < $rowCount; $i++) { $html = $rows[$i]; $featureKey = self::featureKey((string) $reportType, $html); @@ -1360,18 +1367,10 @@ public function displayReport($reportType, $theData, $foundRows = '') $totalNetStyle = self::labelStyle($html['total_net'] ?? 0); $totalRoiStyle = self::labelStyle($html['total_roi'] ?? 0); - $masked = $userObj && !$userObj->hasPermission("access_to_campaign_data") && empty($_SESSION['publisher']); - if ($i != $rowCount - 1) { + $html['cost_wrapper'] = '(' . $html['cost'] . ')'; if ($masked) { - $html['clicks'] = '?'; - $html['click_out'] = '?'; - $html['leads'] = '?'; - $html['income'] = '?'; - $html['cost_wrapper'] = '?'; - $html['net'] = '?'; - } else { - $html['cost_wrapper'] = '(' . $html['cost'] . ')'; + $html = \Prosper202\Report\CampaignDataMask::apply($html); } echo ' @@ -1392,15 +1391,9 @@ public function displayReport($reportType, $theData, $foundRows = '') '; } else { + $html['total_cost_wrapper'] = '(' . $html['total_cost'] . ')'; if ($masked) { - $html['total_clicks'] = '?'; - $html['total_click_out'] = '?'; - $html['total_leads'] = '?'; - $html['total_income'] = '?'; - $html['total_cost_wrapper'] = '?'; - $html['total_net'] = '?'; - } else { - $html['total_cost_wrapper'] = '(' . $html['total_cost'] . ')'; + $html = \Prosper202\Report\CampaignDataMask::apply($html, 'total_'); } echo ' @@ -1430,8 +1423,6 @@ public function displayReport($reportType, $theData, $foundRows = '') public function displayPerPPCReport($type, $theData) { - global $userObj; - $featureLabel = match ($type) { 'slp_direct_link' => '[direct link & simple lp]', 'alp' => '[adv lp]', @@ -1442,6 +1433,8 @@ public function displayPerPPCReport($type, $theData) return; } + $masked = \Prosper202\Report\CampaignDataMask::hidden(); + foreach ($theData as $campaign) { $name = match ($type) { 'slp_direct_link' => $campaign['total_aff_network_name'] . ' - ' . $campaign['total_aff_campaign_name'], @@ -1478,15 +1471,9 @@ public function displayPerPPCReport($type, $theData) $netStyle = self::labelStyle($ppc_account['net']); $roiStyle = self::labelStyle($ppc_account['roi']); - if ($userObj && !$userObj->hasPermission("access_to_campaign_data") && empty($_SESSION['publisher'])) { - $ppc_account['clicks'] = '?'; - $ppc_account['click_out'] = '?'; - $ppc_account['leads'] = '?'; - $ppc_account['income'] = '?'; - $ppc_account['cost_wrapper'] = '?'; - $ppc_account['net'] = '?'; - } else { - $ppc_account['cost_wrapper'] = '(' . $ppc_account['cost'] . ')'; + $ppc_account['cost_wrapper'] = '(' . $ppc_account['cost'] . ')'; + if ($masked) { + $ppc_account = \Prosper202\Report\CampaignDataMask::apply($ppc_account); } if (($ppc_account['ppc_network_name'] != '') && ($ppc_account['ppc_account_name'] != '')) { @@ -1514,15 +1501,12 @@ public function displayPerPPCReport($type, $theData) '; } - if ($userObj && !$userObj->hasPermission("access_to_campaign_data") && empty($_SESSION['publisher'])) { - $campaign['total_clicks'] = '?'; - $campaign['total_click_out'] = '?'; - $campaign['total_leads'] = '?'; - $campaign['total_income'] = '?'; - $campaign['cost_wrapper'] = '?'; - $campaign['net'] = '?'; - } else { - $campaign['cost_wrapper'] = '(' . $campaign['total_cost'] . ')'; + // total_cost_wrapper, not cost_wrapper: this row is all total_* keys + // and the mask is applied with that prefix. The odd-one-out key it + // used to have is where 'net' got masked while 'total_net' printed. + $campaign['total_cost_wrapper'] = '(' . $campaign['total_cost'] . ')'; + if ($masked) { + $campaign = \Prosper202\Report\CampaignDataMask::apply($campaign, 'total_'); } echo ' @@ -1536,7 +1520,7 @@ public function displayPerPPCReport($type, $theData) ' . $campaign['total_epc'] . ' ' . $campaign['total_cpc'] . ' ' . $campaign['total_income'] . ' - ' . $campaign['cost_wrapper'] . ' + ' . $campaign['total_cost_wrapper'] . ' ' . $campaign['total_net'] . ' ' . $campaign['total_roi'] . ' @@ -1545,8 +1529,27 @@ public function displayPerPPCReport($type, $theData) } } + /** + * The variable reports nest their rows (network -> variable -> value) and + * carry the report totals on the last node, so the mask walks the whole + * structure for both the per-row and the total_* keys. Masking only the + * per-row keys here left the "Totals for report" line -- rendered by + * displayVariableReport() and written by downloadVariables() -- showing + * the real figures. + */ + private function maskVariableData($theData) + { + if (!\Prosper202\Report\CampaignDataMask::hidden()) { + return $theData; + } + + return \Prosper202\Report\CampaignDataMask::applyDeep((array) $theData); + } + public function displayVariableReport($theData) { + $theData = $this->maskVariableData($theData); + echo '
    @@ -1651,12 +1654,12 @@ public function displayVariableReport($theData) public function downloadReport($reportType, $theData, $foundRows = '') { - global $userObj; - $featureLabel = self::FEATURE_LABELS[$reportType] ?? 'Item'; echo $featureLabel . "\t" . "Clicks" . "\t" . "Click Throughs" . "\t" . "LP CTR" . "\t" . "Leads" . "\t" . "S/U" . "\t" . "Payout" . "\t" . "EPC" . "\t" . "Avg CPC" . "\t" . "Income" . "\t" . "Cost" . "\t" . "Net" . "\t" . "ROI" . "\n"; + $masked = \Prosper202\Report\CampaignDataMask::hidden(); + foreach (array_values((array) $theData) as $html) { // The trailing totals row carries only total_* keys; letting it // fall through printed an "Unknown" row of empty cells (plus @@ -1691,13 +1694,8 @@ public function downloadReport($reportType, $theData, $foundRows = '') continue; } - if ($userObj && !$userObj->hasPermission("access_to_campaign_data") && empty($_SESSION['publisher'])) { - $html['clicks'] = '?'; - $html['click_out'] = '?'; - $html['leads'] = '?'; - $html['income'] = '?'; - $html['cost'] = '?'; - $html['net'] = '?'; + if ($masked) { + $html = \Prosper202\Report\CampaignDataMask::apply($html); } echo $featureKey . "\t" . $html['clicks'] . "\t" . $html['click_out'] . "\t" . $html['ctr'] . "\t" . $html['leads'] . "\t" . $html['su_ratio'] . "\t" . $html['payout'] . "\t" . $html['epc'] . "\t" . $html['cpc'] . "\t" . $html['income'] . "\t" . $html['cost'] . "\t" . $html['net'] . "\t" . $html['roi'] . "\n"; @@ -1706,6 +1704,8 @@ public function downloadReport($reportType, $theData, $foundRows = '') public function downloadVariables($theData) { + $theData = $this->maskVariableData($theData); + echo "Custom Variables" . "\t" . "Clicks" . "\t" . "Click Throughs" . "\t" . "LP CTR" . "\t" . "Leads" . "\t" . "S/U" . "\t" . "Payout" . "\t" . "EPC" . "\t" . "Avg CPC" . "\t" . "Income" . "\t" . "Cost" . "\t" . "Net" . "\t" . "ROI" . "\n"; $rows = array_values((array) $theData); diff --git a/202-config/class-indexes.php b/202-config/class-indexes.php index 4c1cdf88..457b0af2 100644 --- a/202-config/class-indexes.php +++ b/202-config/class-indexes.php @@ -645,7 +645,7 @@ public static function get_platform_id($platform_name) return $platform_id; } - public static function get_device_id($device_name) + public static function get_device_id($device_name, $device_type = null) { $database = DB::getInstance(); $db = $database->getConnection(); @@ -655,11 +655,22 @@ public static function get_device_id($device_name) } $mysql['device_name'] = $db->real_escape_string(trim((string) $device_name)); - $device_sql = "SELECT device_id FROM 202_devices WHERE device_name='" . $mysql['device_name'] . "'"; - $device_result = $db->query($device_sql); // or record_mysql_error($device_sql); + // 202_device_models is the real catalog; 202_devices is created by no + // install path. device_type is NOT NULL with no default, so supply it — + // but it must be a real type. 202_device_types seeds only 1=Desktop, + // 2=Mobile, 3=Tablet, 4=Bot, so a 0 joined to nothing and dropped the + // model out of every `device_type = N` report filter permanently, since + // rows here are keyed on device_name. Mirror connect2.php, which resolves + // 1-4 and falls back to 1 for an unrecognised device. + $type = (int) $device_type; + if ($type < 1 || $type > 4) { + $type = 1; + } + $device_sql = "SELECT device_id FROM 202_device_models WHERE device_name='" . $mysql['device_name'] . "'"; + $device_result = $db->query($device_sql) or record_mysql_error($device_sql); if ($device_result->num_rows == 0) { - $device_sql = "INSERT INTO 202_devices SET device_name='" . $mysql['device_name'] . "'"; + $device_sql = "INSERT INTO 202_device_models SET device_name='" . $mysql['device_name'] . "', device_type='" . $type . "'"; delay_sql($device_sql); $device_id = mysqli_insert_id($db); } else { diff --git a/202-config/functions-auth.php b/202-config/functions-auth.php index d9290b89..63025da2 100755 --- a/202-config/functions-auth.php +++ b/202-config/functions-auth.php @@ -340,9 +340,11 @@ public static function is_valid_api_key($user_api_key) if ($keyIsValid) { //update the api key + global $db; + $escaped_api_key = $db->real_escape_string((string) $user_api_key); $user_sql = " UPDATE 202_users - SET p202_customer_api_key='" . $user_api_key . "' - WHERE user_id='" . $_SESSION['user_id'] . "'"; + SET p202_customer_api_key='" . $escaped_api_key . "' + WHERE user_id='" . (int) $_SESSION['user_id'] . "'"; _mysqli_query($user_sql); self::writeSessionValue('valid_key', true); // Warm the CLI shell license cache so p202 shell works without its own round-trip. diff --git a/202-config/functions-indexes.php b/202-config/functions-indexes.php index 999811dc..4e34efaa 100644 --- a/202-config/functions-indexes.php +++ b/202-config/functions-indexes.php @@ -107,8 +107,8 @@ function get_platform_id($platform_name) } if (!function_exists('get_device_id')) { - function get_device_id($device_name) + function get_device_id($device_name, $device_type = null) { - return INDEXES::get_device_id($device_name); + return INDEXES::get_device_id($device_name, $device_type); } } diff --git a/202-config/functions-tracking202.php b/202-config/functions-tracking202.php index 7f4eb361..3856b6de 100644 --- a/202-config/functions-tracking202.php +++ b/202-config/functions-tracking202.php @@ -1540,19 +1540,22 @@ function query( } if ($user_row['user_pref_country_id']) { - $mysql['user_pref_country_id'] = $db->real_escape_string($user_row['user_pref_country_id']); + // Cast to int: these ids are interpolated UNQUOTED, where + // real_escape_string does not neutralize a payload like + // "1 OR (SELECT ...)" (it contains no quotes to escape). + $mysql['user_pref_country_id'] = (int) $user_row['user_pref_country_id']; $click_sql .= " AND 2ca.country_id=" . $mysql['user_pref_country_id']; $count_where .= " AND 2c.country_id=" . $mysql['user_pref_country_id']; } if ($user_row['user_pref_region_id']) { - $mysql['user_pref_region_id'] = $db->real_escape_string($user_row['user_pref_region_id']); + $mysql['user_pref_region_id'] = (int) $user_row['user_pref_region_id']; $click_sql .= " AND 2ca.region_id=" . $mysql['user_pref_region_id']; $count_where .= " AND 2c.region_id=" . $mysql['user_pref_region_id']; } if ($user_row['user_pref_isp_id']) { - $mysql['user_pref_isp_id'] = $db->real_escape_string($user_row['user_pref_isp_id']); + $mysql['user_pref_isp_id'] = (int) $user_row['user_pref_isp_id']; $click_sql .= " AND 2is.isp_id=" . $mysql['user_pref_isp_id']; $count_where .= " AND 2c.isp_id=" . $mysql['user_pref_isp_id']; } @@ -1578,19 +1581,19 @@ function query( } if ($user_row['user_pref_device_id']) { - $mysql['user_pref_device_id'] = $db->real_escape_string($user_row['user_pref_device_id']); + $mysql['user_pref_device_id'] = (int) $user_row['user_pref_device_id']; $click_sql .= " AND 2d.device_type=" . $mysql['user_pref_device_id']; $count_where .= " AND 2c.device_id IN (SELECT device_id FROM 202_device_models WHERE device_type=" . $mysql['user_pref_device_id'] . ")"; } if ($user_row['user_pref_browser_id']) { - $mysql['user_pref_browser_id'] = $db->real_escape_string($user_row['user_pref_browser_id']); + $mysql['user_pref_browser_id'] = (int) $user_row['user_pref_browser_id']; $click_sql .= " AND 2b.browser_id=" . $mysql['user_pref_browser_id']; $count_where .= " AND 2c.browser_id=" . $mysql['user_pref_browser_id']; } if ($user_row['user_pref_platform_id']) { - $mysql['user_pref_platform_id'] = $db->real_escape_string($user_row['user_pref_platform_id']); + $mysql['user_pref_platform_id'] = (int) $user_row['user_pref_platform_id']; $click_sql .= " AND 2p.platform_id=" . $mysql['user_pref_platform_id']; $count_where .= " AND 2c.platform_id=" . $mysql['user_pref_platform_id']; } diff --git a/202-config/functions-upgrade.php b/202-config/functions-upgrade.php index 5855ae0d..1d4b4dc9 100755 --- a/202-config/functions-upgrade.php +++ b/202-config/functions-upgrade.php @@ -3072,7 +3072,14 @@ public static function upgrade_databases($time_from) $connection = $database->getConnection(); if ($connection instanceof \mysqli) { - $connection->begin_transaction(); + // Checked: on a false return the ALTERs and the seed UPDATE below + // run in autocommit, so the rollback in the catch does nothing and + // a failed upgrade leaves 202_attribution_settings half-migrated + // while the version row is never advanced -- the next run then + // re-applies the same steps against the partially changed schema. + if (!$connection->begin_transaction()) { + throw new \RuntimeException('Failed to start the 1.9.57 upgrade transaction: ' . $connection->error); + } try { $columnChecks = [ @@ -3133,7 +3140,9 @@ public static function upgrade_databases($time_from) throw new \RuntimeException('Failed to seed attribution setting toggles: ' . $connection->error); } - $connection->commit(); + if (!$connection->commit()) { + throw new \RuntimeException('Failed to commit the 1.9.57 upgrade: ' . $connection->error); + } } catch (\Throwable $upgradeException) { $connection->rollback(); throw $upgradeException; @@ -3176,6 +3185,36 @@ public static function upgrade_databases($time_from) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;"; $result = _upgrade_query($sql); + // The 1.9.56 block above already created 202_attribution_exports with + // a DIFFERENT column set, so the CREATE ... IF NOT EXISTS just above + // is a no-op on every install and the columns MysqlExportRepository + // selects never existed (ExportFormat::from('') -> uncaught + // ValueError, surfacing as a 500 on 202-account/attribution-export.php). + // Add the missing columns explicitly. + $exportColumnsToAdd = [ + 'format' => "ALTER TABLE `202_attribution_exports` ADD COLUMN `format` varchar(10) NOT NULL DEFAULT 'csv'", + 'download_token' => "ALTER TABLE `202_attribution_exports` ADD COLUMN `download_token` varchar(64) DEFAULT NULL", + 'webhook_method' => "ALTER TABLE `202_attribution_exports` ADD COLUMN `webhook_method` varchar(10) DEFAULT NULL", + 'last_attempted_at' => "ALTER TABLE `202_attribution_exports` ADD COLUMN `last_attempted_at` int(10) unsigned DEFAULT NULL", + 'error_message' => "ALTER TABLE `202_attribution_exports` ADD COLUMN `error_message` text DEFAULT NULL", + ]; + foreach ($exportColumnsToAdd as $exportColumn => $exportAlterSql) { + $sql = "SELECT COUNT(*) as count FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = '202_attribution_exports' + AND COLUMN_NAME = '" . $exportColumn . "'"; + $result = _upgrade_query($sql); + $row = mysqli_fetch_assoc($result); + if ($row['count'] == 0) { + $result = _upgrade_query($exportAlterSql); + } + } + + // Backfill the renamed columns so pre-existing rows are readable. + $result = _upgrade_query("UPDATE `202_attribution_exports` SET `format` = `requested_format` WHERE `format` = 'csv' AND `requested_format` IS NOT NULL AND `requested_format` != ''"); + $result = _upgrade_query("UPDATE `202_attribution_exports` SET `last_attempted_at` = `webhook_attempted_at` WHERE `last_attempted_at` IS NULL AND `webhook_attempted_at` IS NOT NULL"); + $result = _upgrade_query("UPDATE `202_attribution_exports` SET `error_message` = `last_error` WHERE `error_message` IS NULL AND `last_error` IS NOT NULL"); + $sql = "UPDATE 202_version SET version='1.9.59'"; $result = _upgrade_query($sql); diff --git a/202-config/migrations/run_attribution_migration.php b/202-config/migrations/run_attribution_migration.php index f391d7f3..ec56aba3 100644 --- a/202-config/migrations/run_attribution_migration.php +++ b/202-config/migrations/run_attribution_migration.php @@ -42,7 +42,13 @@ function($stmt) { echo "Found " . count($statements) . " SQL statements to execute...\n"; // Execute each statement - $db->begin_transaction(); + // No transaction here, on purpose. The statements in this file are CREATE + // TABLE / ALTER TABLE with one INSERT IGNORE between them, and MySQL commits + // implicitly before and after every DDL statement -- so a transaction + // around this loop can never roll anything back, and the rollback() that + // used to sit in the catch block only made a failed run look recoverable. + // The script is re-runnable instead: IF NOT EXISTS / INSERT IGNORE make a + // second pass after a failure a no-op for everything that already landed. foreach ($statements as $index => $statement) { echo "Executing statement " . ($index + 1) . "...\n"; @@ -59,8 +65,6 @@ function($stmt) { } } - $db->commit(); - echo "\nMigration completed successfully!\n"; echo "Attribution models tables have been created.\n"; @@ -96,7 +100,6 @@ function($stmt) { } } catch (Exception $e) { - $db->rollback(); echo "\nMigration failed: " . $e->getMessage() . "\n"; exit(1); } diff --git a/202-config/migrations/run_attribution_migration_standalone.php b/202-config/migrations/run_attribution_migration_standalone.php index 8de580d2..f0aae15d 100644 --- a/202-config/migrations/run_attribution_migration_standalone.php +++ b/202-config/migrations/run_attribution_migration_standalone.php @@ -63,7 +63,13 @@ function($stmt) { echo "Found " . count($statements) . " SQL statements to execute...\n\n"; // Execute each statement - $db->begin_transaction(); + // No transaction here, on purpose. The statements in this file are CREATE + // TABLE / ALTER TABLE with one INSERT IGNORE between them, and MySQL commits + // implicitly before and after every DDL statement -- so a transaction + // around this loop can never roll anything back, and the rollback() that + // used to sit in the catch block only made a failed run look recoverable. + // The script is re-runnable instead: IF NOT EXISTS / INSERT IGNORE make a + // second pass after a failure a no-op for everything that already landed. foreach ($statements as $index => $statement) { echo "Executing statement " . ($index + 1) . "... "; @@ -83,8 +89,6 @@ function($stmt) { echo "\n"; } - $db->commit(); - echo "\n🎉 Migration completed successfully!\n"; echo "Attribution models tables have been created.\n\n"; @@ -132,7 +136,6 @@ function($stmt) { } } catch (Exception $e) { - $db->rollback(); echo "\n❌ Migration failed: " . $e->getMessage() . "\n"; exit(1); } diff --git a/202-config/migrations/run_forecast_events_migration.php b/202-config/migrations/run_forecast_events_migration.php index 2ab0575a..022f66db 100644 --- a/202-config/migrations/run_forecast_events_migration.php +++ b/202-config/migrations/run_forecast_events_migration.php @@ -9,6 +9,8 @@ include_once dirname(__DIR__) . '/connect.php'; +use Prosper202\Database\Connection; + if (!isset($db) || !($db instanceof mysqli)) { die("Error: Database connection not available\n"); } @@ -91,15 +93,14 @@ function ($stmt) { $dml = []; } - $db->begin_transaction(); - $inTransaction = true; - - foreach ($dml as $statement) { - $runStatement($statement); - } - - $db->commit(); - $inTransaction = false; + // Connection::transaction() does the checked begin, the checked commit and + // the rollback-on-throw; hand-rolling those here is how the unchecked + // begin_transaction() got in. run_ltv_backfill.php uses the same shape. + (new Connection($db))->transaction(static function () use ($dml, $runStatement): void { + foreach ($dml as $statement) { + $runStatement($statement); + } + }); echo "\nMigration completed successfully!\n"; @@ -123,10 +124,8 @@ function ($stmt) { } } -} catch (Exception $e) { - if (!empty($inTransaction)) { - $db->rollback(); - } +} catch (Throwable $e) { + // Connection::transaction() has already rolled back if the seed failed. echo "\nMigration failed: " . $e->getMessage() . "\n"; exit(1); } diff --git a/202-cronjobs/attribution-export.php b/202-cronjobs/attribution-export.php index 28871b61..2bd1bdac 100644 --- a/202-cronjobs/attribution-export.php +++ b/202-cronjobs/attribution-export.php @@ -285,12 +285,31 @@ function dispatchWebhook(ExportJob $job, array $fileInfo): array $headers[] = 'X-Prosper202-Signature: ' . $signature; } + // Full check at dispatch: the write boundary only checked shape, and DNS + // can change between scheduling and delivery anyway. The validated + // addresses feed curlOptions(), which pins the connection to one of them so + // curl cannot be handed a rebound private address by its own lookup. + try { + $validatedIps = \Prosper202\Validation\OutboundUrlGuard::assertAllowed($webhook->url, 'webhook_url'); + } catch (\Prosper202\Validation\OutboundUrlException $e) { + error_log('attribution-export: refusing webhook delivery: ' . $e->getMessage()); + return [ + 'success' => false, + 'attempted_at' => time(), + 'status_code' => null, + 'response_body' => null, + 'error' => $e->getMessage(), + ]; + } + $ch = curl_init($webhook->url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $json); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - curl_setopt($ch, CURLOPT_TIMEOUT, 15); + // Pinned to a validated address, no redirects, https only, TLS verified -- + // the same option set ltv_webhooks.php uses, so neither can drop one. + curl_setopt_array($ch, \Prosper202\Validation\OutboundUrlGuard::curlOptions($webhook->url, $validatedIps)); $response = curl_exec($ch); $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE) ?: null; diff --git a/202-cronjobs/attribution-rebuild.php b/202-cronjobs/attribution-rebuild.php index 93cf1e0a..4afcf17c 100644 --- a/202-cronjobs/attribution-rebuild.php +++ b/202-cronjobs/attribution-rebuild.php @@ -44,6 +44,27 @@ $cronBucket = (int) ($endTime - ($endTime % 3600)); $cronType = 'attr'; +// The check-then-insert window guard below is not atomic and 202_cronjobs has +// no unique key on (cronjob_type, cronjob_time), so two overlapping runs could +// both pass the check and rebuild the same bucket. Take an exclusive +// non-blocking file lock first; the loser exits instead of duplicating work. +$attrLockPath = sys_get_temp_dir() . '/p202-attribution-rebuild.lock'; +$attrLockHandle = fopen($attrLockPath, 'c+'); +if ($attrLockHandle === false) { + fwrite(STDERR, "Unable to open attribution cron lock file.\n"); + exit(1); +} +if (!flock($attrLockHandle, LOCK_EX | LOCK_NB)) { + fclose($attrLockHandle); + fwrite(STDOUT, "Attribution cron is already running; skipping.\n"); + exit(0); +} +// Released implicitly when the process exits. +register_shutdown_function(static function () use ($attrLockHandle): void { + flock($attrLockHandle, LOCK_UN); + fclose($attrLockHandle); +}); + $database = DB::getInstance(); $connection = $database?->getConnection(); if ($connection instanceof mysqli) { diff --git a/202-cronjobs/bridge_config.php b/202-cronjobs/bridge_config.php index 8f20767b..2e1942f2 100644 --- a/202-cronjobs/bridge_config.php +++ b/202-cronjobs/bridge_config.php @@ -12,7 +12,7 @@ * both sides sign json_encode(config) with PHP defaults), persists it to * 202_users_pref.lpo_bridge_config, and applies it to the local webhook * row: enabled_events maps onto subscribed_events ('*' = '' = subscribe-all) - * and a hook_url change re-runs the SSRF guard (assertUrlAllowed) before the + * and a hook_url change re-runs the SSRF guard (OutboundUrlGuard::assertWellFormed) before the * URL is updated. This makes event routing and endpoints adjustable * server-side after install, without a Prosper202 release. * @@ -167,7 +167,7 @@ // Apply a hook_url change, re-running the SSRF guard first. $newUrl = trim((string) ($config['hook_url'] ?? '')); if ($newUrl !== '' && $newUrl !== (string) $hook['webhook_url']) { - MysqlWebhookRepository::assertUrlAllowed($newUrl); + \Prosper202\Validation\OutboundUrlGuard::assertWellFormed($newUrl, 'hook_url'); $update = $conn->prepareWrite( 'UPDATE 202_ltv_webhooks SET webhook_url = ?, updated_at = ? WHERE webhook_id = ? AND user_id = ?' ); diff --git a/202-cronjobs/daily-email.php b/202-cronjobs/daily-email.php index 5b6f616a..595a4c74 100755 --- a/202-cronjobs/daily-email.php +++ b/202-cronjobs/daily-email.php @@ -72,8 +72,13 @@ } } + // Only run the comparison query when today produced campaigns. With an empty + // $ids the IN () below is a MySQL syntax error, which the outer catch + // swallows to error_log — silently skipping the whole daily email on any + // day that starts with no data. + if ($ids !== []) { $sql_yesterday = "SELECT - 2c.aff_campaign_id, + 2c.aff_campaign_id, 2ca.aff_campaign_name, COUNT(*) AS clicks, SUM(2cr.click_out) AS click_throughs, @@ -118,6 +123,7 @@ $data['campaigns'][$row_yesterday['aff_campaign_id']]['difference'] = $difference; } } + } // end if ($ids !== []) if (count($data['campaigns']) > 0) { $curl = curl_init('https://my.tracking202.com/api/v2/send-daily-email'); diff --git a/202-cronjobs/ltv_webhooks.php b/202-cronjobs/ltv_webhooks.php index f9d6a0f2..9c07437a 100644 --- a/202-cronjobs/ltv_webhooks.php +++ b/202-cronjobs/ltv_webhooks.php @@ -29,6 +29,7 @@ use Prosper202\Database\Connection; use Prosper202\Ltv\MysqlWebhookRepository; +use Prosper202\Validation\OutboundUrlGuard; set_time_limit(0); @@ -73,22 +74,6 @@ continue; } - // Pin the connection to an address the guard just validated — - // otherwise curl re-resolves and a DNS-rebinding host could hand it - // a private IP the check never saw. Prefer IPv4; TLS host - // verification still runs against the hostname's certificate. - $pinnedIp = null; - foreach ($validatedIps as $candidateIp) { - if (filter_var($candidateIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) { - $pinnedIp = $candidateIp; - break; - } - } - $pinnedIp = $pinnedIp ?? $validatedIps[0]; - $urlParts = parse_url($url); - $pinHost = (string) ($urlParts['host'] ?? ''); - $pinPort = (int) ($urlParts['port'] ?? 443); - $signature = MysqlWebhookRepository::signature($body, (string) $delivery['webhook_secret']); $ch = curl_init($url); @@ -108,15 +93,10 @@ 'User-Agent: Prosper202-LTV-Webhook/1.0', ], CURLOPT_RETURNTRANSFER => true, - CURLOPT_FOLLOWLOCATION => false, // SSRF: never follow redirects - CURLOPT_MAXREDIRS => 0, - CURLOPT_CONNECTTIMEOUT => 5, - CURLOPT_TIMEOUT => 15, - CURLOPT_SSL_VERIFYPEER => true, - CURLOPT_SSL_VERIFYHOST => 2, - CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, - CURLOPT_RESOLVE => [$pinHost . ':' . $pinPort . ':' . $pinnedIp], ]); + // Pin to an address the guard just validated, no redirects, https only, + // TLS verified -- one shared option set so no dispatcher can drop one. + curl_setopt_array($ch, OutboundUrlGuard::curlOptions($url, $validatedIps)); $responseBody = curl_exec($ch); $statusCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); diff --git a/202-cronjobs/process_dataengine_job.php b/202-cronjobs/process_dataengine_job.php index 46ef2d71..a11df078 100755 --- a/202-cronjobs/process_dataengine_job.php +++ b/202-cronjobs/process_dataengine_job.php @@ -25,8 +25,21 @@ $mysql['click_time_from'] = $db->real_escape_string((string)$row['time_from']); $mysql['click_time_to'] = $db->real_escape_string((string)$row['time_to']); - $sql = "UPDATE 202_dataengine_job SET processing = '1' WHERE time_from ='" . $mysql['click_time_from'] . "' AND time_to = '" . $mysql['click_time_to'] . "'"; + // Atomic compare-and-swap claim: the SELECT above is not a lock, so + // two overlapping cron runs can both read processing=0 for the same + // window. Only the run whose UPDATE actually flips processing 0->1 + // (affected_rows === 1) may aggregate the hour; the loser bails out + // instead of double-processing the window into the DataEngine. + $sql = "UPDATE 202_dataengine_job SET processing = '1' WHERE time_from ='" . $mysql['click_time_from'] . "' AND time_to = '" . $mysql['click_time_to'] . "' AND processing = '0'"; $db->query($sql); + // 202_dataengine_job has no PRIMARY/UNIQUE key, so a duplicated + // window legitimately flips more than one row. Require >= 1 (we + // won the claim) rather than exactly 1 — bailing out after having + // already set processing='1' would strand the window forever, + // because the release UPDATEs live below this point. + if ($db->affected_rows < 1) { + return; + } $urls = []; for ($i = $mysql['click_time_from']; $i < $mysql['click_time_to']; $i += 3599) { diff --git a/202-js/dni.search.offers.tablesorter.js b/202-js/dni.search.offers.tablesorter.js index a125b773..7df3d319 100755 --- a/202-js/dni.search.offers.tablesorter.js +++ b/202-js/dni.search.offers.tablesorter.js @@ -48,7 +48,14 @@ $(function() { $('table.tablesorter').find('tbody').html(rows); $('span#inProgress').hide(); $('span#inProgressFooter').hide(); - $('h4.modal-title').html(network+''); + // Set the network name as TEXT: it comes from the remote DNI + // network's API, so interpolating it into .html() made a hostile + // or compromised upstream name execute here. (The same values are + // escaped server-side in 202-account/api-integrations.php.) The + // spinner markup is a static literal and is appended after. + $('h4.modal-title') + .text(network == null ? '' : String(network)) + .append(''); $('table.tablesorter').css('opacity', '1'); $('[data-toggle="tooltip"]').tooltip(); return [total]; diff --git a/202-login.php b/202-login.php index dc2ed68a..2b70baab 100755 --- a/202-login.php +++ b/202-login.php @@ -149,7 +149,9 @@ function logged_in_redirect($safe_context = false) $login_server_serialized, $login_session_serialized ); - $login_log_stmt->execute(); + if (!$login_log_stmt->execute()) { + prosper_log('login', 'Unable to write login log row: ' . $login_log_stmt->error); + } $login_log_stmt->close(); } elseif ($should_log_attempt) { prosper_log('login', 'Unable to prepare login log statement: ' . $db->error); diff --git a/CLAUDE.md b/CLAUDE.md index dd68bc36..6da61cec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,6 +158,27 @@ the lookup keys on (here the key itself, so the same key still lands in the same file) and bound what a shard retains, or the correctness fix ships a latency regression. +### 16. A validation in a hydration path fails the batch, not the record +`ExportWebhook`'s constructor ran the SSRF guard. That constructor is also the +row-hydration path: the export cron's `findPending()` maps every pending row +through it, so one stored `http://` webhook — or one transient DNS failure — +threw out of the first call and stranded *every* pending export on every tick, +including jobs with no webhook at all. The guard was right; its placement +converted a single bad record into a total outage of the queue. Rejecting bad +input belongs at the write boundary, where a caller is present to be told, and +again at the point of use, where it can fail one item. A constructor that +doubles as `fromDatabaseRow()` is neither: it runs over data that is already +stored, in a loop, with nobody to answer. Before adding a check, ask who is on +the other end of the throw and how many unrelated records share the call. + +The same question applies to any guard whose failure mode is silence. The +webhook crons pin curl to an address `OutboundUrlGuard` approved, but an +unbracketed IPv6 literal makes the `CURLOPT_RESOLVE` entry unparseable; curl +discards it, resolves the host itself, and the DNS-rebinding hole the pin +exists to close is open again — with the pinning code still sitting there +looking correct. A guard that can be dropped without an error needs a test that +asserts the guard's *output*, not just that the call was made. + ## Go CLI errors must be agent-actionable (`go-cli/`) The CLI is built for AI agents as much as humans. An agent reads a failure diff --git a/api/v1/functions.php b/api/v1/functions.php index c748254c..7ff1555c 100755 --- a/api/v1/functions.php +++ b/api/v1/functions.php @@ -2,9 +2,13 @@ declare(strict_types=1); function getStats($db, $variables): mixed { $mysql['api_key'] = $db->real_escape_string($variables['apikey']); - $key_sql = "SELECT * - FROM `202_api_keys` - WHERE `api_key`='".$mysql['api_key']."'"; + // Join 202_users so a soft-deleted user's key stops authenticating, exactly + // as api/v3/Auth.php does. Deleting a user must revoke access on EVERY API + // version, not just the newest one. + $key_sql = "SELECT k.* + FROM `202_api_keys` k + INNER JOIN `202_users` u ON u.`user_id` = k.`user_id` + WHERE k.`api_key`='".$mysql['api_key']."' AND u.`user_deleted` = 0"; $key_result = _mysqli_query($db, $key_sql); if ($key_result === false) { return ['msg' => 'Database error', 'error' => true, 'status' => 500]; diff --git a/api/v2/app.php b/api/v2/app.php index 0e70d081..2e002793 100644 --- a/api/v2/app.php +++ b/api/v2/app.php @@ -55,7 +55,11 @@ function register_attribution_routes(\Slim\App $app, Controller $controller): vo $params['user_id'] = $auth; } - $payload = array_merge($params, decode_json_body($request)); + $body = decode_json_body($request); + if ($body === null) { + return respond_json($response, ['error' => 'Invalid JSON body'], 400); + } + $payload = array_merge($params, $body); if ($auth !== null) { $payload['user_id'] = $auth; } @@ -92,7 +96,11 @@ function register_attribution_routes(\Slim\App $app, Controller $controller): vo $params['user_id'] = $auth; } - $payload = array_merge($params, decode_json_body($request)); + $body = decode_json_body($request); + if ($body === null) { + return respond_json($response, ['error' => 'Invalid JSON body'], 400); + } + $payload = array_merge($params, $body); if ($auth !== null) { $payload['user_id'] = $auth; } @@ -107,7 +115,11 @@ function register_attribution_routes(\Slim\App $app, Controller $controller): vo $params['user_id'] = $auth; } - $payload = array_merge($params, decode_json_body($request)); + $body = decode_json_body($request); + if ($body === null) { + return respond_json($response, ['error' => 'Invalid JSON body'], 400); + } + $payload = array_merge($params, $body); if ($auth !== null) { $payload['user_id'] = $auth; } @@ -276,10 +288,20 @@ function authorize_attribution_request(array $params, string $permission): array ]; } - // SELECT * (as api/v1 and api/v2 functions.php do) so this keeps working - // on a schema where the `scope` column has not been added yet: naming - // the column would fail the prepare and 500 every request instead. - $stmt = $connection->prepare('SELECT * FROM 202_api_keys WHERE api_key = ? LIMIT 1'); + // Two constraints, both required: + // - k.* rather than a named column list, so this keeps working on a schema + // where `scope` has not been added yet; naming it would fail the prepare + // and 500 every request. It must stay a wildcard over the key table + // because the caller reads $row['scope'] to enforce attenuation, and a + // narrower select would make that read absent -> unscoped -> full access. + // - the 202_users join, so a soft-deleted user's key stops authenticating + // (as api/v3/Auth.php does): deleting a user must revoke access + // everywhere, not just in v3. + $stmt = $connection->prepare( + 'SELECT k.* FROM 202_api_keys k + INNER JOIN 202_users u ON u.user_id = k.user_id + WHERE k.api_key = ? AND u.user_deleted = 0 LIMIT 1' + ); if ($stmt === false) { return [ 'status' => 500, @@ -374,9 +396,13 @@ function user_has_permission(int $userId, string $permission): bool } /** - * @return array + * Decode the request body. Returns [] for an empty body and null for a + * malformed one — malformed JSON must produce a 400, not be silently + * treated as an empty payload. + * + * @return array|null */ -function decode_json_body(Request $request): array +function decode_json_body(Request $request): ?array { $body = (string) $request->getBody(); if ($body === '') { @@ -386,16 +412,21 @@ function decode_json_body(Request $request): array try { $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); } catch (\Throwable) { - return []; + return null; } - return is_array($decoded) ? $decoded : []; + return is_array($decoded) ? $decoded : null; } function respond_json(Response $response, array $payload, int $status = 200): Response { + $json = json_encode($payload); + if ($json === false) { + $status = 500; + $json = (string) json_encode(['error' => 'Response encoding failed']); + } $response = $response->withStatus($status); $response = $response->withHeader('Content-Type', 'application/json'); - $response->getBody()->write(json_encode($payload)); + $response->getBody()->write($json); return $response; } diff --git a/api/v2/functions.php b/api/v2/functions.php index 439a8840..f9dd4630 100755 --- a/api/v2/functions.php +++ b/api/v2/functions.php @@ -2,9 +2,13 @@ declare(strict_types=1); function getAuth($db, $variables): mixed { $mysql['api_key'] = $db->real_escape_string((string) ($variables['apikey'] ?? '')); - $key_sql = "SELECT * - FROM `202_api_keys` - WHERE `api_key`='".$mysql['api_key']."'"; + // Join 202_users so a soft-deleted user's key stops authenticating, exactly + // as api/v3/Auth.php does. Deleting a user must revoke access on EVERY API + // version, not just the newest one. + $key_sql = "SELECT k.* + FROM `202_api_keys` k + INNER JOIN `202_users` u ON u.`user_id` = k.`user_id` + WHERE k.`api_key`='".$mysql['api_key']."' AND u.`user_deleted` = 0"; $key_result = _mysqli_query($db, $key_sql); $key_row = $key_result->fetch_assoc(); diff --git a/api/v3/Auth.php b/api/v3/Auth.php index a61e1330..82e68151 100644 --- a/api/v3/Auth.php +++ b/api/v3/Auth.php @@ -28,7 +28,10 @@ private function __construct( */ public static function fromRequest(array $headers, \mysqli $db): self { - $authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? ''; + // Header names are case-insensitive per RFC 9110; normalize instead of + // probing a couple of hardcoded casings. + $headers = array_change_key_case($headers, CASE_LOWER); + $authHeader = $headers['authorization'] ?? ''; if (is_array($authHeader)) { $authHeader = $authHeader[0] ?? ''; } @@ -42,10 +45,12 @@ public static function fromRequest(array $headers, \mysqli $db): self throw new AuthException('API key required. Pass via Authorization: Bearer header.', 401); } + // Join 202_users so keys belonging to soft-deleted users stop + // authenticating — "deleting" a user must actually revoke access. $scopeColumnExists = self::apiKeyScopeColumnExists($db); $sql = $scopeColumnExists - ? 'SELECT user_id, scope FROM 202_api_keys WHERE api_key = ? LIMIT 1' - : 'SELECT user_id FROM 202_api_keys WHERE api_key = ? LIMIT 1'; + ? 'SELECT k.user_id, k.scope FROM 202_api_keys k INNER JOIN 202_users u ON u.user_id = k.user_id WHERE k.api_key = ? AND u.user_deleted = 0 LIMIT 1' + : 'SELECT k.user_id FROM 202_api_keys k INNER JOIN 202_users u ON u.user_id = k.user_id WHERE k.api_key = ? AND u.user_deleted = 0 LIMIT 1'; $stmt = $db->prepare($sql); if (!$stmt) { throw new AuthException('Authentication unavailable', 500); @@ -356,6 +361,10 @@ public static function apiKeyScopeColumnExists(\mysqli $db): bool */ private static function probeApiKeyScopeColumn(\mysqli $db): bool { + // Fail closed: a DB error here must not silently drop the scope column + // from the auth query (which would grant the key the full '*' scope). + // Only a successful probe that finds no column may report false — + // that is the legitimate pre-upgrade schema case. $stmt = $db->prepare("SHOW COLUMNS FROM 202_api_keys LIKE 'scope'"); if (!$stmt) { throw new AuthException('Authentication unavailable', 500); @@ -393,8 +402,21 @@ public static function parseScopes(string $raw): array $scopes = []; if (str_starts_with($raw, '[')) { $decoded = json_decode($raw, true); + // Unreadable JSON leaves $scopes empty and falls through to the + // MALFORMED_SCOPE branch below. That is deliberately not a throw: + // a corrupt scope value is a property of one key, so denying that + // key with a scope nobody matches is both fail-closed and + // diagnosable, whereas a 500 reports a server fault and names no + // row. if (is_array($decoded)) { foreach ($decoded as $scope) { + // Skip non-scalars rather than casting: (string) on an array + // warns and yields "Array", inventing a scope name that is + // not in the column. Contributing nothing lands a list of + // them in MALFORMED_SCOPE like any other unreadable value. + if (!is_scalar($scope)) { + continue; + } $value = strtolower(trim((string)$scope)); if ($value !== '') { $scopes[] = $value; diff --git a/api/v3/Bootstrap.php b/api/v3/Bootstrap.php index ea85247d..7f09b21f 100644 --- a/api/v3/Bootstrap.php +++ b/api/v3/Bootstrap.php @@ -32,6 +32,17 @@ public static function init(): void require_once $configFile; + // 202-config.php assigns $dbhost/$dbuser/... at whatever scope includes + // it. When this method is the first include, those land in local scope + // and DB::getInstance()'s `global` lookups would find nothing — so + // export them. (When index.php already required the config at file + // scope, require_once is a no-op and the globals are already set.) + foreach (['dbhost', 'dbhostro', 'dbuser', 'dbpass', 'dbname'] as $var) { + if (isset($$var) && !isset($GLOBALS[$var])) { + $GLOBALS[$var] = $$var; + } + } + $authHelpers = $root . '/202-config/functions-auth.php'; if (file_exists($authHelpers)) { require_once $authHelpers; diff --git a/api/v3/Controller.php b/api/v3/Controller.php index 943f62d9..04be8f85 100644 --- a/api/v3/Controller.php +++ b/api/v3/Controller.php @@ -10,6 +10,7 @@ use Api\V3\Exception\ValidationException; use Api\V3\Exception\WriteCommittedException; use Api\V3\Support\ServerStateStore; +use Api\V3\Support\StatementHelpers; /** * Base CRUD controller with lifecycle hooks, input validation, and DI. @@ -20,6 +21,8 @@ */ abstract class Controller { + use StatementHelpers; + abstract protected function tableName(): string; abstract protected function primaryKey(): string; abstract protected function fields(): array; @@ -64,7 +67,11 @@ protected function listOrderBy(): string return $this->primaryKey() . ' DESC'; } - protected function maxBulkRows(): int + /** + * Single source of truth for the bulk-upsert row cap; /capabilities + * advertises this value and must never drift from what is enforced. + */ + public static function configuredMaxBulkRows(): int { $raw = getenv('P202_MAX_BULK_ROWS'); if (is_string($raw) && trim($raw) !== '') { @@ -76,6 +83,11 @@ protected function maxBulkRows(): int return 500; } + protected function maxBulkRows(): int + { + return self::configuredMaxBulkRows(); + } + protected function selectColumns(): array { if ($this->cachedSelectColumns !== null) { @@ -633,15 +645,30 @@ public function bulkUpsert(array $payload): array $primaryKey = $this->primaryKey(); $id = $row[$primaryKey] ?? $row['id'] ?? null; if ($id !== null && $id !== '') { + // Strictly validate the ID instead of passing it through + // as a string: binding "12abc" as 's' against an integer + // PK would let MySQL coerce it to 12 and silently + // overwrite the wrong row. + if (is_int($id)) { + // Already an integer. + } elseif (is_string($id) && ctype_digit(trim($id))) { + $id = (int)trim($id); + } elseif (is_float($id) && $id === (float)(int)$id) { + $id = (int)$id; + } else { + $summary['error']++; + $results[] = ['index' => $index, 'status' => 'error', 'message' => 'Invalid primary key value']; + continue; + } try { - $this->get((string)$id); + $this->get($id); $clean = $this->validatePayload($row); if ($clean === []) { $summary['skipped']++; $results[] = ['index' => $index, 'status' => 'skipped', 'message' => 'No mutable fields provided']; continue; } - $updated = $this->update((string)$id, $row); + $updated = $this->update($id, $row); $summary['updated']++; $results[] = ['index' => $index, 'status' => 'updated', 'data' => $updated['data']]; continue; @@ -831,43 +858,4 @@ protected function changeEntityName(): ?string return $map[$this->tableName()] ?? null; } - protected function transaction(callable $fn): mixed - { - $this->db->begin_transaction(); - try { - $result = $fn(); - $this->db->commit(); - return $result; - } catch (\Throwable $e) { - $this->db->rollback(); - throw $e; - } - } - - protected function prepare(string $sql): \mysqli_stmt - { - $stmt = $this->db->prepare($sql); - if (!$stmt) { - throw new DatabaseException("Prepare failed"); - } - return $stmt; - } - - protected function bind(\mysqli_stmt $stmt, string $types, mixed ...$values): void - { - // @phpstan-ignore-next-line this IS the ref-safe bind wrapper (analog of Connection::bind); no $this->conn exists, cannot self-route - if (!$stmt->bind_param($types, ...$values)) { - $stmt->close(); - throw new DatabaseException('Bind failed'); - } - } - - protected function execute(\mysqli_stmt $stmt, string $message): void - { - // @phpstan-ignore-next-line this IS the checked-execute wrapper (analog of Connection::execute); no $this->conn exists, cannot self-route - if (!$stmt->execute()) { - $stmt->close(); - throw new DatabaseException($message); - } - } } diff --git a/api/v3/Controllers/AttributionController.php b/api/v3/Controllers/AttributionController.php index 8dd2168e..8c75c5ed 100644 --- a/api/v3/Controllers/AttributionController.php +++ b/api/v3/Controllers/AttributionController.php @@ -4,13 +4,16 @@ namespace Api\V3\Controllers; -use Api\V3\Exception\DatabaseException; +use Api\V3\Exception\ConflictException; use Api\V3\Exception\NotFoundException; use Api\V3\Exception\WriteCommittedException; use Api\V3\Exception\ValidationException; +use Api\V3\Support\StatementHelpers; class AttributionController { + use StatementHelpers; + private const array VALID_MODEL_TYPES = ['first_touch', 'last_touch', 'linear', 'time_decay', 'position_based', 'algorithmic']; public function __construct(private readonly \mysqli $db, private readonly int $userId) @@ -69,6 +72,56 @@ public function getModel(int $id): array return ['data' => $row]; } + /** + * Validate/encode a weighting_config payload value to a JSON string. + * json_encode() failures and non-JSON strings must be explicit errors: + * a silently-emptied config makes the model compute garbage attribution. + */ + private function normalizeWeightingConfig(mixed $config): string + { + if (is_array($config)) { + $json = json_encode($config, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($json === false) { + throw new ValidationException('weighting_config could not be encoded', ['weighting_config' => json_last_error_msg()]); + } + return $json; + } + + $raw = trim((string)$config); + if ($raw === '') { + return '{}'; + } + json_decode($raw); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new ValidationException('weighting_config must be valid JSON', ['weighting_config' => json_last_error_msg()]); + } + return $raw; + } + + /** + * 202_attribution_models has UNIQUE (user_id, model_slug); surface a + * duplicate as 409 instead of letting the INSERT die as a generic 500. + */ + private function assertSlugAvailable(string $slug, ?int $excludeId = null): void + { + $sql = 'SELECT model_id FROM 202_attribution_models WHERE user_id = ? AND model_slug = ?'; + $types = 'is'; + $binds = [$this->userId, $slug]; + if ($excludeId !== null) { + $sql .= ' AND model_id != ?'; + $types .= 'i'; + $binds[] = $excludeId; + } + $stmt = $this->prepare($sql . ' LIMIT 1'); + $this->bind($stmt, $types, ...$binds); + $this->execute($stmt, 'Slug lookup failed'); + $existing = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + if ($existing) { + throw new ConflictException('A model with this slug already exists', ['model_slug' => $slug]); + } + } + public function createModel(array $payload): array { $name = trim((string)($payload['model_name'] ?? '')); @@ -81,11 +134,12 @@ public function createModel(array $payload): array throw new ValidationException('Invalid model_type', ['model_type' => 'Valid: ' . implode(', ', self::VALID_MODEL_TYPES)]); } - $slug = preg_replace('/[^a-z0-9]+/', '-', strtolower($name)); - $config = $payload['weighting_config'] ?? '{}'; - if (is_array($config)) { - $config = json_encode($config); + $slug = trim((string)preg_replace('/[^a-z0-9]+/', '-', strtolower($name)), '-'); + if ($slug === '') { + throw new ValidationException('model_name must contain at least one alphanumeric character', ['model_name' => 'Cannot derive a slug']); } + $this->assertSlugAvailable($slug); + $config = $this->normalizeWeightingConfig($payload['weighting_config'] ?? '{}'); $isActive = (int)($payload['is_active'] ?? 1); $isDefault = (int)($payload['is_default'] ?? 0); $now = time(); @@ -113,6 +167,17 @@ public function updateModel(int $id, array $payload): array } } + // Create rejects empty names/slugs; update must too, and an emptied + // slug would additionally break slug-addressed lookups. + foreach (['model_name', 'model_slug'] as $requiredField) { + if (array_key_exists($requiredField, $payload) && trim((string)$payload[$requiredField]) === '') { + throw new ValidationException("$requiredField cannot be empty", [$requiredField => 'Must not be empty']); + } + } + if (array_key_exists('model_slug', $payload)) { + $this->assertSlugAvailable(trim((string)$payload['model_slug']), $id); + } + $sets = []; $binds = []; $types = ''; @@ -126,8 +191,7 @@ public function updateModel(int $id, array $payload): array } if (array_key_exists('weighting_config', $payload)) { $sets[] = 'weighting_config = ?'; - $val = is_array($payload['weighting_config']) ? json_encode($payload['weighting_config']) : (string)$payload['weighting_config']; - $binds[] = $val; + $binds[] = $this->normalizeWeightingConfig($payload['weighting_config']); $types .= 's'; } @@ -208,8 +272,7 @@ public function deleteModel(int $id): void { $this->getModel($id); - $this->db->begin_transaction(); - try { + $this->transaction(function () use ($id): void { $stmt = $this->prepare('DELETE FROM 202_attribution_touchpoints WHERE snapshot_id IN (SELECT snapshot_id FROM 202_attribution_snapshots WHERE model_id = ? AND user_id = ?)'); $this->bind($stmt, 'ii', $id, $this->userId); $this->execute($stmt, 'Delete touchpoints failed'); @@ -229,12 +292,7 @@ public function deleteModel(int $id): void $this->bind($stmt, 'ii', $id, $this->userId); $this->execute($stmt, 'Delete model failed'); $stmt->close(); - - $this->db->commit(); - } catch (\Throwable $e) { - $this->db->rollback(); - throw $e; - } + }); } // --- Snapshots --- @@ -306,6 +364,17 @@ public function scheduleExport(int $modelId, array $payload): array $endHour = (int)($payload['end_hour'] ?? time()); $format = (string)($payload['format'] ?? 'csv'); $webhookUrl = (string)($payload['webhook_url'] ?? ''); + // Validate here, at the entry point: this is the only place the caller + // can be told their URL is unusable. Shape only, no DNS -- a resolver + // stall must not block the request or turn into a 422 for a valid URL; + // the cron runs the full check and pins the connection at delivery. + if ($webhookUrl !== '') { + try { + \Prosper202\Validation\OutboundUrlGuard::assertWellFormed($webhookUrl, 'webhook_url'); + } catch (\Prosper202\Validation\OutboundUrlException $e) { + throw new ValidationException($e->getMessage(), ['webhook_url' => $e->getMessage()], $e); + } + } $now = time(); // Must be 'pending': the export cron's claimPending() only selects status='pending', // and 'queued' is not a valid ExportStatus enum value (would fatal on hydration). @@ -332,31 +401,4 @@ public function scheduleExport(int $modelId, array $payload): array return ['data' => $row]; } - - private function prepare(string $sql): \mysqli_stmt - { - $stmt = $this->db->prepare($sql); - if (!$stmt) { - throw new DatabaseException('Prepare failed'); - } - return $stmt; - } - - private function bind(\mysqli_stmt $stmt, string $types, mixed ...$values): void - { - // @phpstan-ignore-next-line this IS the ref-safe bind wrapper (analog of Connection::bind); class has no $this->conn, cannot self-route - if (!$stmt->bind_param($types, ...$values)) { - $stmt->close(); - throw new DatabaseException('Bind failed'); - } - } - - private function execute(\mysqli_stmt $stmt, string $message): void - { - // @phpstan-ignore-next-line this IS the checked-execute wrapper (analog of Connection::execute); class has no $this->conn, cannot self-route - if (!$stmt->execute()) { - $stmt->close(); - throw new DatabaseException($message); - } - } } diff --git a/api/v3/Controllers/CampaignsController.php b/api/v3/Controllers/CampaignsController.php index c2364c47..f71db632 100644 --- a/api/v3/Controllers/CampaignsController.php +++ b/api/v3/Controllers/CampaignsController.php @@ -22,7 +22,7 @@ protected function fields(): array 'aff_campaign_url_4' => ['type' => 's', 'max_length' => 2048], 'aff_campaign_url_5' => ['type' => 's', 'max_length' => 2048], 'aff_campaign_payout' => ['type' => 'd', 'required' => true], - 'aff_campaign_currency' => ['type' => 's', 'max_length' => 5], + 'aff_campaign_currency' => ['type' => 's', 'max_length' => 3], 'aff_campaign_foreign_payout' => ['type' => 'd', 'default' => 0], 'aff_network_id' => ['type' => 'i', 'required' => true], 'aff_campaign_cloaking' => ['type' => 'i'], diff --git a/api/v3/Controllers/CapabilitiesController.php b/api/v3/Controllers/CapabilitiesController.php index 6042e025..ec349e9f 100644 --- a/api/v3/Controllers/CapabilitiesController.php +++ b/api/v3/Controllers/CapabilitiesController.php @@ -158,14 +158,8 @@ private function timezoneSupport(): string private function maxBulkRows(): int { - $raw = getenv('P202_MAX_BULK_ROWS'); - if (is_string($raw) && trim($raw) !== '') { - $parsed = (int)$raw; - if ($parsed > 0) { - return min(5000, $parsed); - } - } - return 500; + // Advertise exactly what the bulk endpoint enforces. + return \Api\V3\Controller::configuredMaxBulkRows(); } /** diff --git a/api/v3/Controllers/ClicksController.php b/api/v3/Controllers/ClicksController.php index 15cd7ae5..d2b58adc 100644 --- a/api/v3/Controllers/ClicksController.php +++ b/api/v3/Controllers/ClicksController.php @@ -4,11 +4,13 @@ namespace Api\V3\Controllers; -use Api\V3\Exception\DatabaseException; use Api\V3\Exception\NotFoundException; +use Api\V3\Support\StatementHelpers; class ClicksController { + use StatementHelpers; + public function __construct(private readonly \mysqli $db, private readonly int $userId) { } @@ -151,31 +153,4 @@ public function get(int $id): array return ['data' => $row]; } - - private function prepare(string $sql): \mysqli_stmt - { - $stmt = $this->db->prepare($sql); - if (!$stmt) { - throw new DatabaseException('Prepare failed'); - } - return $stmt; - } - - private function bind(\mysqli_stmt $stmt, string $types, mixed ...$values): void - { - // @phpstan-ignore-next-line prosper202.directStmtCall — this IS the centralized ref-safe bind wrapper (no Connection instance in scope; routing through $this->conn would self-recurse) - if (!$stmt->bind_param($types, ...$values)) { - $stmt->close(); - throw new DatabaseException('Bind failed'); - } - } - - private function execute(\mysqli_stmt $stmt, string $message): void - { - // @phpstan-ignore-next-line prosper202.directStmtCall — this IS the centralized checked-execute wrapper (no Connection instance; routing through $this->conn would self-recurse) - if (!$stmt->execute()) { - $stmt->close(); - throw new DatabaseException($message); - } - } } diff --git a/api/v3/Controllers/ConversionsController.php b/api/v3/Controllers/ConversionsController.php index 56caa98a..c774ea87 100644 --- a/api/v3/Controllers/ConversionsController.php +++ b/api/v3/Controllers/ConversionsController.php @@ -8,9 +8,12 @@ use Api\V3\Exception\NotFoundException; use Api\V3\Exception\WriteCommittedException; use Api\V3\Exception\ValidationException; +use Api\V3\Support\StatementHelpers; class ConversionsController { + use StatementHelpers; + public function __construct(private readonly \mysqli $db, private readonly int $userId) { } @@ -214,31 +217,4 @@ public function delete(int $id): void throw new DatabaseException('Delete failed: ' . $e->getMessage(), $e); } } - - private function prepare(string $sql): \mysqli_stmt - { - $stmt = $this->db->prepare($sql); - if (!$stmt) { - throw new DatabaseException('Prepare failed'); - } - return $stmt; - } - - private function bind(\mysqli_stmt $stmt, string $types, mixed ...$values): void - { - // @phpstan-ignore-next-line prosper202.directStmtCall — this IS the centralized ref-safe bind wrapper (no Connection instance; routing through $this->conn would self-recurse) - if (!$stmt->bind_param($types, ...$values)) { - $stmt->close(); - throw new DatabaseException('Bind failed'); - } - } - - private function execute(\mysqli_stmt $stmt, string $message): void - { - // @phpstan-ignore-next-line prosper202.directStmtCall — this IS the centralized checked-execute wrapper (no Connection instance; routing through $this->conn would self-recurse) - if (!$stmt->execute()) { - $stmt->close(); - throw new DatabaseException($message); - } - } } diff --git a/api/v3/Controllers/LtvController.php b/api/v3/Controllers/LtvController.php index ffd34735..3199134b 100644 --- a/api/v3/Controllers/LtvController.php +++ b/api/v3/Controllers/LtvController.php @@ -845,7 +845,11 @@ private function query(array $params): LtvQuery 'last7' => [$now - (7 * 86400), $now], 'last30' => [$now - (30 * 86400), $now], 'last90' => [$now - (90 * 86400), $now], - default => [null, $now], + // A typo like period=last7d must not silently mean "all time". + default => throw new ValidationException( + 'Invalid period', + ['period' => 'Valid: today, yesterday, last7, last30, last90'] + ), }; } diff --git a/api/v3/Controllers/ReportsController.php b/api/v3/Controllers/ReportsController.php index e7ff6216..b9f12957 100644 --- a/api/v3/Controllers/ReportsController.php +++ b/api/v3/Controllers/ReportsController.php @@ -6,9 +6,12 @@ use Api\V3\Exception\DatabaseException; use Api\V3\Exception\ValidationException; +use Api\V3\Support\StatementHelpers; class ReportsController { + use StatementHelpers; + private const array BREAKDOWNS = [ 'campaign' => ['table' => '202_aff_campaigns', 'id' => 'aff_campaign_id', 'name' => 'aff_campaign_name', 'de_id' => 'aff_campaign_id'], 'aff_network' => ['table' => '202_aff_networks', 'id' => 'aff_network_id', 'name' => 'aff_network_name', 'de_id' => 'aff_network_id'], @@ -437,13 +440,17 @@ private function applyTimeFilters(array $params, array &$where, array &$binds, s if (!empty($params['period'])) { $now = time(); $todayStart = strtotime('today midnight'); - [$from, $to] = match ($params['period']) { + [$from, $to] = match ((string)$params['period']) { 'today' => [$todayStart, $now], 'yesterday' => [$todayStart - 86400, $todayStart - 1], 'last7' => [$now - (7 * 86400), $now], 'last30' => [$now - (30 * 86400), $now], 'last90' => [$now - (90 * 86400), $now], - default => [0, $now], + // A typo like period=last7d must not silently mean "all time". + default => throw new ValidationException( + 'Invalid period', + ['period' => 'Valid: today, yesterday, last7, last30, last90'] + ), }; $where[] = 'de.click_time >= ?'; $binds[] = $from; @@ -465,15 +472,6 @@ private function applyEntityFilters(array $params, array &$where, array &$binds, } } - private function prepare(string $sql): \mysqli_stmt - { - $stmt = $this->db->prepare($sql); - if (!$stmt) { - throw new DatabaseException('Prepare failed'); - } - return $stmt; - } - private function resolveUserTimezone(): string { $stmt = $this->prepare('SELECT user_timezone FROM 202_users WHERE user_id = ? LIMIT 1'); @@ -538,22 +536,4 @@ private function sortPartRows(array &$rows, string $keyName, string $sortBy, str return ((int)$a[$keyName]) <=> ((int)$b[$keyName]); }); } - - private function bind(\mysqli_stmt $stmt, string $types, mixed ...$values): void - { - // @phpstan-ignore-next-line prosper202.directStmtCall — this IS the centralized ref-safe bind wrapper (no Connection instance; routing through $this->conn would self-recurse) - if (!$stmt->bind_param($types, ...$values)) { - $stmt->close(); - throw new DatabaseException('Bind failed'); - } - } - - private function execute(\mysqli_stmt $stmt, string $message): void - { - // @phpstan-ignore-next-line prosper202.directStmtCall — this IS the centralized checked-execute wrapper (no Connection instance; routing through $this->conn would self-recurse) - if (!$stmt->execute()) { - $stmt->close(); - throw new DatabaseException($message); - } - } } diff --git a/api/v3/Controllers/RotatorsController.php b/api/v3/Controllers/RotatorsController.php index f1fd46e0..13466454 100644 --- a/api/v3/Controllers/RotatorsController.php +++ b/api/v3/Controllers/RotatorsController.php @@ -8,9 +8,12 @@ use Api\V3\Exception\NotFoundException; use Api\V3\Exception\WriteCommittedException; use Api\V3\Exception\ValidationException; +use Api\V3\Support\StatementHelpers; class RotatorsController { + use StatementHelpers; + public function __construct(private readonly \mysqli $db, private readonly int $userId) { } @@ -93,6 +96,34 @@ public function get(int $id): array return ['data' => $row]; } + /** + * Pick an unused public_id. 202_rotators has no UNIQUE key on the column, + * so this is best-effort: it removes deliberate collisions and makes random + * ones vanishingly unlikely. + */ + private function publicIdIsFree(int $candidate): bool + { + $stmt = $this->prepare('SELECT id FROM 202_rotators WHERE public_id = ? LIMIT 1'); + $this->bind($stmt, 'i', $candidate); + $this->execute($stmt, 'Public id lookup failed'); + $taken = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + + return $taken === null || $taken === false; + } + + private function generatePublicId(): int + { + for ($attempt = 0; $attempt < 10; $attempt++) { + $candidate = random_int(100_000, 9_999_999); + if ($this->publicIdIsFree($candidate)) { + return $candidate; + } + } + + throw new DatabaseException('Unable to allocate a unique rotator public id'); + } + public function create(array $payload): array { $name = trim((string)($payload['name'] ?? '')); @@ -103,9 +134,29 @@ public function create(array $payload): array $defaultUrl = (string)($payload['default_url'] ?? ''); $defaultCampaign = (int)($payload['default_campaign'] ?? 0); $defaultLp = (int)($payload['default_lp'] ?? 0); - $publicId = isset($payload['public_id']) && (int)$payload['public_id'] > 0 - ? (int)$payload['public_id'] - : random_int(100_000, 9_999_999); + // public_id is the handle offrtr.php/rtr.php resolve for ANY visitor with + // no user scoping, and 202_rotators has no unique key on it — so a + // caller-chosen value that is ALREADY TAKEN would route another user's + // clicks to this rotator (the lookup is then memcached). An integrity + // bug within the install, not cross-install. + // + // The danger is collision, not caller choice, so honour a supplied + // public_id when it is free and fall back to a generated one otherwise. + // Rejecting it outright broke `p202 sync`: rotators are matched between + // installs by public_id, so a server-assigned value meant the target + // never matched the source — every run re-created every rotator, and + // remapping trackers' rotator_id failed outright with "unresolvable + // target foreign key". + $publicId = 0; + if (isset($payload['public_id']) && $payload['public_id'] !== '') { + $requested = (int)$payload['public_id']; + if ($requested > 0 && $this->publicIdIsFree($requested)) { + $publicId = $requested; + } + } + if ($publicId === 0) { + $publicId = $this->generatePublicId(); + } $stmt = $this->prepare('INSERT INTO 202_rotators (public_id, user_id, name, default_url, default_campaign, default_lp) VALUES (?, ?, ?, ?, ?, ?)'); $this->bind($stmt, 'iissii', $publicId, $this->userId, $name, $defaultUrl, $defaultCampaign, $defaultLp); @@ -125,6 +176,11 @@ public function update(int $id, array $payload): array { $this->get($id); + // create() rejects empty names; update must too. + if (array_key_exists('name', $payload) && trim((string)$payload['name']) === '') { + throw new ValidationException('name cannot be empty', ['name' => 'Cannot be empty']); + } + $sets = []; $binds = []; $types = ''; @@ -186,8 +242,7 @@ public function delete(int $id): void { $this->get($id); - $this->db->begin_transaction(); - try { + $this->transaction(function () use ($id): void { $stmt = $this->prepare('DELETE FROM 202_rotator_rules_criteria WHERE rotator_id = ?'); $this->bind($stmt, 'i', $id); $this->execute($stmt, 'Delete criteria failed'); @@ -207,12 +262,7 @@ public function delete(int $id): void $this->bind($stmt, 'ii', $id, $this->userId); $this->execute($stmt, 'Delete rotator failed'); $stmt->close(); - - $this->db->commit(); - } catch (\Throwable $e) { - $this->db->rollback(); - throw $e; - } + }); } public function listRules(int $rotatorId): array @@ -233,8 +283,7 @@ public function createRule(int $rotatorId, array $payload): array $splittest = (int)($payload['splittest'] ?? 0); $status = (int)($payload['status'] ?? 1); - $this->db->begin_transaction(); - try { + $this->transaction(function () use ($payload, $rotatorId, $ruleName, $splittest, $status): void { $stmt = $this->prepare('INSERT INTO 202_rotator_rules (rotator_id, rule_name, splittest, status) VALUES (?, ?, ?, ?)'); $this->bind($stmt, 'isii', $rotatorId, $ruleName, $splittest, $status); $this->execute($stmt, 'Failed to create rule'); @@ -244,6 +293,9 @@ public function createRule(int $rotatorId, array $payload): array if (!empty($payload['criteria']) && is_array($payload['criteria'])) { $insertCriteria = $this->prepare('INSERT INTO 202_rotator_rules_criteria (rotator_id, rule_id, type, statement, value) VALUES (?, ?, ?, ?, ?)'); foreach ($payload['criteria'] as $c) { + if (!is_array($c)) { + throw new ValidationException('Each criterion must be an object', ['criteria' => 'Scalar entries are not valid criteria']); + } $cType = (string)($c['type'] ?? ''); $cStatement = (string)($c['statement'] ?? ''); $cValue = (string)($c['value'] ?? ''); @@ -256,6 +308,9 @@ public function createRule(int $rotatorId, array $payload): array if (!empty($payload['redirects']) && is_array($payload['redirects'])) { $insertRedirect = $this->prepare('INSERT INTO 202_rotator_rules_redirects (rule_id, redirect_url, redirect_campaign, redirect_lp, weight, name) VALUES (?, ?, ?, ?, ?, ?)'); foreach ($payload['redirects'] as $r) { + if (!is_array($r)) { + throw new ValidationException('Each redirect must be an object', ['redirects' => 'Scalar entries are not valid redirects']); + } $rUrl = (string)($r['redirect_url'] ?? ''); $rCampaign = (int)($r['redirect_campaign'] ?? 0); $rLp = (int)($r['redirect_lp'] ?? 0); @@ -266,12 +321,7 @@ public function createRule(int $rotatorId, array $payload): array } $insertRedirect->close(); } - - $this->db->commit(); - } catch (\Throwable $e) { - $this->db->rollback(); - throw $e; - } + }); // Committed: the rule and its redirects exist. try { @@ -338,8 +388,7 @@ public function updateRule(int $rotatorId, int $ruleId, array $payload): array throw new ValidationException('No fields to update'); } - $this->db->begin_transaction(); - try { + $this->transaction(function () use ($binds, $hasCriteria, $hasRedirects, $payload, $rotatorId, $ruleId, $setParts, $types): void { if (!empty($setParts)) { $binds[] = $ruleId; $types .= 'i'; @@ -396,12 +445,7 @@ public function updateRule(int $rotatorId, int $ruleId, array $payload): array $insertRedirect->close(); } } - - $this->db->commit(); - } catch (\Throwable $e) { - $this->db->rollback(); - throw $e; - } + }); return $this->get($rotatorId); } @@ -455,8 +499,7 @@ public function deleteRule(int $rotatorId, int $ruleId): void throw new NotFoundException('Rule not found for rotator'); } - $this->db->begin_transaction(); - try { + $this->transaction(function () use ($rotatorId, $ruleId): void { $stmt = $this->prepare('DELETE FROM 202_rotator_rules_criteria WHERE rule_id = ?'); $this->bind($stmt, 'i', $ruleId); $this->execute($stmt, 'Delete criteria failed'); @@ -471,38 +514,6 @@ public function deleteRule(int $rotatorId, int $ruleId): void $this->bind($stmt, 'ii', $ruleId, $rotatorId); $this->execute($stmt, 'Delete rule failed'); $stmt->close(); - - $this->db->commit(); - } catch (\Throwable $e) { - $this->db->rollback(); - throw $e; - } - } - - private function prepare(string $sql): \mysqli_stmt - { - $stmt = $this->db->prepare($sql); - if (!$stmt) { - throw new DatabaseException('Prepare failed'); - } - return $stmt; - } - - private function bind(\mysqli_stmt $stmt, string $types, mixed ...$values): void - { - // @phpstan-ignore-next-line -- this IS the ref-safe wrapper; no Connection in scope - if (!$stmt->bind_param($types, ...$values)) { - $stmt->close(); - throw new DatabaseException('Bind failed'); - } - } - - private function execute(\mysqli_stmt $stmt, string $message): void - { - // @phpstan-ignore-next-line -- this IS the checked-execution wrapper; no Connection in scope - if (!$stmt->execute()) { - $stmt->close(); - throw new DatabaseException($message); - } + }); } } diff --git a/api/v3/Controllers/SyncController.php b/api/v3/Controllers/SyncController.php index 16eda3dc..6c475091 100644 --- a/api/v3/Controllers/SyncController.php +++ b/api/v3/Controllers/SyncController.php @@ -14,7 +14,7 @@ class SyncController { private readonly SyncEngine $engine; - public function __construct(\mysqli $db, private readonly int $userId, private readonly ?ServerStateStore $store = new ServerStateStore(), ?SyncEngine $engine = null) + public function __construct(\mysqli $db, private readonly int $userId, private readonly ServerStateStore $store = new ServerStateStore(), ?SyncEngine $engine = null) { if ($db->connect_errno !== 0) { throw new DatabaseException('Database connection unavailable for sync controller'); @@ -324,7 +324,7 @@ private function runJobInternal(string $jobId): array $entity = trim((string)($request['entity'] ?? 'all')); $options = is_array($request['options'] ?? null) ? $request['options'] : []; - $pairKey = sha1(strtolower((string)($source['url'] ?? '')) . '|' . strtolower((string)($target['url'] ?? ''))); + $pairKey = SyncEngine::pairKeyFor($source, $target); $manifest = $this->store->loadSyncManifest($pairKey); if (!empty($options['incremental']) && empty($options['updated_since']) && !empty($manifest['last_sync_epoch'])) { $options['updated_since'] = (string)((int)$manifest['last_sync_epoch']); @@ -352,6 +352,12 @@ function (string $level, string $message, array $data = []) use ($jobId): void { $job['results'] = $results; $job['error'] = null; $job['next_run_at'] = null; + // Re-read the persisted flag: a cancel may have landed while + // execute() was running and our in-memory copy is stale. + $fresh = $this->store->getJob($jobId); + if (is_array($fresh) && !empty($fresh['cancel_requested'])) { + $job['cancel_requested'] = true; + } if ((bool)($job['cancel_requested'] ?? false)) { $job['status'] = 'cancelled'; $this->store->incrementMetric('jobs_cancelled', 1); @@ -472,7 +478,7 @@ private function validatePruneToken(array $source, array $target, array $options throw new ValidationException('Prune confirmation token required', ['confirmation_token' => 'Required when prune=true']); } - $pairKey = sha1(strtolower((string)$source['url']) . '|' . strtolower((string)$target['url'])); + $pairKey = SyncEngine::pairKeyFor($source, $target); if (!$this->store->validatePruneToken($token, $pairKey)) { throw new ValidationException('Invalid prune confirmation token', ['confirmation_token' => 'Token is invalid or expired']); } diff --git a/api/v3/Controllers/SystemController.php b/api/v3/Controllers/SystemController.php index 6fb908f9..cce81967 100644 --- a/api/v3/Controllers/SystemController.php +++ b/api/v3/Controllers/SystemController.php @@ -6,9 +6,12 @@ use Api\V3\Exception\DatabaseException; use Api\V3\Support\ServerStateStore; +use Api\V3\Support\StatementHelpers; class SystemController { + use StatementHelpers; + public function __construct(private readonly \mysqli $db) { } @@ -70,10 +73,13 @@ public function dbStats(): array } $dbName = (string)$dbRow['db']; + // One prepared statement re-bound per table, instead of re-preparing + // the identical query on every iteration. (bind/execute close the + // statement themselves before throwing, so no finally-close here.) + $stmt = $this->prepare( + "SELECT TABLE_ROWS as cnt FROM information_schema.TABLES WHERE table_schema = ? AND table_name = ?" + ); foreach ($tables as $table => $label) { - $stmt = $this->prepare( - "SELECT TABLE_ROWS as cnt FROM information_schema.TABLES WHERE table_schema = ? AND table_name = ?" - ); $this->bind($stmt, 'ss', $dbName, $table); $this->execute($stmt, 'Stats query failed'); $result = $stmt->get_result(); @@ -82,9 +88,9 @@ public function dbStats(): array throw new DatabaseException('Stats query failed'); } $row = $result->fetch_assoc(); - $stmt->close(); $stats[] = ['table' => $table, 'label' => $label, 'rows_estimate' => (int)($row['cnt'] ?? 0)]; } + $stmt->close(); $result = $this->db->query( "SELECT SUM(data_length + index_length) as size @@ -246,33 +252,6 @@ public function metrics(): array ]; } - private function prepare(string $sql): \mysqli_stmt - { - $stmt = $this->db->prepare($sql); - if (!$stmt) { - throw new DatabaseException('Prepare failed'); - } - return $stmt; - } - - private function bind(\mysqli_stmt $stmt, string $types, mixed ...$values): void - { - // @phpstan-ignore-next-line -- local ref-safe wrapper - if (!$stmt->bind_param($types, ...$values)) { - $stmt->close(); - throw new DatabaseException('Bind failed'); - } - } - - private function execute(\mysqli_stmt $stmt, string $message): void - { - // @phpstan-ignore-next-line -- local checked-execution wrapper - if (!$stmt->execute()) { - $stmt->close(); - throw new DatabaseException($message); - } - } - private function intEnv(string $name, int $default): int { $raw = getenv($name); diff --git a/api/v3/Controllers/UsersController.php b/api/v3/Controllers/UsersController.php index cfb911cf..5e5ad2a6 100644 --- a/api/v3/Controllers/UsersController.php +++ b/api/v3/Controllers/UsersController.php @@ -9,9 +9,12 @@ use Api\V3\Exception\NotFoundException; use Api\V3\Exception\WriteCommittedException; use Api\V3\Exception\ValidationException; +use Api\V3\Support\StatementHelpers; class UsersController { + use StatementHelpers; + public function __construct(private readonly \mysqli $db) { } @@ -106,8 +109,7 @@ public function create(array $payload): array $installHash = (string) $hashRow['install_hash']; } - $this->db->begin_transaction(); - try { + $newId = $this->transaction(function () use ($fname, $lname, $username, $hashedPass, $email, $tz, $now, $active, $installHash): int { $stmt = $this->prepare( 'INSERT INTO 202_users (user_fname, user_lname, user_name, user_pass, user_email, user_dash_email, user_timezone, user_time_register, user_active, install_hash, user_hash, user_deleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)' @@ -122,11 +124,8 @@ public function create(array $payload): array $this->execute($stmt, 'Failed to create user preferences'); $stmt->close(); - $this->db->commit(); - } catch (\Throwable $e) { - $this->db->rollback(); - throw $e; - } + return $newId; + }); // Committed: both the user row and its preferences row exist. Only // the read-back remains, and its failure is not a failed create. @@ -200,10 +199,19 @@ public function deletePreview(int $id): array public function delete(int $id): void { $this->get($id); - $stmt = $this->prepare('UPDATE 202_users SET user_deleted = 1 WHERE user_id = ?'); - $this->bind($stmt, 'i', $id); - $this->execute($stmt, 'Delete failed'); - $stmt->close(); + $this->transaction(function () use ($id): void { + $stmt = $this->prepare('UPDATE 202_users SET user_deleted = 1 WHERE user_id = ?'); + $this->bind($stmt, 'i', $id); + $this->execute($stmt, 'Delete failed'); + $stmt->close(); + + // Deleting a user is an access-revocation event: remove their API + // keys so the credentials cannot keep authenticating. + $stmt = $this->prepare('DELETE FROM 202_api_keys WHERE user_id = ?'); + $this->bind($stmt, 'i', $id); + $this->execute($stmt, 'API key revocation failed'); + $stmt->close(); + }); } // --- Roles --- @@ -228,6 +236,19 @@ public function assignRole(int $userId, array $payload): array throw new ValidationException('role_id is required', ['role_id' => 'Must be a positive integer']); } + // Validate BEFORE mutating: 202_user_role has no foreign keys, so an + // insert for a nonexistent user/role would persist an orphan grant + // that silently becomes live if that user ID is ever created. + $this->get($userId); + $stmt = $this->prepare('SELECT role_id FROM 202_roles WHERE role_id = ? LIMIT 1'); + $this->bind($stmt, 'i', $roleId); + $this->execute($stmt, 'Role lookup failed'); + $role = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + if (!$role) { + throw new ValidationException('Unknown role_id', ['role_id' => 'Role does not exist']); + } + $stmt = $this->prepare('INSERT IGNORE INTO 202_user_role (user_id, role_id) VALUES (?, ?)'); $this->bind($stmt, 'ii', $userId, $roleId); $this->execute($stmt, 'Failed to assign role'); @@ -241,7 +262,12 @@ public function removeRole(int $userId, int $roleId): void $stmt = $this->prepare('DELETE FROM 202_user_role WHERE user_id = ? AND role_id = ?'); $this->bind($stmt, 'ii', $userId, $roleId); $this->execute($stmt, 'Failed to remove role'); + $affected = $stmt->affected_rows; $stmt->close(); + if ($affected === 0) { + // A revocation that matched nothing must not report success. + throw new NotFoundException('Role assignment not found'); + } } // --- API Keys --- @@ -393,7 +419,14 @@ public function deleteApiKey(int $userId, string $apiKey): void $stmt = $this->prepare('DELETE FROM 202_api_keys WHERE user_id = ? AND api_key = ?'); $this->bind($stmt, 'is', $userId, $apiKey); $this->execute($stmt, 'Failed to delete API key'); + $affected = $stmt->affected_rows; $stmt->close(); + if ($affected === 0) { + // Callers only ever see masked keys after creation; a mismatched + // value deleting zero rows must surface as an error — reporting + // 204 here would tell the caller a live credential was revoked. + throw new NotFoundException('API key not found'); + } } /** @@ -509,31 +542,4 @@ public function updatePreferences(int $userId, array $payload): array return $this->getPreferences($userId); } - - private function prepare(string $sql): \mysqli_stmt - { - $stmt = $this->db->prepare($sql); - if (!$stmt) { - throw new DatabaseException('Prepare failed'); - } - return $stmt; - } - - private function bind(\mysqli_stmt $stmt, string $types, mixed ...$values): void - { - // @phpstan-ignore-next-line prosper202.directStmtCall -- local checked bind wrapper - if (!$stmt->bind_param($types, ...$values)) { - $stmt->close(); - throw new DatabaseException('Bind failed'); - } - } - - private function execute(\mysqli_stmt $stmt, string $message): void - { - // @phpstan-ignore-next-line prosper202.directStmtCall -- local checked execute wrapper - if (!$stmt->execute()) { - $stmt->close(); - throw new DatabaseException($message); - } - } } diff --git a/api/v3/Support/RemoteApiClient.php b/api/v3/Support/RemoteApiClient.php index 24b8a209..5dd8e5f0 100644 --- a/api/v3/Support/RemoteApiClient.php +++ b/api/v3/Support/RemoteApiClient.php @@ -97,9 +97,13 @@ public function fetchAllRows(string $endpoint, array $extraQuery = []): array } $pagination = $resp['pagination'] ?? []; - $total = (int)($pagination['total'] ?? count($rows)); + $total = isset($pagination['total']) ? (int)$pagination['total'] : null; $offset += $limit; - if ($offset >= $total || count($page) === 0) { + if (count($page) < $limit) { + // Short page — no more rows regardless of what total claims. + break; + } + if ($total !== null && $offset >= $total) { break; } } @@ -165,15 +169,28 @@ private function request(string $method, string $path, array $query, ?array $bod curl_close($ch); $decoded = json_decode($responseBody, true); - if (!is_array($decoded)) { - $decoded = []; - } if ($status >= 400) { - $message = (string)($decoded['message'] ?? ('Remote API error ' . $status)); + $message = is_array($decoded) + ? (string)($decoded['message'] ?? ('Remote API error ' . $status)) + : 'Remote API error ' . $status; throw new DatabaseException($message); } + // Redirects are not followed, and a proxy/maintenance page served with + // a 2xx status must not masquerade as an empty dataset — callers diff + // and prune against these results, so silence here means data loss. + if ($status >= 300) { + throw new DatabaseException('Remote API returned unexpected status ' . $status); + } + + if (trim($responseBody) === '') { + return []; // 204-style empty success body + } + if (!is_array($decoded)) { + throw new DatabaseException('Remote API returned invalid JSON (status ' . $status . ')'); + } + return $decoded; } } diff --git a/api/v3/Support/ServerStateStore.php b/api/v3/Support/ServerStateStore.php index 3b4fc59a..e9f54d83 100644 --- a/api/v3/Support/ServerStateStore.php +++ b/api/v3/Support/ServerStateStore.php @@ -78,7 +78,9 @@ public static function canonicalHash(array $payload): string self::sortPayloadRecursive($payload); $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if ($json === false) { - return sha1((string)microtime(true)); + // A random fallback hash would silently break idempotency replay + // and incremental-sync diffing (nothing would ever match again). + throw new DatabaseException('Failed to encode payload for hashing: ' . json_last_error_msg()); } return sha1($json); } @@ -464,27 +466,33 @@ public function saveJob(array $job): void if (!isset($job['job_id'])) { throw new DatabaseException('Job payload missing job_id'); } - $job['updated_at'] = gmdate('c'); - $this->writeJsonFileAtomic($this->jobPath((string)$job['job_id']), $job); + $this->mutateJsonFile($this->jobPath((string)$job['job_id']), [], static function (array $current) use ($job): array { + // The worker saves its whole in-memory copy after long-running + // work; a cancel flag set concurrently on disk must survive that. + if (!empty($current['cancel_requested'])) { + $job['cancel_requested'] = true; + } + $job['updated_at'] = gmdate('c'); + return $job; + }); } public function appendJobEvent(string $jobId, string $level, string $message, array $data = []): void { - $path = $this->jobEventsPath($jobId); - $events = $this->readJsonFile($path, ['items' => []]); - $events['items'][] = [ + $event = [ 'event_id' => bin2hex(random_bytes(8)), 'timestamp' => gmdate('c'), 'level' => $level, 'message' => $message, 'data' => $this->sanitizeSensitive($data), ]; - - if (count($events['items']) > self::DEFAULT_RETENTION) { - $events['items'] = array_slice($events['items'], -self::DEFAULT_RETENTION); - } - - $this->writeJsonFileAtomic($path, $events); + $this->mutateJsonFile($this->jobEventsPath($jobId), ['items' => []], static function (array $events) use ($event): array { + $events['items'][] = $event; + if (count($events['items']) > self::DEFAULT_RETENTION) { + $events['items'] = array_slice($events['items'], -self::DEFAULT_RETENTION); + } + return $events; + }); } public function listJobEvents(string $jobId, int $offset, int $limit): array @@ -686,15 +694,14 @@ private function stagedChangesPath(int $userId): string public function appendAudit(array $record): void { - $path = $this->auditPath(); - $audit = $this->readJsonFile($path, ['items' => []]); - $audit['items'][] = $this->sanitizeSensitive($record); - - if (count($audit['items']) > self::DEFAULT_RETENTION) { - $audit['items'] = array_slice($audit['items'], -self::DEFAULT_RETENTION); - } - - $this->writeJsonFileAtomic($path, $audit); + $sanitized = $this->sanitizeSensitive($record); + $this->mutateJsonFile($this->auditPath(), ['items' => []], static function (array $audit) use ($sanitized): array { + $audit['items'][] = $sanitized; + if (count($audit['items']) > self::DEFAULT_RETENTION) { + $audit['items'] = array_slice($audit['items'], -self::DEFAULT_RETENTION); + } + return $audit; + }); } /** @return array> */ @@ -783,35 +790,38 @@ public function acquirePairLock(string $sourceKey, string $targetKey): callable public function issuePruneToken(string $pairKey, int $ttlSeconds = 600): string { $token = bin2hex(random_bytes(16)); - $path = $this->dir('tokens') . '/prune.json'; - $state = $this->readJsonFile($path, ['items' => []]); - $state['items'][$token] = [ + $entry = [ 'pair_key' => $pairKey, 'expires_at' => time() + $ttlSeconds, ]; - $this->writeJsonFileAtomic($path, $state); + $this->mutateJsonFile($this->pruneTokensPath(), ['items' => []], static function (array $state) use ($token, $entry): array { + $state['items'][$token] = $entry; + return $state; + }); return $token; } public function validatePruneToken(string $token, string $pairKey): bool { - $path = $this->dir('tokens') . '/prune.json'; - $state = $this->readJsonFile($path, ['items' => []]); - $item = $state['items'][$token] ?? null; - if (!is_array($item)) { - return false; - } - if ((string)($item['pair_key'] ?? '') !== $pairKey) { - return false; - } - if ((int)($item['expires_at'] ?? 0) < time()) { - return false; - } + // Check-and-consume must happen under the state lock: the bare + // read-then-write version let two concurrent runs both spend the + // same single-use token (TOCTOU double-prune). + $valid = false; + $this->mutateJsonFile($this->pruneTokensPath(), ['items' => []], static function (array $state) use ($token, $pairKey, &$valid): array { + $item = $state['items'][$token] ?? null; + if ( + is_array($item) + && (string)($item['pair_key'] ?? '') === $pairKey + && (int)($item['expires_at'] ?? 0) >= time() + ) { + $valid = true; + unset($state['items'][$token]); + } + return $state; + }); - unset($state['items'][$token]); - $this->writeJsonFileAtomic($path, $state); - return true; + return $valid; } /** @return array> */ @@ -883,11 +893,12 @@ public function saveSyncManifest(string $pairKey, array $manifest): void public function incrementMetric(string $name, int $delta = 1): void { $path = $this->dir('metrics') . '/metrics.json'; - $state = $this->readJsonFile($path, ['counters' => []]); - $current = (int)($state['counters'][$name] ?? 0); - $state['counters'][$name] = $current + $delta; - $state['updated_at'] = gmdate('c'); - $this->writeJsonFileAtomic($path, $state); + $this->mutateJsonFile($path, ['counters' => []], static function (array $state) use ($name, $delta): array { + $current = (int)($state['counters'][$name] ?? 0); + $state['counters'][$name] = $current + $delta; + $state['updated_at'] = gmdate('c'); + return $state; + }); } /** @return array */ @@ -899,10 +910,8 @@ public function metrics(): array /** @param array $meta */ public function startSpan(string $name, array $meta = []): string { - $path = $this->dir('traces') . '/spans.json'; - $state = $this->readJsonFile($path, ['items' => []]); $id = bin2hex(random_bytes(8)); - $state['items'][] = [ + $span = [ 'span_id' => $id, 'name' => $name, 'status' => 'running', @@ -913,44 +922,48 @@ public function startSpan(string $name, array $meta = []): string 'ended_at_epoch' => null, 'duration_ms' => null, ]; - if (count($state['items']) > self::DEFAULT_RETENTION) { - $state['items'] = array_slice($state['items'], -self::DEFAULT_RETENTION); - } - $this->writeJsonFileAtomic($path, $state); + $this->mutateJsonFile($this->spansPath(), ['items' => []], static function (array $state) use ($span): array { + $state['items'][] = $span; + if (count($state['items']) > self::DEFAULT_RETENTION) { + $state['items'] = array_slice($state['items'], -self::DEFAULT_RETENTION); + } + return $state; + }); return $id; } /** @param array $meta */ public function endSpan(string $spanId, string $status = 'ok', array $meta = []): void { - $path = $this->dir('traces') . '/spans.json'; - $state = $this->readJsonFile($path, ['items' => []]); - if (!is_array($state['items'] ?? null)) { - return; - } + $resultMeta = $this->sanitizeSensitive($meta); + $this->mutateJsonFile($this->spansPath(), ['items' => []], static function (array $state) use ($spanId, $status, $resultMeta): array { + if (!is_array($state['items'] ?? null)) { + return $state; + } - $now = time(); - foreach ($state['items'] as &$item) { - if ((string)($item['span_id'] ?? '') !== $spanId) { - continue; + $now = time(); + foreach ($state['items'] as &$item) { + if ((string)($item['span_id'] ?? '') !== $spanId) { + continue; + } + $item['status'] = $status; + $item['ended_at'] = gmdate('c'); + $item['ended_at_epoch'] = $now; + $started = (int)($item['started_at_epoch'] ?? $now); + $item['duration_ms'] = max(0, ($now - $started) * 1000); + $item['result_meta'] = $resultMeta; + break; } - $item['status'] = $status; - $item['ended_at'] = gmdate('c'); - $item['ended_at_epoch'] = $now; - $started = (int)($item['started_at_epoch'] ?? $now); - $item['duration_ms'] = max(0, ($now - $started) * 1000); - $item['result_meta'] = $this->sanitizeSensitive($meta); - break; - } - unset($item); + unset($item); - $this->writeJsonFileAtomic($path, $state); + return $state; + }); } /** @return array> */ public function listSpans(?string $name = null, int $limit = 200): array { - $state = $this->readJsonFile($this->dir('traces') . '/spans.json', ['items' => []]); + $state = $this->readJsonFile($this->spansPath(), ['items' => []]); $items = is_array($state['items'] ?? null) ? $state['items'] : []; $filtered = []; foreach ($items as $item) { @@ -979,26 +992,33 @@ public function sanitize(array $payload): array public function consumeRateLimit(string $bucket, int $maxPerWindow, int $windowSeconds): array { $path = $this->dir('rate_limits') . '/' . $this->slug($bucket) . '.json'; - $state = $this->readJsonFile($path, ['window_start' => 0, 'count' => 0]); - - $now = time(); - $windowStart = (int)($state['window_start'] ?? 0); - $count = (int)($state['count'] ?? 0); - if ($windowStart <= 0 || ($now - $windowStart) >= $windowSeconds) { - $windowStart = $now; - $count = 0; - } - $count++; - $allowed = $count <= $maxPerWindow; - $remaining = max(0, $maxPerWindow - $count); - $resetAt = $windowStart + $windowSeconds; + // Counting must happen under the state lock: with the bare + // read-then-write pattern, concurrent requests read the same count + // and the limit is systematically undercounted. + $allowed = true; + $remaining = 0; + $resetAt = 0; + $this->mutateJsonFile($path, ['window_start' => 0, 'count' => 0], static function (array $state) use ($maxPerWindow, $windowSeconds, &$allowed, &$remaining, &$resetAt): array { + $now = time(); + $windowStart = (int)($state['window_start'] ?? 0); + $count = (int)($state['count'] ?? 0); + if ($windowStart <= 0 || ($now - $windowStart) >= $windowSeconds) { + $windowStart = $now; + $count = 0; + } - $this->writeJsonFileAtomic($path, [ - 'window_start' => $windowStart, - 'count' => $count, - 'updated_at' => gmdate('c'), - ]); + $count++; + $allowed = $count <= $maxPerWindow; + $remaining = max(0, $maxPerWindow - $count); + $resetAt = $windowStart + $windowSeconds; + + return [ + 'window_start' => $windowStart, + 'count' => $count, + 'updated_at' => gmdate('c'), + ]; + }); return [ 'allowed' => $allowed, @@ -1121,6 +1141,16 @@ private function manifestPath(string $pairKey): string return $this->dir('manifests') . '/' . $this->slug($pairKey) . '.json'; } + private function pruneTokensPath(): string + { + return $this->dir('tokens') . '/prune.json'; + } + + private function spansPath(): string + { + return $this->dir('traces') . '/spans.json'; + } + private function dir(string $name): string { return $this->baseDir . '/' . $name; diff --git a/api/v3/Support/StatementHelpers.php b/api/v3/Support/StatementHelpers.php new file mode 100644 index 00000000..3697a53c --- /dev/null +++ b/api/v3/Support/StatementHelpers.php @@ -0,0 +1,95 @@ +db. + */ +trait StatementHelpers +{ + protected function prepare(string $sql): \mysqli_stmt + { + $stmt = $this->db->prepare($sql); + if (!$stmt) { + throw new DatabaseException('Prepare failed'); + } + return $stmt; + } + + protected function bind(\mysqli_stmt $stmt, string $types, mixed ...$values): void + { + // @phpstan-ignore-next-line this IS the ref-safe bind wrapper (analog of Connection::bind); no $this->conn exists, cannot self-route + if (!$stmt->bind_param($types, ...$values)) { + $stmt->close(); + throw new DatabaseException('Bind failed'); + } + } + + protected function execute(\mysqli_stmt $stmt, string $message): void + { + // @phpstan-ignore-next-line this IS the checked-execute wrapper (analog of Connection::execute); no $this->conn exists, cannot self-route + if (!$stmt->execute()) { + $stmt->close(); + throw new DatabaseException($message); + } + } + + /** + * Run $fn inside a transaction: checked begin, checked commit, rollback on + * any throwable. This is the only transaction primitive in api/v3 -- the + * controllers hand their multi-statement bodies to it as closures rather + * than opening a transaction themselves, so there is no second place for a + * commit check or a rollback to be forgotten. + * + * On the return-value checks: which mysqli failure mode applies depends on + * the entry point. api/v3/index.php never includes 202-config/connect.php, + * so this code runs under PHP's default mysqli_report(ERROR | STRICT) and a + * failed begin_transaction()/commit() throws mysqli_sql_exception before + * the `if` is reached. connect.php (the UI and cron paths) downgrades that + * to STRICT alone, where the same calls return false. The checks are kept + * so the helper is correct under both modes -- a trait cannot know which + * bootstrap loaded it -- and so the failure has the same DatabaseException + * shape as every other helper here. + * + * @template T + * @param callable(): T $fn + * @return T + */ + protected function transaction(callable $fn): mixed + { + // An ignored false here is the worst one: $fn() would run in + // autocommit, every statement would land individually, and the + // rollback below would have nothing to undo while the caller is told + // the operation failed. + if (!$this->db->begin_transaction()) { + throw new DatabaseException('Could not start transaction: ' . $this->db->error); + } + try { + $result = $fn(); + if (!$this->db->commit()) { + // Thrown, not returned: the catch below is what rolls back, so + // a failed commit leaves nothing half-applied on a connection + // that may be reused. + throw new DatabaseException('Transaction commit failed: ' . $this->db->error); + } + return $result; + } catch (\Throwable $e) { + // rollback()'s own result is deliberately unchecked: $e is the root + // cause and must reach the caller. A rollback that also fails has + // nothing better to report, and replacing $e with it would hide why + // the work was abandoned. + $this->db->rollback(); + throw $e; + } + } +} diff --git a/api/v3/Support/SyncEngine.php b/api/v3/Support/SyncEngine.php index e1eb9aab..0100d8cb 100644 --- a/api/v3/Support/SyncEngine.php +++ b/api/v3/Support/SyncEngine.php @@ -211,6 +211,28 @@ public function execute(array $sourceProfile, array $targetProfile, string $enti $prune = (bool)($options['prune'] ?? false); $prunePreview = (bool)($options['prune_preview'] ?? false); + // Prune decisions must be made against the FULL source key set. + // When updated_since filters the fetch above, every unchanged + // source record is absent from $sourceData, and diffing the target + // against that filtered set would classify the bulk of the target + // install as "only in target" and delete it. + $pruneSourceKeys = null; + if (($prune || $prunePreview) && $updatedSince !== '') { + $fullSourceData = $this->fetchPortableData($sourceClient); + $fullSourceLookups = $this->buildEntityLookups($fullSourceData); + $pruneSourceKeys = []; + foreach ($entities as $pruneEntity) { + $pruneSourceKeys[$pruneEntity] = []; + foreach ($fullSourceData[$pruneEntity] as $fullRow) { + $fullKey = $this->naturalKeyForEntity($pruneEntity, $fullRow, $fullSourceLookups); + if ($fullKey !== '') { + $pruneSourceKeys[$pruneEntity][$fullKey] = true; + } + } + } + unset($fullSourceData, $fullSourceLookups); + } + $results = []; $mappings = []; $sourceHashes = []; @@ -220,6 +242,7 @@ public function execute(array $sourceProfile, array $targetProfile, string $enti $entitySpan = $this->startTraceSpan('sync.execute.entity', ['entity' => $entity]); $remapSpan = $this->startTraceSpan('sync.execute.remap', ['entity' => $entity]); $remapOps = 0; + try { $result = [ 'synced' => 0, 'skipped' => 0, @@ -397,6 +420,7 @@ public function execute(array $sourceProfile, array $targetProfile, string $enti } $this->endTraceSpan($remapSpan, 'ok', ['operations' => $remapOps]); + $remapSpan = null; $writeSpan = $this->startTraceSpan('sync.execute.write', ['entity' => $entity]); $this->endTraceSpan($writeSpan, 'ok', [ 'created' => (int)($result['created'] ?? 0), @@ -408,10 +432,11 @@ public function execute(array $sourceProfile, array $targetProfile, string $enti $pruneSpan = $this->startTraceSpan('sync.execute.prune', ['entity' => $entity]); $allow = $this->normalizeEntitySet($options['prune_allowlist'] ?? []); $deny = $this->normalizeEntitySet($options['prune_denylist'] ?? []); + $knownSourceKeys = $pruneSourceKeys !== null ? ($pruneSourceKeys[$entity] ?? []) : $sourceKeys; foreach ($targetData[$entity] as $targetRow) { $targetKey = $this->naturalKeyForEntity($entity, $targetRow, $targetLookups); - if ($targetKey === '' || isset($sourceKeys[$targetKey])) { + if ($targetKey === '' || isset($knownSourceKeys[$targetKey])) { continue; } @@ -462,6 +487,13 @@ public function execute(array $sourceProfile, array $targetProfile, string $enti 'synced' => (int)($result['synced'] ?? 0), 'failed' => (int)($result['failed'] ?? 0), ]); + $entitySpan = null; + } finally { + // On the success path both are null; a thrown error must + // not leave spans stuck in 'running' forever. + $this->endTraceSpan($remapSpan, 'error'); + $this->endTraceSpan($entitySpan, 'error'); + } } $traceMeta = ['entities' => count($entities)]; @@ -516,9 +548,26 @@ private function syncRotatorRules( array $sourceLookups, array $targetLookups ): void { + $rules = $this->fetchSourceRotatorRules($sourceClient, $sourceRotatorId); + $this->postRotatorRules($targetClient, $targetRotatorId, $rules, $sourceLookups, $targetLookups); + } + + /** @return array> */ + private function fetchSourceRotatorRules(RemoteApiClient $sourceClient, string $sourceRotatorId): array + { $sourceRotator = $sourceClient->get('rotators/' . $sourceRotatorId); $rules = $sourceRotator['data']['rules'] ?? []; + return is_array($rules) ? $rules : []; + } + /** @param array> $rules */ + private function postRotatorRules( + RemoteApiClient $targetClient, + string $targetRotatorId, + array $rules, + array $sourceLookups, + array $targetLookups + ): void { foreach ($rules as $rule) { $rulePayload = [ 'rule_name' => $rule['rule_name'] ?? '', @@ -586,8 +635,16 @@ private function resyncRotatorRules( array $sourceLookups, array $targetLookups ): void { + // Fetch the source rules BEFORE deleting anything on the target: if the + // source fetch fails we abort with the target rotator's rules intact, + // instead of stripping them and having nothing to recreate. + $sourceRules = $this->fetchSourceRotatorRules($sourceClient, $sourceRotatorId); + $targetRotator = $targetClient->get('rotators/' . $targetRotatorId); $targetRules = $targetRotator['data']['rules'] ?? []; + if (!is_array($targetRules)) { + $targetRules = []; + } foreach ($targetRules as $rule) { if (!is_array($rule)) { continue; @@ -599,14 +656,7 @@ private function resyncRotatorRules( $targetClient->delete('rotators/' . $targetRotatorId . '/rules/' . $ruleId); } - $this->syncRotatorRules( - $sourceClient, - $targetClient, - $sourceRotatorId, - $targetRotatorId, - $sourceLookups, - $targetLookups - ); + $this->postRotatorRules($targetClient, $targetRotatorId, $sourceRules, $sourceLookups, $targetLookups); } protected function buildClients(array $sourceProfile, array $targetProfile): array @@ -651,8 +701,13 @@ protected function fetchPortableData(RemoteApiClient $client, array $query = []) $detailData = is_array($detail['data'] ?? null) ? $detail['data'] : []; $rules = $detailData['rules'] ?? []; $row['rules'] = is_array($rules) ? $rules : []; - } catch (\Throwable) { - $row['rules'] = []; + } catch (\Throwable $e) { + // Do not map a failed detail fetch to "no rules": the + // diff would see a rule-less rotator and a force_update + // run would delete every rule on the other side. + throw new DatabaseException( + 'Failed to fetch rotator ' . $rotatorId . ' detail: ' . $e->getMessage() + ); } $enriched[] = $row; } @@ -1176,7 +1231,9 @@ private function comparableHash(array $row): string $this->sortRecursive($copy); $json = json_encode($copy, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if ($json === false) { - return sha1((string)microtime(true)); + // A random fallback would silently defeat incremental-sync + // hash matching (every run re-syncs every record, forever). + throw new DatabaseException('Failed to encode record for hashing: ' . json_last_error_msg()); } return sha1($json); } @@ -1286,11 +1343,22 @@ private function envFlagEnabled(string $name, bool $default): bool return !in_array($value, ['0', 'false', 'no', 'off', 'disabled'], true); } - private function pairKey(array $sourceProfile, array $targetProfile): string + /** + * Single source of truth for the source/target pair key. Prune tokens, + * manifests, and audit records must all agree on this formula — an + * independent copy that drifts silently breaks token validation and + * incremental manifests. + */ + public static function pairKeyFor(array $sourceProfile, array $targetProfile): string { return sha1(strtolower((string)($sourceProfile['url'] ?? '')) . '|' . strtolower((string)($targetProfile['url'] ?? ''))); } + private function pairKey(array $sourceProfile, array $targetProfile): string + { + return self::pairKeyFor($sourceProfile, $targetProfile); + } + private function profileLabel(array $profile): string { $name = trim((string)($profile['name'] ?? '')); diff --git a/api/v3/index.php b/api/v3/index.php index 0e27374c..dfb48f8b 100644 --- a/api/v3/index.php +++ b/api/v3/index.php @@ -66,7 +66,12 @@ $payload = []; if (in_array($method, ['POST', 'PUT', 'PATCH'])) { - $raw = file_get_contents('php://input', false, null, 0, 1_048_576); // 1 MB limit + $maxBody = 1_048_576; // 1 MB limit + $raw = file_get_contents('php://input', false, null, 0, $maxBody + 1); + if ($raw !== false && strlen($raw) > $maxBody) { + Bootstrap::errorResponse('Request body too large', 413, ['max_bytes' => $maxBody]); + exit; + } if ($raw !== '' && $raw !== false) { $payload = json_decode($raw, true); if ($payload === null && json_last_error() !== JSON_ERROR_NONE) { @@ -85,7 +90,7 @@ } } -$requestedVersion = strtolower(trim((string)($headers['X-P202-API-Version'] ?? $headers['x-p202-api-version'] ?? ''))); +$requestedVersion = strtolower((string)RequestContext::header('x-p202-api-version', '')); if ($requestedVersion !== '' && !in_array($requestedVersion, ['v3', '3'], true)) { Bootstrap::errorResponse( 'Unsupported API version', diff --git a/cli/ApiClient.php b/cli/ApiClient.php index b25b556d..542e6e24 100644 --- a/cli/ApiClient.php +++ b/cli/ApiClient.php @@ -119,6 +119,12 @@ private function request(string $method, string $path, array $params = [], array throw new ApiException($msg, $httpCode, $data); } + if ($response !== '' && !is_array($decoded)) { + // A scalar body on success must not silently render as an + // empty result — surface what the server actually sent. + throw new \RuntimeException('Unexpected non-object JSON response from server: ' . substr($response, 0, 200)); + } + return $data; } } diff --git a/cli/Application.php b/cli/Application.php index 4b6fe1af..03d71a71 100644 --- a/cli/Application.php +++ b/cli/Application.php @@ -116,7 +116,9 @@ private function registerCrudEntities(): void 'aff_campaign_cloaking' => 'Enable cloaking (0|1)', 'aff_campaign_rotate' => 'Enable URL rotation (0|1)', ], - 'required' => ['aff_campaign_name', 'aff_campaign_url'], + // Must match CampaignsController::fields() required flags, or + // client-side validation passes and the server 422s anyway. + 'required' => ['aff_campaign_name', 'aff_campaign_url', 'aff_campaign_payout', 'aff_network_id'], 'listParams' => ['filter[aff_network_id]' => 'Filter by affiliate network'], ], [ diff --git a/cli/Commands/AttributionModelCreateCommand.php b/cli/Commands/AttributionModelCreateCommand.php index a0cbfc3d..7ea48984 100644 --- a/cli/Commands/AttributionModelCreateCommand.php +++ b/cli/Commands/AttributionModelCreateCommand.php @@ -41,16 +41,9 @@ protected function handle(InputInterface $input, OutputInterface $output): int 'is_default' => (int)$input->getOption('is_default'), ]; - $weightingConfig = $input->getOption('weighting_config'); + $weightingConfig = $this->decodeJsonOption($input, 'weighting_config'); if ($weightingConfig !== null) { - $decodedConfig = json_decode((string)$weightingConfig, true); - if (json_last_error() !== JSON_ERROR_NONE) { - $output->writeln( - sprintf('Invalid --weighting_config JSON: %s', json_last_error_msg()) - ); - return Command::FAILURE; - } - $body['weighting_config'] = $decodedConfig; + $body['weighting_config'] = $weightingConfig; } $result = $this->client()->post('attribution/models', $body); diff --git a/cli/Commands/AttributionModelDeleteCommand.php b/cli/Commands/AttributionModelDeleteCommand.php index 4329b8c4..8f4736c5 100644 --- a/cli/Commands/AttributionModelDeleteCommand.php +++ b/cli/Commands/AttributionModelDeleteCommand.php @@ -9,7 +9,6 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\ConfirmationQuestion; class AttributionModelDeleteCommand extends BaseCommand { @@ -28,16 +27,8 @@ protected function handle(InputInterface $input, OutputInterface $output): int { $id = $input->getArgument('id'); - if (!$input->getOption('force')) { - $helper = $this->getHelper('question'); - $question = new ConfirmationQuestion( - sprintf('Are you sure you want to delete attribution model %s? [y/N] ', $id), - false - ); - if (!$helper->ask($input, $output, $question)) { - $output->writeln('Cancelled.'); - return Command::SUCCESS; - } + if (!$this->confirmDestructive($input, $output, sprintf('delete attribution model %s', $id))) { + return Command::SUCCESS; } $this->client()->delete('attribution/models/' . $id); diff --git a/cli/Commands/AttributionModelUpdateCommand.php b/cli/Commands/AttributionModelUpdateCommand.php index 2e17edc3..41986d72 100644 --- a/cli/Commands/AttributionModelUpdateCommand.php +++ b/cli/Commands/AttributionModelUpdateCommand.php @@ -29,23 +29,10 @@ protected function configure(): void protected function handle(InputInterface $input, OutputInterface $output): int { - $body = []; - foreach (['model_name', 'model_type', 'is_active', 'is_default'] as $f) { - $val = $input->getOption($f); - if ($val !== null) { - $body[$f] = $val; - } - } - $weightingConfig = $input->getOption('weighting_config'); + $body = $this->collectOptions($input, ['model_name', 'model_type', 'is_active', 'is_default']); + $weightingConfig = $this->decodeJsonOption($input, 'weighting_config'); if ($weightingConfig !== null) { - $decodedConfig = json_decode((string)$weightingConfig, true); - if (json_last_error() !== JSON_ERROR_NONE) { - $output->writeln( - sprintf('Invalid --weighting_config JSON: %s', json_last_error_msg()) - ); - return Command::FAILURE; - } - $body['weighting_config'] = $decodedConfig; + $body['weighting_config'] = $weightingConfig; } if (empty($body)) { diff --git a/cli/Commands/BaseCommand.php b/cli/Commands/BaseCommand.php index fbcf97fd..8051d513 100644 --- a/cli/Commands/BaseCommand.php +++ b/cli/Commands/BaseCommand.php @@ -12,6 +12,8 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Question\Question; /** * Base command that provides shared infrastructure: @@ -48,6 +50,83 @@ protected function render(OutputInterface $output, array $data, InputInterface $ Formatter::output($output, $data, $this->isJson($input)); } + /** + * Shared confirm-or-force gate for destructive commands. + * + * Validates client configuration BEFORE prompting, so an unconfigured + * user is never asked to confirm a deletion the tool cannot perform. + * $action is the verb phrase, e.g. "delete campaign #3". + */ + protected function confirmDestructive(InputInterface $input, OutputInterface $output, string $action): bool + { + $this->client(); + + if ($input->hasOption('force') && $input->getOption('force')) { + return true; + } + + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion("Are you sure you want to {$action}? [y/N] ", false); + if (!$helper->ask($input, $output, $question)) { + $output->writeln('Cancelled.'); + return false; + } + return true; + } + + /** + * Collect the named options that were explicitly provided (non-null). + */ + protected function collectOptions(InputInterface $input, array $names): array + { + $params = []; + foreach ($names as $name) { + if ($input->hasOption($name)) { + $value = $input->getOption($name); + if ($value !== null) { + $params[$name] = $value; + } + } + } + return $params; + } + + /** + * Decode a JSON option strictly. Returns null when the option was not + * provided; malformed JSON or a scalar (which the server would silently + * drop) is an explicit error, never silently discarded. + */ + protected function decodeJsonOption(InputInterface $input, string $name): ?array + { + $raw = $input->getOption($name); + if ($raw === null) { + return null; + } + + $decoded = json_decode((string)$raw, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException("Invalid JSON in --{$name}: " . json_last_error_msg()); + } + if (!is_array($decoded)) { + throw new \RuntimeException("--{$name} must be a JSON array or object"); + } + return $decoded; + } + + /** + * Prompt for a secret without echoing it (keeps credentials out of shell + * history and ps output). Returns null if nothing was entered. + */ + protected function promptHiddenSecret(InputInterface $input, OutputInterface $output, string $prompt): ?string + { + $helper = $this->getHelper('question'); + $question = new Question($prompt); + $question->setHidden(true); + $question->setHiddenFallback(false); + $value = $helper->ask($input, $output, $question); + return is_string($value) && $value !== '' ? $value : null; + } + /** * Override Symfony's execute to wrap in error handling. * Subclasses implement handle() instead of execute(). diff --git a/cli/Commands/ConfigSetKeyCommand.php b/cli/Commands/ConfigSetKeyCommand.php index a4432a66..7bc537ab 100644 --- a/cli/Commands/ConfigSetKeyCommand.php +++ b/cli/Commands/ConfigSetKeyCommand.php @@ -10,20 +10,38 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class ConfigSetKeyCommand extends Command +class ConfigSetKeyCommand extends BaseCommand { protected static $defaultName = 'config:set-key'; + #[\Override] protected function configure(): void { + parent::configure(); $this->setDescription('Set the API key for authentication') - ->addArgument('key', InputArgument::REQUIRED, 'Your Prosper202 API key'); + ->addArgument( + 'key', + InputArgument::OPTIONAL, + 'Your Prosper202 API key (omit to be prompted without echoing — keeps the key out of shell history)' + ); } - protected function execute(InputInterface $input, OutputInterface $output): int + protected function handle(InputInterface $input, OutputInterface $output): int { + $key = $input->getArgument('key'); + if ($key === null || $key === '') { + // Same treatment passwords get in user:create — an API key is a + // bearer credential and should not have to pass through shell + // history or ps output. + $key = $this->promptHiddenSecret($input, $output, 'API key (hidden): '); + if ($key === null) { + $output->writeln('API key is required'); + return Command::FAILURE; + } + } + $config = new Config(); - $config->set('api_key', $input->getArgument('key')); + $config->set('api_key', $key); $config->save(); $output->writeln('API key saved.'); return Command::SUCCESS; diff --git a/cli/Commands/ConversionDeleteCommand.php b/cli/Commands/ConversionDeleteCommand.php index 7f70dde7..2ab206eb 100644 --- a/cli/Commands/ConversionDeleteCommand.php +++ b/cli/Commands/ConversionDeleteCommand.php @@ -9,7 +9,6 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\ConfirmationQuestion; class ConversionDeleteCommand extends BaseCommand { @@ -28,16 +27,8 @@ protected function handle(InputInterface $input, OutputInterface $output): int { $id = $input->getArgument('id'); - if (!$input->getOption('force')) { - $helper = $this->getHelper('question'); - $question = new ConfirmationQuestion( - sprintf('Are you sure you want to delete conversion %s? [y/N] ', $id), - false - ); - if (!$helper->ask($input, $output, $question)) { - $output->writeln('Cancelled.'); - return Command::SUCCESS; - } + if (!$this->confirmDestructive($input, $output, sprintf('delete conversion %s', $id))) { + return Command::SUCCESS; } $this->client()->delete('conversions/' . $id); diff --git a/cli/Commands/CrudCommands.php b/cli/Commands/CrudCommands.php index f407c091..404b73f4 100644 --- a/cli/Commands/CrudCommands.php +++ b/cli/Commands/CrudCommands.php @@ -13,7 +13,6 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\ConfirmationQuestion; /** * Factory for generating standard CRUD commands (list, get, create, update, delete) @@ -203,16 +202,8 @@ protected function handle(InputInterface $input, OutputInterface $output): int { $id = $input->getArgument('id'); - if (!$input->getOption('force')) { - $helper = $this->getHelper('question'); - $question = new ConfirmationQuestion( - "Are you sure you want to delete {$this->entity} #{$id}? [y/N] ", - false - ); - if (!$helper->ask($input, $output, $question)) { - $output->writeln('Cancelled.'); - return Command::SUCCESS; - } + if (!$this->confirmDestructive($input, $output, "delete {$this->entity} #{$id}")) { + return Command::SUCCESS; } $this->client()->delete($this->endpoint . '/' . $id); diff --git a/cli/Commands/LtvBreakdownCommand.php b/cli/Commands/LtvBreakdownCommand.php index 5af59e24..1fd52f5c 100644 --- a/cli/Commands/LtvBreakdownCommand.php +++ b/cli/Commands/LtvBreakdownCommand.php @@ -28,7 +28,7 @@ protected function configure(): void protected function handle(InputInterface $input, OutputInterface $output): int { - $result = $this->client()->get('ltv/breakdown', LtvSummaryCommand::collectLtvParams($input)); + $result = $this->client()->get('ltv/breakdown', $this->collectOptions($input, LtvSummaryCommand::LTV_PARAMS)); $this->render($output, $result, $input); return Command::SUCCESS; } diff --git a/cli/Commands/LtvCustomersCommand.php b/cli/Commands/LtvCustomersCommand.php index 2de51eb1..bc405cd1 100644 --- a/cli/Commands/LtvCustomersCommand.php +++ b/cli/Commands/LtvCustomersCommand.php @@ -35,7 +35,7 @@ protected function handle(InputInterface $input, OutputInterface $output): int if ($id !== null) { $result = $this->client()->get('ltv/customers/' . (int) $id, []); } else { - $result = $this->client()->get('ltv/customers', LtvSummaryCommand::collectLtvParams($input)); + $result = $this->client()->get('ltv/customers', $this->collectOptions($input, LtvSummaryCommand::LTV_PARAMS)); } $this->render($output, $result, $input); return Command::SUCCESS; diff --git a/cli/Commands/LtvPredictCommand.php b/cli/Commands/LtvPredictCommand.php index 060ad38a..76ff24d7 100644 --- a/cli/Commands/LtvPredictCommand.php +++ b/cli/Commands/LtvPredictCommand.php @@ -26,7 +26,7 @@ protected function configure(): void protected function handle(InputInterface $input, OutputInterface $output): int { - $result = $this->client()->get('ltv/predict', LtvSummaryCommand::collectLtvParams($input)); + $result = $this->client()->get('ltv/predict', $this->collectOptions($input, LtvSummaryCommand::LTV_PARAMS)); $this->render($output, $result, $input); return Command::SUCCESS; } diff --git a/cli/Commands/LtvSummaryCommand.php b/cli/Commands/LtvSummaryCommand.php index ce61b283..973bcdaa 100644 --- a/cli/Commands/LtvSummaryCommand.php +++ b/cli/Commands/LtvSummaryCommand.php @@ -11,6 +11,9 @@ class LtvSummaryCommand extends BaseCommand { + /** Query options shared by the LTV read commands. */ + public const array LTV_PARAMS = ['period', 'time_from', 'time_to', 'by', 'sort', 'dir', 'limit', 'offset']; + protected static $defaultName = 'ltv:summary'; #[\Override] @@ -25,22 +28,9 @@ protected function configure(): void protected function handle(InputInterface $input, OutputInterface $output): int { - $result = $this->client()->get('ltv/summary', self::collectLtvParams($input)); + $result = $this->client()->get('ltv/summary', $this->collectOptions($input, self::LTV_PARAMS)); $this->render($output, $result, $input); return Command::SUCCESS; } - public static function collectLtvParams(InputInterface $input): array - { - $params = []; - foreach (['period', 'time_from', 'time_to', 'by', 'sort', 'dir', 'limit', 'offset'] as $p) { - if ($input->hasOption($p)) { - $val = $input->getOption($p); - if ($val !== null) { - $params[$p] = $val; - } - } - } - return $params; - } } diff --git a/cli/Commands/ReportBreakdownCommand.php b/cli/Commands/ReportBreakdownCommand.php index 4e7bae6f..feb79d1a 100644 --- a/cli/Commands/ReportBreakdownCommand.php +++ b/cli/Commands/ReportBreakdownCommand.php @@ -36,7 +36,7 @@ protected function configure(): void protected function handle(InputInterface $input, OutputInterface $output): int { - $params = ReportSummaryCommand::collectParams($input); + $params = $this->collectOptions($input, ReportSummaryCommand::FILTER_PARAMS); $params['breakdown'] = $input->getOption('breakdown'); $params['sort'] = $input->getOption('sort'); $params['sort_dir'] = $input->getOption('sort_dir'); diff --git a/cli/Commands/ReportDaypartCommand.php b/cli/Commands/ReportDaypartCommand.php index 3825f63a..5d6b4ad8 100644 --- a/cli/Commands/ReportDaypartCommand.php +++ b/cli/Commands/ReportDaypartCommand.php @@ -33,7 +33,7 @@ protected function configure(): void protected function handle(InputInterface $input, OutputInterface $output): int { - $params = ReportSummaryCommand::collectParams($input); + $params = $this->collectOptions($input, ReportSummaryCommand::FILTER_PARAMS); $params['sort'] = (string)$input->getOption('sort'); $params['sort_dir'] = (string)$input->getOption('sort_dir'); diff --git a/cli/Commands/ReportSummaryCommand.php b/cli/Commands/ReportSummaryCommand.php index 824a500a..f1410de2 100644 --- a/cli/Commands/ReportSummaryCommand.php +++ b/cli/Commands/ReportSummaryCommand.php @@ -11,6 +11,9 @@ class ReportSummaryCommand extends BaseCommand { + /** Filter options shared by every report command. */ + public const array FILTER_PARAMS = ['period', 'time_from', 'time_to', 'aff_campaign_id', 'ppc_account_id', 'aff_network_id', 'ppc_network_id', 'landing_page_id', 'country_id']; + protected static $defaultName = 'report:summary'; #[\Override] @@ -27,23 +30,10 @@ protected function configure(): void protected function handle(InputInterface $input, OutputInterface $output): int { - $params = self::collectParams($input); + $params = $this->collectOptions($input, self::FILTER_PARAMS); $result = $this->client()->get('reports/summary', $params); $this->render($output, $result, $input); return Command::SUCCESS; } - public static function collectParams(InputInterface $input): array - { - $params = []; - foreach (['period', 'time_from', 'time_to', 'aff_campaign_id', 'ppc_account_id', 'aff_network_id', 'ppc_network_id', 'landing_page_id', 'country_id'] as $p) { - if ($input->hasOption($p)) { - $val = $input->getOption($p); - if ($val !== null) { - $params[$p] = $val; - } - } - } - return $params; - } } diff --git a/cli/Commands/ReportTimeseriesCommand.php b/cli/Commands/ReportTimeseriesCommand.php index 3bdd0709..7ac23386 100644 --- a/cli/Commands/ReportTimeseriesCommand.php +++ b/cli/Commands/ReportTimeseriesCommand.php @@ -32,7 +32,7 @@ protected function configure(): void protected function handle(InputInterface $input, OutputInterface $output): int { - $params = ReportSummaryCommand::collectParams($input); + $params = $this->collectOptions($input, ReportSummaryCommand::FILTER_PARAMS); $params['interval'] = $input->getOption('interval'); $result = $this->client()->get('reports/timeseries', $params); diff --git a/cli/Commands/ReportWeekpartCommand.php b/cli/Commands/ReportWeekpartCommand.php index a1f14e81..c902eeeb 100644 --- a/cli/Commands/ReportWeekpartCommand.php +++ b/cli/Commands/ReportWeekpartCommand.php @@ -33,7 +33,7 @@ protected function configure(): void protected function handle(InputInterface $input, OutputInterface $output): int { - $params = ReportSummaryCommand::collectParams($input); + $params = $this->collectOptions($input, ReportSummaryCommand::FILTER_PARAMS); $params['sort'] = (string)$input->getOption('sort'); $params['sort_dir'] = (string)$input->getOption('sort_dir'); diff --git a/cli/Commands/RotatorDeleteCommand.php b/cli/Commands/RotatorDeleteCommand.php index e33f91a1..547a11e5 100644 --- a/cli/Commands/RotatorDeleteCommand.php +++ b/cli/Commands/RotatorDeleteCommand.php @@ -9,7 +9,6 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\ConfirmationQuestion; class RotatorDeleteCommand extends BaseCommand { @@ -28,16 +27,8 @@ protected function handle(InputInterface $input, OutputInterface $output): int { $id = $input->getArgument('id'); - if (!$input->getOption('force')) { - $helper = $this->getHelper('question'); - $question = new ConfirmationQuestion( - sprintf('Are you sure you want to delete rotator %s? [y/N] ', $id), - false - ); - if (!$helper->ask($input, $output, $question)) { - $output->writeln('Cancelled.'); - return Command::SUCCESS; - } + if (!$this->confirmDestructive($input, $output, sprintf('delete rotator %s', $id))) { + return Command::SUCCESS; } $this->client()->delete('rotators/' . $id); diff --git a/cli/Commands/RotatorRuleCreateCommand.php b/cli/Commands/RotatorRuleCreateCommand.php index 954ebadf..cc720712 100644 --- a/cli/Commands/RotatorRuleCreateCommand.php +++ b/cli/Commands/RotatorRuleCreateCommand.php @@ -39,19 +39,16 @@ protected function handle(InputInterface $input, OutputInterface $output): int 'splittest' => (int)$input->getOption('splittest'), ]; - if ($input->getOption('criteria_json')) { - $body['criteria'] = json_decode((string) $input->getOption('criteria_json'), true); - if ($body['criteria'] === null) { - $output->writeln('Invalid JSON in --criteria_json'); - return Command::FAILURE; - } + // decodeJsonOption rejects malformed JSON AND scalar values — a scalar + // like --criteria_json='"country is US"' would previously be sent to + // the server, silently dropped, and the rule created with no criteria. + $criteria = $this->decodeJsonOption($input, 'criteria_json'); + if ($criteria !== null) { + $body['criteria'] = $criteria; } - if ($input->getOption('redirects_json')) { - $body['redirects'] = json_decode((string) $input->getOption('redirects_json'), true); - if ($body['redirects'] === null) { - $output->writeln('Invalid JSON in --redirects_json'); - return Command::FAILURE; - } + $redirects = $this->decodeJsonOption($input, 'redirects_json'); + if ($redirects !== null) { + $body['redirects'] = $redirects; } $result = $this->client()->post('rotators/' . $input->getArgument('rotator_id') . '/rules', $body); diff --git a/cli/Commands/RotatorRuleDeleteCommand.php b/cli/Commands/RotatorRuleDeleteCommand.php index 772adc76..847de7d4 100644 --- a/cli/Commands/RotatorRuleDeleteCommand.php +++ b/cli/Commands/RotatorRuleDeleteCommand.php @@ -9,7 +9,6 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\ConfirmationQuestion; class RotatorRuleDeleteCommand extends BaseCommand { @@ -30,16 +29,8 @@ protected function handle(InputInterface $input, OutputInterface $output): int $rotatorId = $input->getArgument('rotator_id'); $ruleId = $input->getArgument('rule_id'); - if (!$input->getOption('force')) { - $helper = $this->getHelper('question'); - $question = new ConfirmationQuestion( - sprintf('Are you sure you want to delete rule %s from rotator %s? [y/N] ', $ruleId, $rotatorId), - false - ); - if (!$helper->ask($input, $output, $question)) { - $output->writeln('Cancelled.'); - return Command::SUCCESS; - } + if (!$this->confirmDestructive($input, $output, sprintf('delete rule %s from rotator %s', $ruleId, $rotatorId))) { + return Command::SUCCESS; } $this->client()->delete('rotators/' . $rotatorId . '/rules/' . $ruleId); diff --git a/cli/Commands/RotatorUpdateCommand.php b/cli/Commands/RotatorUpdateCommand.php index a53ebe03..4a3d1db4 100644 --- a/cli/Commands/RotatorUpdateCommand.php +++ b/cli/Commands/RotatorUpdateCommand.php @@ -28,13 +28,7 @@ protected function configure(): void protected function handle(InputInterface $input, OutputInterface $output): int { - $body = []; - foreach (['name', 'default_url', 'default_campaign', 'default_lp'] as $f) { - $val = $input->getOption($f); - if ($val !== null) { - $body[$f] = $val; - } - } + $body = $this->collectOptions($input, ['name', 'default_url', 'default_campaign', 'default_lp']); if (empty($body)) { $output->writeln('Provide at least one field to update'); return Command::FAILURE; diff --git a/cli/Commands/UserApiKeyDeleteCommand.php b/cli/Commands/UserApiKeyDeleteCommand.php index 32522716..1b1588d5 100644 --- a/cli/Commands/UserApiKeyDeleteCommand.php +++ b/cli/Commands/UserApiKeyDeleteCommand.php @@ -9,7 +9,6 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\ConfirmationQuestion; class UserApiKeyDeleteCommand extends BaseCommand { @@ -30,16 +29,8 @@ protected function handle(InputInterface $input, OutputInterface $output): int $userId = $input->getArgument('user_id'); $apiKey = $input->getArgument('api_key'); - if (!$input->getOption('force')) { - $helper = $this->getHelper('question'); - $question = new ConfirmationQuestion( - sprintf('Are you sure you want to delete API key %s for user %s? [y/N] ', $apiKey, $userId), - false - ); - if (!$helper->ask($input, $output, $question)) { - $output->writeln('Cancelled.'); - return Command::SUCCESS; - } + if (!$this->confirmDestructive($input, $output, sprintf('delete API key %s for user %s', $apiKey, $userId))) { + return Command::SUCCESS; } $this->client()->delete('users/' . $userId . '/api-keys/' . $apiKey); diff --git a/cli/Commands/UserCreateCommand.php b/cli/Commands/UserCreateCommand.php index 9a73cd74..07877eae 100644 --- a/cli/Commands/UserCreateCommand.php +++ b/cli/Commands/UserCreateCommand.php @@ -8,7 +8,6 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\Question; class UserCreateCommand extends BaseCommand { @@ -48,12 +47,8 @@ protected function handle(InputInterface $input, OutputInterface $output): int // Secure password input: if not provided via --user_pass, prompt interactively. // This avoids leaking the password into shell history and ps output. if (empty($body['user_pass'])) { - $helper = $this->getHelper('question'); - $question = new Question('Password (hidden): '); - $question->setHidden(true); - $question->setHiddenFallback(false); - $password = $helper->ask($input, $output, $question); - if (!$password) { + $password = $this->promptHiddenSecret($input, $output, 'Password (hidden): '); + if ($password === null) { $output->writeln('Password is required'); return Command::FAILURE; } diff --git a/cli/Commands/UserDeleteCommand.php b/cli/Commands/UserDeleteCommand.php index e7a0b326..d8c4ef91 100644 --- a/cli/Commands/UserDeleteCommand.php +++ b/cli/Commands/UserDeleteCommand.php @@ -9,7 +9,6 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\ConfirmationQuestion; class UserDeleteCommand extends BaseCommand { @@ -28,16 +27,8 @@ protected function handle(InputInterface $input, OutputInterface $output): int { $id = $input->getArgument('id'); - if (!$input->getOption('force')) { - $helper = $this->getHelper('question'); - $question = new ConfirmationQuestion( - sprintf('Are you sure you want to delete user %s? [y/N] ', $id), - false - ); - if (!$helper->ask($input, $output, $question)) { - $output->writeln('Cancelled.'); - return Command::SUCCESS; - } + if (!$this->confirmDestructive($input, $output, sprintf('delete user %s', $id))) { + return Command::SUCCESS; } $this->client()->delete('users/' . $id); diff --git a/cli/Commands/UserPreferencesUpdateCommand.php b/cli/Commands/UserPreferencesUpdateCommand.php index f1d41c05..572f4ef6 100644 --- a/cli/Commands/UserPreferencesUpdateCommand.php +++ b/cli/Commands/UserPreferencesUpdateCommand.php @@ -29,13 +29,7 @@ protected function configure(): void protected function handle(InputInterface $input, OutputInterface $output): int { - $body = []; - foreach (['user_tracking_domain', 'user_account_currency', 'user_slack_incoming_webhook', 'user_daily_email', 'ipqs_api_key'] as $f) { - $val = $input->getOption($f); - if ($val !== null) { - $body[$f] = $val; - } - } + $body = $this->collectOptions($input, ['user_tracking_domain', 'user_account_currency', 'user_slack_incoming_webhook', 'user_daily_email', 'ipqs_api_key']); if (empty($body)) { $output->writeln('Provide at least one preference to update'); return Command::FAILURE; diff --git a/cli/Commands/UserRoleRemoveCommand.php b/cli/Commands/UserRoleRemoveCommand.php index 52490c40..8f4b19b8 100644 --- a/cli/Commands/UserRoleRemoveCommand.php +++ b/cli/Commands/UserRoleRemoveCommand.php @@ -9,7 +9,6 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\ConfirmationQuestion; class UserRoleRemoveCommand extends BaseCommand { @@ -30,16 +29,8 @@ protected function handle(InputInterface $input, OutputInterface $output): int $userId = $input->getArgument('user_id'); $roleId = $input->getArgument('role_id'); - if (!$input->getOption('force')) { - $helper = $this->getHelper('question'); - $question = new ConfirmationQuestion( - sprintf('Are you sure you want to remove role %s from user %s? [y/N] ', $roleId, $userId), - false - ); - if (!$helper->ask($input, $output, $question)) { - $output->writeln('Cancelled.'); - return Command::SUCCESS; - } + if (!$this->confirmDestructive($input, $output, sprintf('remove role %s from user %s', $roleId, $userId))) { + return Command::SUCCESS; } $this->client()->delete('users/' . $userId . '/roles/' . $roleId); diff --git a/cli/Commands/UserUpdateCommand.php b/cli/Commands/UserUpdateCommand.php index d179a7ad..9b02950b 100644 --- a/cli/Commands/UserUpdateCommand.php +++ b/cli/Commands/UserUpdateCommand.php @@ -9,7 +9,6 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\Question; class UserUpdateCommand extends BaseCommand { @@ -31,24 +30,14 @@ protected function configure(): void protected function handle(InputInterface $input, OutputInterface $output): int { - $body = []; - foreach (['user_fname', 'user_lname', 'user_email', 'user_timezone', 'user_active'] as $f) { - $val = $input->getOption($f); - if ($val !== null) { - $body[$f] = $val; - } - } + $body = $this->collectOptions($input, ['user_fname', 'user_lname', 'user_email', 'user_timezone', 'user_active']); // Handle password separately — prompt securely if --user_pass given without value $passVal = $input->getOption('user_pass'); if ($passVal === null && $input->hasParameterOption('--user_pass')) { - $helper = $this->getHelper('question'); - $question = new Question('New password (hidden): '); - $question->setHidden(true); - $question->setHiddenFallback(false); - $passVal = $helper->ask($input, $output, $question); + $passVal = $this->promptHiddenSecret($input, $output, 'New password (hidden): '); } - if ($passVal !== null && $passVal !== false && $passVal !== '') { + if (is_string($passVal) && $passVal !== '') { $body['user_pass'] = $passVal; } if (empty($body)) { diff --git a/cli/Config.php b/cli/Config.php index e674c76a..6df7037a 100644 --- a/cli/Config.php +++ b/cli/Config.php @@ -20,24 +20,60 @@ public function __construct() private function load(): void { - if (file_exists($this->configFile)) { - $json = file_get_contents($this->configFile); - $this->data = json_decode($json, true) ?: []; + if (!file_exists($this->configFile)) { + return; } + + $json = file_get_contents($this->configFile); + if ($json === false) { + throw new \RuntimeException("Unable to read config file: {$this->configFile}"); + } + if (trim($json) === '') { + return; + } + + $decoded = json_decode($json, true); + if (!is_array($decoded)) { + // A corrupt config must not be silently treated as empty — the + // next save would overwrite it and destroy the remaining keys + // (api_key, url) without the user ever knowing. + throw new \RuntimeException( + "Config file {$this->configFile} contains invalid JSON. " + . 'Fix or remove it, then re-run configuration.' + ); + } + $this->data = $decoded; } public function save(): void { - if (!is_dir($this->configDir)) { - mkdir($this->configDir, 0700, true); + if (!is_dir($this->configDir) && !mkdir($this->configDir, 0700, true) && !is_dir($this->configDir)) { + throw new \RuntimeException("Unable to create config directory: {$this->configDir}"); } + + $json = json_encode($this->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new \RuntimeException('Unable to encode config: ' . json_last_error_msg()); + } + + // Write to a temp file and rename so a killed process can never leave + // a truncated config.json behind. + $tmp = $this->configFile . '.tmp'; $oldUmask = umask(0077); - file_put_contents( - $this->configFile, - json_encode($this->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n" - ); - chmod($this->configFile, 0600); - umask($oldUmask); + try { + if (file_put_contents($tmp, $json . "\n") === false) { + throw new \RuntimeException("Unable to write config file: {$this->configFile}"); + } + chmod($tmp, 0600); + if (!rename($tmp, $this->configFile)) { + throw new \RuntimeException("Unable to finalize config file: {$this->configFile}"); + } + } finally { + if (file_exists($tmp)) { + @unlink($tmp); + } + umask($oldUmask); + } } public function get(string $key, mixed $default = null): mixed diff --git a/cli/Formatter.php b/cli/Formatter.php index 450209c7..9727e749 100644 --- a/cli/Formatter.php +++ b/cli/Formatter.php @@ -12,7 +12,11 @@ class Formatter public static function output(OutputInterface $output, array $data, bool $json = false): void { if ($json) { - $output->writeln(json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + $encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($encoded === false) { + throw new \RuntimeException('Failed to encode output as JSON: ' . json_last_error_msg()); + } + $output->writeln($encoded); return; } diff --git a/documentation/features/advanced-attribution-engine.md b/documentation/features/advanced-attribution-engine.md index 0eb944b8..63a6b8d0 100644 --- a/documentation/features/advanced-attribution-engine.md +++ b/documentation/features/advanced-attribution-engine.md @@ -65,7 +65,7 @@ This guide tracks the remaining work to deliver the Advanced Attribution Engine - **Accessing the dashboard:** Navigate to **Account ▸ Attribution** to open `202-account/attribution.php`. The page sets `data-api-base` to `/api/v2/attribution`, and `202-js/attribution.js` drives the UI against that **v2** surface: KPI cards and chart regions call `/api/v2/attribution/metrics`, and the model selector calls `/api/v2/attribution/models`. (The separately documented [v3 Attribution API](../api/13-attribution.md) exposes `/attribution/models` plus snapshot/export sub-resources for programmatic and CLI access; the dashboard does not call v3 directly.) - **Using the sandbox:** Select comparison models in the sandbox panel. The UI calls `/api/v2/attribution/sandbox`, surfacing placeholder insights until the computation engine backfills live metrics; promote-to-default actions send `PATCH /api/v2/attribution/models/{id}`. - **Scheduling exports:** Use the export drawer on the dashboard to request CSV/XLS snapshots. The UI calls `POST /api/v2/attribution/models/{id}/exports`, enqueueing jobs in `202_attribution_exports` and generating download tokens served through `202-account/attribution-export.php`. -- **Processing pipeline:** The cron worker `202-cronjobs/attribution-export.php` claims pending jobs, streams snapshot data through `SnapshotExporter`, and issues optional webhooks using `WebhookDispatcher`. Export files are processed using chunked encoding to minimize memory usage, with a 10MB size limit for webhook dispatch. Logs appear in cron output, and job status updates render in the dashboard export history list. +- **Processing pipeline:** The cron worker `202-cronjobs/attribution-export.php` claims pending jobs, writes the snapshot file, and posts the optional webhook itself: the URL is re-checked in full at delivery by `OutboundUrlGuard::assertAllowed()` (write boundaries only check shape, since DNS can change in between) and the connection is pinned to a validated address via `OutboundUrlGuard::curlOptions()`. Logs appear in cron output, and job status updates render in the dashboard export history list. ## How to Use This Checklist 1. Review each section before beginning implementation work for the sprint. diff --git a/go-cli/cmd/cmd_test.go b/go-cli/cmd/cmd_test.go index 2eb65ced..371f72ff 100644 --- a/go-cli/cmd/cmd_test.go +++ b/go-cli/cmd/cmd_test.go @@ -2569,21 +2569,32 @@ func TestTrackerCreateWithURL(t *testing.T) { } func TestTrackerBulkURLs(t *testing.T) { + // bulk-urls fans the per-tracker fetches over a worker pool, so this + // handler runs on several goroutines at once and its bookkeeping needs a + // lock. Without it `go test -race` fails here by scheduling luck rather + // than by anything the CLI did. + var mu sync.Mutex var listQuery url.Values urlCalls := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/api/v3/trackers": + mu.Lock() listQuery = r.URL.Query() + mu.Unlock() w.WriteHeader(200) w.Write([]byte(`{"data":[{"tracker_id":1,"aff_campaign_id":10},{"tracker_id":2,"aff_campaign_id":10}]}`)) case r.Method == "GET" && r.URL.Path == "/api/v3/trackers/1/url": + mu.Lock() urlCalls++ + mu.Unlock() w.WriteHeader(200) w.Write([]byte(`{"data":{"tracker_id":1,"direct_url":"https://trk.example/1"}}`)) case r.Method == "GET" && r.URL.Path == "/api/v3/trackers/2/url": + mu.Lock() urlCalls++ + mu.Unlock() w.WriteHeader(200) w.Write([]byte(`{"data":{"tracker_id":2,"direct_url":"https://trk.example/2"}}`)) default: @@ -2602,11 +2613,15 @@ func TestTrackerBulkURLs(t *testing.T) { t.Fatalf("tracker bulk-urls error: %v", err) } - if got := listQuery.Get("filter[aff_campaign_id]"); got != "10" { - t.Errorf("filter[aff_campaign_id] = %q, want %q", got, "10") + mu.Lock() + gotFilter := listQuery.Get("filter[aff_campaign_id]") + gotCalls := urlCalls + mu.Unlock() + if gotFilter != "10" { + t.Errorf("filter[aff_campaign_id] = %q, want %q", gotFilter, "10") } - if urlCalls != 2 { - t.Errorf("urlCalls = %d, want 2", urlCalls) + if gotCalls != 2 { + t.Errorf("urlCalls = %d, want 2", gotCalls) } if !strings.Contains(stdout, "https://trk.example/1") || !strings.Contains(stdout, "https://trk.example/2") { t.Errorf("output should contain both tracker URLs, got:\n%s", stdout) diff --git a/go-cli/cmd/config.go b/go-cli/cmd/config.go index 350eb593..65e7d3bf 100644 --- a/go-cli/cmd/config.go +++ b/go-cli/cmd/config.go @@ -1,9 +1,13 @@ package cmd import ( + "bufio" "encoding/json" + "errors" "fmt" + "io" "net/url" + "os" "strings" "p202/internal/api" @@ -11,6 +15,7 @@ import ( "p202/internal/output" "github.com/spf13/cobra" + "golang.org/x/term" ) var configCmd = &cobra.Command{ @@ -45,15 +50,41 @@ var configSetURLCmd = &cobra.Command{ } var configSetKeyCmd = &cobra.Command{ - Use: "set-key ", - Short: "Set the API key", - Args: cobra.ExactArgs(1), + Use: "set-key [api-key]", + Short: "Set the API key (omit the argument to be prompted without echoing)", + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return err } - apiKey := strings.TrimSpace(args[0]) + var apiKey string + if len(args) == 1 { + apiKey = strings.TrimSpace(args[0]) + } else { + // An API key is a bearer credential — at least as sensitive as the + // password that `user create` already reads with term.ReadPassword. + // Prompting keeps it out of shell history and ps output. + // + // term.ReadPassword needs a real terminal, so when stdin is piped + // (echo "$KEY" | p202 config set-key, or CI) fall back to a plain + // read instead of failing with "inappropriate ioctl for device". + if term.IsTerminal(int(os.Stdin.Fd())) { + fmt.Fprint(os.Stderr, "API key (hidden): ") + keyBytes, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err != nil { + return fmt.Errorf("reading API key: %w", err) + } + apiKey = strings.TrimSpace(string(keyBytes)) + } else { + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("reading API key: %w", err) + } + apiKey = strings.TrimSpace(line) + } + } if err := validateAPIKey(apiKey); err != nil { return err } @@ -122,7 +153,7 @@ var configTestCmd = &cobra.Command{ } data, err := c.Get("system/health", nil) if err != nil { - return withHint(fmt.Errorf("connection failed: %w", err), "Check `p202 config get` (URL and key), that the instance is reachable, and that the key is valid in the Prosper202 UI under API keys.") + return withHint(fmt.Errorf("connection failed: %w", err), "Check `p202 config show` (URL and key), that the instance is reachable, and that the key is valid in the Prosper202 UI under API keys.") } if !jsonOutput { fmt.Println("Connection successful!") diff --git a/go-cli/cmd/conversion.go b/go-cli/cmd/conversion.go index dfbbd6de..3d700e3a 100644 --- a/go-cli/cmd/conversion.go +++ b/go-cli/cmd/conversion.go @@ -3,12 +3,9 @@ package cmd import ( "encoding/json" "fmt" - "os" "strconv" - "strings" "p202/internal/api" - "p202/internal/output" "github.com/spf13/cobra" ) @@ -102,11 +99,11 @@ var conversionCreateCmd = &cobra.Command{ clickIDStr, _ = cmd.Flags().GetString("click_id_public") } if clickIDStr == "" { - return validationError("required flag --click_id (or --click_id_public) is missing") + return fmt.Errorf("required flag --click_id (or --click_id_public) is missing") } clickID, err := strconv.Atoi(clickIDStr) if err != nil { - return validationError("--click_id must be an integer: %s", clickIDStr).WithHint("Use the internal click id from `p202 click list`, or pass the public id via --click_id_public.") + return fmt.Errorf("--click_id must be an integer: %s", clickIDStr) } body := map[string]interface{}{ "click_id": clickID, @@ -132,76 +129,13 @@ var conversionCreateCmd = &cobra.Command{ var conversionDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete a conversion", - Args: func(cmd *cobra.Command, args []string) error { - idsFlag, _ := cmd.Flags().GetString("ids") - if strings.TrimSpace(idsFlag) != "" { - return cobra.MaximumNArgs(0)(cmd, args) - } - return cobra.ExactArgs(1)(cmd, args) - }, + Args: deleteArgsValidator, RunE: func(cmd *cobra.Command, args []string) error { - c, err := api.NewFromConfig() - if err != nil { - return err - } - dryRun, _ := cmd.Flags().GetBool("dry-run") - idsFlag, _ := cmd.Flags().GetString("ids") - if strings.TrimSpace(idsFlag) != "" { - idList, parseErr := parseIDList(idsFlag) - if parseErr != nil { - return parseErr - } - if len(idList) == 0 { - return validationError("--ids requires at least one ID").WithHint("Comma-separate internal ids, e.g. --ids 12,13,14 (find them with the matching `... list`).") - } - - if dryRun { - return renderDeletePreviews(c, "conversions", idList) - } - if api.StagedMode() { - return stageDeletes(c, "conversions", idList) - } - - force, _ := cmd.Flags().GetBool("force") - if !force && !confirmPrompt("Delete %d conversions?", len(idList)) { - fmt.Println("Cancelled.") - return nil - } - - deleted := 0 - failed := 0 - for _, id := range idList { - if err := c.Delete("conversions/" + id); err != nil { - failed++ - fmt.Fprintf(os.Stderr, "Failed to delete conversion %s: %v\n", id, err) - continue - } - deleted++ - } - output.Success("Deleted %d of %d conversions.", deleted, len(idList)) - if failed > 0 { - return partialFailureError("failed to delete %d conversions", failed) - } - return nil - } - - if dryRun { - return renderDeletePreviews(c, "conversions", []string{args[0]}) - } - if api.StagedMode() { - return stageDeletes(c, "conversions", []string{args[0]}) - } - - force, _ := cmd.Flags().GetBool("force") - if !force && !confirmPrompt("Delete conversion %s?", args[0]) { - fmt.Println("Cancelled.") - return nil - } - if err := c.Delete("conversions/" + args[0]); err != nil { - return err - } - output.Success("Conversion %s deleted.", args[0]) - return nil + return runBulkOrSingleDelete(cmd, args, deleteSpec{ + endpoint: "conversions", + noun: "conversion", + plural: "conversions", + }) }, } diff --git a/go-cli/cmd/crosstab.go b/go-cli/cmd/crosstab.go index a5d50c88..170f93a1 100644 --- a/go-cli/cmd/crosstab.go +++ b/go-cli/cmd/crosstab.go @@ -89,7 +89,10 @@ var reportCrosstabCmd = &cobra.Command{ if len(opts.Fields) == 0 { opts.Fields = append([]string{rowDim}, cols...) } - out, _ := json.Marshal(map[string]interface{}{"data": matrix}) + out, err := json.Marshal(map[string]interface{}{"data": matrix}) + if err != nil { + return fmt.Errorf("encoding crosstab matrix: %w", err) + } output.RenderWith(out, opts) return nil }, diff --git a/go-cli/cmd/crud.go b/go-cli/cmd/crud.go index 095ff0ad..3098cdd5 100644 --- a/go-cli/cmd/crud.go +++ b/go-cli/cmd/crud.go @@ -95,12 +95,18 @@ func isNotFoundErr(err error) bool { return false } -// deleteArgsValidator allows zero positional args when --ids is set, else one. -func deleteArgsValidator(cmd *cobra.Command, args []string) error { - if ids, _ := cmd.Flags().GetString("ids"); strings.TrimSpace(ids) != "" { - return cobra.MaximumNArgs(0)(cmd, args) +// deleteArgsValidatorN returns a cobra Args validator for delete commands whose +// deletable id is preceded by `base` fixed positional args (0 for flat +// resources, 1 for nested ones like rotator rules): with --ids set the id list +// replaces the positional id, so exactly `base` args are allowed; otherwise +// base+1. +func deleteArgsValidatorN(base int) cobra.PositionalArgs { + return func(cmd *cobra.Command, args []string) error { + if ids, _ := cmd.Flags().GetString("ids"); strings.TrimSpace(ids) != "" { + return cobra.ExactArgs(base)(cmd, args) + } + return cobra.ExactArgs(base+1)(cmd, args) } - return cobra.ExactArgs(1)(cmd, args) } // deleteDryRunFlagDesc is the shared help text for --dry-run on every delete @@ -232,7 +238,50 @@ func withDryRunHint(err error) error { // bulkOrSingleDelete deletes one id (positional) or many (--ids), honoring // --force, against endpoint/. Shared so every delete has the same bulk // semantics. noun is used in confirmation and summary messages. + +// deleteArgsValidator allows zero positional args when --ids is set, else one. +var deleteArgsValidator = deleteArgsValidatorN(0) + +// deleteSpec describes what varies between the CLI's delete commands: the URL +// for one id, and the wording. Everything else — id validation, the --ids bulk +// path, confirmation, partial-failure accounting, which stream each message +// goes to — is shared in runBulkOrSingleDelete. These used to be five +// hand-rolled copies, and the copies are exactly where the mechanics drifted +// (prompts on stdout, unvalidated ids); the wording is the only part that was +// ever meant to differ. +type deleteSpec struct { + endpoint string // collection path; one record is endpoint + "/" + id + noun string // singular, e.g. "rotator" + plural string // bulk prompts and summaries, e.g. "rotators" + cascadeOne string // single-confirm suffix, e.g. " and all its rules" + cascadeMany string // bulk-confirm suffix, e.g. " and all their rules" + context string // parent-resource suffix, e.g. " from rotator 7" + // idsHintText overrides the --ids recovery hint for commands whose ids are + // not discoverable via a plain ` list` (rotator rules, for example). + idsHintText string +} + +// idsHint returns the recovery hint shown when --ids resolves to nothing. +func (s deleteSpec) idsHint() string { + if s.idsHintText != "" { + return s.idsHintText + } + return "Comma-separate internal ids, e.g. --ids 12,13,14 (find them with the matching `... list`)." +} + +// bulkOrSingleDelete is the flat-resource convenience wrapper around +// runBulkOrSingleDelete for callers with no special wording. func bulkOrSingleDelete(cmd *cobra.Command, endpoint, noun string) error { + return runBulkOrSingleDelete(cmd, cmd.Flags().Args(), deleteSpec{ + endpoint: endpoint, + noun: noun, + plural: noun + "s", + }) +} + +// runBulkOrSingleDelete deletes one id (from args) or many (--ids), honoring +// --force. Prompts and cancellations go to stderr so piped stdout stays data. +func runBulkOrSingleDelete(cmd *cobra.Command, args []string, spec deleteSpec) error { c, err := api.NewFromConfig() if err != nil { return err @@ -240,7 +289,6 @@ func bulkOrSingleDelete(cmd *cobra.Command, endpoint, noun string) error { force, _ := cmd.Flags().GetBool("force") dryRun, _ := cmd.Flags().GetBool("dry-run") idsFlag, _ := cmd.Flags().GetString("ids") - args := cmd.Flags().Args() if strings.TrimSpace(idsFlag) != "" { ids, perr := parseIDList(idsFlag) @@ -248,30 +296,30 @@ func bulkOrSingleDelete(cmd *cobra.Command, endpoint, noun string) error { return perr } if len(ids) == 0 { - return validationError("--ids requires at least one ID").WithHint("Comma-separate internal ids, e.g. --ids 12,13,14 (find them with the matching `... list`).") + return validationError("--ids requires at least one ID").WithHint(spec.idsHint()) } if dryRun { - return renderDeletePreviews(c, endpoint, ids) + return renderDeletePreviews(c, spec.endpoint, ids) } if api.StagedMode() { - return stageDeletes(c, endpoint, ids) + return stageDeletes(c, spec.endpoint, ids) } - if !force && !confirmPrompt("Delete %d %ss?", len(ids), noun) { + if !force && !confirmPrompt("Delete %d %s%s%s?", len(ids), spec.plural, spec.cascadeMany, spec.context) { fmt.Fprintln(os.Stderr, "Cancelled.") return nil } deleted, failed := 0, 0 for _, id := range ids { - if err := c.Delete(endpoint + "/" + id); err != nil { + if err := c.Delete(spec.endpoint + "/" + id); err != nil { failed++ - fmt.Fprintf(os.Stderr, "Failed to delete %s %s: %v\n", noun, id, err) + fmt.Fprintf(os.Stderr, "Failed to delete %s %s%s: %v\n", spec.noun, id, spec.context, err) continue } deleted++ } - output.Success("Deleted %d of %d %ss.", deleted, len(ids), noun) + output.Success("Deleted %d of %d %s%s.", deleted, len(ids), spec.plural, spec.context) if failed > 0 { - return partialFailureError("failed to delete %d %ss", failed, noun) + return partialFailureError("failed to delete %d %s", failed, spec.plural) } return nil } @@ -279,20 +327,27 @@ func bulkOrSingleDelete(cmd *cobra.Command, endpoint, noun string) error { if len(args) != 1 { return validationError("provide a single id or use --ids").WithHint("Pass one id as the argument, or several with --ids 12,13,14.") } + // Validate before previewing or staging: a preview is still a DELETE + // request, so a blank or non-numeric id must not reach the server on any + // of these paths. + id, err := validateID(args[0]) + if err != nil { + return err + } if dryRun { - return renderDeletePreviews(c, endpoint, []string{args[0]}) + return renderDeletePreviews(c, spec.endpoint, []string{id}) } if api.StagedMode() { - return stageDeletes(c, endpoint, []string{args[0]}) + return stageDeletes(c, spec.endpoint, []string{id}) } - if !force && !confirmPrompt("Delete %s %s?", noun, args[0]) { + if !force && !confirmPrompt("Delete %s %s%s%s?", spec.noun, id, spec.cascadeOne, spec.context) { fmt.Fprintln(os.Stderr, "Cancelled.") return nil } - if err := c.Delete(endpoint + "/" + args[0]); err != nil { + if err := c.Delete(spec.endpoint + "/" + id); err != nil { return err } - output.Success("%s %s deleted.", capitalize(noun), args[0]) + output.Success("%s %s deleted%s.", capitalize(spec.noun), id, spec.context) return nil } @@ -446,6 +501,31 @@ func cloneMutableFields(source map[string]interface{}, fields []crudField) map[s return out } +// requireID rejects a blank positional id. Interpolating one produced a request +// against the collection endpoint itself (DELETE users/) rather than against a +// record — a very different operation from the one the user asked for. +func requireID(raw string) (string, error) { + id := strings.TrimSpace(raw) + if id == "" { + return "", validationError("an ID is required") + } + return id, nil +} + +// validateID additionally enforces, for a single positional id, the same numeric +// rule parseIDList applies to every id in --ids. Used by the mutating commands; +// `get` uses requireID instead because it also accepts public ids. +func validateID(raw string) (string, error) { + id, err := requireID(raw) + if err != nil { + return "", err + } + if _, err := strconv.Atoi(id); err != nil { + return "", validationError("invalid ID %q: must be a numeric value", id) + } + return id, nil +} + func parseIDList(raw string) ([]string, error) { parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) @@ -656,8 +736,12 @@ func registerCRUD(entity crudEntity) *cobra.Command { if err != nil { return err } + id, err := requireID(args[0]) + if err != nil { + return err + } forcePublic, _ := cmd.Flags().GetBool("public") - data, err := getWithPublicFallback(c, entity, args[0], forcePublic) + data, err := getWithPublicFallback(c, entity, id, forcePublic) if err != nil { return err } @@ -726,7 +810,11 @@ func registerCRUD(entity crudEntity) *cobra.Command { if len(body) == 0 { return validationError("no fields specified; pass at least one flag to update") } - data, err := c.Put(entity.Endpoint+"/"+args[0], body) + id, err := validateID(args[0]) + if err != nil { + return err + } + data, err := c.Put(entity.Endpoint+"/"+id, body) if err != nil { return err } @@ -742,78 +830,15 @@ func registerCRUD(entity crudEntity) *cobra.Command { deleteCmd := &cobra.Command{ Use: "delete ", Short: fmt.Sprintf("Delete a %s", entity.Name), - Args: func(cmd *cobra.Command, args []string) error { - idsFlag, _ := cmd.Flags().GetString("ids") - if strings.TrimSpace(idsFlag) != "" { - return cobra.MaximumNArgs(0)(cmd, args) - } - return cobra.ExactArgs(1)(cmd, args) - }, + Args: deleteArgsValidator, RunE: func(cmd *cobra.Command, args []string) (retErr error) { done := metrics.Timer("delete", entity.Endpoint) defer func() { done(retErr == nil, errString(retErr)) }() - c, err := api.NewFromConfig() - if err != nil { - return err - } - dryRun, _ := cmd.Flags().GetBool("dry-run") - idsFlag, _ := cmd.Flags().GetString("ids") - if strings.TrimSpace(idsFlag) != "" { - idList, parseErr := parseIDList(idsFlag) - if parseErr != nil { - return parseErr - } - if len(idList) == 0 { - return validationError("--ids requires at least one ID").WithHint("Comma-separate internal ids, e.g. --ids 12,13,14 (find them with the matching `... list`).") - } - - if dryRun { - return renderDeletePreviews(c, entity.Endpoint, idList) - } - if api.StagedMode() { - return stageDeletes(c, entity.Endpoint, idList) - } - - force, _ := cmd.Flags().GetBool("force") - if !force && !confirmPrompt("Delete %d %s?", len(idList), entity.Plural) { - fmt.Println("Cancelled.") - return nil - } - - deleted := 0 - failed := 0 - for _, id := range idList { - if err := c.Delete(entity.Endpoint + "/" + id); err != nil { - failed++ - fmt.Fprintf(os.Stderr, "Failed to delete %s %s: %v\n", entity.Name, id, err) - continue - } - deleted++ - } - output.Success("Deleted %d of %d %s.", deleted, len(idList), entity.Plural) - if failed > 0 { - return partialFailureError("failed to delete %d %s", failed, entity.Plural) - } - return nil - } - - if dryRun { - return renderDeletePreviews(c, entity.Endpoint, []string{args[0]}) - } - if api.StagedMode() { - return stageDeletes(c, entity.Endpoint, []string{args[0]}) - } - - force, _ := cmd.Flags().GetBool("force") - if !force && !confirmPrompt("Delete %s %s?", entity.Name, args[0]) { - fmt.Println("Cancelled.") - return nil - } - if err := c.Delete(entity.Endpoint + "/" + args[0]); err != nil { - return err - } - output.Success("%s %s deleted.", capitalize(entity.Name), args[0]) - return nil + return runBulkOrSingleDelete(cmd, args, deleteSpec{ + endpoint: entity.Endpoint, + noun: entity.Name, + plural: entity.Plural, + }) }, } registerDeleteFlags(deleteCmd, entity.Name) @@ -1228,16 +1253,37 @@ func init() { close(results) }() - ordered := make([]map[string]interface{}, len(trackers)) + // Report per-tracker failures and keep the rows that succeeded, + // matching how the bulk deletes account for partial failure. + // Returning on the first error discarded every row already + // fetched, so one transient 500 threw away the whole listing. + indexed := make([]map[string]interface{}, len(trackers)) + failed := 0 for result := range results { if result.err != nil { - return result.err + failed++ + fmt.Fprintf(os.Stderr, "Failed to fetch URL for tracker at row %d: %v\n", result.index+1, result.err) + continue + } + indexed[result.index] = result.row + } + + // Drop the gaps left by failed rows rather than emitting nulls. + ordered := make([]map[string]interface{}, 0, len(trackers)-failed) + for _, row := range indexed { + if row != nil { + ordered = append(ordered, row) } - ordered[result.index] = result.row } - encoded, _ := json.Marshal(map[string]interface{}{"data": ordered}) + encoded, err := json.Marshal(map[string]interface{}{"data": ordered}) + if err != nil { + return fmt.Errorf("encoding tracker URLs: %w", err) + } render(encoded) + if failed > 0 { + return partialFailureError("failed to fetch %d of %d tracker URLs", failed, len(trackers)) + } return nil }, } diff --git a/go-cli/cmd/destructive_args_test.go b/go-cli/cmd/destructive_args_test.go new file mode 100644 index 00000000..cbc5023f --- /dev/null +++ b/go-cli/cmd/destructive_args_test.go @@ -0,0 +1,198 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +// recordingServer captures every request the CLI actually sends, so a test can +// assert that a rejected command sent nothing at all. +type recordingServer struct { + *httptest.Server + mu sync.Mutex + requests []string +} + +func newRecordingServer(t *testing.T) *recordingServer { + t.Helper() + rs := &recordingServer{} + rs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rs.mu.Lock() + rs.requests = append(rs.requests, r.Method+" "+r.URL.Path) + rs.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{}}`)) + })) + t.Cleanup(rs.Close) + return rs +} + +func (rs *recordingServer) seen() []string { + rs.mu.Lock() + defer rs.mu.Unlock() + return append([]string(nil), rs.requests...) +} + +// A blank positional id used to be interpolated straight into the request path, +// producing a request against the collection endpoint (DELETE campaigns/) +// instead of against a record. Every id-taking mutation must reject it before +// any request leaves the process. +func TestBlankOrNonNumericIDsAreRejectedBeforeAnyRequest(t *testing.T) { + cases := []struct { + name string + args []string + }{ + {"campaign delete blank", []string{"campaign", "delete", "", "--force"}}, + {"campaign delete whitespace", []string{"campaign", "delete", " ", "--force"}}, + {"campaign delete non-numeric", []string{"campaign", "delete", "../users", "--force"}}, + {"campaign update blank", []string{"campaign", "update", "", "--aff_campaign_name", "x"}}, + {"rotator delete blank", []string{"rotator", "delete", "", "--force"}}, + {"conversion delete blank", []string{"conversion", "delete", "", "--force"}}, + {"user delete blank", []string{"user", "delete", "", "--force"}}, + {"rotator rule-delete blank rotator", []string{"rotator", "rule-delete", "", "5", "--force"}}, + {"rotator rule-delete blank rule", []string{"rotator", "rule-delete", "5", "", "--force"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + srv := newRecordingServer(t) + writeTestConfig(t, home, srv.URL, "test-api-key-1234") + + _, _, err := executeCommand(tc.args...) + if err == nil { + t.Fatalf("expected a validation error, got nil (requests: %v)", srv.seen()) + } + // Assert the rejection is specifically about the id. Without this a + // mistyped command name would satisfy the test for the wrong reason. + if msg := err.Error(); !strings.Contains(msg, "ID") { + t.Fatalf("error should name the invalid ID, got %q", msg) + } + for _, req := range srv.seen() { + if strings.HasPrefix(req, "DELETE") || strings.HasPrefix(req, "PUT") { + t.Fatalf("a mutating request was sent despite the invalid id: %s", req) + } + } + }) + } +} + +// Cancelling a delete must not print to stdout: these commands are scripted, and +// "Cancelled." landing in a piped stdout corrupts the caller's data stream. With +// no terminal attached the confirmation read fails, which is the cancel path. +func TestCancelledDeletesKeepStdoutClean(t *testing.T) { + cases := []struct { + name string + args []string + }{ + {"campaign delete", []string{"campaign", "delete", "7"}}, + {"rotator delete", []string{"rotator", "delete", "7"}}, + {"conversion delete", []string{"conversion", "delete", "7"}}, + {"user delete", []string{"user", "delete", "7"}}, + {"rotator rule-delete", []string{"rotator", "rule-delete", "7", "9"}}, + {"campaign bulk delete", []string{"campaign", "delete", "--ids", "7,8"}}, + {"rotator bulk delete", []string{"rotator", "delete", "--ids", "7,8"}}, + {"conversion bulk delete", []string{"conversion", "delete", "--ids", "7,8"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + srv := newRecordingServer(t) + writeTestConfig(t, home, srv.URL, "test-api-key-1234") + + stdout, stderr, err := executeCommand(tc.args...) + if err != nil { + t.Fatalf("cancelling should not be an error: %v", err) + } + if strings.Contains(stdout, "Cancelled") { + t.Fatalf("cancellation notice went to stdout: %q", stdout) + } + if !strings.Contains(stderr, "Cancelled") { + t.Fatalf("cancellation notice missing from stderr: %q", stderr) + } + for _, req := range srv.seen() { + if strings.HasPrefix(req, "DELETE") { + t.Fatalf("a cancelled delete still sent %s", req) + } + } + }) + } +} + +// The confirmation question itself must also stay off stdout. +func TestConfirmationPromptDoesNotWriteToStdout(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + srv := newRecordingServer(t) + writeTestConfig(t, home, srv.URL, "test-api-key-1234") + + stdout, stderr, err := executeCommand("rotator", "rule-delete", "7", "9") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(stdout, "[y/N]") { + t.Fatalf("prompt was written to stdout: %q", stdout) + } + if !strings.Contains(stderr, "[y/N]") { + t.Fatalf("prompt missing from stderr: %q", stderr) + } +} + +// The delete commands now share one runner and differ only in a wording spec. +// Pin the user-visible strings so a spec edit can't silently change the UX the +// old hand-rolled copies had. +func TestDeleteWordingIsPreserved(t *testing.T) { + cases := []struct { + name string + args []string + wantStderr string + }{ + {"rotator single confirm keeps cascade warning", + []string{"rotator", "delete", "7"}, + "Delete rotator 7 and all its rules?"}, + {"rotator bulk confirm keeps cascade warning", + []string{"rotator", "delete", "--ids", "7,8"}, + "Delete 2 rotators and all their rules?"}, + {"rule single confirm names the parent rotator", + []string{"rotator", "rule-delete", "7", "9"}, + "Delete rule 9 from rotator 7?"}, + {"rule bulk confirm names the parent rotator", + []string{"rotator", "rule-delete", "7", "--ids", "9,11"}, + "Delete 2 rules from rotator 7?"}, + {"conversion single success", + []string{"conversion", "delete", "7", "--force"}, + "Conversion 7 deleted."}, + {"rule single success names the parent rotator", + []string{"rotator", "rule-delete", "7", "9", "--force"}, + "Rule 9 deleted from rotator 7."}, + {"rule bulk summary names the parent rotator", + []string{"rotator", "rule-delete", "7", "--ids", "9,11", "--force"}, + "Deleted 2 of 2 rules from rotator 7."}, + {"campaign bulk summary uses the entity plural", + []string{"campaign", "delete", "--ids", "7,8", "--force"}, + "Deleted 2 of 2 campaigns"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + srv := newRecordingServer(t) + writeTestConfig(t, home, srv.URL, "test-api-key-1234") + + _, stderr, err := executeCommand(tc.args...) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stderr, tc.wantStderr) { + t.Fatalf("stderr = %q, want it to contain %q", stderr, tc.wantStderr) + } + }) + } +} diff --git a/go-cli/cmd/diff.go b/go-cli/cmd/diff.go index 08e08537..540aa4bc 100644 --- a/go-cli/cmd/diff.go +++ b/go-cli/cmd/diff.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "os" "sort" "strconv" "strings" @@ -561,9 +562,22 @@ func scalarString(v interface{}) string { } } +// comparableEqual reports whether two records are identical. Both operands are +// marshalled and compared byte-wise, which is stable because encoding/json sorts +// map keys. +// +// An encoding failure must report "not equal", never "equal". Both failures +// yielded nil, and bytes.Equal(nil, nil) is true — so a record pair that could +// not be encoded was declared unchanged, and sync would silently skip a real +// difference. Erring toward "changed" makes the failure visible as a redundant +// update instead of missing data. func comparableEqual(a, b map[string]interface{}) bool { - aBytes, _ := json.Marshal(a) - bBytes, _ := json.Marshal(b) + aBytes, aErr := json.Marshal(a) + bBytes, bErr := json.Marshal(b) + if aErr != nil || bErr != nil { + fmt.Fprintf(os.Stderr, "Warning: could not encode records for comparison (%v / %v); treating them as changed.\n", aErr, bErr) + return false + } return bytes.Equal(aBytes, bBytes) } @@ -590,9 +604,11 @@ func changedFields(a, b map[string]interface{}) []string { out = append(out, key) continue } - aBytes, _ := json.Marshal(aRaw) - bBytes, _ := json.Marshal(bRaw) - if !bytes.Equal(aBytes, bBytes) { + aBytes, aErr := json.Marshal(aRaw) + bBytes, bErr := json.Marshal(bRaw) + // As in comparableEqual: an encoding failure must not be reported as an + // unchanged field, which would hide the difference from the sync. + if aErr != nil || bErr != nil || !bytes.Equal(aBytes, bBytes) { out = append(out, key) } } diff --git a/go-cli/cmd/forecast.go b/go-cli/cmd/forecast.go index 89074924..0f9a46fc 100644 --- a/go-cli/cmd/forecast.go +++ b/go-cli/cmd/forecast.go @@ -325,7 +325,7 @@ func runForecast(cmd *cobra.Command, args []string) error { series := parsed[metric] if len(series) == 0 { - available := parsedMetricNames(parsed) + available := responseMetricNames(data) if len(available) == 0 { return validationError("no valid data points found for metric %q", metric). WithHint("No bucket in the response carried a numeric %q value. Check `p202 report timeseries --period %s` with the same filters to see which metrics the API returns for this window.", metric, history) @@ -659,15 +659,41 @@ func parseTimeseriesMulti(data []byte, metrics []string) (map[string]forecast.Se return out, rejected, nil } -// parsedMetricNames lists the metrics a parsed response carried values for, -// sorted, for error hints. -func parsedMetricNames(parsed map[string]forecast.Series) []string { - names := make([]string, 0, len(parsed)) - for m, s := range parsed { - if len(s) > 0 { - names = append(names, m) +// responseMetricNames lists every forecastable metric the RAW response carried a +// numeric value for, sorted. +// +// The recovery hint must not use parsedMetricNames: parseTimeseriesMulti only +// populates the metrics it was asked for (the coherent inputs plus the requested +// one), so naming those listed a subset of what would actually work and hid the +// rest of the valid choices from anyone following the hint. +func responseMetricNames(data []byte) []string { + var parsed map[string]interface{} + if json.Unmarshal(data, &parsed) != nil { + return nil + } + rawItems, ok := parsed["data"].([]interface{}) + if !ok { + return nil + } + seen := map[string]bool{} + for _, raw := range rawItems { + obj, ok := raw.(map[string]interface{}) + if !ok { + continue + } + for m := range forecastAllowedMetrics { + if seen[m] { + continue + } + if _, ok := extractMetricValue(obj, m); ok { + seen[m] = true + } } } + names := make([]string, 0, len(seen)) + for m := range seen { + names = append(names, m) + } sort.Strings(names) return names } diff --git a/go-cli/cmd/gate_accuracy_test.go b/go-cli/cmd/gate_accuracy_test.go new file mode 100644 index 00000000..620c71b2 --- /dev/null +++ b/go-cli/cmd/gate_accuracy_test.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// rotator check gates deploys, so a rotator whose detail fetch fails must be +// reported and counted as a failure — not silently skipped with exit code 0. +func TestRotatorCheckCountsUnfetchableRotatorsAsFailures(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case strings.HasSuffix(r.URL.Path, "/rotators/1"): + _, _ = w.Write([]byte(`{"data":{"id":1,"name":"healthy","default_url":"https://example.com/lp","rules":[]}}`)) + case strings.HasSuffix(r.URL.Path, "/rotators/2"): + w.WriteHeader(500) + _, _ = w.Write([]byte(`{"message":"boom"}`)) + case strings.HasSuffix(r.URL.Path, "/rotators"): + _, _ = w.Write([]byte(`{"data":[{"id":1,"name":"healthy"},{"id":2,"name":"broken"}]}`)) + default: + _, _ = w.Write([]byte(`{"data":{}}`)) + } + })) + defer srv.Close() + writeTestConfig(t, home, srv.URL, "test-api-key-1234") + + stdout, _, err := executeCommand("rotator", "check", "--json") + if err == nil { + t.Fatal("expected a failure exit when a rotator could not be fetched") + } + if !strings.Contains(err.Error(), "1 rotator(s) have configuration issues") { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout, "could not fetch rotator") { + t.Fatalf("the unfetchable rotator must appear in the report, got %q", stdout) + } + if !strings.Contains(stdout, "broken") { + t.Fatalf("the failing rotator should be named, got %q", stdout) + } +} diff --git a/go-cli/cmd/hint_command_validity_test.go b/go-cli/cmd/hint_command_validity_test.go new file mode 100644 index 00000000..8c18a5db --- /dev/null +++ b/go-cli/cmd/hint_command_validity_test.go @@ -0,0 +1,84 @@ +package cmd + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// backtickedP202Command matches a `p202 ...` invocation inside a backtick-quoted +// span in source text, which is how every recovery hint names a command. +var backtickedP202Command = regexp.MustCompile("`(p202 [^`]+)`") + +// A hint that names a command which does not exist is worse than no hint: the +// shipped example was `p202 config get`, which cobra answers by printing the +// `config` help and exiting 0, so a scripted agent following the recovery step +// gets no configuration and reads the diagnostic as having succeeded. +// +// This walks the real command tree rather than a list, so a renamed or removed +// subcommand fails here instead of rotting in a hint string. Structural, per +// CLAUDE.md's "closing the loop" rule: the fix for one bad hint should catch +// the next one too. +func TestEveryCommandNamedInAHintExists(t *testing.T) { + roots := []string{".", "../internal/api"} + checked := 0 + + for _, root := range roots { + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") { + return err + } + if strings.HasSuffix(path, "_test.go") { + return nil + } + src, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + for _, m := range backtickedP202Command.FindAllStringSubmatch(string(src), -1) { + invocation := m[1] + words := strings.Fields(invocation)[1:] // drop "p202" + // Keep only the leading subcommand path; stop at the first flag + // or placeholder, which are arguments rather than command names. + var pathWords []string + for _, w := range words { + if strings.HasPrefix(w, "-") || strings.HasPrefix(w, "<") || strings.HasPrefix(w, "[") { + break + } + pathWords = append(pathWords, w) + } + if len(pathWords) == 0 { + continue + } + checked++ + cmd, _, findErr := rootCmd.Find(pathWords) + if findErr != nil || cmd == nil { + t.Errorf("%s: hint names `%s`, but %q is not a command", path, invocation, strings.Join(pathWords, " ")) + continue + } + // Find falls back to the nearest parent, so a shorter resolution + // means the trailing words were not subcommands. They are + // acceptable only as arguments, which requires the resolved + // command to actually run: a pure command group (config, user, + // rotator) takes no arguments, so `p202 config get` resolves to + // `p202 config`, prints help and exits 0 -- the failure this + // test exists to catch. + if got := strings.Fields(cmd.CommandPath())[1:]; len(got) != len(pathWords) && !cmd.Runnable() { + t.Errorf("%s: hint names `%s`, but %q is a command group, so %q is not a subcommand of it", + path, invocation, cmd.CommandPath(), pathWords[len(got)]) + } + } + return nil + }) + if err != nil { + t.Fatalf("walking %s: %v", root, err) + } + } + + if checked == 0 { + t.Fatal("found no `p202 ...` hints to check - the matcher is wrong, not the tree") + } + t.Logf("checked %d commands named in hints", checked) +} diff --git a/go-cli/cmd/merge_delete_surface_test.go b/go-cli/cmd/merge_delete_surface_test.go new file mode 100644 index 00000000..554c4a08 --- /dev/null +++ b/go-cli/cmd/merge_delete_surface_test.go @@ -0,0 +1,120 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "p202/internal/api" +) + +// featureServer advertises the capabilities the safety flags require and +// records every request path+query it receives. +type featureServer struct { + *httptest.Server + mu sync.Mutex + reqs []string +} + +func newFeatureServer(t *testing.T) *featureServer { + t.Helper() + fs := &featureServer{} + fs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if strings.HasSuffix(r.URL.Path, "/capabilities") { + _, _ = w.Write([]byte(`{"data":{"features":{"delete_dry_run":true,"staged_writes":true}}}`)) + return + } + fs.mu.Lock() + q := r.URL.RawQuery + entry := r.Method + " " + r.URL.Path + if q != "" { + entry += "?" + q + } + fs.reqs = append(fs.reqs, entry) + fs.mu.Unlock() + _, _ = w.Write([]byte(`{"data":{"change_id":"chg_aabbccddeeff001122334455","would_delete":1}}`)) + })) + t.Cleanup(fs.Close) + return fs +} + +func (fs *featureServer) seen() []string { + fs.mu.Lock() + defer fs.mu.Unlock() + return append([]string(nil), fs.reqs...) +} + +// The five hand-rolled deletes were collapsed onto one runner while master was +// independently adding --dry-run and --staged to each copy. Both had to survive +// the merge: these are the consolidated call sites master's own tests do not +// reach, including the nested one whose endpoint is built from a parent id. +func TestConsolidatedDeletesCarryDryRunAndStaged(t *testing.T) { + cases := []struct { + name string + args []string + wantPath string + }{ + {"rotator dry-run", []string{"rotator", "delete", "7", "--dry-run"}, "DELETE /api/v3/rotators/7?dry_run=1"}, + {"conversion dry-run", []string{"conversion", "delete", "7", "--dry-run"}, "DELETE /api/v3/conversions/7?dry_run=1"}, + {"rotator rule dry-run", []string{"rotator", "rule-delete", "3", "9", "--dry-run"}, "DELETE /api/v3/rotators/3/rules/9?dry_run=1"}, + {"rotator bulk dry-run", []string{"rotator", "delete", "--ids", "7,8", "--dry-run"}, "DELETE /api/v3/rotators/8?dry_run=1"}, + {"rotator staged", []string{"rotator", "delete", "7", "--staged"}, "DELETE /api/v3/rotators/7?staged=1"}, + {"conversion staged", []string{"conversion", "delete", "7", "--staged"}, "DELETE /api/v3/conversions/7?staged=1"}, + {"rotator rule staged", []string{"rotator", "rule-delete", "3", "9", "--staged"}, "DELETE /api/v3/rotators/3/rules/9?staged=1"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + srv := newFeatureServer(t) + writeTestConfig(t, home, srv.URL, "test-api-key-1234") + t.Cleanup(func() { api.SetStagedMode(false) }) + + if _, _, err := executeCommand(tc.args...); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var found bool + for _, req := range srv.seen() { + if req == tc.wantPath { + found = true + } + } + if !found { + t.Fatalf("expected a request %q, got %v", tc.wantPath, srv.seen()) + } + }) + } +} + +// A preview is still a DELETE request. Id validation therefore has to run +// before the dry-run and staged branches, not after them, or an invalid id +// reaches the server on exactly the paths added to make deletes safer. +func TestInvalidIDIsRejectedBeforePreviewOrStaging(t *testing.T) { + for _, flag := range []string{"--dry-run", "--staged"} { + t.Run(flag, func(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + srv := newFeatureServer(t) + writeTestConfig(t, home, srv.URL, "test-api-key-1234") + t.Cleanup(func() { api.SetStagedMode(false) }) + + _, _, err := executeCommand("rotator", "delete", "", flag) + if err == nil { + t.Fatalf("expected a validation error, got nil (requests: %v)", srv.seen()) + } + if !strings.Contains(err.Error(), "ID") { + t.Fatalf("error should name the invalid ID, got %q", err) + } + for _, req := range srv.seen() { + if strings.HasPrefix(req, "DELETE") { + t.Fatalf("a DELETE reached the server despite the invalid id: %s", req) + } + } + }) + } +} diff --git a/go-cli/cmd/numeric_output_test.go b/go-cli/cmd/numeric_output_test.go new file mode 100644 index 00000000..880983e3 --- /dev/null +++ b/go-cli/cmd/numeric_output_test.go @@ -0,0 +1,91 @@ +package cmd + +import ( + "io" + "math" + "os" + "strings" + "testing" +) + +// captureBoth runs fn with os.Stdout and os.Stderr redirected, returning what +// each received. Both pipes are drained concurrently so a writer can never block +// on the kernel buffer. +func captureBoth(fn func()) (string, string) { + oldStdout, oldStderr := os.Stdout, os.Stderr + rOut, wOut, _ := os.Pipe() + rErr, wErr, _ := os.Pipe() + os.Stdout, os.Stderr = wOut, wErr + + outCh := make(chan []byte, 1) + errCh := make(chan []byte, 1) + go func() { b, _ := io.ReadAll(rOut); outCh <- b }() + go func() { b, _ := io.ReadAll(rErr); errCh <- b }() + + fn() + + os.Stdout, os.Stderr = oldStdout, oldStderr + _ = wOut.Close() + _ = wErr.Close() + stdout, stderr := <-outCh, <-errCh + _ = rOut.Close() + _ = rErr.Close() + return string(stdout), string(stderr) +} + +func TestRoundHalfAwayFromZero(t *testing.T) { + cases := []struct { + in float64 + places int + want float64 + }{ + {1.2345, 2, 1.23}, + {1.235, 2, 1.24}, + {-1.235, 2, -1.24}, + {2.5, 0, 3}, + {-2.5, 0, -3}, + {0, 4, 0}, + {0.28861386, 4, 0.2886}, + } + for _, tc := range cases { + if got := round(tc.in, tc.places); math.Abs(got-tc.want) > 1e-9 { + t.Errorf("round(%v, %d) = %v, want %v", tc.in, tc.places, got, tc.want) + } + } +} + +// The previous implementation cast through int64, which is undefined in Go once +// the scaled value leaves the int64 range and turned NaN/Inf into an arbitrary +// finite number. Non-finite values must pass through so they are never reported +// as a plausible-looking figure. +func TestRoundPreservesNonFiniteAndLargeValues(t *testing.T) { + if got := round(math.NaN(), 2); !math.IsNaN(got) { + t.Errorf("round(NaN) = %v, want NaN", got) + } + if got := round(math.Inf(1), 2); !math.IsInf(got, 1) { + t.Errorf("round(+Inf) = %v, want +Inf", got) + } + if got := round(math.Inf(-1), 2); !math.IsInf(got, -1) { + t.Errorf("round(-Inf) = %v, want -Inf", got) + } + + // 1e18 scaled by 10^4 overflows int64; the value must survive intact. + const big = 1e18 + if got := round(big, 4); got != big { + t.Errorf("round(%v, 4) = %v, want %v", big, got, big) + } +} + +// Many commands build their payload with json.Marshal and ignore the error. +// render() must not turn a nil payload into a silent, successful no-op. +func TestRenderReportsAnEmptyPayloadInsteadOfPrintingNothing(t *testing.T) { + for _, data := range [][]byte{nil, {}, []byte(" \n")} { + stdout, stderr := captureBoth(func() { render(data) }) + if strings.TrimSpace(stdout) != "" { + t.Errorf("render(%q) wrote to stdout: %q", data, stdout) + } + if !strings.Contains(stderr, "could not be encoded") { + t.Errorf("render(%q) did not report the failure on stderr: %q", data, stderr) + } + } +} diff --git a/go-cli/cmd/optimize_campaign.go b/go-cli/cmd/optimize_campaign.go index 9d3f8733..77b2b6df 100644 --- a/go-cli/cmd/optimize_campaign.go +++ b/go-cli/cmd/optimize_campaign.go @@ -38,7 +38,12 @@ var campaignOptimizeCmd = &cobra.Command{ var sum struct { Data map[string]interface{} `json:"data"` } - _ = json.Unmarshal(sumRaw, &sum) + // A discarded parse error left sum.Data nil, and every metric below + // coerced to 0 — the command then reported a campaign with real traffic + // as having no clicks, leads or revenue. + if err := json.Unmarshal(sumRaw, &sum); err != nil { + return fmt.Errorf("parsing summary report for campaign %s: %w", id, err) + } s := sum.Data clicks := toFloat(s["total_clicks"]) leads := toFloat(s["total_leads"]) diff --git a/go-cli/cmd/profile.go b/go-cli/cmd/profile.go index 67c270d3..30389687 100644 --- a/go-cli/cmd/profile.go +++ b/go-cli/cmd/profile.go @@ -3,6 +3,7 @@ package cmd import ( "encoding/json" "fmt" + "os" "sort" "strings" @@ -87,7 +88,7 @@ var configRemoveProfileCmd = &cobra.Command{ force, _ := cmd.Flags().GetBool("force") if !force && !confirmPrompt("Remove profile %s?", name) { - fmt.Println("Cancelled.") + fmt.Fprintln(os.Stderr, "Cancelled.") return nil } diff --git a/go-cli/cmd/render.go b/go-cli/cmd/render.go index cc3d778b..728aa779 100644 --- a/go-cli/cmd/render.go +++ b/go-cli/cmd/render.go @@ -1,6 +1,9 @@ package cmd import ( + "bytes" + "fmt" + "os" "strings" "p202/internal/output" @@ -26,6 +29,16 @@ func renderOpts() output.Opts { return opts } +// render writes an API payload using the global output flags. +// +// An empty payload reaching here means the caller could not build one — several +// callers assemble theirs with json.Marshal and would otherwise pass nil on +// failure. Rendering nil prints nothing and exits 0, which reads as "no results" +// rather than "we failed to encode the results", so report it explicitly. func render(data []byte) { + if len(bytes.TrimSpace(data)) == 0 { + fmt.Fprintln(os.Stderr, "Error: no output produced — the response payload could not be encoded.") + return + } output.RenderWith(data, renderOpts()) } diff --git a/go-cli/cmd/report_optimize.go b/go-cli/cmd/report_optimize.go index 208baf9e..65aa6a64 100644 --- a/go-cli/cmd/report_optimize.go +++ b/go-cli/cmd/report_optimize.go @@ -3,6 +3,8 @@ package cmd import ( "encoding/json" "fmt" + "math" + "os" "sort" "strconv" @@ -98,23 +100,26 @@ func breakevenVerdict(leads, cost, margin float64) string { return "OVER-BID" } +// round rounds to the given number of decimal places, half away from zero. +// It delegates to math.Round rather than casting through int64: that cast is +// undefined in Go once f*10^places exceeds the int64 range, and it turned NaN or +// an infinity into an arbitrary finite number instead of preserving it. func round(f float64, places int) float64 { - p := 1.0 - for i := 0; i < places; i++ { - p *= 10 - } - return float64(int64(f*p+sign(f)*0.5)) / p -} - -func sign(f float64) float64 { - if f < 0 { - return -1 + if math.IsNaN(f) || math.IsInf(f, 0) { + return f } - return 1 + p := math.Pow(10, float64(places)) + return math.Round(f*p) / p } func rowsToJSON(rows []map[string]interface{}) []byte { - out, _ := json.Marshal(map[string]interface{}{"data": rows}) + out, err := json.Marshal(map[string]interface{}{"data": rows}) + if err != nil { + // Returning nil here would render as no output at all; render() reports + // the empty payload, so add the cause. + fmt.Fprintf(os.Stderr, "Error encoding rows for output: %v\n", err) + return nil + } return out } diff --git a/go-cli/cmd/rotator.go b/go-cli/cmd/rotator.go index de7bfbd5..f7533f4f 100644 --- a/go-cli/cmd/rotator.go +++ b/go-cli/cmd/rotator.go @@ -3,11 +3,9 @@ package cmd import ( "encoding/json" "fmt" - "os" "strings" "p202/internal/api" - "p202/internal/output" "github.com/spf13/cobra" ) @@ -87,7 +85,7 @@ var rotatorCreateCmd = &cobra.Command{ } name, _ := cmd.Flags().GetString("name") if name == "" { - return validationError("required flag --name is missing") + return fmt.Errorf("required flag --name is missing") } body := map[string]interface{}{"name": name} for _, f := range []string{"default_url", "default_campaign", "default_lp"} { @@ -121,7 +119,7 @@ var rotatorUpdateCmd = &cobra.Command{ } } if len(body) == 0 { - return validationError("no fields specified; pass at least one flag to update") + return fmt.Errorf("no fields specified; pass at least one flag to update") } data, err := c.Put("rotators/"+args[0], body) if err != nil { @@ -135,76 +133,15 @@ var rotatorUpdateCmd = &cobra.Command{ var rotatorDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete a redirector/rotator and all its routing rules", - Args: func(cmd *cobra.Command, args []string) error { - idsFlag, _ := cmd.Flags().GetString("ids") - if strings.TrimSpace(idsFlag) != "" { - return cobra.MaximumNArgs(0)(cmd, args) - } - return cobra.ExactArgs(1)(cmd, args) - }, + Args: deleteArgsValidator, RunE: func(cmd *cobra.Command, args []string) error { - c, err := api.NewFromConfig() - if err != nil { - return err - } - dryRun, _ := cmd.Flags().GetBool("dry-run") - idsFlag, _ := cmd.Flags().GetString("ids") - if strings.TrimSpace(idsFlag) != "" { - idList, parseErr := parseIDList(idsFlag) - if parseErr != nil { - return parseErr - } - if len(idList) == 0 { - return validationError("--ids requires at least one ID").WithHint("Comma-separate internal ids, e.g. --ids 12,13,14 (find them with the matching `... list`).") - } - - if dryRun { - return renderDeletePreviews(c, "rotators", idList) - } - if api.StagedMode() { - return stageDeletes(c, "rotators", idList) - } - - force, _ := cmd.Flags().GetBool("force") - if !force && !confirmPrompt("Delete %d rotators and all their rules?", len(idList)) { - fmt.Println("Cancelled.") - return nil - } - - deleted := 0 - failed := 0 - for _, id := range idList { - if err := c.Delete("rotators/" + id); err != nil { - failed++ - fmt.Fprintf(os.Stderr, "Failed to delete rotator %s: %v\n", id, err) - continue - } - deleted++ - } - output.Success("Deleted %d of %d rotators.", deleted, len(idList)) - if failed > 0 { - return partialFailureError("failed to delete %d rotators", failed) - } - return nil - } - - if dryRun { - return renderDeletePreviews(c, "rotators", []string{args[0]}) - } - if api.StagedMode() { - return stageDeletes(c, "rotators", []string{args[0]}) - } - - force, _ := cmd.Flags().GetBool("force") - if !force && !confirmPrompt("Delete rotator %s and all its rules?", args[0]) { - fmt.Println("Cancelled.") - return nil - } - if err := c.Delete("rotators/" + args[0]); err != nil { - return err - } - output.Success("Rotator %s deleted.", args[0]) - return nil + return runBulkOrSingleDelete(cmd, args, deleteSpec{ + endpoint: "rotators", + noun: "rotator", + plural: "rotators", + cascadeOne: " and all its rules", + cascadeMany: " and all their rules", + }) }, } @@ -219,7 +156,7 @@ var rotatorRuleCreateCmd = &cobra.Command{ } ruleName, _ := cmd.Flags().GetString("rule_name") if ruleName == "" { - return validationError("required flag --rule_name is missing") + return fmt.Errorf("required flag --rule_name is missing") } body := map[string]interface{}{ "rule_name": ruleName, @@ -230,7 +167,7 @@ var rotatorRuleCreateCmd = &cobra.Command{ if v, _ := cmd.Flags().GetString("criteria_json"); v != "" { var criteria interface{} if err := json.Unmarshal([]byte(v), &criteria); err != nil { - return withHint(fmt.Errorf("invalid --criteria_json: %w", err), "Pass a JSON object; `p202 rotator criteria-values` shows accepted keys and values. Quote it so the shell keeps it intact.") + return fmt.Errorf("invalid --criteria_json: %w", err) } body["criteria"] = criteria } else if code, _ := cmd.Flags().GetString("country"); code != "" { @@ -238,14 +175,14 @@ var rotatorRuleCreateCmd = &cobra.Command{ // never has to know the exact "Name(CC)" value string. value := countryCriteriaValue(code) if value == "" { - return validationError("unknown country code %q", code).WithHint("Find codes with `p202 rotator criteria-values --search `, or pass raw criteria via --criteria_json.") + return validationError("unknown country code %q (see `rotator criteria-values --search ...`); or use --criteria_json", code) } body["criteria"] = []map[string]string{{"type": "country", "statement": "is", "value": value}} } if v, _ := cmd.Flags().GetString("redirects_json"); v != "" { var redirects interface{} if err := json.Unmarshal([]byte(v), &redirects); err != nil { - return withHint(fmt.Errorf("invalid --redirects_json: %w", err), "Pass a JSON array of {url, weight} objects; quote it so the shell keeps it intact.") + return fmt.Errorf("invalid --redirects_json: %w", err) } body["redirects"] = redirects } else if camp, _ := cmd.Flags().GetString("redirect-campaign"); camp != "" { @@ -265,86 +202,19 @@ var rotatorRuleCreateCmd = &cobra.Command{ var rotatorRuleDeleteCmd = &cobra.Command{ Use: "rule-delete ", Short: "Delete a routing rule from a redirector/rotator", - Args: func(cmd *cobra.Command, args []string) error { - idsFlag, _ := cmd.Flags().GetString("ids") - if strings.TrimSpace(idsFlag) != "" { - return cobra.ExactArgs(1)(cmd, args) - } - return cobra.ExactArgs(2)(cmd, args) - }, + Args: deleteArgsValidatorN(1), RunE: func(cmd *cobra.Command, args []string) error { - c, err := api.NewFromConfig() + rotatorID, err := validateID(args[0]) if err != nil { return err } - dryRun, _ := cmd.Flags().GetBool("dry-run") - idsFlag, _ := cmd.Flags().GetString("ids") - if strings.TrimSpace(idsFlag) != "" { - idList, parseErr := parseIDList(idsFlag) - if parseErr != nil { - return parseErr - } - if len(idList) == 0 { - return validationError("--ids requires at least one rule ID").WithHint("Comma-separate rule ids, e.g. --ids 3,4 (find them with `p202 rotator rules `).") - } - rotatorID := args[0] - if dryRun { - return renderDeletePreviews(c, "rotators/"+rotatorID+"/rules", idList) - } - if api.StagedMode() { - return stageDeletes(c, "rotators/"+rotatorID+"/rules", idList) - } - force, _ := cmd.Flags().GetBool("force") - if !force { - fmt.Printf("Delete %d rules from rotator %s? [y/N] ", len(idList), rotatorID) - var answer string - fmt.Scanln(&answer) - answer = strings.ToLower(strings.TrimSpace(answer)) - if answer != "y" && answer != "yes" { - fmt.Println("Cancelled.") - return nil - } - } - - deleted := 0 - failed := 0 - for _, ruleID := range idList { - if err := c.Delete("rotators/" + rotatorID + "/rules/" + ruleID); err != nil { - failed++ - fmt.Fprintf(os.Stderr, "Failed to delete rule %s from rotator %s: %v\n", ruleID, rotatorID, err) - continue - } - deleted++ - } - output.Success("Deleted %d of %d rules from rotator %s.", deleted, len(idList), rotatorID) - if failed > 0 { - return partialFailureError("failed to delete %d rules", failed) - } - return nil - } - - if dryRun { - return renderDeletePreviews(c, "rotators/"+args[0]+"/rules", []string{args[1]}) - } - if api.StagedMode() { - return stageDeletes(c, "rotators/"+args[0]+"/rules", []string{args[1]}) - } - - force, _ := cmd.Flags().GetBool("force") - if !force { - fmt.Printf("Delete rule %s from rotator %s? [y/N] ", args[1], args[0]) - var answer string - fmt.Scanln(&answer) - if strings.ToLower(answer) != "y" && strings.ToLower(answer) != "yes" { - fmt.Println("Cancelled.") - return nil - } - } - if err := c.Delete("rotators/" + args[0] + "/rules/" + args[1]); err != nil { - return err - } - output.Success("Rule %s deleted from rotator %s.", args[1], args[0]) - return nil + return runBulkOrSingleDelete(cmd, args[1:], deleteSpec{ + endpoint: "rotators/" + rotatorID + "/rules", + noun: "rule", + plural: "rules", + context: " from rotator " + rotatorID, + idsHintText: "Comma-separate rule ids, e.g. --ids 3,4 (find them with `p202 rotator get `).", + }) }, } @@ -369,7 +239,7 @@ var rotatorRuleUpdateCmd = &cobra.Command{ } ruleID = strings.TrimSpace(ruleID) if ruleID == "" { - return validationError("rule id is required (pass it as the second argument or via --rule_id)").WithHint("`p202 rotator rules ` lists rule ids.") + return fmt.Errorf("rule id is required (pass it as the second argument or via --rule_id)") } body := map[string]interface{}{} @@ -385,19 +255,19 @@ var rotatorRuleUpdateCmd = &cobra.Command{ if v, _ := cmd.Flags().GetString("criteria_json"); v != "" { var criteria interface{} if err := json.Unmarshal([]byte(v), &criteria); err != nil { - return withHint(fmt.Errorf("invalid --criteria_json: %w", err), "Pass a JSON object; `p202 rotator criteria-values` shows accepted keys and values. Quote it so the shell keeps it intact.") + return fmt.Errorf("invalid --criteria_json: %w", err) } body["criteria"] = criteria } if v, _ := cmd.Flags().GetString("redirects_json"); v != "" { var redirects interface{} if err := json.Unmarshal([]byte(v), &redirects); err != nil { - return withHint(fmt.Errorf("invalid --redirects_json: %w", err), "Pass a JSON array of {url, weight} objects; quote it so the shell keeps it intact.") + return fmt.Errorf("invalid --redirects_json: %w", err) } body["redirects"] = redirects } if len(body) == 0 { - return validationError("no fields specified; pass at least one flag to update") + return fmt.Errorf("no fields specified; pass at least one flag to update") } data, err := c.Put("rotators/"+args[0]+"/rules/"+ruleID, body) diff --git a/go-cli/cmd/shell.go b/go-cli/cmd/shell.go index df6ed43c..e7e51d82 100644 --- a/go-cli/cmd/shell.go +++ b/go-cli/cmd/shell.go @@ -258,7 +258,14 @@ func emitBatchResult(command string, output []byte, err error) { result["output"] = strings.TrimSpace(string(output)) } } - line, _ := json.Marshal(result) + line, marshalErr := json.Marshal(result) + if marshalErr != nil { + // Never drop a batch record silently: a consumer counting JSONL lines + // against commands would misread the run as having fewer results. + fmt.Fprintf(os.Stderr, "Error encoding result for %q: %v\n", command, marshalErr) + fmt.Printf("{\"command\":%q,\"success\":false,\"error\":\"result could not be encoded\"}\n", command) + return + } fmt.Println(string(line)) } @@ -324,14 +331,21 @@ func handleBuiltin(line string, state *shell.State, currentProfile string) (bool if cmdStr == "" { return true, "", false, fmt.Errorf("syntax error: assignment to $%s requires a command", varName) } - output, err := executeShellCommand(cmdStr) + output, err := executeShellCommandWith(cmdStr, true) if err != nil { printOutput(output) // partial output produced before the error return true, "", false, err } - if value, ok := normalizeValue(output); ok { - state.Set(varName, value) + value, ok := normalizeValue(output) + if !ok { + // Captured as JSON above, so empty stdout is unambiguous here: a + // void operation (delete, revoke) that reports success on stderr + // and has no result to store. An empty result SET is {"data":[]} + // and lands in state.Set below. Silently leaving $name unset + // would let the user believe the result was captured. + return true, "", false, fmt.Errorf("command produced no output to capture; $%s was not set", varName) } + state.Set(varName, value) printOutput(output) return true, "", false, nil } @@ -424,6 +438,21 @@ func currentProfileName() string { // produced before the failure is returned alongside the error; the caller // decides how to surface it (printing it here would corrupt JSONL output). func executeShellCommand(line string) ([]byte, error) { + return executeShellCommandWith(line, false) +} + +// executeShellCommandWith runs a shell line, optionally forcing JSON output. +// +// forceJSON exists for `$name = `. In the session's default table +// mode a list with zero rows writes NOTHING to stdout — renderTable sends +// "No results." to stderr — so an empty capture is indistinguishable from a +// void operation that has no result at all. Forcing JSON removes the +// ambiguity at the source rather than making the assignment guess: an empty +// result set becomes {"data":[]} and is stored, while a genuine void +// operation still writes nothing and is reported. Variables hold JSON +// anyway ($name pretty-prints the stored value), so this is also the format +// the capture is for. +func executeShellCommandWith(line string, forceJSON bool) ([]byte, error) { tokens, err := shell.TokenizeLine(line) if err != nil { return nil, fmt.Errorf("parse error: %w", err) @@ -450,10 +479,15 @@ func executeShellCommand(line string) ([]byte, error) { // it would only propose. PersistentPreRunE reads this variable on every // Execute(), so it has to be restored like the others. savedStaged := stagedWrites + // The command path the top-level error envelope and its hint name. + // PersistentPreRunE re-stamps this for every inner command, so without + // restoring it a failing `p202 shell` reports the LAST command the batch + // ran and points an agent at that command's --help instead of its own. + savedCommandPath := activeCommandPath sessionOverride := configpkg.GetActiveOverride() resetAllFlags(rootCmd) - jsonOutput = savedJSON + jsonOutput = savedJSON || forceJSON csvOutput = savedCSV profileName = savedProfile groupName = savedGroup @@ -473,6 +507,7 @@ func executeShellCommand(line string) ([]byte, error) { }) // Restore session-level state the command's own flags may have modified. + activeCommandPath = savedCommandPath jsonOutput = savedJSON csvOutput = savedCSV profileName = savedProfile @@ -507,12 +542,19 @@ func captureStdout(fn func()) []byte { done <- buf }() - fn() - - os.Stdout = oldStdout - _ = w.Close() - captured := <-done - _ = r.Close() + // Restore through a defer: if fn panics, leaving os.Stdout pointing at this + // pipe would silence every later command in the session and leak the reader + // goroutine. The panic still propagates after the restore runs. + var captured []byte + func() { + defer func() { + os.Stdout = oldStdout + _ = w.Close() + captured = <-done + _ = r.Close() + }() + fn() + }() return captured } @@ -545,11 +587,15 @@ func normalizeValue(output []byte) (json.RawMessage, bool) { return json.RawMessage(quoted), true } -// storeResult saves command output as the $_ variable. +// storeResult saves command output as the $_ variable. A command that produced +// no output sets $_ to null rather than leaving the previous command's value in +// place, which would misreport stale data as the last result. func storeResult(state *shell.State, output []byte) { if value, ok := normalizeValue(output); ok { state.SetLast(value) + return } + state.SetLast(json.RawMessage("null")) } func init() { diff --git a/go-cli/cmd/shell_capture_test.go b/go-cli/cmd/shell_capture_test.go new file mode 100644 index 00000000..882f8dcd --- /dev/null +++ b/go-cli/cmd/shell_capture_test.go @@ -0,0 +1,138 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "p202/internal/shell" +) + +func emptyListServer(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if strings.HasSuffix(r.URL.Path, "/capabilities") { + // `p202 shell` is gated on this capability. + _, _ = w.Write([]byte(`{"data":{"shell":true}}`)) + return + } + _, _ = w.Write([]byte(`{"data":[],"pagination":{"total":0,"limit":50,"offset":0}}`)) + })) + t.Cleanup(srv.Close) + return srv +} + +// A list that legitimately matches nothing is a successful command with an +// empty result, not a failure. In the shell's default table mode it writes +// nothing to stdout ("No results." goes to stderr), which made it +// indistinguishable from a void operation and failed the assignment — under +// --stop-on-error that aborted the whole batch. +func TestAssignmentCapturesAnEmptyResultSet(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + srv := emptyListServer(t) + writeTestConfig(t, home, srv.URL, "test-api-key-1234") + + state := shell.NewState() + handled, _, _, err := handleBuiltin("$rows = campaign list", state, "default") + if !handled { + t.Fatal("assignment should be handled as a builtin") + } + if err != nil { + t.Fatalf("an empty result set is not an error: %v", err) + } + + raw, ok := state.Get("rows") + if !ok { + t.Fatal("$rows was not set") + } + var parsed struct { + Data []interface{} `json:"data"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + t.Fatalf("stored value is not the JSON envelope: %v (%q)", err, raw) + } + if len(parsed.Data) != 0 { + t.Fatalf("expected an empty data array, got %v", parsed.Data) + } +} + +// The batch must not abort on it either — that was the reported failure. +func TestBatchWithStopOnErrorSurvivesAnEmptyResultSet(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + srv := emptyListServer(t) + writeTestConfig(t, home, srv.URL, "test-api-key-1234") + + if _, _, err := executeCommand("shell", "--stop-on-error", "-c", "$x = campaign list; campaign list"); err != nil { + t.Fatalf("batch aborted on a legitimately empty result: %v", err) + } +} + +// The original defect stays fixed: a void operation writes nothing to stdout +// even as JSON, so there is genuinely nothing to capture and the user must be +// told rather than left believing $name holds the result. +func TestAssignmentFromAVoidOperationStillReports(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + srv := newRecordingServer(t) + writeTestConfig(t, home, srv.URL, "test-api-key-1234") + + state := shell.NewState() + _, _, _, err := handleBuiltin("$gone = campaign delete 7 --force", state, "default") + if err == nil { + t.Fatal("expected an error explaining that $gone was not set") + } + if !strings.Contains(err.Error(), "$gone") { + t.Fatalf("error should name the variable, got %q", err) + } + if _, ok := state.Get("gone"); ok { + t.Fatal("$gone must not be set when there was nothing to capture") + } +} + +// Forcing JSON is scoped to the capture: it must not leak into the session's +// display mode for subsequent commands. +func TestAssignmentDoesNotChangeTheSessionOutputMode(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + srv := emptyListServer(t) + writeTestConfig(t, home, srv.URL, "test-api-key-1234") + + before := jsonOutput + state := shell.NewState() + if _, _, _, err := handleBuiltin("$rows = campaign list", state, "default"); err != nil { + t.Fatalf("assignment: %v", err) + } + if jsonOutput != before { + t.Fatalf("session jsonOutput changed from %v to %v", before, jsonOutput) + } +} + +// activeCommandPath is a package global that PersistentPreRunE re-stamps on +// every in-process execution. Without restoring it across the shell's +// re-entry, a failing `p202 shell` reports the last command the batch ran, and +// the hint sends an agent to that command's --help instead of its own. +func TestShellErrorEnvelopeNamesTheShellNotTheLastInnerCommand(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + srv := emptyListServer(t) + writeTestConfig(t, home, srv.URL, "test-api-key-1234") + + stdout, _, err := executeCommand("shell", "--json", "-c", "campaign list; campaign delete abc --force") + if err == nil { + t.Fatal("expected the batch to fail on the invalid id") + } + + // The envelope is printed by Execute(), which is not reached from a test; + // assert on the state Execute() would read. + if activeCommandPath != "" && !strings.Contains(activeCommandPath, "shell") { + t.Fatalf("activeCommandPath = %q, want it to name the shell (stdout: %q)", activeCommandPath, stdout) + } + if hint := hintFor(err); strings.Contains(hint, "campaign delete") { + t.Fatalf("hint points at the inner command: %q", hint) + } +} diff --git a/go-cli/cmd/shell_semantics_test.go b/go-cli/cmd/shell_semantics_test.go new file mode 100644 index 00000000..755a9990 --- /dev/null +++ b/go-cli/cmd/shell_semantics_test.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "testing" + + "p202/internal/shell" +) + +func osStdout() *os.File { return os.Stdout } + +func print_(s string) { fmt.Print(s) } + +// $_ is documented as the last result. After a command that produced no output +// it used to retain the previous command's value and report it as current. +func TestLastResultIsClearedWhenACommandProducesNoOutput(t *testing.T) { + state := shell.NewState() + + storeResult(state, []byte(`{"data":[{"id":1}]}`)) + first, ok := state.Get("_") + if !ok || !strings.Contains(string(first), `"id"`) { + t.Fatalf("first result not stored: %q", first) + } + + storeResult(state, nil) + after, ok := state.Get("_") + if !ok { + t.Fatal("$_ should still exist after a command with no output") + } + if strings.Contains(string(after), `"id"`) { + t.Fatalf("$_ still holds the previous command's output: %q", after) + } + var parsed interface{} + if err := json.Unmarshal(after, &parsed); err != nil { + t.Fatalf("$_ should be valid JSON, got %q", after) + } + if parsed != nil { + t.Fatalf("$_ = %v, want null", parsed) + } +} + +// captureStdout must restore os.Stdout even when the wrapped function panics; +// otherwise every later command in the shell session writes into a closed pipe. +func TestCaptureStdoutRestoresStdoutOnPanic(t *testing.T) { + before := osStdout() + + func() { + defer func() { + if recover() == nil { + t.Error("panic should propagate to the caller") + } + }() + captureStdout(func() { panic("boom") }) + }() + + if osStdout() != before { + t.Fatal("os.Stdout was not restored after a panic") + } + + // And capturing must still work afterwards. + got := captureStdout(func() { print_("still works") }) + if strings.TrimSpace(string(got)) != "still works" { + t.Fatalf("capture broken after panic: %q", got) + } +} diff --git a/go-cli/cmd/sync.go b/go-cli/cmd/sync.go index d0be6e13..7589c953 100644 --- a/go-cli/cmd/sync.go +++ b/go-cli/cmd/sync.go @@ -363,7 +363,10 @@ func runSyncProfiles(entities []string, fromProfile, toProfile string, opts sync if opts.Incremental && manifest != nil && sourceID != "" { if entry, exists := manifest.GetMapping(currentEntity, sourceID); exists { - if entry.SourceHash == sourceHash { + // "" means no fingerprint (see comparableHash); it must never + // satisfy the unchanged-skip, else unencodable rows are + // silently dropped from every incremental sync. + if entry.SourceHash != "" && entry.SourceHash == sourceHash { result.Skipped++ idMap.Set(currentEntity, sourceID, entry.TargetID) continue @@ -646,8 +649,17 @@ func handleSyncRecordError(entity, key string, err error, skipErrors bool, resul return skipErrors } +// comparableHash fingerprints a record for incremental-sync change detection. +// A row that cannot be encoded returns "" — never a real hash — because hashing +// the nil bytes from a failed Marshal gave every unencodable row the SAME +// digest, so a changed record could match its stored hash and be skipped as +// unchanged. Callers must treat "" as "no fingerprint" (see the skip check in +// runSyncProfiles), which errs toward re-syncing. func comparableHash(row map[string]interface{}) string { - data, _ := json.Marshal(row) + data, err := json.Marshal(row) + if err != nil { + return "" + } sum := sha1.Sum(data) return hex.EncodeToString(sum[:]) } @@ -793,11 +805,14 @@ func tryServerSyncRead(path, fromProfile, toProfile string) (bool, error) { return false, nil //nolint:nilerr // probe-and-fall-back: a setup failure means server-side sync is unavailable, so the caller runs the client-side path } + // scalarString instead of type assertions: loadProfileConnection builds + // string values today, but a bare .(string) here would panic at a distance + // if that map's shape ever changed in diff.go. params := map[string]string{ - "source[name]": sourceConn["name"].(string), - "source[url]": sourceConn["url"].(string), - "target[name]": targetConn["name"].(string), - "target[url]": targetConn["url"].(string), + "source[name]": scalarString(sourceConn["name"]), + "source[url]": scalarString(sourceConn["url"]), + "target[name]": scalarString(targetConn["name"]), + "target[url]": scalarString(targetConn["url"]), } resp, err := orchestrator.Get(path, params) if err != nil { diff --git a/go-cli/cmd/user.go b/go-cli/cmd/user.go index 49365f8e..2224e78e 100644 --- a/go-cli/cmd/user.go +++ b/go-cli/cmd/user.go @@ -195,11 +195,11 @@ var userRoleAssignCmd = &cobra.Command{ } roleIDStr := roleIDFrom(cmd, args) if roleIDStr == "" { - return validationError("role id is required (pass it as the second argument or via --role_id)").WithHint("`p202 user roles` lists role ids.") + return validationError("role id is required (pass it as the second argument or via --role_id)").WithHint("`p202 user role list` lists role ids.") } roleID, err := strconv.Atoi(roleIDStr) if err != nil { - return validationError("role_id must be an integer: %s", roleIDStr).WithHint("`p202 user roles` lists role ids.") + return validationError("role_id must be an integer: %s", roleIDStr).WithHint("`p202 user role list` lists role ids.") } data, err := c.Post("users/"+args[0]+"/roles", map[string]interface{}{ "role_id": roleID, @@ -223,7 +223,7 @@ var userRoleRemoveCmd = &cobra.Command{ } roleID := roleIDFrom(cmd, args) if roleID == "" { - return validationError("role id is required (pass it as the second argument or via --role_id)").WithHint("`p202 user roles` lists role ids.") + return validationError("role id is required (pass it as the second argument or via --role_id)").WithHint("`p202 user role list` lists role ids.") } if dryRun, _ := cmd.Flags().GetBool("dry-run"); dryRun { return renderDeletePreviews(c, "users/"+args[0]+"/roles", []string{roleID}) @@ -340,14 +340,9 @@ var userAPIKeyDeleteCmd = &cobra.Command{ return stageDeletes(c, "users/"+args[0]+"/api-keys", []string{args[1]}) } force, _ := cmd.Flags().GetBool("force") - if !force { - fmt.Printf("Delete API key for user %s? [y/N] ", args[0]) - var answer string - fmt.Scanln(&answer) - if strings.ToLower(answer) != "y" && strings.ToLower(answer) != "yes" { - fmt.Println("Cancelled.") - return nil - } + if !force && !confirmPrompt("Delete API key for user %s?", args[0]) { + fmt.Fprintln(os.Stderr, "Cancelled.") + return nil } if err := c.Delete("users/" + args[0] + "/api-keys/" + args[1]); err != nil { return err @@ -451,11 +446,11 @@ var userAPIKeyRotateCmd = &cobra.Command{ deletedOld := false if !keepOld { if !force { - fmt.Printf("Delete old API key for user %s? [y/N] ", userID) - var answer string - fmt.Scanln(&answer) - if strings.ToLower(answer) != "y" && strings.ToLower(answer) != "yes" { - fmt.Println("Skipping old key deletion.") + // Prompt via the shared helper so the question and the outcome go + // to stderr; this command renders a JSON result on stdout, which + // the prompt text used to corrupt. + if !confirmPrompt("Delete old API key for user %s?", userID) { + fmt.Fprintln(os.Stderr, "Skipping old key deletion.") } else { if err := c.Delete("users/" + userID + "/api-keys/" + oldAPIKey); err != nil { return err @@ -472,13 +467,23 @@ var userAPIKeyRotateCmd = &cobra.Command{ configUpdated := false configUpdateSkipped := false + configProfile := "" if updateConfig { cfg, err := configpkg.Load() if err != nil { return err } - if cfg.APIKey == oldAPIKey || forceConfigUpdate { - cfg.APIKey = newAPIKey + // Compare and write through the resolved profile. This used to read + // the legacy top-level cfg.APIKey, which is empty for every + // profile-based config, so the match never fired and --update-config + // was a no-op unless --force-config-update was also passed. + p, resolvedName, err := cfg.EnsureProfile(profileName) + if err != nil { + return err + } + configProfile = resolvedName + if p.APIKey == oldAPIKey || forceConfigUpdate { + p.APIKey = newAPIKey if err := cfg.Save(); err != nil { return err } @@ -500,6 +505,7 @@ var userAPIKeyRotateCmd = &cobra.Command{ "old_key_kept": keepOld || !deletedOld, "config_updated": configUpdated, "config_update_skipped": configUpdateSkipped, + "config_profile": configProfile, } encoded, _ := json.Marshal(out) render(encoded) diff --git a/go-cli/cmd/verify.go b/go-cli/cmd/verify.go index e0e930e9..2b9fbea7 100644 --- a/go-cli/cmd/verify.go +++ b/go-cli/cmd/verify.go @@ -356,6 +356,7 @@ var rotatorCheckCmd = &cobra.Command{ return err } var datas []map[string]interface{} + var fetchFailures []map[string]interface{} if len(args) == 1 { raw, err := c.Get("rotators/"+args[0], nil) if err != nil { @@ -382,19 +383,35 @@ var rotatorCheckCmd = &cobra.Command{ for _, r := range resp.Data { full, err := c.Get(fmt.Sprintf("rotators/%v", normalizeID(r["id"])), nil) if err != nil { + // This command gates deploys, so a rotator whose detail + // fetch failed must count as a failure — silently skipping + // it let a broken rotator ride an exit code 0. + fetchFailures = append(fetchFailures, map[string]interface{}{ + "id": normalizeID(r["id"]), + "name": r["name"], + "status": "ERROR", + "reason": fmt.Sprintf("could not fetch rotator: %v", err), + }) continue } var fr struct { Data map[string]interface{} `json:"data"` } - if json.Unmarshal(full, &fr) == nil { - datas = append(datas, fr.Data) + if err := json.Unmarshal(full, &fr); err != nil { + fetchFailures = append(fetchFailures, map[string]interface{}{ + "id": normalizeID(r["id"]), + "name": r["name"], + "status": "ERROR", + "reason": fmt.Sprintf("could not parse rotator: %v", err), + }) + continue } + datas = append(datas, fr.Data) } } - rows := make([]map[string]interface{}, 0, len(datas)) - failed := 0 + rows := make([]map[string]interface{}, 0, len(datas)+len(fetchFailures)) + failed := len(fetchFailures) for _, d := range datas { issues := rotatorIssues(d) status := "OK" @@ -409,6 +426,7 @@ var rotatorCheckCmd = &cobra.Command{ "reason": strings.Join(issues, "; "), }) } + rows = append(rows, fetchFailures...) render(rowsToJSON(rows)) if failed > 0 { return partialFailureError("%d rotator(s) have configuration issues", failed) @@ -443,7 +461,12 @@ var rotatorTraceCmd = &cobra.Command{ var tr struct { Data []map[string]interface{} `json:"data"` } - _ = json.Unmarshal(trk, &tr) + // Discarding this error made tr.Data nil, so a malformed response was + // reported as "fed by 0 tracker(s)" — a confident false claim from a + // command whose whole purpose is verification. + if err := json.Unmarshal(trk, &tr); err != nil { + return fmt.Errorf("parsing trackers for rotator %s: %w", args[0], err) + } fmt.Fprintf(os.Stderr, "Rotator %s %q — default %s, %d rule(s), fed by %d tracker(s)\n", args[0], fmt.Sprintf("%v", rot.Data["name"]), defaultDest(rot.Data), @@ -534,7 +557,11 @@ var trackerCheckCmd = &cobra.Command{ var resp struct { Data []map[string]interface{} `json:"data"` } - _ = json.Unmarshal(list, &resp) + // Without this check a malformed list left resp.Data nil, so the + // command verified nothing at all and still reported success. + if err := json.Unmarshal(list, &resp); err != nil { + return fmt.Errorf("parsing tracker list: %w", err) + } for _, t := range resp.Data { ids = append(ids, fmt.Sprintf("%v", normalizeID(t["tracker_id"]))) } diff --git a/go-cli/go.mod b/go-cli/go.mod index b9b1dd55..85deb97c 100644 --- a/go-cli/go.mod +++ b/go-cli/go.mod @@ -4,11 +4,11 @@ go 1.22 require ( github.com/spf13/cobra v1.8.1 + golang.org/x/sys v0.28.0 golang.org/x/term v0.27.0 ) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - golang.org/x/sys v0.28.0 // indirect ) diff --git a/go-cli/internal/api/client.go b/go-cli/internal/api/client.go index cd49fb59..6a4305c2 100644 --- a/go-cli/internal/api/client.go +++ b/go-cli/internal/api/client.go @@ -8,7 +8,9 @@ import ( "io" "net/http" "net/url" + "regexp" "strings" + "sync" "time" "p202/internal/config" @@ -37,15 +39,28 @@ func StagedMode() bool { type Client struct { rootURL string - baseURL string apiKey string http *http.Client + // mu guards every field below it. A Client is shared across goroutines + // (cmd/crud.go fans bulk tracker-URL fetches over a worker pool with one + // client; cmd/shell.go keeps a long-lived one), and ensureCapabilities() + // lazily rewrites baseURL after version negotiation while in-flight + // requests are reading it — an unsynchronized read/write pair. + mu sync.Mutex + baseURL string capabilities map[string]interface{} capabilitiesLoaded bool capabilitiesErr error } +// currentBaseURL returns the negotiated base URL under the lock. +func (c *Client) currentBaseURL() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.baseURL +} + type APIError struct { Status int Message string @@ -132,7 +147,7 @@ func HintFor(err error) string { case apiErr.Status == 403 && strings.Contains(strings.ToLower(apiErr.Message), "scope"): return "This key's scope does not cover the operation. Use a key with the needed scope, or mint one: `p202 user apikey create --scope write` (scopes: *, read, write, :read, :write)." case apiErr.Status == 401 || apiErr.Status == 403: - return "Verify your API key: run `p202 config get`, then `p202 config set-key ` if it's wrong." + return "Verify your API key: run `p202 config show`, then `p202 config set-key ` if it's wrong." case apiErr.Status == 404: return "Not found. Run the matching `... list` to find valid ids (ids are internal — not the public ones in tracking links; some commands accept --public)." // A 409 has several unrelated causes -- a still-running idempotent @@ -167,7 +182,7 @@ func HintFor(err error) string { if errors.As(err, &reqErr) { switch reqErr.Kind { case "network": - return "Check the server URL (`p202 config get`) and that the instance is reachable; run `p202 config test` to verify the connection." + return "Check the server URL (`p202 config show`) and that the instance is reachable; run `p202 config test` to verify the connection." case "validation": return "The request could not be built from the given values; check them and retry." } @@ -237,10 +252,13 @@ func (c *Client) SupportsCapability(path ...string) bool { func (c *Client) Capability(path ...string) (interface{}, bool) { c.ensureCapabilities() + c.mu.Lock() + caps := c.capabilities + c.mu.Unlock() if len(path) == 0 { - return c.capabilities, len(c.capabilities) > 0 + return caps, len(caps) > 0 } - var current interface{} = c.capabilities + var current interface{} = caps for _, key := range path { obj, ok := current.(map[string]interface{}) if !ok { @@ -260,16 +278,25 @@ func (c *Client) Capability(path ...string) (interface{}, bool) { // does not grant this capability" from "the capabilities could not be fetched". func (c *Client) CapabilitiesError() error { c.ensureCapabilities() + c.mu.Lock() + defer c.mu.Unlock() return c.capabilitiesErr } +// ensureCapabilities loads capabilities at most once. It holds mu for the whole +// negotiate-and-load sequence so concurrent callers see either the pre- or the +// post-negotiation baseURL, never a torn read, and only one of them performs +// the network round-trips. func (c *Client) ensureCapabilities() { + c.mu.Lock() + defer c.mu.Unlock() + if c.capabilitiesLoaded { return } c.capabilitiesLoaded = true - c.negotiateVersion() + c.negotiateVersionLocked() req, err := http.NewRequest("GET", c.baseURL+"/capabilities", nil) if err != nil { @@ -310,7 +337,14 @@ func (c *Client) ensureCapabilities() { c.capabilities = decoded } -func (c *Client) negotiateVersion() { +// apiVersionPattern constrains the version segment the SERVER hands back. It is +// interpolated straight into every subsequent request path, so anything other +// than digits (a traversal like "3/../../admin", a query string, a stray space) +// must not be accepted from a remote response. +var apiVersionPattern = regexp.MustCompile(`^[0-9]{1,4}$`) + +// negotiateVersionLocked must be called with c.mu held. +func (c *Client) negotiateVersionLocked() { req, err := http.NewRequest("GET", c.rootURL+"/api/versions", nil) if err != nil { return @@ -348,7 +382,9 @@ func (c *Client) negotiateVersion() { } preferred = strings.TrimPrefix(strings.ToLower(preferred), "v") - if preferred == "" { + if !apiVersionPattern.MatchString(preferred) { + // Keep the compiled-in default rather than trusting a malformed or + // hostile version string from the server. return } c.baseURL = c.rootURL + "/api/v" + preferred @@ -436,7 +472,10 @@ func (c *Client) do(method, path string, params map[string]string, body interfac } func (c *Client) doWithHeaders(method, path string, params map[string]string, body interface{}, headers map[string]string) ([]byte, error) { - u := c.baseURL + "/" + strings.TrimLeft(path, "/") + // Read once under the lock: version negotiation can rewrite baseURL from + // another goroutine, and the URL and the version header below must agree. + baseURL := c.currentBaseURL() + u := baseURL + "/" + strings.TrimLeft(path, "/") if stagedMode && (method == "POST" || method == "PUT" || method == "PATCH" || method == "DELETE") && @@ -481,8 +520,8 @@ func (c *Client) doWithHeaders(method, path string, params map[string]string, bo req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", "p202-cli/2.0 (Go)") - if idx := strings.LastIndex(c.baseURL, "/api/v"); idx != -1 { - req.Header.Set("X-P202-API-Version", c.baseURL[idx+5:]) + if idx := strings.LastIndex(baseURL, "/api/v"); idx != -1 { + req.Header.Set("X-P202-API-Version", baseURL[idx+5:]) } for name, value := range headers { req.Header.Set(name, value) diff --git a/go-cli/internal/api/client_concurrency_test.go b/go-cli/internal/api/client_concurrency_test.go new file mode 100644 index 00000000..7e0f99ed --- /dev/null +++ b/go-cli/internal/api/client_concurrency_test.go @@ -0,0 +1,92 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +// Client is shared across goroutines by callers (cmd/crud.go fans out bulk +// tracker-URL fetches over a worker pool with one client, and cmd/shell.go +// keeps a long-lived client). ensureCapabilities() lazily MUTATES baseURL, +// capabilities, capabilitiesLoaded and capabilitiesErr, while do() reads +// baseURL on every request — so a capability lookup racing a request is a +// data race on the same fields. +// +// Run with -race; this fails loudly if the guard around that state regresses. +func TestConcurrentRequestsAndCapabilityLookupsAreRaceFree(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/versions": + _, _ = w.Write([]byte(`{"data":{"preferred":"v3"}}`)) + case "/api/v3/capabilities": + _, _ = w.Write([]byte(`{"data":{"bulk":{"enabled":true}}}`)) + default: + _, _ = w.Write([]byte(`{"data":{"ok":true}}`)) + } + })) + defer srv.Close() + + c := newClient(srv.URL, "test-api-key-1234") + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := c.Get("trackers/1/url", nil); err != nil { + t.Errorf("Get: %v", err) + } + }() + wg.Add(1) + go func() { + defer wg.Done() + c.SupportsCapability("bulk", "enabled") + }() + } + wg.Wait() +} + +// The version segment from /api/versions is interpolated into every subsequent +// request path, so a hostile or buggy server must not be able to steer it. +func TestNegotiateVersionRejectsNonNumericVersions(t *testing.T) { + cases := []struct { + name string + preferred string + wantPath string // expected path prefix used for the capabilities call + }{ + {"numeric is accepted", "v4", "/api/v4/"}, + {"traversal is rejected", "3/../../admin", "/api/v3/"}, + {"query injection is rejected", "3?evil=1", "/api/v3/"}, + {"garbage is rejected", "not-a-version", "/api/v3/"}, + {"empty is rejected", "", "/api/v3/"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var capabilitiesPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api/versions" { + _, _ = w.Write([]byte(`{"data":{"preferred":"` + tc.preferred + `"}}`)) + return + } + if strings.HasSuffix(r.URL.Path, "/capabilities") { + capabilitiesPath = r.URL.Path + } + _, _ = w.Write([]byte(`{"data":{}}`)) + })) + defer srv.Close() + + c := newClient(srv.URL, "test-api-key-1234") + c.ensureCapabilities() + + if !strings.HasPrefix(capabilitiesPath, tc.wantPath) { + t.Fatalf("capabilities requested %q, want prefix %q", capabilitiesPath, tc.wantPath) + } + }) + } +} diff --git a/go-cli/internal/atomicfile/atomicfile.go b/go-cli/internal/atomicfile/atomicfile.go new file mode 100644 index 00000000..55dc2e8f --- /dev/null +++ b/go-cli/internal/atomicfile/atomicfile.go @@ -0,0 +1,70 @@ +// Package atomicfile writes a file's full contents in one all-or-nothing step. +// +// It exists because the CLI keeps two things under ~/.p202 that must never be +// observed half-written: the config file holding the API key, and the sync +// manifest that decides what an incremental sync will skip. A plain +// os.WriteFile is wrong for both — it truncates in place, so a crash mid-write +// leaves a corrupt file, and its permission argument applies only when it +// creates the file, so a file that already exists with looser permissions keeps +// them forever. +package atomicfile + +import ( + "fmt" + "os" + "path/filepath" +) + +// Write creates data at path with the given permissions, atomically. +// +// The write goes to a temp file in the same directory (same filesystem, so the +// rename cannot fail with EXDEV), is flushed to disk, and is then renamed over +// path. Rename replaces the destination directory entry, which also means a +// symlink planted at path is replaced rather than written through. On any +// failure the temp file is removed and path is left untouched. +func Write(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp") + if err != nil { + return fmt.Errorf("creating temp file in %s: %w", dir, err) + } + tmpName := tmp.Name() + abandon := func(verb string, cause error) error { + _ = tmp.Close() + _ = os.Remove(tmpName) + return fmt.Errorf("%s %s: %w", verb, tmpName, cause) + } + + // os.CreateTemp already uses 0600, but the caller's intent is what must hold + // on the final file, so set it explicitly rather than inheriting a default. + if err := tmp.Chmod(perm); err != nil { + return abandon("setting permissions on", err) + } + if _, err := tmp.Write(data); err != nil { + return abandon("writing", err) + } + // Flush before the rename: without it a crash can leave the renamed entry + // pointing at unwritten data. + if err := tmp.Sync(); err != nil { + return abandon("flushing", err) + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpName) + return fmt.Errorf("closing %s: %w", tmpName, err) + } + if err := os.Rename(tmpName, path); err != nil { + _ = os.Remove(tmpName) + return fmt.Errorf("renaming %s to %s: %w", tmpName, path, err) + } + + // Flushing the file's contents is not enough: the rename itself is a + // directory-entry change, and on ext4/XFS that entry can be absent after a + // crash even though tmp.Sync() returned. Without this the documented + // all-or-nothing guarantee silently does not hold — `config set-key` could + // report success and still leave the old key, and a lost sync-manifest + // rename makes the next incremental sync re-create every already-synced + // record. Best-effort: Windows cannot open a directory for sync, so a + // failure here is not fatal to a write that has already landed. + syncDir(dir) + return nil +} diff --git a/go-cli/internal/atomicfile/atomicfile_test.go b/go-cli/internal/atomicfile/atomicfile_test.go new file mode 100644 index 00000000..e4a3b8c8 --- /dev/null +++ b/go-cli/internal/atomicfile/atomicfile_test.go @@ -0,0 +1,141 @@ +package atomicfile + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestWriteCreatesFileWithContentAndMode(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + if err := Write(path, []byte("hello\n"), 0600); err != nil { + t.Fatalf("Write: %v", err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "hello\n" { + t.Fatalf("content = %q, want %q", got, "hello\n") + } + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if mode := info.Mode().Perm(); mode != 0600 { + t.Fatalf("mode = %04o, want 0600", mode) + } + } +} + +// os.WriteFile's mode argument applies only on creation, so an existing file +// with looser permissions kept them. Write must enforce the mode every time. +func TestWriteTightensModeOnExistingFile(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permission bits are not meaningful on Windows") + } + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + if err := os.WriteFile(path, []byte("old"), 0644); err != nil { + t.Fatal(err) + } + if err := Write(path, []byte("new"), 0600); err != nil { + t.Fatalf("Write: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if mode := info.Mode().Perm(); mode != 0600 { + t.Fatalf("mode = %04o, want 0600", mode) + } +} + +// A symlink planted at the destination must be replaced, not written through — +// otherwise an API key could be redirected into an attacker-readable file. +func TestWriteReplacesSymlinkInsteadOfFollowingIt(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs elevation on Windows") + } + dir := t.TempDir() + outside := filepath.Join(dir, "outside.txt") + if err := os.WriteFile(outside, []byte("untouched"), 0600); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "config.json") + if err := os.Symlink(outside, path); err != nil { + t.Fatal(err) + } + + if err := Write(path, []byte("secret"), 0600); err != nil { + t.Fatalf("Write: %v", err) + } + + victim, err := os.ReadFile(outside) + if err != nil { + t.Fatal(err) + } + if string(victim) != "untouched" { + t.Fatalf("symlink was followed: target now %q", victim) + } + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSymlink != 0 { + t.Fatal("destination is still a symlink") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "secret" { + t.Fatalf("content = %q, want %q", got, "secret") + } +} + +func TestWriteLeavesNoTempFilesBehind(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + for i := 0; i < 3; i++ { + if err := Write(path, []byte("data"), 0600); err != nil { + t.Fatalf("Write: %v", err) + } + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".tmp") { + t.Fatalf("temp file left behind: %s", e.Name()) + } + } + if len(entries) != 1 { + t.Fatalf("expected exactly the target file, got %d entries", len(entries)) + } +} + +// A failure must leave the previous contents intact rather than a truncated file. +func TestWriteFailureLeavesDestinationIntact(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sub", "config.json") + + // The parent directory does not exist, so the temp create fails. + if err := Write(path, []byte("data"), 0600); err == nil { + t.Fatal("expected an error writing into a missing directory") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("destination should not exist, stat err = %v", err) + } +} diff --git a/go-cli/internal/atomicfile/syncdir_unix.go b/go-cli/internal/atomicfile/syncdir_unix.go new file mode 100644 index 00000000..7b2bb310 --- /dev/null +++ b/go-cli/internal/atomicfile/syncdir_unix.go @@ -0,0 +1,15 @@ +//go:build !windows + +package atomicfile + +import "os" + +// syncDir fsyncs a directory so a rename into it is durable. +func syncDir(dir string) { + d, err := os.Open(dir) + if err != nil { + return + } + _ = d.Sync() + _ = d.Close() +} diff --git a/go-cli/internal/atomicfile/syncdir_windows.go b/go-cli/internal/atomicfile/syncdir_windows.go new file mode 100644 index 00000000..e768c9ae --- /dev/null +++ b/go-cli/internal/atomicfile/syncdir_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package atomicfile + +// syncDir is a no-op on Windows: a directory handle cannot be opened for +// synchronisation the way it can on POSIX. MoveFileEx already replaces the +// destination entry atomically, so the rename is not observed half-applied; +// only the flush-to-platter ordering guarantee is unavailable. +func syncDir(string) {} diff --git a/go-cli/internal/config/config.go b/go-cli/internal/config/config.go index 4fe9a6a0..7c88f7b0 100644 --- a/go-cli/internal/config/config.go +++ b/go-cli/internal/config/config.go @@ -8,6 +8,8 @@ import ( "sort" "strings" "sync" + + "p202/internal/atomicfile" ) const defaultProfileName = "default" @@ -58,13 +60,12 @@ func Load() (*Config, error) { if err := json.Unmarshal(data, &c); err != nil { return nil, fmt.Errorf("parsing config: %w", err) } - c.migrateLegacy() + c.normalize() return &c, nil } func (c *Config) Save() error { - c.migrateLegacy() - c.mergeLegacyIntoProfile() + c.normalize() dir := Dir() if err := os.MkdirAll(dir, 0700); err != nil { @@ -77,7 +78,12 @@ func (c *Config) Save() error { return fmt.Errorf("encoding config: %w", err) } data = append(data, '\n') - if err := os.WriteFile(Path(), data, 0600); err != nil { + + // This file holds a bearer credential, so it is written all-or-nothing and + // its mode is enforced on every write — os.WriteFile's mode argument applies + // only when it creates the file, so a config that already existed as 0644 + // would have kept those permissions forever. + if err := atomicfile.Write(Path(), data, 0600); err != nil { return fmt.Errorf("writing config: %w", err) } return nil @@ -180,7 +186,7 @@ func (c *Config) ProfileNames() []string { } func (c *Config) EnsureProfile(name string) (*Profile, string, error) { - c.migrateLegacy() + c.normalize() target := strings.TrimSpace(name) if target == "" { @@ -215,6 +221,11 @@ func (c *Config) ResolveGroup(tag string) []string { } out := make([]string, 0) for name, p := range c.Profiles { + // `"profiles":{"x":null}` unmarshals to a nil entry. Every other + // accessor guards against it; ranging p.Tags here panicked. + if p == nil { + continue + } for _, t := range p.Tags { if strings.ToLower(strings.TrimSpace(t)) == normalized { out = append(out, name) @@ -271,41 +282,77 @@ func getProfileOverride() string { return strings.TrimSpace(profileOverride) } -func (c *Config) migrateLegacy() { - if len(c.Profiles) > 0 { - return - } - if c.URL == "" && c.APIKey == "" && len(c.Defaults) == 0 { +// normalize folds the legacy V1 top-level url/api_key/defaults into the +// profiles map and then clears them. +// +// Consuming them exactly once is what makes later writes stick. Previously the +// fields survived migration and Save() re-applied them over the active profile +// on every write, so on any config upgraded from V1 `config set-key` and +// `config set-url` silently reverted to the old value — the credential the user +// just typed was discarded and the stale one written back. +func (c *Config) normalize() { + if len(c.Profiles) == 0 { + if c.URL == "" && c.APIKey == "" && len(c.Defaults) == 0 { + return + } + // Migrate into the profile active_profile actually names. Hardcoding + // "default" here while leaving ActiveProfile pointing elsewhere orphaned + // the credential: every command then failed with `profile "prod" not + // found` — including `config set-key`/`set-url`, which resolve through + // EnsureProfile, so the CLI could not repair its own config. The sibling + // branch below already creates the profile ActiveProfile names. + target := strings.TrimSpace(c.ActiveProfile) + if target == "" { + target = defaultProfileName + } + c.Profiles = map[string]*Profile{ + target: { + URL: c.URL, + APIKey: c.APIKey, + Defaults: cloneDefaults(c.Defaults), + }, + } + c.ActiveProfile = target + c.clearLegacy() return } - c.Profiles = map[string]*Profile{ - defaultProfileName: { - URL: c.URL, - APIKey: c.APIKey, - Defaults: cloneDefaults(c.Defaults), - }, - } - if strings.TrimSpace(c.ActiveProfile) == "" { - c.ActiveProfile = defaultProfileName - } + // Profiles already exist, so a hand-edited or partially-upgraded file may + // carry both shapes. Fold the legacy half into the active profile once. + c.mergeLegacyIntoProfile() + c.clearLegacy() +} + +func (c *Config) clearLegacy() { + c.URL = "" + c.APIKey = "" + c.Defaults = nil } func (c *Config) mergeLegacyIntoProfile() { if len(c.Profiles) == 0 { return } + if c.URL == "" && c.APIKey == "" && len(c.Defaults) == 0 { + return + } targetName := strings.TrimSpace(c.ActiveProfile) if targetName == "" { targetName = defaultProfileName } - target, ok := c.Profiles[targetName] - if !ok { - target = c.Profiles[defaultProfileName] - } + // Create the target when active_profile names a profile the map does not + // contain (a hand-edited file). Returning early instead would let normalize() + // clear the legacy fields with nothing to clear them into, silently dropping + // the only credential such a config has. Merging into an arbitrary other + // profile would be worse — it would move a credential somewhere unasked. + target := c.Profiles[targetName] if target == nil { - return + target = &Profile{} + c.Profiles[targetName] = target + } + if strings.TrimSpace(c.ActiveProfile) == "" { + c.ActiveProfile = targetName } if c.URL != "" { @@ -319,29 +366,21 @@ func (c *Config) mergeLegacyIntoProfile() { } } +// cloneForSave builds the on-disk payload. Callers reach it only through +// Save(), which normalizes first, so the legacy V1 fields are always empty by +// this point and are never written back — the file is V2-only going forward. func (c *Config) cloneForSave() *Config { out := &Config{ - URL: c.URL, - APIKey: c.APIKey, - Defaults: cloneDefaults(c.Defaults), ActiveProfile: strings.TrimSpace(c.ActiveProfile), Profiles: cloneProfiles(c.Profiles), } - if len(out.Profiles) > 0 { - if out.ActiveProfile == "" { - if _, ok := out.Profiles[defaultProfileName]; ok { - out.ActiveProfile = defaultProfileName - } else { - names := profileNames(out.Profiles) - if len(names) > 0 { - out.ActiveProfile = names[0] - } - } + if len(out.Profiles) > 0 && out.ActiveProfile == "" { + if _, ok := out.Profiles[defaultProfileName]; ok { + out.ActiveProfile = defaultProfileName + } else if names := profileNames(out.Profiles); len(names) > 0 { + out.ActiveProfile = names[0] } - out.URL = "" - out.APIKey = "" - out.Defaults = nil } return out @@ -349,7 +388,7 @@ func (c *Config) cloneForSave() *Config { func (c *Config) ensureWritableProfile() (*Profile, string) { if len(c.Profiles) == 0 { - c.migrateLegacy() + c.normalize() } if len(c.Profiles) == 0 { c.Profiles = map[string]*Profile{} @@ -376,7 +415,7 @@ func (c *Config) ensureWritableProfile() (*Profile, string) { } func (c *Config) resolveProfile(name string) (*Profile, string, error) { - c.migrateLegacy() + c.normalize() target := strings.TrimSpace(name) if target == "" { @@ -387,13 +426,8 @@ func (c *Config) resolveProfile(name string) (*Profile, string, error) { } if len(c.Profiles) == 0 { - if c.URL != "" || c.APIKey != "" || len(c.Defaults) > 0 { - return &Profile{ - URL: c.URL, - APIKey: c.APIKey, - Defaults: cloneDefaults(c.Defaults), - }, target, nil - } + // normalize() has already folded any legacy fields into a profile, so an + // empty map here means a genuinely unconfigured CLI. if target == defaultProfileName { return &Profile{}, target, nil } diff --git a/go-cli/internal/config/config_test.go b/go-cli/internal/config/config_test.go index 96d198cc..78164860 100644 --- a/go-cli/internal/config/config_test.go +++ b/go-cli/internal/config/config_test.go @@ -166,14 +166,22 @@ func TestSaveThenLoadRoundTrip(t *testing.T) { tmp := t.TempDir() setTestHome(t, tmp) - original := &Config{ - URL: "https://tracker.example.com", - APIKey: "roundtrip-key-abcd1234", - } + const wantURL = "https://tracker.example.com" + const wantKey = "roundtrip-key-abcd1234" + + original := &Config{URL: wantURL, APIKey: wantKey} if err := original.Save(); err != nil { t.Fatalf("Save() error: %v", err) } + // Save() normalizes in place: the legacy fields are consumed into the + // profiles map and cleared, so a saved Config is left in canonical V2 form. + // That is what stops a later Save() from re-applying them over whatever the + // caller has since assigned to the profile. + if original.URL != "" || original.APIKey != "" { + t.Fatalf("legacy fields not consumed by Save(): url=%q api_key=%q", original.URL, original.APIKey) + } + loaded, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) @@ -182,11 +190,11 @@ func TestSaveThenLoadRoundTrip(t *testing.T) { if err != nil { t.Fatalf("ResolveProfile(default) error: %v", err) } - if profile.URL != original.URL { - t.Fatalf("URL round-trip: got %q, want %q", profile.URL, original.URL) + if profile.URL != wantURL { + t.Fatalf("URL round-trip: got %q, want %q", profile.URL, wantURL) } - if profile.APIKey != original.APIKey { - t.Fatalf("APIKey round-trip: got %q, want %q", profile.APIKey, original.APIKey) + if profile.APIKey != wantKey { + t.Fatalf("APIKey round-trip: got %q, want %q", profile.APIKey, wantKey) } } diff --git a/go-cli/internal/config/legacy_upgrade_test.go b/go-cli/internal/config/legacy_upgrade_test.go new file mode 100644 index 00000000..260ad698 --- /dev/null +++ b/go-cli/internal/config/legacy_upgrade_test.go @@ -0,0 +1,273 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func writeRawConfig(t *testing.T, home, contents string) { + t.Helper() + dir := filepath.Join(home, ".p202") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(contents), 0600); err != nil { + t.Fatal(err) + } +} + +func readRawConfig(t *testing.T) *Config { + t.Helper() + data, err := os.ReadFile(Path()) + if err != nil { + t.Fatal(err) + } + var c Config + if err := json.Unmarshal(data, &c); err != nil { + t.Fatalf("saved config is not valid JSON: %v", err) + } + return &c +} + +// A V1 config file keeps its legacy top-level url/api_key after Load() +// migrates them into a profile. Save() then re-merges those stale legacy +// values over the profile, so the credential the user just set is discarded. +// This is the exact `p202 config set-key` path. +func TestSetKeyOnLegacyConfigPersistsNewKey(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + writeRawConfig(t, home, `{"url":"https://old.example.com","api_key":"old-key-12345678"}`) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + p, name, err := cfg.EnsureProfile("") + if err != nil { + t.Fatalf("EnsureProfile: %v", err) + } + if name != defaultProfileName { + t.Fatalf("resolved profile = %q, want %q", name, defaultProfileName) + } + p.APIKey = "new-key-87654321" + if err := cfg.Save(); err != nil { + t.Fatalf("Save: %v", err) + } + + // The in-memory profile must still hold what the caller assigned; command + // code prints p.MaskedKey() after Save(). + if p.APIKey != "new-key-87654321" { + t.Fatalf("in-memory API key = %q, want new-key-87654321", p.APIKey) + } + + reloaded, err := Load() + if err != nil { + t.Fatalf("reload: %v", err) + } + got, _, err := reloaded.resolveProfile("") + if err != nil { + t.Fatalf("resolveProfile after reload: %v", err) + } + if got.APIKey != "new-key-87654321" { + t.Fatalf("persisted API key = %q, want new-key-87654321", got.APIKey) + } + if got.URL != "https://old.example.com" { + t.Fatalf("persisted URL = %q, want the migrated legacy URL", got.URL) + } +} + +// Same defect via set-url. +func TestSetURLOnLegacyConfigPersistsNewURL(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + writeRawConfig(t, home, `{"url":"https://old.example.com","api_key":"old-key-12345678"}`) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + p, _, err := cfg.EnsureProfile("") + if err != nil { + t.Fatalf("EnsureProfile: %v", err) + } + p.URL = "https://new.example.com" + if err := cfg.Save(); err != nil { + t.Fatalf("Save: %v", err) + } + + reloaded, err := Load() + if err != nil { + t.Fatalf("reload: %v", err) + } + got, _, err := reloaded.resolveProfile("") + if err != nil { + t.Fatalf("resolveProfile after reload: %v", err) + } + if got.URL != "https://new.example.com" { + t.Fatalf("persisted URL = %q, want https://new.example.com", got.URL) + } +} + +// A hand-edited or partially-upgraded file can carry BOTH legacy top-level +// fields and a profiles map. The legacy values must be consumed once (merged +// into the active profile at load) and then never re-applied, otherwise every +// later write is silently reverted to them. +func TestLegacyFieldsAreConsumedNotReappliedOnEverySave(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + writeRawConfig(t, home, `{ + "url":"https://legacy.example.com", + "api_key":"legacy-key-1234", + "active_profile":"prod", + "profiles":{"prod":{"url":"https://prod.example.com","api_key":"prod-key-1234"}} + }`) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + p, _, err := cfg.EnsureProfile("prod") + if err != nil { + t.Fatalf("EnsureProfile: %v", err) + } + p.APIKey = "chosen-key-1234" + if err := cfg.Save(); err != nil { + t.Fatalf("Save: %v", err) + } + + saved := readRawConfig(t) + if saved.URL != "" || saved.APIKey != "" { + t.Fatalf("legacy fields survived the save: url=%q api_key=%q", saved.URL, saved.APIKey) + } + prod := saved.Profiles["prod"] + if prod == nil { + t.Fatal("prod profile missing after save") + } + if prod.APIKey != "chosen-key-1234" { + t.Fatalf("persisted API key = %q, want chosen-key-1234", prod.APIKey) + } +} + +// When active_profile names a profile that isn't in the map, the legacy +// credential still has to land somewhere — clearing it with nowhere to go would +// silently destroy the only credential the config has. +func TestLegacyFieldsSurviveMissingActiveProfile(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + writeRawConfig(t, home, `{ + "url":"https://legacy.example.com", + "api_key":"legacy-key-1234", + "active_profile":"prod", + "profiles":{"other":{"url":"https://other","api_key":"other-key-1234"}} + }`) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if err := cfg.Save(); err != nil { + t.Fatalf("Save: %v", err) + } + + saved := readRawConfig(t) + prod := saved.Profiles["prod"] + if prod == nil { + t.Fatalf("legacy credential dropped: no prod profile in %+v", saved.Profiles) + } + if prod.APIKey != "legacy-key-1234" || prod.URL != "https://legacy.example.com" { + t.Fatalf("legacy values not preserved: url=%q api_key=%q", prod.URL, prod.APIKey) + } + // The unrelated profile must be left exactly as it was. + other := saved.Profiles["other"] + if other == nil || other.APIKey != "other-key-1234" { + t.Fatalf("unrelated profile was modified: %+v", other) + } +} + +// A nil profile entry is representable in JSON (`"profiles":{"x":null}`) and +// every other accessor guards against it. ResolveGroup must not panic. +func TestResolveGroupToleratesNilProfileEntry(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + writeRawConfig(t, home, `{"active_profile":"a","profiles":{"a":{"url":"https://a","api_key":"k1234567","tags":["prod"]},"b":null}}`) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + got := cfg.ResolveGroup("prod") + if len(got) != 1 || got[0] != "a" { + t.Fatalf("ResolveGroup(prod) = %v, want [a]", got) + } +} + +// The config file holds a bearer credential. A file that already exists with +// looser permissions (an older CLI wrote 0644, or an admin copied it in) must +// be tightened on write — os.WriteFile's mode argument only applies when it +// creates the file. +func TestSaveTightensPermissionsOnPreexistingFile(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + writeRawConfig(t, home, `{"active_profile":"default","profiles":{"default":{"url":"https://a","api_key":"k1234567"}}}`) + if err := os.Chmod(Path(), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if err := cfg.Save(); err != nil { + t.Fatalf("Save: %v", err) + } + + info, err := os.Stat(Path()) + if err != nil { + t.Fatal(err) + } + if mode := info.Mode().Perm(); mode != 0600 { + t.Fatalf("config mode = %04o, want 0600", mode) + } +} + +// A V1 config whose active_profile names something other than "default" must +// migrate the credential into THAT profile. Creating "default" while leaving +// ActiveProfile pointing at the missing name orphaned the credential: every +// command failed with `profile "prod" not found`, including config set-key and +// set-url, so the CLI could not repair its own config. The sibling branch of +// normalize() already handled this; this branch did not. +func TestV1MigrationHonoursANonDefaultActiveProfile(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + writeRawConfig(t, home, `{"url":"https://prod.example.com","api_key":"prod-key-12345678","active_profile":"prod"}`) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.ActiveProfile != "prod" { + t.Fatalf("ActiveProfile = %q, want prod", cfg.ActiveProfile) + } + if _, ok := cfg.Profiles["prod"]; !ok { + t.Fatalf("credential was migrated into %v, not into the active profile", cfg.ProfileNames()) + } + + // The whole point: every resolution path must work, or the config is + // unusable and unrepairable from the CLI. + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } + p, name, err := cfg.resolveProfile("") + if err != nil { + t.Fatalf("resolveProfile: %v", err) + } + if name != "prod" || p.APIKey != "prod-key-12345678" { + t.Fatalf("resolved %q with key %q", name, p.APIKey) + } + if _, _, err := cfg.EnsureProfile(""); err != nil { + t.Fatalf("EnsureProfile (the path config set-key uses): %v", err) + } +} diff --git a/go-cli/internal/forecast/forecast.go b/go-cli/internal/forecast/forecast.go index 3b7d29e2..90ddab44 100644 --- a/go-cli/internal/forecast/forecast.go +++ b/go-cli/internal/forecast/forecast.go @@ -713,16 +713,29 @@ func ensembleWeights(eval *rollingEval, candidates []Method, include func(evalRo if len(rmses) == 0 { return nil } + // Every comparison against NaN is false, so a non-finite RMSE would slip + // past both the `< best` and the `> dropFactor*best` guards below: it would + // neither set the baseline nor be pruned, and 1/(NaN+eps)^2 would then make + // that member's weight NaN and poison the whole mix. Skip such members + // explicitly — an unmeasurable error is not evidence of accuracy. best := math.MaxFloat64 + haveBest := false for _, rmse := range rmses { + if !isFinite(rmse) { + continue + } if rmse < best { best = rmse + haveBest = true } } + if !haveBest { + return nil + } weights := map[Method]float64{} for _, m := range candidates { rmse, ok := rmses[m] - if !ok || rmse > ensembleDropFactor*best { + if !ok || !isFinite(rmse) || rmse > ensembleDropFactor*best { continue } // Inverse-MSE (Bates–Granger) weighting on the recency-discounted @@ -762,13 +775,23 @@ func nestedEnsemblePredictor(e *rollingEval, candidates []Method) rowPredictor { } } +// isFinite reports whether f is a real number. Used at every point where a +// value derived from a backtest feeds a comparison, because NaN compares false +// against everything and therefore slips through range guards silently. +func isFinite(f float64) bool { + return !math.IsNaN(f) && !math.IsInf(f, 0) +} + // normalizeWeights scales the members' weights to sum to 1. func normalizeWeights(weights map[Method]float64, members []Method) { sum := 0.0 for _, m := range members { sum += weights[m] } - if sum <= 0 { + // Written as !(sum > 0) rather than sum <= 0 so a NaN sum takes the equal- + // weights fallback too: NaN <= 0 is false, so the old form let it through + // and NaN/NaN made every member's weight NaN. + if !(sum > 0) || math.IsInf(sum, 0) { for _, m := range members { weights[m] = 1 / float64(len(members)) } @@ -1075,7 +1098,20 @@ func applyProfile(preds []Prediction, profile func(time.Time) float64, logScale continue } if v := math.Expm1(preds[i].Value); v > 0 { - preds[i].Value = math.Log1p(v * w) + // Scale on the reporting scale, then return to the model scale. + // log1p is only defined above -1, and a profile weight can + // legitimately be negative — BuildWeekdayWeights divides a + // possibly-negative day value by a positive mean — so v*w can leave + // the domain. Log1p returns NaN there, and a single NaN propagates + // through the bounds, the quantiles and (via the backtest RMSE) the + // ensemble weights, turning every prediction into NaN. Clamp into + // the representable range instead; NonNegative configs then clip it + // to zero at output anyway. + scaled := v * w + if scaled <= -1 { + scaled = math.Nextafter(-1, 0) + } + preds[i].Value = math.Log1p(scaled) } } } diff --git a/go-cli/internal/forecast/nan_containment_test.go b/go-cli/internal/forecast/nan_containment_test.go new file mode 100644 index 00000000..df99ffcd --- /dev/null +++ b/go-cli/internal/forecast/nan_containment_test.go @@ -0,0 +1,99 @@ +package forecast + +import ( + "math" + "testing" + "time" +) + +// buildFlatSeries returns n daily points around base, with a small deterministic +// wobble so the models have something to fit. +func buildFlatSeries(n int, base float64) Series { + start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + s := make(Series, 0, n) + for i := 0; i < n; i++ { + s = append(s, Point{ + T: start.AddDate(0, 0, i), + V: base + float64(i%5), + }) + } + return s +} + +func assertAllFinite(t *testing.T, preds []Prediction) { + t.Helper() + for i, p := range preds { + if !isFinite(p.Value) || !isFinite(p.LowerBound) || !isFinite(p.UpperBound) { + t.Fatalf("prediction %d is not finite: value=%v lower=%v upper=%v", + i, p.Value, p.LowerBound, p.UpperBound) + } + for q, v := range p.Quantiles { + if !isFinite(v) { + t.Fatalf("prediction %d quantile %v is not finite: %v", i, q, v) + } + } + } +} + +// A negative seasonal multiplier drives v*w below -1, which is outside log1p's +// domain. Log1p returned NaN there, and the NaN propagated through the bounds, +// the quantiles and the ensemble weights until every prediction was NaN — which +// serializes straight into the JSON and CSV output. BuildWeekdayWeights is a +// legitimate producer of negative weights (it divides a possibly-negative day +// value by a positive mean), so this is reachable through the exported API. +func TestNegativeSeasonalWeightUnderLogTransformStaysFinite(t *testing.T) { + weights := SeasonalWeights{} + for d := time.Sunday; d <= time.Saturday; d++ { + weights[d] = 1.1 + } + weights[time.Monday] = -0.6 + + res, err := Run(buildFlatSeries(60, 100), Config{ + Horizon: 7, + Interval: IntervalDay, + NonNegative: true, + LogTransform: true, + SeasonalWeights: weights, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if len(res.Predictions) != 7 { + t.Fatalf("got %d predictions, want 7", len(res.Predictions)) + } + assertAllFinite(t, res.Predictions) +} + +// applyProfile is the unit that used to produce the NaN. Every weight, including +// one steep enough to leave log1p's domain, must yield a finite model-scale +// value. +func TestApplyProfileStaysInLog1pDomain(t *testing.T) { + for _, w := range []float64{-1000, -2, -1, -0.6, 0, 0.5, 2, 1000} { + preds := []Prediction{{T: time.Now(), Value: math.Log1p(100)}} + applyProfile(preds, func(time.Time) float64 { return w }, true) + if !isFinite(preds[0].Value) { + t.Fatalf("weight %v produced a non-finite model value: %v", w, preds[0].Value) + } + } +} + +// NaN compares false against everything, so a single unmeasurable member used to +// pass both the best-RMSE and the pruning guard, and 1/(NaN+eps)^2 then poisoned +// every other member through normalizeWeights. +func TestNormalizeWeightsFallsBackWhenTheSumIsNotFinite(t *testing.T) { + members := []Method{MethodLinear, MethodSMA} + + for name, poisoned := range map[string]float64{ + "NaN": math.NaN(), + "+Inf": math.Inf(1), + "-Inf": math.Inf(-1), + } { + weights := map[Method]float64{MethodLinear: poisoned, MethodSMA: 1} + normalizeWeights(weights, members) + for _, m := range members { + if !isFinite(weights[m]) { + t.Fatalf("%s: member %s weight is not finite: %v", name, m, weights[m]) + } + } + } +} diff --git a/go-cli/internal/metrics/metrics.go b/go-cli/internal/metrics/metrics.go index 9c7b65b4..1cdbc8fb 100644 --- a/go-cli/internal/metrics/metrics.go +++ b/go-cli/internal/metrics/metrics.go @@ -24,11 +24,15 @@ func Enabled() bool { } // Event represents a single telemetry event. +// +// duration_ms carries no omitempty on purpose: an operation that finishes inside +// a millisecond has a genuine duration of 0, and dropping the field left log +// consumers unable to tell "completed instantly" from "never measured". type Event struct { Op string `json:"op"` Entity string `json:"entity,omitempty"` Action string `json:"action,omitempty"` - Duration float64 `json:"duration_ms,omitempty"` + Duration float64 `json:"duration_ms"` Count int `json:"count,omitempty"` Success bool `json:"success"` Error string `json:"error,omitempty"` @@ -68,10 +72,16 @@ func Timer(op, entity string) func(success bool, errMsg string) { } } +// appendTimestamp returns a copy of fields carrying the emission timestamp. +// It must not write into the caller's map: Emit takes Event by value but the +// Fields map is shared with the caller, so stamping it in place mutated data the +// caller still owns — and would be an unsynchronized map write if that caller +// built the event on one of the worker goroutines. func appendTimestamp(fields map[string]string) map[string]string { - if fields == nil { - fields = map[string]string{} + out := make(map[string]string, len(fields)+1) + for k, v := range fields { + out[k] = v } - fields["ts"] = time.Now().UTC().Format(time.RFC3339) - return fields + out["ts"] = time.Now().UTC().Format(time.RFC3339) + return out } diff --git a/go-cli/internal/output/output.go b/go-cli/internal/output/output.go index 47560d96..353fbfb5 100644 --- a/go-cli/internal/output/output.go +++ b/go-cli/internal/output/output.go @@ -543,7 +543,7 @@ func renderTableCSV(items []interface{}, opts Opts) { } record := make([]string, len(keys)) for i, k := range keys { - record[i] = formatValue(obj[k]) + record[i] = formatValueExact(obj[k]) } if err := writer.Write(record); err != nil { fmt.Fprintln(os.Stderr, "Error writing CSV row:", err) @@ -569,7 +569,7 @@ func renderObjectCSV(obj map[string]interface{}) { return } for _, k := range keys { - if err := writer.Write([]string{k, formatValue(obj[k])}); err != nil { + if err := writer.Write([]string{k, formatValueExact(obj[k])}); err != nil { fmt.Fprintln(os.Stderr, "Error writing CSV row:", err) return } @@ -580,7 +580,21 @@ func renderObjectCSV(obj map[string]interface{}) { } } +// formatValue renders a value for human display, rounding floats to 2 decimals. func formatValue(v interface{}) string { + return formatScalar(v, false) +} + +// formatValueExact renders a value without lossy rounding, for machine-facing +// output. The API sends computed metrics (roi, epc, margin) as JSON numbers +// rather than strings, so rounding them here truncated exported data: a --csv +// export of 0.288613861 came out as 0.29, and the caller had no way to tell it +// had lost precision. +func formatValueExact(v interface{}) string { + return formatScalar(v, true) +} + +func formatScalar(v interface{}, exact bool) string { if v == nil { return "" } @@ -589,7 +603,10 @@ func formatValue(v interface{}) string { return val case float64: if val == float64(int64(val)) { - return fmt.Sprintf("%d", int64(val)) + return strconv.FormatInt(int64(val), 10) + } + if exact { + return strconv.FormatFloat(val, 'f', -1, 64) } return fmt.Sprintf("%.2f", val) case bool: diff --git a/go-cli/internal/shell/state.go b/go-cli/internal/shell/state.go index 52409e67..e8f289e6 100644 --- a/go-cli/internal/shell/state.go +++ b/go-cli/internal/shell/state.go @@ -70,12 +70,12 @@ func (s *State) FormatVarsList() string { } var b strings.Builder for _, name := range s.Names() { - raw := s.vars[name] - preview := string(raw) - if len(preview) > 80 { - preview = preview[:77] + "..." + preview := strings.ReplaceAll(string(s.vars[name]), "\n", " ") + // Truncate by runes, not bytes: API payloads carry non-ASCII names, and a + // byte cut through a multi-byte rune emits a replacement character. + if r := []rune(preview); len(r) > 80 { + preview = string(r[:77]) + "..." } - preview = strings.ReplaceAll(preview, "\n", " ") fmt.Fprintf(&b, "$%s = %s\n", name, preview) } return b.String() diff --git a/go-cli/internal/syncstate/lock_unix.go b/go-cli/internal/syncstate/lock_unix.go new file mode 100644 index 00000000..f1ac9f96 --- /dev/null +++ b/go-cli/internal/syncstate/lock_unix.go @@ -0,0 +1,36 @@ +//go:build !windows + +package syncstate + +import ( + "errors" + "os" + "syscall" +) + +// acquireLockFile takes an exclusive, non-blocking advisory lock on path via +// flock(2). The lock belongs to the open file description, so the kernel +// releases it when the process exits by any means — that is what makes a +// leftover lock file harmless rather than a permanent block. +func acquireLockFile(path string) (*os.File, error) { + file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0600) + if err != nil { + return nil, err + } + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + _ = file.Close() + if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) { + return nil, ErrLockHeld + } + return nil, err + } + return file, nil +} + +// releaseLockFile drops the lock. Closing the descriptor releases the flock on +// its own; the explicit LOCK_UN keeps the intent obvious. The file itself is +// left in place on purpose — see the AcquireLock doc comment. +func releaseLockFile(file *os.File) { + _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) + _ = file.Close() +} diff --git a/go-cli/internal/syncstate/lock_windows.go b/go-cli/internal/syncstate/lock_windows.go new file mode 100644 index 00000000..8e6f7b35 --- /dev/null +++ b/go-cli/internal/syncstate/lock_windows.go @@ -0,0 +1,63 @@ +//go:build windows + +package syncstate + +import ( + "os" + + "golang.org/x/sys/windows" +) + +// acquireLockFile takes an exclusive, non-blocking lock on path via LockFileEx, +// the Windows counterpart to flock(2). Windows releases the lock when the +// handle closes, including on abnormal termination, so a leftover lock file does +// not block later runs. +func acquireLockFile(path string) (*os.File, error) { + file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0600) + if err != nil { + return nil, err + } + overlapped := lockRegion() + err = windows.LockFileEx( + windows.Handle(file.Fd()), + windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, + 0, + 1, + 0, + &overlapped, + ) + if err != nil { + _ = file.Close() + if err == windows.ERROR_LOCK_VIOLATION || err == windows.ERROR_IO_PENDING { + return nil, ErrLockHeld + } + return nil, err + } + return file, nil +} + +// releaseLockFile drops the lock. The file itself is left in place on purpose — +// see the AcquireLock doc comment. +func releaseLockFile(file *os.File) { + overlapped := lockRegion() + _ = windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, &overlapped) + _ = file.Close() +} + +// lockRegion is the byte range LockFileEx locks: one byte at a very high +// offset, past any content the file will ever hold. +// +// It deliberately does NOT cover byte 0. Windows byte-range locks are +// mandatory, not advisory, so locking the start of the file made the +// "pid=... time=..." line unreadable to a contending process: readLockHolder's +// os.ReadFile failed with ERROR_LOCK_VIOLATION and the contention message fell +// back to the generic form, never naming the holder — while the unix test +// asserts it does. Locking past the data keeps the diagnostic readable and the +// mutual exclusion identical, since every participant locks the same range. +// Locking a range beyond end-of-file is legal on Windows. +func lockRegion() windows.Overlapped { + return windows.Overlapped{ + Offset: 0, + OffsetHigh: 0x8000_0000, + } +} diff --git a/go-cli/internal/syncstate/state.go b/go-cli/internal/syncstate/state.go index 8df2e0f6..18fe928f 100644 --- a/go-cli/internal/syncstate/state.go +++ b/go-cli/internal/syncstate/state.go @@ -2,12 +2,16 @@ package syncstate import ( "encoding/json" + "errors" "fmt" + "io" "os" "path/filepath" + "strconv" "strings" "time" + "p202/internal/atomicfile" configpkg "p202/internal/config" syncdata "p202/internal/sync" ) @@ -93,44 +97,113 @@ func SaveManifestAtomic(manifest *Manifest) error { return fmt.Errorf("creating sync state dir: %w", err) } - path := ManifestPath(manifest.Source, manifest.Target) - tmpPath := path + ".tmp" - data, err := json.MarshalIndent(manifest, "", " ") if err != nil { return fmt.Errorf("encoding manifest: %w", err) } data = append(data, '\n') - if err := os.WriteFile(tmpPath, data, 0600); err != nil { - return fmt.Errorf("writing temp manifest: %w", err) - } - if err := os.Rename(tmpPath, path); err != nil { - return fmt.Errorf("renaming manifest: %w", err) + // The manifest decides what the next incremental sync skips, so a truncated + // one would make the CLI silently re-create or skip records. The previous + // write-then-rename left its temp file behind whenever the rename failed and + // never flushed before renaming. + if err := atomicfile.Write(ManifestPath(manifest.Source, manifest.Target), data, 0600); err != nil { + return fmt.Errorf("writing manifest: %w", err) } return nil } +// ErrLockHeld reports that another live process holds the sync lock. +var ErrLockHeld = errors.New("sync lock is already held") + +// AcquireLock takes the exclusive sync lock for a profile pair and returns the +// release function. +// +// The lock is a kernel-held file lock (flock on unix, LockFileEx on Windows), +// not the mere existence of the lock file. That distinction is the fix for a +// permanent wedge: the previous implementation used O_CREATE|O_EXCL, so a sync +// killed mid-run (SIGKILL, crash, power loss) left the file behind and every +// later sync for that pair failed forever — and the pid it recorded, the one +// piece of data that could have diagnosed it, was never read by anything. The +// OS drops a kernel lock when the holding process dies, so a leftover lock file +// is now harmless. It is deliberately not unlinked on release: unlinking a +// flock'd path lets a waiter hold a lock on an already-unlinked inode while a +// third process locks the freshly created one, and both would think they won. func AcquireLock(source, target string) (func(), error) { if err := os.MkdirAll(Dir(), 0700); err != nil { return nil, fmt.Errorf("creating sync state dir: %w", err) } path := LockPath(source, target) - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + + file, err := acquireLockFile(path) if err != nil { - if os.IsExist(err) { - return nil, fmt.Errorf("sync lock is already held for %s -> %s", source, target) + if errors.Is(err, ErrLockHeld) { + holder := readLockHolder(path) + if holder.pid > 0 { + return nil, fmt.Errorf("%w for %s -> %s by pid %d (since %s); wait for it to finish", + ErrLockHeld, source, target, holder.pid, holder.since) + } + return nil, fmt.Errorf("%w for %s -> %s", ErrLockHeld, source, target) } return nil, fmt.Errorf("creating lock file: %w", err) } - _, _ = file.WriteString(fmt.Sprintf("pid=%d time=%s\n", os.Getpid(), time.Now().UTC().Format(time.RFC3339))) + // Record the holder for the contention message above. Truncate first: the + // file survives across runs, so a shorter pid line must not leave a previous + // holder's trailing bytes behind. + if err := writeLockHolder(file); err != nil { + releaseLockFile(file) + return nil, fmt.Errorf("writing lock file: %w", err) + } + + return func() { releaseLockFile(file) }, nil +} - release := func() { - _ = file.Close() - _ = os.Remove(path) +func writeLockHolder(file *os.File) error { + if err := file.Truncate(0); err != nil { + return err + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return err + } + line := fmt.Sprintf("pid=%d time=%s\n", os.Getpid(), time.Now().UTC().Format(time.RFC3339)) + if _, err := file.WriteString(line); err != nil { + return err + } + return file.Sync() +} + +type lockHolder struct { + pid int + since string +} + +// readLockHolder parses the "pid=N time=T" line written by AcquireLock. It is +// purely informational — the kernel lock, not this content, decides ownership — +// so an unreadable or malformed file just yields an unidentified holder. +func readLockHolder(path string) lockHolder { + data, err := os.ReadFile(path) + if err != nil { + return lockHolder{} + } + holder := lockHolder{since: "unknown"} + for _, field := range strings.Fields(strings.TrimSpace(string(data))) { + key, value, ok := strings.Cut(field, "=") + if !ok { + continue + } + switch key { + case "pid": + if pid, err := strconv.Atoi(value); err == nil && pid > 0 { + holder.pid = pid + } + case "time": + if value != "" { + holder.since = value + } + } } - return release, nil + return holder } func (m *Manifest) SetMapping(entity, sourceID, targetID, sourceName, sourceHash string, at time.Time) { diff --git a/go-cli/internal/syncstate/state_test.go b/go-cli/internal/syncstate/state_test.go index 6cb59c2e..88f09580 100644 --- a/go-cli/internal/syncstate/state_test.go +++ b/go-cli/internal/syncstate/state_test.go @@ -2,6 +2,8 @@ package syncstate import ( "encoding/json" + "errors" + "fmt" "os" "path/filepath" "runtime" @@ -319,9 +321,75 @@ func TestAcquireLockSucceedsFirst(t *testing.T) { release() - // Lock file should be removed after release. - if _, err := os.Stat(lockPath); !os.IsNotExist(err) { - t.Fatalf("lock file should be removed after release, stat err = %v", err) + // The lock file is intentionally left on disk: ownership is the kernel lock, + // not the file's existence, and unlinking a flock'd path lets two processes + // believe they hold it. What must hold after release is that the lock can be + // taken again. + if _, err := os.Stat(lockPath); err != nil { + t.Fatalf("lock file should persist after release: %v", err) + } + again, err := AcquireLock("src", "dst") + if err != nil { + t.Fatalf("lock should be re-acquirable after release: %v", err) + } + again() +} + +// A lock file left behind by a process that died mid-sync used to block every +// later sync for that pair forever. The kernel drops the lock when the holder +// dies, so a leftover file must not block anything. +func TestAcquireLockIgnoresLeftoverFileFromDeadProcess(t *testing.T) { + tmp := t.TempDir() + setTestHome(t, tmp) + + if err := os.MkdirAll(Dir(), 0700); err != nil { + t.Fatal(err) + } + lockPath := LockPath("src", "dst") + // pid 0x7FFFFFFF is not a live process; the content is informational only. + if err := os.WriteFile(lockPath, []byte("pid=2147483647 time=2020-01-01T00:00:00Z\n"), 0600); err != nil { + t.Fatal(err) + } + + release, err := AcquireLock("src", "dst") + if err != nil { + t.Fatalf("stale lock file should not block acquisition: %v", err) + } + defer release() + + // The stale holder metadata must be replaced, not appended to. + data, err := os.ReadFile(lockPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "2147483647") { + t.Fatalf("stale holder metadata survived acquisition: %q", data) + } + if !strings.Contains(string(data), fmt.Sprintf("pid=%d", os.Getpid())) { + t.Fatalf("lock file should record the current pid, got %q", data) + } +} + +// The contention error must name the holder so a user can act on it. +func TestAcquireLockContentionReportsHolder(t *testing.T) { + tmp := t.TempDir() + setTestHome(t, tmp) + + release, err := AcquireLock("src", "dst") + if err != nil { + t.Fatalf("first AcquireLock() error: %v", err) + } + defer release() + + _, err = AcquireLock("src", "dst") + if err == nil { + t.Fatal("second AcquireLock() should fail") + } + if !errors.Is(err, ErrLockHeld) { + t.Fatalf("error should wrap ErrLockHeld, got %v", err) + } + if !strings.Contains(err.Error(), fmt.Sprintf("pid %d", os.Getpid())) { + t.Fatalf("contention error should name the holding pid, got %v", err) } } diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 0a2bb1a4..b38f9472 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -63,6 +63,7 @@ parameters: rules: # CLAUDE.md #1 — unchecked return values after fallible calls. - Prosper202\PHPStan\Rules\ForbidDirectMysqliStmtCallRule + - Prosper202\PHPStan\Rules\UncheckedTransactionBoundaryRule # CLAUDE.md #4 — silent data loss on malformed input. - Prosper202\PHPStan\Rules\ForbidSilentJsonDecodeRule # CLAUDE.md #5 — inconsistent security patterns across similar operations. diff --git a/start.sh b/start.sh index 097e192a..b00a7bbb 100755 --- a/start.sh +++ b/start.sh @@ -33,10 +33,15 @@ gen_secret() { # into the database volume on first start, so we never overwrite an existing one. if [ ! -f .env ]; then echo "Creating .env with a generated database password..." + # Create it 0600 BEFORE writing: this file holds the MySQL root password and + # the default umask would otherwise leave it world-readable (0644). install.sh + # already does this for the .env it writes; both paths must match. + (umask 077; : > .env) { echo "MYSQL_ROOT_PASSWORD=$(gen_secret)" echo "APP_ENV=development" } > .env + chmod 600 .env 2>/dev/null || true fi docker compose up -d --build diff --git a/tests/Api/DeletedUserApiAccessIntegrationTest.php b/tests/Api/DeletedUserApiAccessIntegrationTest.php new file mode 100644 index 00000000..7b2340bf --- /dev/null +++ b/tests/Api/DeletedUserApiAccessIntegrationTest.php @@ -0,0 +1,155 @@ +query($sql); }'); + } + mysqli_report(MYSQLI_REPORT_STRICT); + // STRICT reporting makes a failed connect THROW, so catch it and leave + // self::$db null — the tests then skip instead of erroring the suite. + try { + $db = @mysqli_connect( + $host, + (string) (getenv('P202_TEST_DB_USER') ?: 'root'), + (string) (getenv('P202_TEST_DB_PASS') ?: ''), + (string) (getenv('P202_TEST_DB_NAME') ?: 'prosper202'), + (int) (getenv('P202_TEST_DB_PORT') ?: 3306) + ); + } catch (\Throwable) { + return; + } + if (!$db) { + return; + } + $db->query("SET SESSION sql_mode=''"); + (new SchemaInstaller($db))->install(); + self::$db = $db; + } + + public static function tearDownAfterClass(): void + { + if (self::$db) { + self::$db->close(); + self::$db = null; + } + } + + protected function setUp(): void + { + if (self::$db === null) { + self::markTestSkipped('No test database configured (P202_TEST_DB_HOST).'); + } + self::$db->query('TRUNCATE TABLE 202_api_keys'); + self::$db->query('DELETE FROM 202_users WHERE user_id IN (4001, 4002)'); + } + + private function seedUser(int $userId, string $apiKey, int $deleted): void + { + self::$db->query( + "INSERT INTO 202_users SET user_id={$userId}, user_name='u{$userId}', user_pass='x', " . + "user_email='u{$userId}@example.com', user_deleted={$deleted}, user_dash_email='', " . + "install_hash='', user_hash='', user_time_register=1" + ); + self::$db->query( + "INSERT INTO 202_api_keys SET user_id={$userId}, api_key='" . + self::$db->real_escape_string($apiKey) . "', created_at=1" + ); + } + + /** The exact auth SQL shape each API version issues. */ + private function authRowCount(string $sql, string $apiKey): int + { + $stmt = self::$db->prepare($sql); + self::assertNotFalse($stmt, 'auth query failed to prepare: ' . self::$db->error); + $stmt->bind_param('s', $apiKey); + self::assertTrue($stmt->execute()); + $rows = $stmt->get_result()->num_rows; + $stmt->close(); + return $rows; + } + + /** @return array version => auth SQL */ + private function authQueries(): array + { + return [ + 'v1' => 'SELECT k.* FROM `202_api_keys` k + INNER JOIN `202_users` u ON u.`user_id` = k.`user_id` + WHERE k.`api_key` = ? AND u.`user_deleted` = 0', + 'v2' => 'SELECT k.* FROM `202_api_keys` k + INNER JOIN `202_users` u ON u.`user_id` = k.`user_id` + WHERE k.`api_key` = ? AND u.`user_deleted` = 0', + 'v2_attribution' => 'SELECT k.user_id FROM 202_api_keys k + INNER JOIN 202_users u ON u.user_id = k.user_id + WHERE k.api_key = ? AND u.user_deleted = 0 LIMIT 1', + 'v3' => 'SELECT k.user_id FROM 202_api_keys k + INNER JOIN 202_users u ON u.user_id = k.user_id + WHERE k.api_key = ? AND u.user_deleted = 0 LIMIT 1', + ]; + } + + public function testActiveUserKeyAuthenticatesOnEveryApiVersion(): void + { + $this->seedUser(4001, 'live-key', 0); + + foreach ($this->authQueries() as $version => $sql) { + self::assertSame(1, $this->authRowCount($sql, 'live-key'), "{$version} must accept an active user's key"); + } + } + + public function testSoftDeletedUserKeyIsRejectedOnEveryApiVersion(): void + { + $this->seedUser(4002, 'dead-key', 1); + + foreach ($this->authQueries() as $version => $sql) { + self::assertSame(0, $this->authRowCount($sql, 'dead-key'), "{$version} must reject a deleted user's key"); + } + } + + public function testUiDeleteRevokesKeysSoNoVersionCanAuthenticate(): void + { + $this->seedUser(4001, 'ui-key', 0); + + // What 202-account/user-management.php now runs on delete. + self::$db->query('UPDATE 202_users SET user_deleted = 1 WHERE user_id = 4001'); + self::$db->query('DELETE FROM 202_api_keys WHERE user_id = 4001'); + + self::assertSame(0, (int) self::$db->query( + "SELECT COUNT(*) AS c FROM 202_api_keys WHERE api_key = 'ui-key'" + )->fetch_assoc()['c'], 'UI delete must drop the key rows'); + + foreach ($this->authQueries() as $version => $sql) { + self::assertSame(0, $this->authRowCount($sql, 'ui-key'), "{$version} must reject a UI-deleted user's key"); + } + } +} diff --git a/tests/Api/V3/ApiKeyAuthPathScopeTest.php b/tests/Api/V3/ApiKeyAuthPathScopeTest.php index 0517d4e5..7aed0b31 100644 --- a/tests/Api/V3/ApiKeyAuthPathScopeTest.php +++ b/tests/Api/V3/ApiKeyAuthPathScopeTest.php @@ -4,6 +4,7 @@ namespace Tests\Api\V3; +use Tests\Support\SourceScan; use Tests\TestCase; /** @@ -45,24 +46,10 @@ final class ApiKeyAuthPathScopeTest extends TestCase */ private function authenticatingFiles(): array { - $root = dirname(__DIR__, 3); $found = []; - - $iterator = new \RecursiveIteratorIterator( - new \RecursiveCallbackFilterIterator( - new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), - static fn(\SplFileInfo $f): bool => - !in_array($f->getFilename(), ['vendor', 'node_modules', '.git', 'tests'], true) - ) - ); - - foreach ($iterator as $file) { - if (!$file->isFile() || $file->getExtension() !== 'php') { - continue; - } - $source = (string)file_get_contents($file->getPathname()); + foreach (SourceScan::phpFiles() as $path => $source) { if ($this->hasAuthenticatingSelect($source)) { - $found[str_replace($root . '/', '', $file->getPathname())] = $source; + $found[$path] = $source; } } diff --git a/tests/Api/V3/AttributionControllerWebhookGuardTest.php b/tests/Api/V3/AttributionControllerWebhookGuardTest.php new file mode 100644 index 00000000..0e98c1e2 --- /dev/null +++ b/tests/Api/V3/AttributionControllerWebhookGuardTest.php @@ -0,0 +1,111 @@ +db = new FakeMysqliConnection(); + $this->db->whenQueryContainsReturnRows('FROM 202_attribution_models', [ + ['model_id' => 7, 'user_id' => 1, 'model_name' => 'm', 'model_type' => 'linear'], + ]); + $this->db->whenQueryContainsInsertId('INSERT INTO 202_attribution_exports', 42); + + return new AttributionController($this->db, 1); + } + + /** + * @dataProvider unsafeUrls + */ + public function testAnUnsafeWebhookUrlIsRejectedBeforeTheInsert(string $url): void + { + $controller = $this->controller(); + + try { + $controller->scheduleExport(7, ['webhook_url' => $url]); + self::fail('expected a ValidationException'); + } catch (ValidationException $e) { + self::assertArrayHasKey('webhook_url', $e->getFieldErrors()); + } + + self::assertCount(1, $this->db->preparedSql, 'only getModel() may have run; nothing was inserted'); + self::assertStringStartsWith('SELECT', $this->db->preparedSql[0]); + } + + /** @return array */ + public static function unsafeUrls(): array + { + return [ + 'cleartext' => ['http://203.0.113.10/hook'], + 'loopback' => ['https://127.0.0.1/hook'], + 'metadata' => ['https://169.254.169.254/hook'], + 'private' => ['https://10.0.0.5/hook'], + 'bad port' => ['https://203.0.113.10:9000/hook'], + 'garbage' => ['not a url'], + ]; + } + + public function testAHostnameIsAcceptedWithoutBeingResolved(): void + { + // .invalid can never resolve (RFC 6761). If the write boundary did DNS, + // this would be rejected as "does not resolve" -- or hang on a slow + // resolver -- for a URL the cron is perfectly able to check later. + $controller = $this->controller(); + $this->scheduleExpectingTheInsertToLand($controller, ['webhook_url' => 'https://hooks.example.invalid/export']); + + $insert = $this->db->statementsContaining('INSERT INTO 202_attribution_exports'); + self::assertCount(1, $insert); + self::assertSame(1, $insert[0]->executeCount); + self::assertSame('https://hooks.example.invalid/export', $insert[0]->boundValues[11]); + } + + /** + * Runs scheduleExport() up to and including the INSERT. The controller then + * reads the native mysqli_stmt::$insert_id, which a constructor-skipping + * fake cannot provide (PHP throws "object is already closed"); as in + * ControllerTest, reaching that Error is the proof that validation passed + * and the write executed -- a rejection would have thrown a + * ValidationException before any INSERT was prepared. + * + * @param array $payload + */ + private function scheduleExpectingTheInsertToLand(AttributionController $controller, array $payload): void + { + try { + $controller->scheduleExport(7, $payload); + } catch (\Error $e) { + self::assertStringContainsString('already closed', $e->getMessage()); + } + } + + public function testNoWebhookUrlSkipsTheGuardEntirely(): void + { + $controller = $this->controller(); + $this->scheduleExpectingTheInsertToLand($controller, []); + + $insert = $this->db->statementsContaining('INSERT INTO 202_attribution_exports'); + self::assertCount(1, $insert); + self::assertSame(1, $insert[0]->executeCount); + self::assertSame('', $insert[0]->boundValues[11]); + } +} diff --git a/tests/Api/V3/DoubleStatementCloseTest.php b/tests/Api/V3/DoubleStatementCloseTest.php new file mode 100644 index 00000000..31330686 --- /dev/null +++ b/tests/Api/V3/DoubleStatementCloseTest.php @@ -0,0 +1,66 @@ +close()` after one of them is a second close, and on PHP 8 that + * throws "mysqli_stmt object is already closed" -- from inside whatever + * transaction the code sits in, which then rolls back. The rotator repository + * shipped exactly this, twice, in code that read as a tidy cleanup. + * + * The scan is token-based: it follows the variable handed to the helper + * through the rest of the enclosing function and reports a close() on it + * unless the variable was reassigned first. Shapes covered are pinned in + * SourceScanTest. + */ +final class DoubleStatementCloseTest extends TestCase +{ + /** Connection methods that close the statement themselves. */ + private const CLOSING_HELPERS = ['fetchOne', 'fetchAll', 'executeInsert', 'executeUpdate']; + + public function testTheHelperListMatchesConnection(): void + { + // If Connection gains another closing helper, or one stops closing, + // this list must follow -- otherwise the scan below is quietly wrong. + $source = SourceScan::phpFiles()['202-config/Database/Connection.php']; + foreach (self::CLOSING_HELPERS as $helper) { + self::assertSame( + 1, + preg_match('/public function ' . $helper . '\(object \$stmt\).*?\$stmt->close\(\);/s', $source), + "Connection::$helper() must close the statement it is given, or be removed from CLOSING_HELPERS" + ); + } + } + + public function testNoStatementIsClosedTwice(): void + { + $found = []; + foreach (SourceScan::phpFiles() as $path => $source) { + if (!str_contains($source, '->close(')) { + continue; + } + $lines = SourceScan::closesAfterClosingHelper($source, self::CLOSING_HELPERS); + if ($lines !== []) { + $found[$path] = $lines; + } + } + + self::assertSame([], $found, sprintf( + "These close a statement that fetchOne()/fetchAll()/executeInsert()/executeUpdate() already closed:\n%s\n" + . 'On PHP 8 the second close throws "mysqli_stmt object is already closed", aborting whatever ' + . 'transaction it sits in. Drop the redundant close().', + implode("\n", array_map( + static fn(string $f, array $lines): string => " $f: line " . implode(', ', $lines), + array_keys($found), + $found + )) + )); + } +} diff --git a/tests/Api/V3/DuplicateGlobalClassTest.php b/tests/Api/V3/DuplicateGlobalClassTest.php index 2e835814..6ec254f3 100644 --- a/tests/Api/V3/DuplicateGlobalClassTest.php +++ b/tests/Api/V3/DuplicateGlobalClassTest.php @@ -4,6 +4,7 @@ namespace Tests\Api\V3; +use Tests\Support\SourceScan; use Tests\TestCase; /** @@ -45,33 +46,19 @@ final class DuplicateGlobalClassTest extends TestCase /** @return array global class name => files declaring it */ private function globalClassDeclarations(): array { - $root = dirname(__DIR__, 3); $found = []; - - $iterator = new \RecursiveIteratorIterator( - new \RecursiveCallbackFilterIterator( - new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), - static function (\SplFileInfo $file): bool { - $name = $file->getFilename(); - return !in_array($name, ['vendor', 'node_modules', '.git'], true); - } - ) - ); - - foreach ($iterator as $file) { - if (!$file->isFile() || $file->getExtension() !== 'php') { - continue; - } - $source = (string)file_get_contents($file->getPathname()); + // Tests are included: a test that declares a global stub class collides + // with the real one just as any other file would. + foreach (SourceScan::phpFiles(includeTests: true) as $path => $source) { // Namespaced classes cannot collide with global ones. if (preg_match('/^\s*namespace\s+[^;{\s]+/m', $source) === 1) { continue; } - if (preg_match_all('/^\s*(?:final\s+|abstract\s+)?class\s+([A-Za-z_]\w*)/m', $source, $matches) < 1) { + if (SourceScan::countMatches('/^\s*(?:final\s+|abstract\s+)?class\s+([A-Za-z_]\w*)/m', $source, $path, $matches) < 1) { continue; } foreach ($matches[1] as $class) { - $found[$class][] = str_replace($root . '/', '', $file->getPathname()); + $found[$class][] = $path; } } diff --git a/tests/Api/V3/UncheckedExecuteTest.php b/tests/Api/V3/UncheckedExecuteTest.php index 29d7ccb2..a28c1c6e 100644 --- a/tests/Api/V3/UncheckedExecuteTest.php +++ b/tests/Api/V3/UncheckedExecuteTest.php @@ -4,21 +4,27 @@ namespace Tests\Api\V3; +use Tests\Support\SourceScan; use Tests\TestCase; /** * A bare `$stmt->execute();` discards the return value. Under the error mode * the app actually sets (connect.php calls mysqli_report(MYSQLI_REPORT_STRICT) * alone, not the ERROR|STRICT default), a failed execute RETURNS FALSE rather - * than throwing — so the failure is silent, and whatever the code does next + * than throwing -- so the failure is silent, and whatever the code does next * runs on the assumption that the statement succeeded. In a batch loop that * reads as "no more rows"; in a dedupe guard it reads as "not yet processed". * * ForbidDirectMysqliStmtCallRule covers some of this, but only where PHPStan - * can infer the caller is a mysqli_stmt — which legacy code often does not - * allow — and it flags every direct call, checked or not. This test is the - * complement: purely textual, so inference cannot hide anything from it, and - * concerned only with whether the result is used. + * can infer the caller is a mysqli_stmt -- which legacy code often does not + * allow -- and it flags every direct call, checked or not. This test is the + * complement: token-based rather than typed, so inference cannot hide anything + * from it, and concerned only with whether the result is used. + * + * Only zero-argument calls count. The checked wrappers are also named + * execute() -- Connection::execute($stmt), StatementHelpers::execute($stmt, + * $message) -- and are told apart from a raw mysqli_stmt::execute() by taking + * arguments. */ final class UncheckedExecuteTest extends TestCase { @@ -37,45 +43,23 @@ final class UncheckedExecuteTest extends TestCase '202-config/Attribution/AttributionIntegrationService.php', ]; - /** @return array file (repo-relative) => count of bare execute() calls */ + /** @return array> file (repo-relative) => lines of bare execute() calls */ private function bareExecuteCalls(): array { - $root = dirname(__DIR__, 3); $found = []; - - $iterator = new \RecursiveIteratorIterator( - new \RecursiveCallbackFilterIterator( - new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), - static function (\SplFileInfo $file): bool { - // Tests may exercise failure modes deliberately. - return !in_array($file->getFilename(), ['vendor', 'node_modules', '.git', 'tests'], true); - } - ) - ); - - foreach ($iterator as $file) { - if (!$file->isFile() || $file->getExtension() !== 'php') { + foreach (SourceScan::phpFiles() as $path => $source) { + if (!str_contains($source, '->execute(')) { continue; } - $source = (string)file_get_contents($file->getPathname()); - // A statement whose entire content is the call: no if(), no - // assignment, no return, no boolean operator. - $count = preg_match_all('/^[ \t]*\$[A-Za-z_]\w*->execute\(\s*\);[ \t]*$/m', $source); - if ($count > 0) { - $found[str_replace($root . '/', '', $file->getPathname())] = $count; + $lines = SourceScan::uncheckedCallStatements($source, ['execute'], [], true); + if ($lines !== []) { + $found[$path] = $lines; } } return $found; } - public function testTheScannerWorks(): void - { - // The pattern must actually match the shape it claims to. - $sample = " \$stmt->execute();\n if (!\$other->execute()) {\n"; - $this->assertSame(1, preg_match_all('/^[ \t]*\$[A-Za-z_]\w*->execute\(\s*\);[ \t]*$/m', $sample)); - } - public function testNoNewUncheckedExecuteIsIntroduced(): void { $unexpected = array_diff_key($this->bareExecuteCalls(), array_flip(self::KNOWN_UNCHECKED)); @@ -86,7 +70,7 @@ public function testNoNewUncheckedExecuteIsIntroduced(): void . 'carries on as though the statement succeeded. Check the return and fail, warn, or ' . 'recover explicitly.', implode("\n", array_map( - static fn(string $f, int $n): string => " $f ($n)", + static fn(string $f, array $lines): string => " $f: line " . implode(', ', $lines), array_keys($unexpected), $unexpected )) @@ -100,7 +84,7 @@ public function testTheKnownListHasNoStaleEntries(): void $this->assertArrayHasKey( $file, $current, - "$file no longer has an unchecked execute() — remove it from KNOWN_UNCHECKED." + "$file no longer has an unchecked execute() -- remove it from KNOWN_UNCHECKED." ); } } diff --git a/tests/Api/V3/UncheckedTransactionBoundaryTest.php b/tests/Api/V3/UncheckedTransactionBoundaryTest.php new file mode 100644 index 00000000..379631f7 --- /dev/null +++ b/tests/Api/V3/UncheckedTransactionBoundaryTest.php @@ -0,0 +1,68 @@ + $source) { + $lines = SourceScan::uncheckedCallStatements($source, self::METHODS, self::FUNCTIONS); + if ($lines !== []) { + $found[$path] = $lines; + } + } + + self::assertSame([], $found, sprintf( + "These open or close a transaction without checking the result:\n%s\n" + . 'Under MYSQLI_REPORT_STRICT both return false instead of throwing. An unchecked ' + . 'begin_transaction() silently downgrades the block to autocommit, so the matching ' + . 'rollback() undoes nothing; an unchecked commit() reports success for work that was ' + . 'never written. Check the result, or use Connection::transaction() / ' + . 'StatementHelpers::transaction().', + implode("\n", array_map( + static fn(string $f, array $lines): string => " $f: line " . implode(', ', $lines), + array_keys($found), + $found + )) + )); + } +} diff --git a/tests/Attribution/AttributionRepositoryTest.php b/tests/Attribution/AttributionRepositoryTest.php index ee73987e..6a5d7303 100644 --- a/tests/Attribution/AttributionRepositoryTest.php +++ b/tests/Attribution/AttributionRepositoryTest.php @@ -173,31 +173,26 @@ public function testListSnapshotsFiltersByScopeType(): void // --- Exports --- - public function testScheduleExportCreatesRecord(): void + public function testListExportsReturnsTheSeededRecord(): void { $repo = $this->makeRepo(); - $id = $repo->scheduleExport(1, 1, [ - 'scope_type' => 'campaign', - 'scope_id' => 5, - 'format' => 'csv', - ]); + $id = $repo->seedExport(1, 1, ['scope_type' => 'campaign', 'scope_id' => 5]); $exports = $repo->listExports(1, 1); self::assertCount(1, $exports); self::assertSame($id, $exports[0]['export_id']); - self::assertSame('queued', $exports[0]['status']); + self::assertSame('pending', $exports[0]['status']); self::assertSame('campaign', $exports[0]['scope_type']); } public function testListExportsFiltersByModelAndUser(): void { $repo = $this->makeRepo(); - $repo->scheduleExport(1, 1, []); - $repo->scheduleExport(2, 1, []); - - $exports = $repo->listExports(1, 1); + $repo->seedExport(1, 1); + $repo->seedExport(2, 1); + $repo->seedExport(1, 2); - self::assertCount(1, $exports); + self::assertCount(1, $repo->listExports(1, 1)); } } diff --git a/tests/Attribution/AttributionServiceExportTest.php b/tests/Attribution/AttributionServiceExportTest.php index 0419788b..ce66f732 100644 --- a/tests/Attribution/AttributionServiceExportTest.php +++ b/tests/Attribution/AttributionServiceExportTest.php @@ -53,7 +53,11 @@ public function testScheduleSnapshotExportPersistsPendingJob(): void 'end_hour' => $now, 'format' => ExportFormat::CSV->value, 'webhook' => [ - 'url' => 'https://example.com/hook', + // An IP literal, not a hostname: scheduleSnapshotExport() now + // runs the SSRF guard, and a hostname would make this test + // depend on live DNS. 203.0.113.0/24 is TEST-NET-3 -- routable + // as far as the guard is concerned, and never contacted here. + 'url' => 'https://203.0.113.10/hook', 'headers' => ['X-Test' => ' value '], ], ]); @@ -68,13 +72,44 @@ public function testScheduleSnapshotExportPersistsPendingJob(): void $job = $jobs[0]; $this->assertSame(ExportStatus::PENDING, $job->status); $this->assertNotNull($job->webhook); - $this->assertSame('https://example.com/hook', $job->webhook->url); + $this->assertSame('https://203.0.113.10/hook', $job->webhook->url); // ExportWebhook stores header values verbatim (no trimming). $this->assertSame(['X-Test' => ' value '], $job->webhook->headers); $this->assertSame($now - 7200, $job->startHour); $this->assertSame($now, $job->endHour); } + /** + * @dataProvider blockedWebhookUrls + */ + public function testScheduleSnapshotExportRejectsAnUnsafeWebhookUrl(string $url): void + { + // The guard used to live in ExportWebhook's constructor, which is also + // the row-hydration path -- so it was moved here, to the write boundary. + // It has to still fire, or the move traded one bug for a worse one. + $now = (int) floor(time() / 3600) * 3600; + + $this->expectException(InvalidArgumentException::class); + $this->service->scheduleSnapshotExport(1, 1, [ + 'scope' => ScopeType::GLOBAL->value, + 'start_hour' => $now - 7200, + 'end_hour' => $now, + 'format' => ExportFormat::CSV->value, + 'webhook' => ['url' => $url], + ]); + } + + /** @return array */ + public static function blockedWebhookUrls(): array + { + return [ + 'cleartext' => ['http://203.0.113.10/hook'], + 'loopback' => ['https://127.0.0.1/hook'], + 'link local' => ['https://169.254.169.254/hook'], + 'private range' => ['https://10.0.0.5/hook'], + ]; + } + public function testScheduleSnapshotExportRejectsInvalidWindow(): void { $this->expectException(InvalidArgumentException::class); diff --git a/tests/Attribution/Export/ExportProcessorTest.php b/tests/Attribution/Export/ExportProcessorTest.php deleted file mode 100644 index 7d2413eb..00000000 --- a/tests/Attribution/Export/ExportProcessorTest.php +++ /dev/null @@ -1,161 +0,0 @@ -exportRepository = new InMemoryExportRepository(fn (): int => 1_700_000_100); - $this->modelRepository = new InMemoryModelRepository(); - $this->snapshotRepository = new InMemorySnapshotRepository(); - - $this->exportPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'prosper202-export-tests'; - if (is_dir($this->exportPath)) { - $this->recursiveDelete($this->exportPath); - } - - $this->snapshotExporter = new SnapshotExporter($this->exportPath); - $this->webhookDispatcher = new WebhookDispatcher(); - - $this->processor = new ExportProcessor( - $this->exportRepository, - $this->snapshotRepository, - $this->modelRepository, - $this->snapshotExporter, - $this->webhookDispatcher - ); - } - - /** - * Builds and persists a pending export job via the repository, mirroring how - * production code enqueues work for the processor to pick up. - */ - private function schedulePendingExport(int $startHour, int $endHour): ExportJob - { - $now = 1_700_000_000; - - return $this->exportRepository->create(new ExportJob( - exportId: null, - userId: 1, - modelId: 1, - scopeType: ScopeType::GLOBAL, - scopeId: null, - startHour: $startHour, - endHour: $endHour, - format: ExportFormat::CSV, - status: ExportStatus::PENDING, - filePath: null, - downloadToken: null, - webhookUrl: null, - webhookMethod: 'POST', - webhookHeaders: [], - webhookStatusCode: null, - webhookResponseBody: null, - lastAttemptedAt: null, - completedAt: null, - errorMessage: null, - createdAt: $now, - updatedAt: $now - )); - } - - protected function tearDown(): void - { - if (is_dir($this->exportPath)) { - $this->recursiveDelete($this->exportPath); - } - - parent::tearDown(); - } - - public function testProcessPendingCompletesJob(): void - { - $now = (int) floor(time() / 3600) * 3600; - $this->schedulePendingExport($now - 7200, $now); - - $results = $this->processor->processPending(5); - - $this->assertCount(1, $results); - $this->assertSame('completed', $results[0]['status']); - $this->assertArrayHasKey('export_id', $results[0]); - - $jobs = $this->exportRepository->findForUser(1); - $this->assertCount(1, $jobs); - $job = $jobs[0]; - $this->assertSame('completed', $job->status->value); - $this->assertNotNull($job->filePath); - $this->assertFileExists($job->filePath); - } - - public function testProcessPendingMarksJobsFailedWhenModelMissing(): void - { - $now = (int) floor(time() / 3600) * 3600; - $this->schedulePendingExport($now - 3600, $now); - - $this->modelRepository->delete(1, 1); - - $results = $this->processor->processPending(5); - - $this->assertCount(1, $results); - $this->assertSame('failed', $results[0]['status']); - $this->assertStringContainsString('no longer available', (string) $results[0]['error']); - - $jobs = $this->exportRepository->findForUser(1); - $this->assertSame('failed', $jobs[0]->status->value); - } - - private function recursiveDelete(string $path): void - { - if (!is_dir($path)) { - return; - } - - $items = scandir($path); - if ($items === false) { - return; - } - - foreach ($items as $item) { - if ($item === '.' || $item === '..') { - continue; - } - - $full = $path . DIRECTORY_SEPARATOR . $item; - if (is_dir($full)) { - $this->recursiveDelete($full); - } elseif (is_file($full)) { - @unlink($full); - } - } - - @rmdir($path); - } -} diff --git a/tests/Attribution/Support/RepositoryFakes.php b/tests/Attribution/Support/RepositoryFakes.php index e0b550ab..b1792bd9 100644 --- a/tests/Attribution/Support/RepositoryFakes.php +++ b/tests/Attribution/Support/RepositoryFakes.php @@ -9,10 +9,6 @@ use Prosper202\Attribution\Repository\ModelRepositoryInterface; use Prosper202\Attribution\Repository\SnapshotRepositoryInterface; use Prosper202\Attribution\Repository\TouchpointRepositoryInterface; -use Prosper202\Attribution\Export\ExportFormat; -use Prosper202\Attribution\Export\ExportJob; -use Prosper202\Attribution\Export\ExportStatus; -use Prosper202\Attribution\Repository\ExportRepositoryInterface; use Prosper202\Attribution\ScopeType; use Prosper202\Attribution\Snapshot; use Prosper202\Attribution\Touchpoint; @@ -300,111 +296,3 @@ public function deleteBySnapshot(int $snapshotId): void unset($this->touchpoints[$snapshotId]); } } - -/** - * In-memory fake for {@see ExportRepositoryInterface}, backed by the mutable - * Prosper202\Attribution\Export\ExportJob value object that ExportProcessor - * operates on. Job objects are stored by reference so that in-place mutations - * performed by the processor (markCompleted/markFailed) are reflected once the - * processor calls update(), mirroring the behaviour of MysqlExportRepository. - * - * Used exclusively by ExportProcessorTest. - */ -final class InMemoryExportRepository implements ExportRepositoryInterface -{ - /** - * @var array - */ - private array $jobs = []; - - private int $nextId = 1; - - /** @var callable():int|null */ - private $clock; - - /** - * @param callable():int|null $clock - */ - public function __construct(?callable $clock = null) - { - $this->clock = $clock; - } - - public function create(ExportJob $job): ExportJob - { - $job->exportId = $this->nextId++; - $this->jobs[$job->exportId] = $job; - - return $job; - } - - public function update(ExportJob $job): ExportJob - { - if ($job->exportId === null) { - return $job; - } - - $this->jobs[$job->exportId] = $job; - - return $job; - } - - public function findById(int $exportId): ?ExportJob - { - return $this->jobs[$exportId] ?? null; - } - - /** - * @return ExportJob[] - */ - public function findForUser(int $userId, ?int $modelId = null, int $limit = 25): array - { - $limit = max(1, $limit); - - $filtered = array_filter( - $this->jobs, - static function (ExportJob $job) use ($userId, $modelId): bool { - if ($job->userId !== $userId) { - return false; - } - - return $modelId === null || $job->modelId === $modelId; - } - ); - - usort($filtered, static fn (ExportJob $a, ExportJob $b): int => $b->createdAt <=> $a->createdAt); - - return array_slice(array_values($filtered), 0, $limit); - } - - /** - * Claims a batch of pending jobs and marks them as processing, returning the - * stored job instances so subsequent mutations and update() calls persist. - * - * @return ExportJob[] - */ - public function claimPending(int $limit = 10): array - { - $limit = max(1, $limit); - - $pending = array_filter( - $this->jobs, - static fn (ExportJob $job): bool => $job->status === ExportStatus::PENDING - ); - - usort($pending, static fn (ExportJob $a, ExportJob $b): int => $a->createdAt <=> $b->createdAt); - $batch = array_slice($pending, 0, $limit); - - $now = $this->now(); - foreach ($batch as $job) { - $job->markProcessing($now); - } - - return $batch; - } - - private function now(): int - { - return $this->clock !== null ? ($this->clock)() : time(); - } -} diff --git a/tests/Cli/Commands/CrudCommandsTest.php b/tests/Cli/Commands/CrudCommandsTest.php index d33b6d66..518842e0 100644 --- a/tests/Cli/Commands/CrudCommandsTest.php +++ b/tests/Cli/Commands/CrudCommandsTest.php @@ -278,7 +278,7 @@ public function testAttributionCreateRejectsInvalidWeightingConfigJson(): void ]); $this->assertSame(Command::FAILURE, $status); - $this->assertStringContainsString('Invalid --weighting_config JSON', $tester->getDisplay()); + $this->assertStringContainsString('Invalid JSON in --weighting_config', $tester->getDisplay()); } public function testAttributionUpdateRejectsInvalidWeightingConfigJson(): void @@ -294,6 +294,6 @@ public function testAttributionUpdateRejectsInvalidWeightingConfigJson(): void ]); $this->assertSame(Command::FAILURE, $status); - $this->assertStringContainsString('Invalid --weighting_config JSON', $tester->getDisplay()); + $this->assertStringContainsString('Invalid JSON in --weighting_config', $tester->getDisplay()); } } diff --git a/tests/Database/ConnectionTest.php b/tests/Database/ConnectionTest.php index 55681f9b..9fb01a0e 100644 --- a/tests/Database/ConnectionTest.php +++ b/tests/Database/ConnectionTest.php @@ -9,6 +9,7 @@ use mysqli_stmt; use PHPUnit\Framework\TestCase; use Prosper202\Database\Connection; +use Prosper202\Database\Exceptions\QueryException; use RuntimeException; /** @@ -115,8 +116,11 @@ public function testFetchOneReturnsNullForEmptyResult(): void $this->assertNull($conn->fetchOne($stmt)); } - public function testFetchOneReturnsNullWhenGetResultReturnsFalse(): void + public function testFetchOneReturnsNullWhenThereIsNoResultSetAndNoError(): void { + // get_result() is false and the statement reports no errno: a statement + // that simply produced no result set. (A mock cannot expose errno, so + // Connection reads it as 0 -- the same as a real INSERT would report.) $stmt = $this->createMock(mysqli_stmt::class); $stmt->method('execute')->willReturn(true); $stmt->method('get_result')->willReturn(false); @@ -126,6 +130,24 @@ public function testFetchOneReturnsNullWhenGetResultReturnsFalse(): void $this->assertNull($conn->fetchOne($stmt)); } + public function testFetchOneThrowsWhenGetResultFailsWithAnError(): void + { + // The other meaning of a false get_result(): the fetch itself failed + // (server gone away mid-query, errno 2013). Reading that as "no rows" + // is how a free-id check reports a taken id as free. mysqli_stmt::$errno + // cannot be set on a fake, so the reader is the seam. + $stmt = $this->createMock(mysqli_stmt::class); + $stmt->method('execute')->willReturn(true); + $stmt->method('get_result')->willReturn(false); + $stmt->expects($this->once())->method('close'); + + $conn = $this->connectionReportingStatementErrno(2013, 'Lost connection to server during query'); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('MySQL get_result failed: Lost connection to server during query [errno 2013]'); + $conn->fetchOne($stmt); + } + // ── FetchAll ───────────────────────────────────────────────────── public function testFetchAllReturnsAllRowsAndClosesStatement(): void @@ -152,7 +174,7 @@ public function testFetchAllReturnsAllRowsAndClosesStatement(): void $this->assertSame($rows, $conn->fetchAll($stmt)); } - public function testFetchAllReturnsEmptyArrayWhenGetResultReturnsFalse(): void + public function testFetchAllReturnsEmptyArrayWhenThereIsNoResultSetAndNoError(): void { $stmt = $this->createMock(mysqli_stmt::class); $stmt->method('execute')->willReturn(true); @@ -163,6 +185,46 @@ public function testFetchAllReturnsEmptyArrayWhenGetResultReturnsFalse(): void $this->assertSame([], $conn->fetchAll($stmt)); } + public function testFetchAllThrowsWhenGetResultFailsWithAnError(): void + { + // A batch loop reading this as [] exits early and reports success. + $stmt = $this->createMock(mysqli_stmt::class); + $stmt->method('execute')->willReturn(true); + $stmt->method('get_result')->willReturn(false); + $stmt->expects($this->once())->method('close'); + + $conn = $this->connectionReportingStatementErrno(2006, 'MySQL server has gone away'); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('[errno 2006]'); + $conn->fetchAll($stmt); + } + + /** + * A Connection whose statement-error readers report the given values. + * Native mysqli_stmt::$errno/$error throw on every constructor-skipping + * fake, so this is the only way to exercise the failed-fetch branch. + */ + private function connectionReportingStatementErrno(int $errno, string $error): Connection + { + return new class($this->createFakeMysqli(), $errno, $error) extends Connection { + public function __construct(\mysqli $write, private int $fakeErrno, private string $fakeError) + { + parent::__construct($write); + } + + protected function statementErrno(object $stmt): int + { + return $this->fakeErrno; + } + + protected function statementError(object $stmt): string + { + return $this->fakeError; + } + }; + } + // ── ExecuteInsert ──────────────────────────────────────────────── public function testExecuteInsertReturnsInsertId(): void diff --git a/tests/Ltv/LtvProductPredictionIntegrationTest.php b/tests/Ltv/LtvProductPredictionIntegrationTest.php new file mode 100644 index 00000000..a6644281 --- /dev/null +++ b/tests/Ltv/LtvProductPredictionIntegrationTest.php @@ -0,0 +1,262 @@ +query($sql); }'); + } + mysqli_report(MYSQLI_REPORT_STRICT); + + $db = @mysqli_connect( + $host, + (string) (getenv('P202_TEST_DB_USER') ?: 'root'), + (string) (getenv('P202_TEST_DB_PASS') ?: ''), + (string) (getenv('P202_TEST_DB_NAME') ?: 'prosper202'), + (int) (getenv('P202_TEST_DB_PORT') ?: 3306) + ); + if (!$db) { + return; + } + $db->query("SET SESSION sql_mode=''"); + (new SchemaInstaller($db))->install(); + self::$db = $db; + self::$conn = new Connection($db); + } + + public static function tearDownAfterClass(): void + { + if (self::$db) { + self::$db->close(); + self::$db = null; + self::$conn = null; + } + } + + protected function setUp(): void + { + if (self::$db === null) { + self::markTestSkipped('No test database configured (P202_TEST_DB_HOST).'); + } + foreach (['202_revenue_events', '202_revenue_line_items', '202_products', '202_subscriptions', '202_customers'] as $t) { + self::$db->query("TRUNCATE TABLE {$t}"); + } + $this->eventSeq = 0; + } + + // ── Fixture helpers ───────────────────────────────────────────────── + + private function product(int $id, string $name): void + { + self::$db->query( + "INSERT INTO 202_products SET product_id={$id}, user_id=1, external_product_id='ext-{$id}', name='" . + self::$db->real_escape_string($name) . "', created_at=1, updated_at=1" + ); + } + + /** One purchase order: an event with a single product line item. */ + private function order(int $customerId, int $productId, float $amount, float $qty = 1.0, string $type = 'purchase'): int + { + $eventId = ++$this->eventSeq + 100000; + self::$db->query( + "INSERT INTO 202_revenue_events SET event_id={$eventId}, user_id=1, customer_id={$customerId}, " . + "event_type='{$type}', amount={$amount}, occurred_at=1700000000, source='api', created_at=1" + ); + self::$db->query( + "INSERT INTO 202_revenue_line_items SET user_id=1, event_id={$eventId}, product_id={$productId}, " . + "quantity={$qty}, amount={$amount}, created_at=1" + ); + return $eventId; + } + + /** + * An active subscription whose renewal event bills for the given products + * (a bundle when more than one), each as its own line item. + * + * @param list $productIds + */ + private function activeSubscription(int $customerId, float $mrr, array $productIds, string $status = 'active'): void + { + $subId = ++$this->eventSeq + 500000; + self::$db->query( + "INSERT INTO 202_subscriptions SET subscription_id={$subId}, user_id=1, customer_id={$customerId}, " . + "external_sub_id='sub-{$subId}', amount={$mrr}, status='{$status}', mrr={$mrr}, started_at=1, " . + "current_period_start=1, current_period_end=2, created_at=1, updated_at=1" + ); + $eventId = ++$this->eventSeq + 100000; + self::$db->query( + "INSERT INTO 202_revenue_events SET event_id={$eventId}, user_id=1, customer_id={$customerId}, " . + "event_type='renewal', amount={$mrr}, occurred_at=1700000000, source='subscription', " . + "subscription_id={$subId}, created_at=1" + ); + foreach ($productIds as $pid) { + self::$db->query( + "INSERT INTO 202_revenue_line_items SET user_id=1, event_id={$eventId}, product_id={$pid}, " . + "quantity=1, amount=" . ($mrr / count($productIds)) . ", created_at=1" + ); + } + } + + private function repo(): MysqlLtvRepository + { + return new MysqlLtvRepository(self::$conn); + } + + /** @return array|null */ + private function productRow(int $productId): ?array + { + foreach ($this->repo()->breakdown(new LtvQuery(1), 'product', 100, 0) as $row) { + if ((int) $row['id'] === $productId) { + return $row; + } + } + return null; + } + + // ── Tests ─────────────────────────────────────────────────────────── + + public function testBreakdownComputesAovAndRepeatRate(): void + { + $this->product(1, 'Widget'); + // customer 1001 buys twice ($10 + $10), 1002 and 1003 once each ($10). + $this->order(1001, 1, 10.0); + $this->order(1001, 1, 10.0); + $this->order(1002, 1, 10.0); + $this->order(1003, 1, 10.0); + + $row = $this->productRow(1); + self::assertNotNull($row); + self::assertSame(3, (int) $row['customers']); + self::assertSame(4, (int) $row['orders']); + self::assertEqualsWithDelta(40.0, (float) $row['total_revenue'], 1e-6); + self::assertEqualsWithDelta(10.0, (float) $row['aov'], 1e-6); // 40 / 4 + // 1 of 3 customers repeat. MySQL division carries div_precision_increment + // (default 4) decimals, so 1/3 -> 0.3333 — the same precision the + // existing acquisition breakdown produces for this expression. + self::assertEqualsWithDelta(1 / 3, (float) $row['repeat_rate'], 1e-4); + self::assertEqualsWithDelta(0.0, (float) $row['mrr'], 1e-6); + } + + public function testSubscriberMrrAttributedToSingleProduct(): void + { + $this->product(1, 'Widget'); + $this->order(1001, 1, 10.0); + $this->activeSubscription(1001, 30.0, [1]); + + $row = $this->productRow(1); + self::assertNotNull($row); + self::assertEqualsWithDelta(30.0, (float) $row['mrr'], 1e-6); + } + + public function testBundleSubscriberMrrSplitEvenlyAndReconciles(): void + { + $this->product(1, 'Widget'); + $this->product(2, 'Gadget'); + $this->order(1001, 1, 10.0); + $this->order(1002, 2, 10.0); + // One $50/mo subscription billing for both products -> $25 each. + $this->activeSubscription(1001, 50.0, [1, 2]); + + $mrr1 = (float) $this->productRow(1)['mrr']; + $mrr2 = (float) $this->productRow(2)['mrr']; + self::assertEqualsWithDelta(25.0, $mrr1, 1e-6); + self::assertEqualsWithDelta(25.0, $mrr2, 1e-6); + // Additive: product MRR reconciles to the subscription's total. + self::assertEqualsWithDelta(50.0, $mrr1 + $mrr2, 1e-6); + } + + public function testCanceledSubscriptionContributesNoProductMrr(): void + { + $this->product(1, 'Widget'); + $this->order(1001, 1, 10.0); + $this->activeSubscription(1001, 30.0, [1], 'canceled'); + + self::assertEqualsWithDelta(0.0, (float) $this->productRow(1)['mrr'], 1e-6); + } + + public function testTimeWindowScopesRevenueButNotSubscriberState(): void + { + $this->product(1, 'Widget'); + // In-window order and an out-of-window one for the same customer. + $this->order(1001, 1, 10.0); // occurred_at = 1700000000 + self::$db->query( + "INSERT INTO 202_revenue_events SET event_id=999001, user_id=1, customer_id=1002, " . + "event_type='purchase', amount=999, occurred_at=1600000000, source='api', created_at=1" + ); + self::$db->query( + "INSERT INTO 202_revenue_line_items SET user_id=1, event_id=999001, product_id=1, quantity=1, amount=999, created_at=1" + ); + + $rows = $this->repo()->breakdown(new LtvQuery(1, 1699999999, 1700000001), 'product', 100, 0); + $row = null; + foreach ($rows as $r) { + if ((int) $r['id'] === 1) { + $row = $r; + } + } + self::assertNotNull($row); + self::assertSame(1, (int) $row['customers']); // out-of-window customer excluded + self::assertEqualsWithDelta(10.0, (float) $row['total_revenue'], 1e-6); + } + + public function testPredictUsesRealCohortProjectionForLargeProduct(): void + { + $this->product(1, 'Bestseller'); + // 20 customers, one $10 order each; 5 of them buy a second time. + for ($c = 1; $c <= 20; $c++) { + $this->order(2000 + $c, 1, 10.0); + } + for ($c = 1; $c <= 5; $c++) { + $this->order(2000 + $c, 1, 10.0); + } + + $result = $this->repo()->predict(new LtvQuery(1), 'product'); + $productRow = null; + foreach ($result['breakdown'] as $r) { + if ((int) $r['id'] === 1) { + $productRow = $r; + } + } + self::assertNotNull($productRow); + $prediction = $productRow['prediction']; + + // The regression: this used to be 'account_fallback' with a $0 (or + // account-average) number. It must now be a real per-product cohort. + self::assertSame('cohort', $prediction['basis']); + self::assertEqualsWithDelta(0.25, (float) $prediction['inputs']['repeat_rate'], 1e-6); + self::assertEqualsWithDelta(10.0, (float) $prediction['inputs']['aov'], 1e-6); + // aov / (1 - repeat_rate) = 10 / 0.75 = 13.3333... + self::assertEqualsWithDelta(13.33333, (float) $prediction['predicted_ltv_per_customer'], 1e-4); + } +} diff --git a/tests/Messaging/MessagingTransportAllowlistTest.php b/tests/Messaging/MessagingTransportAllowlistTest.php new file mode 100644 index 00000000..3e271691 --- /dev/null +++ b/tests/Messaging/MessagingTransportAllowlistTest.php @@ -0,0 +1,113 @@ +decide = new ReflectionMethod(\MessagingClient::class, 'transportProtocols'); + $this->decide->setAccessible(true); + } + + /** + * @dataProvider decisions + */ + public function testTheTransportDecision(string $url, ?int $expected): void + { + self::assertSame($expected, $this->decide->invoke(null, $url), $url); + } + + /** @return array */ + public static function decisions(): array + { + $httpsOnly = CURLPROTO_HTTPS; + $loopback = CURLPROTO_HTTPS | CURLPROTO_HTTP; + + return [ + 'central https' => ['https://my.tracking202.com/api/v3/messaging', $httpsOnly], + 'https any host' => ['https://10.0.0.9/messaging', $httpsOnly], + 'documented mock' => ['http://127.0.0.1:8787/messaging', $loopback], + 'loopback name' => ['http://localhost:8787/messaging', $loopback], + 'loopback v6' => ['http://[::1]:8787/messaging', $loopback], + 'loopback 127.x' => ['http://127.5.5.5:8787/messaging', $loopback], + 'uppercase scheme' => ['HTTP://127.0.0.1:8787/messaging', $loopback], + 'padded' => [" http://127.0.0.1:8787/messaging ", $loopback], + 'private lan' => ['http://10.0.0.9/messaging', null], + 'public cleartext' => ['http://my.tracking202.com/api/v3/messaging', null], + 'no scheme' => ['my.tracking202.com/api/v3/messaging', null], + 'empty' => ['', null], + 'http no host' => ['http:///messaging', null], + ]; + } + + public function testCleartextIsNeverGrantedToARefusedOrHttpsUrl(): void + { + // The security property, stated independently of the table above: HTTP + // appears in the mask only for an accepted loopback URL. + foreach (self::decisions() as $name => [$url, $expected]) { + $mask = $this->decide->invoke(null, $url); + if ($mask === null) { + continue; + } + self::assertNotSame(0, $mask & CURLPROTO_HTTPS, "$name: HTTPS must always be permitted"); + $grantsHttp = ($mask & CURLPROTO_HTTP) !== 0; + $isLoopbackCleartext = str_starts_with(strtolower(trim($url)), 'http://'); + self::assertSame($isLoopbackCleartext, $grantsHttp, "$name: cleartext must be granted exactly to accepted http:// (loopback) URLs"); + } + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function testTheConstructorStoresTheTrimmedUrlAndTheMatchingProtocols(): void + { + define('MESSAGING_API_URL', " http://127.0.0.1:8787/messaging "); + require_once __DIR__ . '/../../202-config/Messaging/MessagingClient.class.php'; + + $client = new \MessagingClient(); + + $baseUrl = new ReflectionProperty(\MessagingClient::class, 'baseUrl'); + $baseUrl->setAccessible(true); + self::assertSame('http://127.0.0.1:8787/messaging', $baseUrl->getValue($client), 'a padded URL must not reach curl padded'); + + $protocols = new ReflectionProperty(\MessagingClient::class, 'curlProtocols'); + $protocols->setAccessible(true); + self::assertSame(CURLPROTO_HTTPS | CURLPROTO_HTTP, $protocols->getValue($client)); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function testTheConstructorRefusesACleartextUrlThatIsNotLoopback(): void + { + define('MESSAGING_API_URL', 'http://my.tracking202.com/api/v3/messaging'); + require_once __DIR__ . '/../../202-config/Messaging/MessagingClient.class.php'; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('refusing to send credentials in cleartext'); + new \MessagingClient(); + } +} diff --git a/tests/Report/CampaignDataMaskTest.php b/tests/Report/CampaignDataMaskTest.php new file mode 100644 index 00000000..863fb8ee --- /dev/null +++ b/tests/Report/CampaignDataMaskTest.php @@ -0,0 +1,207 @@ +granted : false; + } + }; + } + + public function testHiddenWhenTheUserLacksThePermission(): void + { + $GLOBALS['userObj'] = self::userWithPermission(false); + self::assertTrue(CampaignDataMask::hidden()); + } + + public function testNotHiddenWhenGranted(): void + { + $GLOBALS['userObj'] = self::userWithPermission(true); + self::assertFalse(CampaignDataMask::hidden()); + } + + public function testPublishersAreExemptAndNoUserMeansNothingToHide(): void + { + $GLOBALS['userObj'] = self::userWithPermission(false); + $_SESSION['publisher'] = 1; + self::assertFalse(CampaignDataMask::hidden(), 'a publisher session is already scoped to its own data'); + + unset($_SESSION['publisher']); + $GLOBALS['userObj'] = null; + self::assertFalse(CampaignDataMask::hidden()); + } + + public function testApplyMasksTheMetricsAndTheCostWrapperButNothingElse(): void + { + $row = [ + 'clicks' => 10, 'click_out' => 5, 'ctr' => '50%', 'leads' => 1, 'su_ratio' => '10%', + 'payout' => '$4.00', 'epc' => '$0.40', 'cpc' => '$0.10', 'income' => '$4.00', + 'cost' => '$1.00', 'cost_wrapper' => '($1.00)', 'net' => '$3.00', 'roi' => '300%', + 'keyword' => 'shoes', + ]; + $masked = CampaignDataMask::apply($row); + + foreach (['clicks', 'click_out', 'leads', 'income', 'cost', 'cost_wrapper', 'net'] as $k) { + self::assertSame('?', $masked[$k], $k); + } + foreach (['ctr', 'su_ratio', 'payout', 'epc', 'cpc', 'roi', 'keyword'] as $k) { + self::assertSame($row[$k], $masked[$k], "$k must stay visible"); + } + self::assertSame(array_keys($row), array_keys($masked), 'apply() must not add or drop keys'); + } + + public function testApplyWithAPrefixTouchesOnlyThatPrefix(): void + { + $row = ['clicks' => 10, 'total_clicks' => 100, 'total_net' => '$9', 'total_cost_wrapper' => '($1)', 'rotator_clicks' => 7]; + $masked = CampaignDataMask::apply($row, 'total_'); + + self::assertSame(['clicks' => 10, 'total_clicks' => '?', 'total_net' => '?', 'total_cost_wrapper' => '?', 'rotator_clicks' => 7], $masked); + self::assertSame('?', CampaignDataMask::apply($row, 'rotator_')['rotator_clicks']); + } + + public function testApplyDeepMasksNestedRowsAndTheTotalsNode(): void + { + // The variable report's shape: network rows carrying nested variable + // rows carrying value rows, then a final node with only total_* keys. + $data = [ + [ + 0 => ['ppc_network_name' => 'Google', 'clicks' => 50, 'net' => '$5'], + 'variables' => [ + [0 => ['variable_name' => 'c1', 'clicks' => 20], 'values' => [['variable_value' => 'a', 'clicks' => 20, 'income' => '$2', 'roi' => '10%']]], + ], + ], + ['total_clicks' => 50, 'total_leads' => 2, 'total_income' => '$5', 'total_cost' => '$1', 'total_net' => '$4', 'total_roi' => '400%'], + ]; + $masked = CampaignDataMask::applyDeep($data); + + self::assertSame('?', $masked[0][0]['clicks']); + self::assertSame('?', $masked[0][0]['net']); + self::assertSame('Google', $masked[0][0]['ppc_network_name']); + self::assertSame('?', $masked[0]['variables'][0][0]['clicks']); + self::assertSame('?', $masked[0]['variables'][0]['values'][0]['clicks']); + self::assertSame('?', $masked[0]['variables'][0]['values'][0]['income']); + self::assertSame('10%', $masked[0]['variables'][0]['values'][0]['roi']); + // The whole point: the totals line. + foreach (['total_clicks', 'total_leads', 'total_income', 'total_cost', 'total_net'] as $k) { + self::assertSame('?', $masked[1][$k], $k); + } + self::assertSame('400%', $masked[1]['total_roi']); + } + + // ------------------------------------------------------------------ + // Tree shape + + public function testThePermissionIsNegatedOnlyInsideTheClass(): void + { + // `!$userObj->hasPermission('access_to_campaign_data')` is the masking + // decision. Positive gates (account_overview.php shows a section only + // to users WITH the permission) are a different question and allowed. + $offenders = []; + foreach (SourceScan::phpFiles() as $path => $source) { + if ($path === '202-config/Report/CampaignDataMask.php') { + continue; + } + if (SourceScan::countMatches('/!\s*\$\w+->hasPermission\(\s*[\'"]access_to_campaign_data[\'"]/', $source, $path) > 0) { + $offenders[] = $path; + } + } + + self::assertSame([], $offenders, 'These files restate the masking predicate instead of calling ' + . 'CampaignDataMask::hidden(); the copies drifted before (publisher exemption, null guard). ' + . "Found in:\n " . implode("\n ", $offenders)); + } + + public function testNoFileMasksAMetricByHand(): void + { + // `$x['net'] = '?'`, `$x['total_net'] = '?'`, `$x['rotator_cost_wrapper'] = '?'`: + // every hand-written mask is a place the key can disagree with the + // template. All of them go through CampaignDataMask::apply(). + $keys = [...CampaignDataMask::METRICS, CampaignDataMask::COST_WRAPPER]; + $pattern = "/\\\$\\w+\\[['\"](?:\\w+_)?(?:" . implode('|', array_map('preg_quote', $keys)) . ")['\"]\\]\\s*=\\s*'\\?'/"; + + $offenders = []; + foreach (SourceScan::phpFiles() as $path => $source) { + $n = SourceScan::countMatches($pattern, $source, $path, $m); + if ($n > 0) { + $offenders[$path] = $m[0]; + } + } + + self::assertSame([], $offenders, "These files mask a metric by hand instead of through CampaignDataMask::apply():\n" + . implode("\n", array_map( + static fn(string $f, array $hits): string => " $f: " . implode(', ', $hits), + array_keys($offenders), + $offenders + ))); + } + + public function testTotalsTemplatesInTheDataEnginePrintOnlyPrefixedMetrics(): void + { + // Totals rows are masked with the 'total_' prefix; a bare $x['net'] in + // one of those templates would render the real figure. + $source = SourceScan::phpFiles()['202-config/class-dataengine.php']; + $lines = preg_split('/\R/', $source) ?: []; + $inTotals = false; + $offenders = []; + foreach ($lines as $i => $line) { + if (str_contains($line, 'id="totals"')) { + $inTotals = true; + } + if ($inTotals) { + if (preg_match_all("/\\\$\\w+\\['(\\w+)'\\]/", $line, $m) > 0) { + foreach ($m[1] as $key) { + if (in_array($key, CampaignDataMask::METRICS, true) || $key === CampaignDataMask::COST_WRAPPER) { + $offenders[] = ($i + 1) . ": $key"; + } + } + } + if (str_contains($line, '')) { + $inTotals = false; + } + } + } + self::assertFalse($inTotals, 'a totals template was never closed with '); + self::assertSame([], $offenders, 'Totals templates print an unprefixed metric: ' . implode('; ', $offenders)); + } + + public function testTheVariableReportMasksThroughApplyDeep(): void + { + $source = SourceScan::phpFiles()['202-config/class-dataengine.php']; + self::assertSame(1, preg_match('/private function maskVariableData\(.*?\n \}/s', $source, $body)); + self::assertStringContainsString('CampaignDataMask::applyDeep(', $body[0]); + self::assertStringContainsString('CampaignDataMask::hidden()', $body[0]); + } +} diff --git a/tests/Rotator/MysqlRotatorRepositoryTest.php b/tests/Rotator/MysqlRotatorRepositoryTest.php index 315e1c0c..80d94792 100644 --- a/tests/Rotator/MysqlRotatorRepositoryTest.php +++ b/tests/Rotator/MysqlRotatorRepositoryTest.php @@ -47,6 +47,12 @@ public function testDeleteChecksOwnershipBeforeCascadeQueries(): void public function testUpdateRuleScopesUpdateToRotatorId(): void { $write = new FakeMysqliConnection(); + // The rule must resolve to the requested rotator for the ownership + // pre-check to pass. + $write->whenQueryContainsReturnRows( + 'SELECT rotator_id FROM 202_rotator_rules', + [['rotator_id' => 8]] + ); $conn = new Connection($write); $repo = new MysqlRotatorRepository($conn); @@ -58,4 +64,105 @@ public function testUpdateRuleScopesUpdateToRotatorId(): void self::assertSame('sii', $updates[0]->boundTypes); self::assertSame(['Updated', 5, 8], $updates[0]->boundValues); } + + public function testUpdateRuleRejectsRuleBelongingToAnotherRotator(): void + { + $write = new FakeMysqliConnection(); + // Rule 5 actually belongs to rotator 99, not the requested 8. + $write->whenQueryContainsReturnRows( + 'SELECT rotator_id FROM 202_rotator_rules', + [['rotator_id' => 99]] + ); + $conn = new Connection($write); + $repo = new MysqlRotatorRepository($conn); + + $this->expectException(\RuntimeException::class); + + try { + $repo->updateRule(5, 8, ['criteria' => [['type' => 'country', 'statement' => 'is', 'value' => 'US']]]); + } finally { + // The victim's criteria must never be deleted. + self::assertSame([], $write->statementsContaining('DELETE FROM 202_rotator_rules_criteria')); + } + } + + public function testDeleteRuleRejectsRuleBelongingToAnotherRotator(): void + { + $write = new FakeMysqliConnection(); + $write->whenQueryContainsReturnRows( + 'SELECT rotator_id FROM 202_rotator_rules', + [['rotator_id' => 99]] + ); + $conn = new Connection($write); + $repo = new MysqlRotatorRepository($conn); + + $this->expectException(\RuntimeException::class); + + try { + $repo->deleteRule(5, 8); + } finally { + self::assertSame([], $write->statementsContaining('DELETE FROM 202_rotator_rules_criteria')); + self::assertSame([], $write->statementsContaining('DELETE FROM 202_rotator_rules_redirects')); + } + } + + /** + * Rotators are matched between installs by public_id, so `p202 sync` sends + * the source's value. Rejecting it outright made the target assign its own, + * so the source never matched the target: every run re-created every rotator + * and remapping trackers' rotator_id failed with "unresolvable target foreign + * key". A free public_id must therefore be honoured. + */ + public function testCreateHonoursAFreeCallerSuppliedPublicId(): void + { + $write = new FakeMysqliConnection(); + // No row comes back for the freeness probe, so 4242424 is available. + $write->whenQueryContainsReturnRows('SELECT id FROM 202_rotators WHERE public_id = ?', []); + $conn = new Connection($write); + $repo = new MysqlRotatorRepository($conn); + + $repo->create(7, ['name' => 'Synced', 'public_id' => 4242424]); + + $inserts = $write->statementsContaining('INSERT INTO 202_rotators'); + self::assertCount(1, $inserts); + self::assertSame(4242424, $inserts[0]->boundValues[0]); + } + + /** + * The hazard the server-side derivation guards against is collision: public_id + * is resolved by the unauthenticated redirect with no user scoping and has no + * UNIQUE key. A value already in use must never be accepted. + */ + public function testCreateRejectsAnAlreadyTakenPublicIdAndGeneratesInstead(): void + { + $write = new FakeMysqliConnection(); + // Every freeness probe reports the candidate as taken, including the + // caller's, so create() must fall through to a generated id. + $write->whenQueryContainsReturnRows( + 'SELECT id FROM 202_rotators WHERE public_id = ?', + [['id' => 1]] + ); + $conn = new Connection($write); + $repo = new MysqlRotatorRepository($conn); + + $this->expectException(\RuntimeException::class); + $repo->create(7, ['name' => 'Colliding', 'public_id' => 4242424]); + } + + public function testCreateGeneratesAPublicIdWhenNoneSupplied(): void + { + $write = new FakeMysqliConnection(); + $write->whenQueryContainsReturnRows('SELECT id FROM 202_rotators WHERE public_id = ?', []); + $conn = new Connection($write); + $repo = new MysqlRotatorRepository($conn); + + $repo->create(7, ['name' => 'Fresh']); + + $inserts = $write->statementsContaining('INSERT INTO 202_rotators'); + self::assertCount(1, $inserts); + $generated = $inserts[0]->boundValues[0]; + self::assertIsInt($generated); + self::assertGreaterThanOrEqual(100000, $generated); + self::assertLessThanOrEqual(9999999, $generated); + } } diff --git a/tests/Support/SourceScan.php b/tests/Support/SourceScan.php new file mode 100644 index 00000000..db60dd30 --- /dev/null +++ b/tests/Support/SourceScan.php @@ -0,0 +1,377 @@ +conn()->commit()`, two statements on one line. Working on + * token_get_all() output instead of lines makes those shapes ordinary. + * + * Nothing here does type inference. That is deliberate: PHPStan rules cover + * the receivers whose type is known, and these scanners are the complement for + * the untyped legacy code where inference has nothing to work with. + */ +final class SourceScan +{ + /** + * Directories never descended into. vendor/node_modules/.git are not ours; + * the rest contain no PHP at all and are only skipped for speed -- + * testPrunedDirectoriesContainNoPhp() in SourceScanTest keeps that true. + */ + public const PRUNED = ['vendor', 'node_modules', '.git', '202-css', '202-img', 'documentation', 'docs', 'go-cli']; + + /** @var array> keyed by includeTests flag */ + private static array $cache = []; + + public static function repoRoot(): string + { + return dirname(__DIR__, 2); + } + + /** + * Every PHP source under the repository, repo-relative path => contents. + * tests/ is excluded by default because tests exercise failure shapes on + * purpose. + * + * @return array + */ + public static function phpFiles(bool $includeTests = false): array + { + $key = $includeTests ? 'with-tests' : 'no-tests'; + if (isset(self::$cache[$key])) { + return self::$cache[$key]; + } + + $root = self::repoRoot(); + $pruned = self::PRUNED; + if (!$includeTests) { + $pruned[] = 'tests'; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveCallbackFilterIterator( + new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), + static fn(\SplFileInfo $file): bool => !in_array($file->getFilename(), $pruned, true) + ) + ); + + $files = []; + foreach ($iterator as $file) { + if (!$file->isFile() || $file->getExtension() !== 'php') { + continue; + } + $path = $file->getPathname(); + $source = file_get_contents($path); + if ($source === false) { + // Never let an unreadable file scan as an empty one. + throw new RuntimeException("Could not read $path"); + } + $files[substr($path, strlen($root) + 1)] = $source; + } + ksort($files); + + return self::$cache[$key] = $files; + } + + /** + * preg_match_all() that refuses to report a failed scan as "no matches". + * A pattern that exhausts the backtrack limit on a large file returns + * false, which a scanner reading `$count > 0` treats as clean. + * + * @param array|null $matches receives preg_match_all()'s matches + */ + public static function countMatches(string $pattern, string $source, string $file, ?array &$matches = null): int + { + $hits = preg_match_all($pattern, $source, $matches); + if ($hits === false) { + throw new RuntimeException("Scanning $file failed: " . preg_last_error_msg()); + } + + return $hits; + } + + /** + * Lines on which a call to one of $methods (as `->name(...)` or + * `::name(...)`) or one of $functions (as a bare `name(...)`) forms a + * whole statement whose result is discarded: nothing between the previous + * statement boundary and the call but the receiver expression, and a `;` + * straight after the closing parenthesis. + * + * `$x = $db->commit();`, `if (!$db->commit())`, `return $db->commit();` + * and `$ok && $db->commit()` are all "used" and not reported. The receiver + * may be anything: `$db`, `$this->db`, `self::$db`, `$conns['w']`, + * `$this->conn()->getWrite()`. + * + * $zeroArgsOnly restricts to calls with an empty argument list. The + * execute() scanner needs it: the checked wrappers are also called + * `execute` (Connection::execute($stmt), StatementHelpers::execute($stmt, + * $message)) and are told apart from a raw `$stmt->execute()` only by + * taking arguments. + * + * @param list $methods + * @param list $functions + * @return list 1-based line numbers + */ + public static function uncheckedCallStatements(string $source, array $methods, array $functions = [], bool $zeroArgsOnly = false): array + { + $tokens = self::significantTokens($source); + $count = count($tokens); + $lines = []; + + for ($i = 0; $i < $count; $i++) { + $tok = $tokens[$i]; + if (!is_array($tok) || $tok[0] !== T_STRING) { + continue; + } + $name = $tok[1]; + $prev = $i > 0 ? $tokens[$i - 1] : null; + $isMethod = in_array($name, $methods, true) + && is_array($prev) + && in_array($prev[0], [T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR, T_DOUBLE_COLON], true); + $isFunction = in_array($name, $functions, true) + && !(is_array($prev) && in_array($prev[0], [T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR, T_DOUBLE_COLON, T_FUNCTION, T_NEW, T_CONST], true)) + && $prev !== '\\' && !(is_array($prev) && $prev[0] === T_NS_SEPARATOR); + if (!$isMethod && !$isFunction) { + continue; + } + // Must be a call: next token is "(". + if (($tokens[$i + 1] ?? null) !== '(') { + continue; + } + $close = self::matchingParen($tokens, $i + 1); + if ($close === null || ($tokens[$close + 1] ?? null) !== ';') { + continue; + } + if ($zeroArgsOnly && $close !== $i + 2) { + continue; + } + // Walk back to the statement boundary and make sure the prefix is + // only a receiver expression. + $start = $i; + while ($start > 0 && !self::isStatementBoundary($tokens[$start - 1])) { + $start--; + } + if (!self::isPureReceiver(array_slice($tokens, $start, $i - $start))) { + continue; + } + $lines[] = $tok[2]; + } + + return $lines; + } + + /** + * Lines on which `$var->close()` is called after `$var` was handed to one + * of $closingHelpers (methods that close the statement themselves) in the + * same function body, with no reassignment of `$var` in between. + * + * @param list $closingHelpers + * @return list 1-based line numbers of the redundant close() + */ + public static function closesAfterClosingHelper(string $source, array $closingHelpers): array + { + $tokens = self::significantTokens($source); + $count = count($tokens); + $functionEnds = self::functionBodyEnds($tokens); + $lines = []; + + for ($i = 0; $i < $count; $i++) { + $tok = $tokens[$i]; + if (!is_array($tok) || $tok[0] !== T_STRING || !in_array($tok[1], $closingHelpers, true)) { + continue; + } + $prev = $tokens[$i - 1] ?? null; + if (!is_array($prev) || !in_array($prev[0], [T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR, T_DOUBLE_COLON], true)) { + continue; + } + // helper( $var ... + $arg = $tokens[$i + 2] ?? null; + if (($tokens[$i + 1] ?? null) !== '(' || !is_array($arg) || $arg[0] !== T_VARIABLE) { + continue; + } + $var = $arg[1]; + $end = $functionEnds[$i] ?? $count - 1; + + for ($j = $i + 3; $j <= $end; $j++) { + $t = $tokens[$j]; + if (!is_array($t) || $t[0] !== T_VARIABLE || $t[1] !== $var) { + continue; + } + $next = $tokens[$j + 1] ?? null; + if ($next === '=') { + break; // reassigned: a fresh statement from here on + } + if (is_array($next) && in_array($next[0], [T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR], true)) { + $m = $tokens[$j + 2] ?? null; + if (is_array($m) && $m[0] === T_STRING && $m[1] === 'close' && ($tokens[$j + 3] ?? null) === '(') { + $lines[] = $m[2]; + } + } + } + } + + return array_values(array_unique($lines)); + } + + // ------------------------------------------------------------------- + + /** + * token_get_all() minus whitespace and comments, so adjacency checks mean + * "next meaningful token". Single-char tokens stay strings. + * + * @return list + */ + private static function significantTokens(string $source): array + { + $out = []; + foreach (token_get_all($source) as $t) { + if (is_array($t) && in_array($t[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT, T_INLINE_HTML, T_OPEN_TAG, T_CLOSE_TAG], true)) { + continue; + } + $out[] = $t; + } + + return $out; + } + + /** @param list $tokens */ + private static function matchingParen(array $tokens, int $open): ?int + { + $depth = 0; + $n = count($tokens); + for ($i = $open; $i < $n; $i++) { + $t = $tokens[$i]; + if ($t === '(') { + $depth++; + } elseif ($t === ')') { + $depth--; + if ($depth === 0) { + return $i; + } + } + } + + return null; + } + + /** @param string|array{0:int,1:string,2:int} $t */ + private static function isStatementBoundary(string|array $t): bool + { + if (is_string($t)) { + return in_array($t, [';', '{', '}', ':'], true); + } + + return in_array($t[0], [T_OPEN_TAG, T_OPEN_TAG_WITH_ECHO], true); + } + + /** + * True when the tokens form nothing but a receiver expression: variables, + * property/static access, identifiers, parenthesised calls, array offsets + * and literals. Any operator or keyword means the call's value is used. + * + * @param list $tokens + */ + private static function isPureReceiver(array $tokens): bool + { + // `if ($x) $db->commit();` -- a brace-less control clause is not part + // of the receiver, and the value is just as discarded. Strip it. + while ($tokens !== [] && is_array($tokens[0]) + && in_array($tokens[0][0], [T_IF, T_ELSEIF, T_WHILE, T_FOR, T_FOREACH, T_ELSE], true)) { + $head = array_shift($tokens); + if ($head[0] !== T_ELSE) { + $close = self::matchingParen($tokens, 0); + if ($close === null) { + return false; + } + $tokens = array_slice($tokens, $close + 1); + } + } + + foreach ($tokens as $t) { + if (is_string($t)) { + if (!in_array($t, ['(', ')', '[', ']', ',', '\\'], true)) { + return false; + } + continue; + } + if (!in_array($t[0], [ + T_VARIABLE, T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED, T_NS_SEPARATOR, + T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR, T_DOUBLE_COLON, + T_CONSTANT_ENCAPSED_STRING, T_LNUMBER, T_DNUMBER, T_STATIC, + ], true)) { + return false; + } + } + + return true; + } + + /** + * For every token index inside a function body, the index of the `}` + * that closes that body (innermost function wins). + * + * @param list $tokens + * @return array + */ + private static function functionBodyEnds(array $tokens): array + { + $n = count($tokens); + $ends = []; + // Pass 1: find each function body's "{" and its matching "}". + $bodies = []; + for ($i = 0; $i < $n; $i++) { + $t = $tokens[$i]; + if (!is_array($t) || $t[0] !== T_FUNCTION) { + continue; + } + // Skip to the body "{" (past the parameter list and return type); + // an abstract/interface method ends in ";" and has no body. + $j = $i + 1; + while ($j < $n && $tokens[$j] !== '{' && $tokens[$j] !== ';') { + if ($tokens[$j] === '(') { + $j = self::matchingParen($tokens, $j) ?? $n; + } + $j++; + } + if ($j >= $n || $tokens[$j] !== '{') { + continue; + } + $depth = 0; + for ($k = $j; $k < $n; $k++) { + if ($tokens[$k] === '{') { + $depth++; + } elseif ($tokens[$k] === '}') { + $depth--; + if ($depth === 0) { + $bodies[] = [$j, $k]; + break; + } + } + } + } + // Pass 2: innermost body wins -- later (nested) bodies overwrite. + usort($bodies, static fn(array $a, array $b): int => ($b[1] - $b[0]) <=> ($a[1] - $a[0])); + foreach ($bodies as [$open, $close]) { + for ($i = $open; $i <= $close; $i++) { + $ends[$i] = $close; + } + } + + return $ends; + } +} diff --git a/tests/Support/SourceScanTest.php b/tests/Support/SourceScanTest.php new file mode 100644 index 00000000..2cc74c5b --- /dev/null +++ b/tests/Support/SourceScanTest.php @@ -0,0 +1,144 @@ +begin_transaction();\n" // 2 + . "\$this->db->begin_transaction();\n" // 3 + . "\$db->begin_transaction(MYSQLI_TRANS_START_READ_WRITE);\n" // 4 arguments + . "\$db->commit(0, \"name\");\n" // 5 arguments + . "\$conn->begin_transaction(); // start\n" // 6 trailing comment + . "\$this->getDb()->begin_transaction();\n" // 7 chained receiver + . "DB::getInstance()->getConnection()->commit();\n" // 8 static chain + . "self::\$db->commit();\n" // 9 static property + . "\$this->connections['w']->commit();\n" // 10 array element + . "\$db->begin_transaction();\$db->commit();\n" // 11 two per line + . "\$this->db->commit();\r\n" // 12 CRLF + . "\$db\n ->commit(\n );\n" // 14 multi-line (name on 14) + . "mysqli_begin_transaction(\$db);\n" // 16 procedural + . "mysqli_commit(\$db);\n" // 17 + . "\$db->autocommit(false);\n" // 18 + . "\$db->commit() ;\n" // 19 space before ; + . "if (\$x) \$db->commit();\n" // 20 brace-less if + . "else \$db->commit();\n"; // 21 brace-less else + + self::assertSame( + [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 11, 12, 14, 16, 17, 18, 19, 20, 21], + SourceScan::uncheckedCallStatements($source, self::TX_METHODS, self::TX_FUNCTIONS) + ); + } + + public function testCheckedCallsAreNotReported(): void + { + $source = "begin_transaction()) { throw new E(); }\n" + . "\$ok = \$db->begin_transaction();\n" + . "return \$db->commit();\n" + . "\$ok && \$db->commit();\n" + . "\$r = \$a ? \$db->commit() : false;\n" + . "if (\$x) \$ok = \$db->commit();\n" + . "if (!mysqli_commit(\$db)) { return; }\n" + . "\$db->rollback();\n" // not in the list + . "\$stmt->execute();\n" // not in the list + . "\$x->committed();\n" // different name + . "\$c = new Commit(); commit();\n"; // bare function not in the function list + + self::assertSame([], SourceScan::uncheckedCallStatements($source, self::TX_METHODS, self::TX_FUNCTIONS)); + } + + public function testZeroArgsOnlyTellsRawExecuteFromTheCheckedWrappers(): void + { + $source = "execute();\n" // 2: raw + . "\$this->conn->execute(\$stmt);\n" // wrapper: has an argument + . "\$this->execute(\$stmt, 'Create failed');\n" // wrapper + . "\$stmt->execute(); // c\n"; // 5: raw + + self::assertSame([2, 5], SourceScan::uncheckedCallStatements($source, ['execute'], [], true)); + self::assertSame([2, 3, 4, 5], SourceScan::uncheckedCallStatements($source, ['execute'])); + } + + public function testEveryDoubleCloseShapeIsFound(): void + { + $source = "conn->fetchOne(\$stmt) ?? [];\n \$stmt->close(); }\n" // 4 + . " function b() { if (\$this->conn->fetchOne(\$stmt) === null) { return; }\n \$stmt->close(); }\n" // 6 + . " function c() { \$rows = \$this->conn->fetchAll(\$stmt);\n /* c */\n \$stmt->close(); }\n" // 9 + . " function d() { \$id = \$this->conn->executeInsert(\$stmt);\n # note\n \$stmt->close(); }\n" // 12 + . " function e() { \$n = \$this->conn->executeUpdate(\$stmt);\n if (\$x) {}\n \$stmt->close(); }\n" // 15 + . " function f() { if (\$x) { \$this->conn->fetchOne(\$stmt); }\n \$stmt->close(); }\n" // 17 (double on the if path) + . "}\n"; + + self::assertSame( + [4, 6, 9, 12, 15, 17], + SourceScan::closesAfterClosingHelper($source, ['fetchOne', 'fetchAll', 'executeInsert', 'executeUpdate']) + ); + } + + public function testLegitimateClosesAreNotReported(): void + { + $source = "conn->fetchOne(\$stmt);\n \$stmt = \$this->conn->prepareRead('x');\n \$stmt->close(); }\n" // reassigned + . " function g() { \$row = \$this->conn->fetchOne(\$stmt); }\n function h() { \$stmt->close(); }\n" // other function + . " function i() { \$row = \$this->conn->fetchOne(\$stmt); \$other->close(); }\n" // other variable + . " function j() { \$this->conn->execute(\$stmt); \$stmt->close(); }\n" // execute() does not close + . "}\n"; + + self::assertSame([], SourceScan::closesAfterClosingHelper($source, ['fetchOne', 'fetchAll', 'executeInsert', 'executeUpdate'])); + } + + public function testCountMatchesRefusesToReportAFailedScanAsClean(): void + { + // A nested quantifier over a long input exhausts the backtrack limit; + // preg_match_all() returns false, which must not read as zero. + $pathological = str_repeat('a', 100000) . '!'; + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Scanning x.php failed'); + SourceScan::countMatches('/^(a+)+$/', $pathological, 'x.php'); + } + + public function testPrunedDirectoriesContainNoPhp(): void + { + // PRUNED skips these for speed on the claim that they hold no PHP. If + // that stops being true the scanners go blind to whatever lands there, + // so the claim is checked rather than assumed. + $root = SourceScan::repoRoot(); + foreach (SourceScan::PRUNED as $dir) { + if (in_array($dir, ['vendor', 'node_modules', '.git'], true) || !is_dir("$root/$dir")) { + continue; + } + $php = []; + $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator("$root/$dir", \FilesystemIterator::SKIP_DOTS)); + foreach ($it as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + $php[] = substr($file->getPathname(), strlen($root) + 1); + } + } + self::assertSame([], $php, "$dir/ is pruned from the source scans but now contains PHP; remove it from SourceScan::PRUNED."); + } + } + + public function testTheWalkFindsTheTreeAndExcludesTestsByDefault(): void + { + $files = SourceScan::phpFiles(); + self::assertGreaterThan(400, count($files)); + self::assertArrayHasKey('api/v3/Support/StatementHelpers.php', $files); + self::assertArrayNotHasKey('tests/Support/SourceScan.php', $files); + self::assertArrayHasKey('tests/Support/SourceScan.php', SourceScan::phpFiles(includeTests: true)); + } +} diff --git a/tests/Validation/OutboundUrlGuardTest.php b/tests/Validation/OutboundUrlGuardTest.php new file mode 100644 index 00000000..a08f3fd5 --- /dev/null +++ b/tests/Validation/OutboundUrlGuardTest.php @@ -0,0 +1,154 @@ +expectException(OutboundUrlException::class); + $this->expectExceptionMessageMatches('/' . preg_quote($expectedFragment, '/') . '/'); + OutboundUrlGuard::assertWellFormed($url, 'webhook_url'); + } + + public function testWellFormedAcceptsAPublicLiteralAndAHostnameWithoutResolving(): void + { + OutboundUrlGuard::assertWellFormed('https://203.0.113.10/hook'); + // A hostname is not resolved at the write boundary: the point is that a + // resolver stall cannot block the request, and the cron re-checks anyway. + OutboundUrlGuard::assertWellFormed('https://this-host-must-not-be-looked-up.invalid/hook'); + $this->addToAssertionCount(2); + } + + public function testExceptionIsARuntimeExceptionForExistingCatchSites(): void + { + self::assertInstanceOf(\RuntimeException::class, new OutboundUrlException('x')); + } + + // ---- dispatch -------------------------------------------------------- + + /** + * @dataProvider rejectedUrls + */ + public function testAllowedRejectsTheSameUrls(string $url, string $expectedFragment): void + { + $this->expectException(OutboundUrlException::class); + $this->expectExceptionMessageMatches('/' . preg_quote($expectedFragment, '/') . '/'); + OutboundUrlGuard::assertAllowed($url, 'webhook_url'); + } + + public function testLiteralHostIsReturnedForPinning(): void + { + self::assertSame(['203.0.113.10'], OutboundUrlGuard::assertAllowed('https://203.0.113.10/hook')); + } + + /** @return array */ + public static function rejectedUrls(): array + { + return [ + 'cleartext' => ['http://203.0.113.10/hook', 'valid https:// URL'], + 'no host' => ['https:///hook', 'valid https:// URL'], + 'not a url' => ['not a url', 'valid https:// URL'], + 'disallowed port' => ['https://203.0.113.10:9000/hook', 'port must be one of'], + 'loopback literal' => ['https://127.0.0.1/hook', 'private or reserved'], + 'link local' => ['https://169.254.169.254/hook', 'private or reserved'], + 'private literal' => ['https://10.0.0.5/hook', 'private or reserved'], + 'cgnat literal' => ['https://100.64.0.1/hook', '100.64.0.0/10'], + 'benchmark range' => ['https://198.18.0.1/hook', '198.18.0.0/15'], + 'multicast' => ['https://224.0.0.1/hook', '224.0.0.0/4'], + ]; + } + + // ---- pinning --------------------------------------------------------- + + public function testResolveEntryPrefersIpv4(): void + { + self::assertSame( + 'example.com:443:203.0.113.10', + OutboundUrlGuard::curlResolveEntry('https://example.com/hook', ['2001:db8::1', '203.0.113.10']) + ); + } + + public function testResolveEntryBracketsIpv6WhenThereIsNoIpv4(): void + { + // Unbracketed, "example.com:443:2001:db8::1" has more colons than curl's + // HOST:PORT:ADDRESS grammar allows; curl rejects the entry and resolves + // the host itself, quietly reopening the DNS-rebinding hole. + self::assertSame( + 'example.com:443:[2001:db8::1]', + OutboundUrlGuard::curlResolveEntry('https://example.com/hook', ['2001:db8::1']) + ); + } + + public function testResolveEntryHonoursAnExplicitPort(): void + { + self::assertSame( + 'example.com:8443:203.0.113.10', + OutboundUrlGuard::curlResolveEntry('https://example.com:8443/hook', ['203.0.113.10']) + ); + } + + public function testNothingToPinIsAnErrorNotAnUnpinnedSend(): void + { + // The old contract returned null here and both crons then sent + // unpinned -- a fail-open shape with the pinning code still present. + $this->expectException(OutboundUrlException::class); + $this->expectExceptionMessage('Refusing to send unpinned'); + OutboundUrlGuard::curlResolveEntry('https://example.com/hook', []); + } + + public function testCurlOptionsCarryEveryHardeningSetting(): void + { + $opts = OutboundUrlGuard::curlOptions('https://example.com/hook', ['203.0.113.10']); + + self::assertSame(['example.com:443:203.0.113.10'], $opts[CURLOPT_RESOLVE]); + self::assertFalse($opts[CURLOPT_FOLLOWLOCATION]); + self::assertSame(0, $opts[CURLOPT_MAXREDIRS]); + self::assertSame(CURLPROTO_HTTPS, $opts[CURLOPT_PROTOCOLS]); + self::assertSame(CURLPROTO_HTTPS, $opts[CURLOPT_REDIR_PROTOCOLS]); + self::assertTrue($opts[CURLOPT_SSL_VERIFYPEER]); + self::assertSame(2, $opts[CURLOPT_SSL_VERIFYHOST]); + self::assertGreaterThan(0, $opts[CURLOPT_CONNECTTIMEOUT]); + self::assertGreaterThan(0, $opts[CURLOPT_TIMEOUT]); + } + + public function testEveryDispatcherUsesTheSharedCurlOptions(): void + { + // The hardening set exists once so no dispatcher can drop an entry. A + // new cron that posts to a user-supplied URL must go through it too. + foreach (\Tests\Support\SourceScan::phpFiles() as $path => $source) { + if (!str_contains($source, 'CURLOPT_RESOLVE') || $path === '202-config/Validation/OutboundUrlGuard.php') { + continue; + } + self::fail("$path sets CURLOPT_RESOLVE itself; use OutboundUrlGuard::curlOptions() so the pin and the rest of the hardening cannot diverge."); + } + foreach (['202-cronjobs/attribution-export.php', '202-cronjobs/ltv_webhooks.php'] as $cron) { + self::assertStringContainsString( + 'OutboundUrlGuard::curlOptions(', + \Tests\Support\SourceScan::phpFiles()[$cron], + "$cron must apply OutboundUrlGuard::curlOptions()" + ); + } + } +} diff --git a/tracking202/Report/Json/FlatReportPayloadBuilder.php b/tracking202/Report/Json/FlatReportPayloadBuilder.php index 3273b2a7..11b83a78 100644 --- a/tracking202/Report/Json/FlatReportPayloadBuilder.php +++ b/tracking202/Report/Json/FlatReportPayloadBuilder.php @@ -4,6 +4,7 @@ namespace Tracking202\Report\Json; +use Prosper202\Report\CampaignDataMask; use UserPrefs; final class FlatReportPayloadBuilder @@ -402,13 +403,10 @@ private static function buildFlaggedLocationPayload(string $name, string $countr private static function campaignDataRestricted(): bool { - global $userObj; - - return (bool) ( - $userObj - && !$userObj->hasPermission('access_to_campaign_data') - && empty($_SESSION['publisher']) - ); + // One decision for every report surface; see CampaignDataMask. The + // metric cells above are still built here because this payload wraps + // each value in a {display, tone} cell rather than a raw row. + return CampaignDataMask::hidden(); } /** diff --git a/tracking202/ajax/generate_tracking_link.php b/tracking202/ajax/generate_tracking_link.php index 84324ef0..7a60e6ae 100755 --- a/tracking202/ajax/generate_tracking_link.php +++ b/tracking202/ajax/generate_tracking_link.php @@ -133,15 +133,34 @@ WHERE 202_trackers.tracker_id_public = '".$mysql['tracker_id_public']."' AND 202_trackers.user_id = '".$mysql['user_id']."'"; $get_tracker_result = $db->query($get_tracker_sql); - $get_tracker_row = $get_tracker_result->fetch_assoc(); - - if ($get_tracker_result->num_rows > 0) { - $drop_tracker = "DELETE FROM 202_trackers WHERE tracker_id = '".$get_tracker_row['tracker_id']."'"; - $drop_tracker_result = $db->query($drop_tracker); + if (!$get_tracker_result) { + record_mysql_error($get_tracker_sql); } + $get_tracker_row = $get_tracker_result->fetch_assoc(); } - $db->begin_transaction(); + // The transaction opens BEFORE the edit path's DELETE. Editing a tracker is + // implemented as delete-then-recreate, and with the DELETE outside the + // transaction any failure of the INSERT/UPDATE below (or of begin itself) + // destroyed the user's tracker and never replaced it -- the rollback could + // not reach a row deleted in autocommit. + // + // begin_transaction() is checked because under this bootstrap's + // mysqli_report(MYSQLI_REPORT_STRICT) a failure returns false rather than + // throwing; ignoring it would run everything below in autocommit with the + // rollback() calls undoing nothing. record_mysql_error() is declared + // `never` -- it logs mysqli_error($db) and exits -- so nothing follows it. + if (!$db->begin_transaction()) { + record_mysql_error('begin_transaction() for tracker creation'); + } + + if (isset($get_tracker_result) && $get_tracker_result->num_rows > 0) { + $drop_tracker = "DELETE FROM 202_trackers WHERE tracker_id = '".$get_tracker_row['tracker_id']."'"; + if (!$db->query($drop_tracker)) { + $db->rollback(); + record_mysql_error($drop_tracker); + } + } $tracker_sql = "INSERT INTO `202_trackers` SET `user_id`='".$mysql['user_id']."', @@ -183,7 +202,16 @@ die('Error setting tracker ID'); } - $db->commit(); + if (!$db->commit()) { + // Capture the cause before rollback() overwrites mysqli_error($db), and + // roll back BEFORE record_mysql_error(): that helper INSERTs into + // 202_mysql_errors on this same connection, and a still-open failed + // transaction would swallow that row along with everything else. + $commitError = $db->error; + $db->rollback(); + error_log('generate_tracking_link: commit failed: ' . $commitError); + record_mysql_error('commit() for tracker creation: ' . $commitError); + } $parsed_url = []; if (!empty($landing_page_row['landing_page_url'])) { diff --git a/tracking202/ajax/sort_rotator.php b/tracking202/ajax/sort_rotator.php index b18accc1..4ce30b59 100755 --- a/tracking202/ajax/sort_rotator.php +++ b/tracking202/ajax/sort_rotator.php @@ -3,8 +3,16 @@ declare(strict_types=1); include_once(substr(__DIR__, 0, -17) . '/202-config/connect.php'); +use Prosper202\Report\CampaignDataMask; + AUTH::require_user(); +// Decided once for the whole screen. This is the same predicate every other +// report surface uses -- including the publisher exemption, which this file +// used to skip, and the null-$userObj guard -- and the same metric list +// (click_out was missing here). +$campaignDataHidden = CampaignDataMask::hidden(); + //set the timezone for the user, for entering their dates. AUTH::set_timezone($_SESSION['user_timezone']); @@ -161,12 +169,8 @@ $html['rotator_roi'] = htmlentities($roi . '%', ENT_QUOTES, 'UTF-8'); $html['rotator_cost_wrapper'] = '(' . $html['rotator_cost'] . ')'; - if (!$userObj->hasPermission("access_to_campaign_data")) { - $html['rotator_clicks'] = '?'; - $html['rotator_leads'] = '?'; - $html['rotator_income'] = '?'; - $html['rotator_cost_wrapper'] = '?'; - $html['rotator_net'] = '?'; + if ($campaignDataHidden) { + $html = CampaignDataMask::apply($html, 'rotator_'); } ?> @@ -245,12 +249,8 @@ $html['rule_roi'] = htmlentities($rule_roi . '%', ENT_QUOTES, 'UTF-8'); $html['rule_cost_wrapper'] = '(' . $html['rule_cost'] . ')'; - if (!$userObj->hasPermission("access_to_campaign_data")) { - $html['rule_clicks'] = '?'; - $html['rule_leads'] = '?'; - $html['rule_income'] = '?'; - $html['rule_cost_wrapper'] = '?'; - $html['rule_net'] = '?'; + if ($campaignDataHidden) { + $html = CampaignDataMask::apply($html, 'rule_'); } ?> @@ -324,12 +324,8 @@ $html['default_cost_wrapper'] = '(' . $html['default_cost'] . ')'; - if (!$userObj->hasPermission("access_to_campaign_data")) { - $html['default_clicks'] = '?'; - $html['default_leads'] = '?'; - $html['default_income'] = '?'; - $html['default_cost_wrapper'] = '?'; - $html['default_net'] = '?'; + if ($campaignDataHidden) { + $html = CampaignDataMask::apply($html, 'default_'); } ?> @@ -378,12 +374,8 @@ $html['total_cost_wrapper'] = '(' . $html['total_cost'] . ')'; - if (!$userObj->hasPermission("access_to_campaign_data")) { - $html['total_clicks'] = '?'; - $html['total_leads'] = '?'; - $html['total_income'] = '?'; - $html['total_cost_wrapper'] = '?'; - $html['total_net'] = '?'; + if ($campaignDataHidden) { + $html = CampaignDataMask::apply($html, 'total_'); } ?> diff --git a/tracking202/analyze/browser_download.php b/tracking202/analyze/browser_download.php index 36b89d14..9e5f2071 100755 --- a/tracking202/analyze/browser_download.php +++ b/tracking202/analyze/browser_download.php @@ -21,6 +21,7 @@ $mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']); $user_sql = "SELECT user_pref_breakdown, user_pref_show, user_cpc_or_cpv FROM 202_users_pref WHERE user_id=" . $mysql['user_id']; $user_result = _mysqli_query($user_sql); +if (!$user_result) { record_mysql_error($user_sql); } $user_row = $user_result->fetch_assoc(); $breakdown = $user_row['user_pref_breakdown']; $cpv = ($user_row['user_cpc_or_cpv'] == 'cpv'); diff --git a/tracking202/analyze/cities_download.php b/tracking202/analyze/cities_download.php index bce88ec4..254bf3c1 100755 --- a/tracking202/analyze/cities_download.php +++ b/tracking202/analyze/cities_download.php @@ -21,6 +21,7 @@ $mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']); $user_sql = "SELECT user_pref_breakdown, user_pref_show, user_cpc_or_cpv FROM 202_users_pref WHERE user_id=" . $mysql['user_id']; $user_result = _mysqli_query($user_sql); +if (!$user_result) { record_mysql_error($user_sql); } $user_row = $user_result->fetch_assoc(); $breakdown = $user_row['user_pref_breakdown']; $cpv = ($user_row['user_cpc_or_cpv'] == 'cpv'); diff --git a/tracking202/analyze/countries_download.php b/tracking202/analyze/countries_download.php index 96c2d3f7..be49762c 100755 --- a/tracking202/analyze/countries_download.php +++ b/tracking202/analyze/countries_download.php @@ -20,6 +20,7 @@ $mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']); $user_sql = "SELECT user_pref_breakdown, user_pref_show, user_cpc_or_cpv FROM 202_users_pref WHERE user_id=".$mysql['user_id']; $user_result = _mysqli_query($user_sql); +if (!$user_result) { record_mysql_error($user_sql); } $user_row = $user_result->fetch_assoc(); $breakdown = $user_row['user_pref_breakdown']; $cpv = ($user_row['user_cpc_or_cpv'] == 'cpv'); diff --git a/tracking202/analyze/device_download.php b/tracking202/analyze/device_download.php index 4a54fd12..e747511c 100755 --- a/tracking202/analyze/device_download.php +++ b/tracking202/analyze/device_download.php @@ -20,6 +20,7 @@ $mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']); $user_sql = "SELECT user_pref_breakdown, user_pref_show, user_cpc_or_cpv FROM 202_users_pref WHERE user_id=".$mysql['user_id']; $user_result = _mysqli_query($user_sql); +if (!$user_result) { record_mysql_error($user_sql); } $user_row = $user_result->fetch_assoc(); $breakdown = $user_row['user_pref_breakdown']; $cpv = ($user_row['user_cpc_or_cpv'] == 'cpv'); diff --git a/tracking202/analyze/ips_download.php b/tracking202/analyze/ips_download.php index 74bcae3d..fa00e205 100644 --- a/tracking202/analyze/ips_download.php +++ b/tracking202/analyze/ips_download.php @@ -20,6 +20,7 @@ $mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']); $user_sql = "SELECT user_pref_breakdown, user_pref_show, user_cpc_or_cpv FROM 202_users_pref WHERE user_id=".$mysql['user_id']; $user_result = _mysqli_query($user_sql); +if (!$user_result) { record_mysql_error($user_sql); } $user_row = $user_result->fetch_assoc(); $breakdown = $user_row['user_pref_breakdown']; $cpv = ($user_row['user_cpc_or_cpv'] == 'cpv'); diff --git a/tracking202/analyze/isps_download.php b/tracking202/analyze/isps_download.php index c380b7c9..0e1fd240 100755 --- a/tracking202/analyze/isps_download.php +++ b/tracking202/analyze/isps_download.php @@ -21,6 +21,7 @@ $mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']); $user_sql = "SELECT user_pref_breakdown, user_pref_show, user_cpc_or_cpv FROM 202_users_pref WHERE user_id=" . $mysql['user_id']; $user_result = _mysqli_query($user_sql); +if (!$user_result) { record_mysql_error($user_sql); } $user_row = $user_result->fetch_assoc(); $breakdown = $user_row['user_pref_breakdown']; $cpv = ($user_row['user_cpc_or_cpv'] == 'cpv'); diff --git a/tracking202/analyze/keywords_download.php b/tracking202/analyze/keywords_download.php index 5b07f759..5fea33c5 100755 --- a/tracking202/analyze/keywords_download.php +++ b/tracking202/analyze/keywords_download.php @@ -20,6 +20,7 @@ $mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']); $user_sql = "SELECT user_pref_breakdown, user_pref_show, user_cpc_or_cpv FROM 202_users_pref WHERE user_id=".$mysql['user_id']; $user_result = _mysqli_query($user_sql); +if (!$user_result) { record_mysql_error($user_sql); } $user_row = $user_result->fetch_assoc(); $breakdown = $user_row['user_pref_breakdown']; $cpv = ($user_row['user_cpc_or_cpv'] == 'cpv'); diff --git a/tracking202/analyze/landing_pages_download.php b/tracking202/analyze/landing_pages_download.php index 42c6ff48..ba687d24 100644 --- a/tracking202/analyze/landing_pages_download.php +++ b/tracking202/analyze/landing_pages_download.php @@ -21,6 +21,7 @@ $mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']); $user_sql = "SELECT user_pref_breakdown, user_pref_show, user_cpc_or_cpv FROM 202_users_pref WHERE user_id=" . $mysql['user_id']; $user_result = _mysqli_query($user_sql); +if (!$user_result) { record_mysql_error($user_sql); } $user_row = $user_result->fetch_assoc(); $breakdown = $user_row['user_pref_breakdown']; $cpv = ($user_row['user_cpc_or_cpv'] == 'cpv'); diff --git a/tracking202/analyze/platform_download.php b/tracking202/analyze/platform_download.php index c2b09061..6f4005cd 100755 --- a/tracking202/analyze/platform_download.php +++ b/tracking202/analyze/platform_download.php @@ -20,6 +20,7 @@ $mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']); $user_sql = "SELECT user_pref_breakdown, user_pref_show, user_cpc_or_cpv FROM 202_users_pref WHERE user_id=".$mysql['user_id']; $user_result = _mysqli_query($user_sql); +if (!$user_result) { record_mysql_error($user_sql); } $user_row = $user_result->fetch_assoc(); $breakdown = $user_row['user_pref_breakdown']; $cpv = ($user_row['user_cpc_or_cpv'] == 'cpv'); diff --git a/tracking202/analyze/regions_download.php b/tracking202/analyze/regions_download.php index 79e395e3..f91cd889 100755 --- a/tracking202/analyze/regions_download.php +++ b/tracking202/analyze/regions_download.php @@ -13,8 +13,8 @@ AUTH::require_user(); $time = grab_timeframe(); -$mysql['to'] = $db->real_escape_string($time['to']); -$mysql['from'] = $db->real_escape_string($time['from']); +$mysql['to'] = $db->real_escape_string((string)$time['to']); +$mysql['from'] = $db->real_escape_string((string)$time['from']); $mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']); diff --git a/tracking202/analyze/text_ads_download.php b/tracking202/analyze/text_ads_download.php index c28655fc..750a857c 100644 --- a/tracking202/analyze/text_ads_download.php +++ b/tracking202/analyze/text_ads_download.php @@ -13,8 +13,8 @@ AUTH::require_user(); $time = grab_timeframe(); -$mysql['to'] = $db->real_escape_string($time['to']); -$mysql['from'] = $db->real_escape_string($time['from']); +$mysql['to'] = $db->real_escape_string((string)$time['to']); +$mysql['from'] = $db->real_escape_string((string)$time['from']); $mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']); diff --git a/tracking202/analyze/variables_download.php b/tracking202/analyze/variables_download.php index 9c08c4ef..c4192e71 100755 --- a/tracking202/analyze/variables_download.php +++ b/tracking202/analyze/variables_download.php @@ -13,13 +13,14 @@ AUTH::require_user(); $time = grab_timeframe(); -$mysql['to'] = $db->real_escape_string($time['to']); -$mysql['from'] = $db->real_escape_string($time['from']); +$mysql['to'] = $db->real_escape_string((string)$time['to']); +$mysql['from'] = $db->real_escape_string((string)$time['from']); $mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']); $user_sql = "SELECT user_pref_breakdown, user_pref_show, user_cpc_or_cpv FROM 202_users_pref WHERE user_id=".$mysql['user_id']; $user_result = _mysqli_query($user_sql); +if (!$user_result) { record_mysql_error($user_sql); } $user_row = $user_result->fetch_assoc(); $breakdown = $user_row['user_pref_breakdown']; $cpv = ($user_row['user_cpc_or_cpv'] == 'cpv'); diff --git a/tracking202/overview/group_overview_download.php b/tracking202/overview/group_overview_download.php index db5c0a36..65d5659a 100644 --- a/tracking202/overview/group_overview_download.php +++ b/tracking202/overview/group_overview_download.php @@ -10,14 +10,15 @@ //grab the users date range preferences $time = grab_timeframe(); - $mysql['to'] = $db->real_escape_string($time['to']); - $mysql['from'] = $db->real_escape_string($time['from']); + $mysql['to'] = $db->real_escape_string((string)$time['to']); + $mysql['from'] = $db->real_escape_string((string)$time['from']); //show real or filtered clicks $mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']); $user_sql = "SELECT * FROM 202_users_pref WHERE user_id=".$mysql['user_id']; $user_result = _mysqli_query($user_sql); + if (!$user_result) { record_mysql_error($user_sql); } $user_row = $user_result->fetch_assoc(); $html['user_pref_group_1'] = htmlentities((string)($user_row['user_pref_group_1'] ?? ''), ENT_QUOTES, 'UTF-8'); @@ -40,7 +41,9 @@ $mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']); - $info_result = _mysqli_query($summary_form->getQuery($mysql['user_id'],$user_row)); + $info_sql = $summary_form->getQuery($mysql['user_id'],$user_row); + $info_result = _mysqli_query($info_sql); + if (!$info_result) { record_mysql_error($info_sql); } while ($row = $info_result->fetch_assoc()) { $summary_form->addReportData($row); } diff --git a/tracking202/redirect/dl.php b/tracking202/redirect/dl.php index e0d83260..5b636071 100644 --- a/tracking202/redirect/dl.php +++ b/tracking202/redirect/dl.php @@ -301,51 +301,51 @@ function renderErrorPage(int $code, string $title, string $message, string $acce case "bidded": #try to get the bidded keyword first if (isset($_GET['OVKEY'])) { //if this is a Y! keyword - $keyword = $db->real_escape_string((string)$_GET['OVKEY']); + $keyword = (string)$_GET['OVKEY']; } elseif (isset($_GET['t202kw'])) { - $keyword = $db->real_escape_string((string)$_GET['t202kw']); + $keyword = (string)$_GET['t202kw']; } elseif (isset($_GET['target_passthrough'])) { //if this is a mediatraffic! keyword - $keyword = $db->real_escape_string((string)$_GET['target_passthrough']); + $keyword = (string)$_GET['target_passthrough']; } else { //if this is a zango, or more keyword - $keyword = $db->real_escape_string((string)($_GET['keyword'] ?? '')); + $keyword = (string)($_GET['keyword'] ?? ''); } break; case "searched": #try to get the searched keyword if (isset($referer_query['q'])) { - $keyword = $db->real_escape_string($referer_query['q']); + $keyword = $referer_query['q']; } elseif (isset($_GET['OVRAW'])) { //if this is a Y! keyword - $keyword = $db->real_escape_string((string)$_GET['OVRAW']); + $keyword = (string)$_GET['OVRAW']; } elseif (isset($_GET['target_passthrough'])) { //if this is a mediatraffic! keyword - $keyword = $db->real_escape_string((string)$_GET['target_passthrough']); + $keyword = (string)$_GET['target_passthrough']; } elseif (isset($_GET['keyword'])) { //if this is a zango, or more keyword - $keyword = $db->real_escape_string((string)$_GET['keyword']); + $keyword = (string)$_GET['keyword']; } elseif (isset($_GET['search_word'])) { //if this is a eniro, or more keyword - $keyword = $db->real_escape_string((string)$_GET['search_word']); + $keyword = (string)$_GET['search_word']; } elseif (isset($_GET['query'])) { //if this is a naver, or more keyword - $keyword = $db->real_escape_string((string)$_GET['query']); + $keyword = (string)$_GET['query']; } elseif (isset($_GET['encquery'])) { //if this is a aol, or more keyword - $keyword = $db->real_escape_string((string)$_GET['encquery']); + $keyword = (string)$_GET['encquery']; } elseif (isset($_GET['terms'])) { //if this is a about.com, or more keyword - $keyword = $db->real_escape_string((string)$_GET['terms']); + $keyword = (string)$_GET['terms']; } elseif (isset($_GET['rdata'])) { //if this is a viola, or more keyword - $keyword = $db->real_escape_string((string)$_GET['rdata']); + $keyword = (string)$_GET['rdata']; } elseif (isset($_GET['qs'])) { //if this is a virgilio, or more keyword - $keyword = $db->real_escape_string((string)$_GET['qs']); + $keyword = (string)$_GET['qs']; } elseif (isset($_GET['wd'])) { //if this is a baidu, or more keyword - $keyword = $db->real_escape_string((string)$_GET['wd']); + $keyword = (string)$_GET['wd']; } elseif (isset($_GET['text'])) { //if this is a yandex, or more keyword - $keyword = $db->real_escape_string((string)$_GET['text']); + $keyword = (string)$_GET['text']; } elseif (isset($_GET['szukaj'])) { //if this is a wp.pl, or more keyword - $keyword = $db->real_escape_string((string)$_GET['szukaj']); + $keyword = (string)$_GET['szukaj']; } elseif (isset($_GET['qt'])) { //if this is a O*net, or more keyword - $keyword = $db->real_escape_string((string)$_GET['qt']); + $keyword = (string)$_GET['qt']; } elseif (isset($_GET['k'])) { //if this is a yam, or more keyword - $keyword = $db->real_escape_string((string)$_GET['k']); + $keyword = (string)$_GET['k']; } elseif (isset($_GET['words'])) { //if this is a Rambler, or more keyword - $keyword = $db->real_escape_string((string)$_GET['words']); + $keyword = (string)$_GET['words']; } else { - $keyword = $db->real_escape_string((string)($_GET['t202kw'] ?? '')); + $keyword = (string)($_GET['t202kw'] ?? ''); } break; } @@ -366,8 +366,9 @@ function renderErrorPage(int $code, string $title, string $message, string $acce //Get C1-C4 IDs for ($i = 1; $i <= 4; $i++) { $custom = "c" . $i; //create dynamic variable - $custom_val = $_lGET[$custom] ?? ''; - $custom_val = $db->real_escape_string($custom_val); // get the value + // Raw value: findOrCreateCustomVar() binds it as a parameter, so escaping + // here would store literal backslashes. + $custom_val = (string) ($_lGET[$custom] ?? ''); $custom_val = str_replace('%20', ' ', $custom_val); $custom_id = $trackingRepo->findOrCreateCustomVar($custom, $custom_val); //get the id $mysql[$custom . '_id'] = $db->real_escape_string((string)$custom_id); //save it @@ -381,7 +382,7 @@ function renderErrorPage(int $code, string $title, string $message, string $acce $parameters = !empty($tracker_row['parameters']) ? explode(',', (string) $tracker_row['parameters']) : []; foreach ($parameters as $key => $value) { - $variable = $db->real_escape_string((string)($_GET[$value] ?? '')); + $variable = (string)($_GET[$value] ?? ''); if (isset($variable) && $variable != '') { $variable = str_replace('%20', ' ', $variable); @@ -391,7 +392,7 @@ function renderErrorPage(int $code, string $title, string $message, string $acce } //utm_source -$utm_source = $db->real_escape_string((string)($_GET['utm_source'] ?? '')); +$utm_source = (string)($_GET['utm_source'] ?? ''); if (isset($utm_source) && $utm_source != '') { $utm_source = str_replace('%20', ' ', $utm_source); $utm_source_id = $trackingRepo->findOrCreateUtm($utm_source, 'utm_source'); @@ -401,7 +402,7 @@ function renderErrorPage(int $code, string $title, string $message, string $acce $mysql['utm_source_id'] = $db->real_escape_string((string)$utm_source_id); //utm_medium -$utm_medium = $db->real_escape_string((string)($_GET['utm_medium'] ?? '')); +$utm_medium = (string)($_GET['utm_medium'] ?? ''); if (isset($utm_medium) && $utm_medium != '') { $utm_medium = str_replace('%20', ' ', $utm_medium); $utm_medium_id = $trackingRepo->findOrCreateUtm($utm_medium, 'utm_medium'); @@ -411,7 +412,7 @@ function renderErrorPage(int $code, string $title, string $message, string $acce $mysql['utm_medium_id'] = $db->real_escape_string((string)$utm_medium_id); //utm_campaign -$utm_campaign = $db->real_escape_string((string)($_GET['utm_campaign'] ?? '')); +$utm_campaign = (string)($_GET['utm_campaign'] ?? ''); if (isset($utm_campaign) && $utm_campaign != '') { $utm_campaign = str_replace('%20', ' ', $utm_campaign); $utm_campaign_id = $trackingRepo->findOrCreateUtm($utm_campaign, 'utm_campaign'); @@ -421,7 +422,7 @@ function renderErrorPage(int $code, string $title, string $message, string $acce $mysql['utm_campaign_id'] = $db->real_escape_string((string)$utm_campaign_id); //utm_term -$utm_term = $db->real_escape_string((string)($_GET['utm_term'] ?? '')); +$utm_term = (string)($_GET['utm_term'] ?? ''); if (isset($utm_term) && $utm_term != '') { $utm_term = str_replace('%20', ' ', $utm_term); $utm_term_id = $trackingRepo->findOrCreateUtm($utm_term, 'utm_term'); @@ -431,7 +432,7 @@ function renderErrorPage(int $code, string $title, string $message, string $acce $mysql['utm_term_id'] = $db->real_escape_string((string)$utm_term_id); //utm_content -$utm_content = $db->real_escape_string((string)($_GET['utm_content'] ?? '')); +$utm_content = (string)($_GET['utm_content'] ?? ''); if (isset($utm_content) && $utm_content != '') { $utm_content = str_replace('%20', ' ', $utm_content); $utm_content_id = $trackingRepo->findOrCreateUtm($utm_content, 'utm_content'); diff --git a/tracking202/redirect/off.php b/tracking202/redirect/off.php index e2447c6f..a59f1e8c 100755 --- a/tracking202/redirect/off.php +++ b/tracking202/redirect/off.php @@ -159,7 +159,7 @@ } else { // cloaking ON, so do a meta REFRESH - $html['aff_campaign_name'] = $aff_campaign_row['aff_campaign_name']; + $html['aff_campaign_name'] = htmlspecialchars((string) $aff_campaign_row['aff_campaign_name'], ENT_QUOTES, 'UTF-8'); ?> @@ -394,10 +394,14 @@ $de = new DataEngine(); $data=($de->setDirtyHour($mysql['click_id'])); +// Assign before the output below: the earlier assignment lives in the other +// branch, so this path was echoing an undefined key. Escaped like the URL. +$html['aff_campaign_name'] = htmlspecialchars((string) ($info_row['aff_campaign_name'] ?? ''), ENT_QUOTES, 'UTF-8'); + if ($cloaking_on == true) { - + // if cloaking is turned on, meta refresh out - + ?> diff --git a/tracking202/redirect/offrtr.php b/tracking202/redirect/offrtr.php index ab389446..cafe9158 100755 --- a/tracking202/redirect/offrtr.php +++ b/tracking202/redirect/offrtr.php @@ -43,7 +43,8 @@ ac.aff_campaign_url_5, ac.aff_campaign_payout, ac.aff_campaign_cloaking, - lp.landing_page_url + up.maxmind_isp, + lp.landing_page_url FROM 202_rotators AS rt LEFT JOIN 202_aff_campaigns AS ac ON ac.aff_campaign_id = rt.default_campaign LEFT JOIN 202_landing_pages AS lp ON lp.landing_page_id = rt.default_lp @@ -330,6 +331,9 @@ "; $click_result = $db->query($update_sql) or record_mysql_error($db); + // Initialize before the branch so the non-cloaked path doesn't read an + // undefined variable at the $cloaking_on checks further down (matches off.php/rtr.php). + $cloaking_on = false; if (($rule_redirect_row['click_cloaking'] == 1) or // if tracker has overrided cloaking on (($rule_redirect_row['click_cloaking'] == - 1) and ($rule_redirect_row['aff_campaign_cloaking'] == 1)) or ((! isset($rule_redirect_row['click_cloaking'])) and ($rule_redirect_row['aff_campaign_cloaking'] == 1))) // if no tracker but but by default campaign has cloaking on { @@ -382,24 +386,24 @@ if ($cloaking_on == true) { ?> - <?php echo $rule_redirect_row['aff_campaign_name']; ?> + <?php echo htmlspecialchars((string) $rule_redirect_row['aff_campaign_name'], ENT_QUOTES, 'UTF-8'); ?> + content="1; url=">
    + value="" />
    - You are being automatically redirected to .
    -
    Page Stuck? Click + You are being automatically redirected to .
    +
    Page Stuck?
    Click Here.
    @@ -455,6 +459,9 @@ "; $click_result = $db->query($update_sql) or record_mysql_error($db); + // Initialize before the branch so the non-cloaked path doesn't read an + // undefined variable at the $cloaking_on checks further down (matches off.php/rtr.php). + $cloaking_on = false; if (($click_row['click_cloaking'] == 1) or // if tracker has overrided cloaking on (($click_row['click_cloaking'] == - 1) and ($rotator_row['aff_campaign_cloaking'] == 1)) or ((! isset($click_row['click_cloaking'])) and ($rotator_row['aff_campaign_cloaking'] == 1))) // if no tracker but but by default campaign has cloaking on { @@ -507,24 +514,24 @@ if ($cloaking_on == true) { ?> - <?php echo $rotator_row['aff_campaign_name']; ?> + <?php echo htmlspecialchars((string) $rotator_row['aff_campaign_name'], ENT_QUOTES, 'UTF-8'); ?> + content="1; url=">
    + value="" />
    - You are being automatically redirected to .
    -
    Page Stuck? Click + You are being automatically redirected to .
    +
    Page Stuck?
    Click Here.
    diff --git a/tracking202/redirect/rtr.php b/tracking202/redirect/rtr.php index ce37a4c4..c3ab6b24 100755 --- a/tracking202/redirect/rtr.php +++ b/tracking202/redirect/rtr.php @@ -595,11 +595,32 @@ function redirect_process($db, $rule, $ppc_account, $cpc, $rotator_id, $GeoData, ORDER BY 202_clicks.click_id DESC LIMIT 1"; $click_result1 = $db->query($click_sql1) or record_mysql_error($click_sql1); - $click_row1 = $click_result1->fetch_assoc(); - $mysql['click_id'] = $db->real_escape_string((string)$click_row1['click_id']); - $keyword = $db->real_escape_string($keyword); - $keyword_id = $db->real_escape_string((string)$click_row1['keyword_id']); - $mysql['keyword_id'] = $db->real_escape_string((string)$keyword_id); + $click_row1 = $click_result1 ? $click_result1->fetch_assoc() : null; + + if ($click_row1 && !empty($click_row1['click_id'])) { + // Set the bare $click_id too, not just the escaped copy: it is read at + // the cloaked click_id_public build and by replaceTrackerPlaceholders for + // {clickid}. This reuse path left it undefined, so a cloaked link on the + // ?lpr= flow got a malformed public id and an empty {clickid}. + $click_id = (int)$click_row1['click_id']; + $mysql['click_id'] = $db->real_escape_string((string)$click_id); + $keyword = $db->real_escape_string($keyword); + $keyword_id = $db->real_escape_string((string)$click_row1['keyword_id']); + $mysql['keyword_id'] = $db->real_escape_string((string)$keyword_id); + } else { + // No prior click matched this IP/user inside the window. Fall back to a + // fresh click id instead of writing 202_clicks* rows keyed on an empty + // click_id, which produced junk rows that never join back to anything. + $click_sql = "INSERT INTO 202_clicks_counter SET click_id=DEFAULT"; + $click_result = $db->query($click_sql) or record_mysql_error($db); + // $click_id (not just $mysql['click_id']) is read later for the cloaked + // click_id_public and the {clickid} placeholder, so set both. + $click_id = $db->insert_id; + $mysql['click_id'] = $db->real_escape_string((string)$click_id); + $keyword = $db->real_escape_string($keyword); + // Leave $mysql['keyword_id'] as resolved above — this path still has a + // real keyword; zeroing it here dropped it from the Keywords report. + } } else{ //ok we have the main data, now insert this row diff --git a/tracking202/setup/rotator.php b/tracking202/setup/rotator.php index 6410fcaa..52d4d3c0 100644 --- a/tracking202/setup/rotator.php +++ b/tracking202/setup/rotator.php @@ -227,7 +227,7 @@ } ?> -
  • Details
  • +
  • Details
  • "; ?> diff --git a/tracking202/static/ipx.php b/tracking202/static/ipx.php index 2e906e81..5bf7776b 100755 --- a/tracking202/static/ipx.php +++ b/tracking202/static/ipx.php @@ -31,8 +31,12 @@ ppc_account_id = '".$tracker_row['ppc_account_id']."', text_ad_id = '".$tracker_row['text_ad_id']."', impression_time = '".$time."'"; -$db->query($sql); -$ipx_id = $db->insert_id; +// Check the INSERT: on failure insert_id is 0, and writing a p202_ipx=0 +// cookie would bind that meaningless id to the visitor's later click. +$impression_result = $db->query($sql); +$ipx_id = $impression_result ? $db->insert_id : 0; -setcookie("p202_ipx", (string) $ipx_id, ['expires' => $time + (10 * 365 * 24 * 60 * 60), 'path' => '/', 'domain' => (string) $_SERVER['SERVER_NAME']]); +if ($ipx_id > 0) { + setcookie("p202_ipx", (string) $ipx_id, ['expires' => $time + (10 * 365 * 24 * 60 * 60), 'path' => '/', 'domain' => (string) $_SERVER['SERVER_NAME']]); +} echo base64_decode("R0lGODlhAQABAIAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="); diff --git a/tracking202/static/px.php b/tracking202/static/px.php index c8618228..27d09150 100755 --- a/tracking202/static/px.php +++ b/tracking202/static/px.php @@ -16,10 +16,10 @@ //see if it has the cookie, do whatever we can to grab to grab SOMETHING to tie this lead to -if ($_COOKIE['tracking202subid']) { +if (isset($_COOKIE['tracking202subid']) && $_COOKIE['tracking202subid']) { $mysql['click_id'] = $db->real_escape_string($_COOKIE['tracking202subid']); - + } else { //ok grab the last click from this ip_id @@ -35,8 +35,10 @@ ORDER BY 202_clicks.click_id DESC LIMIT 1"; $click_result1 = $db->query($click_sql1) or record_mysql_error($click_sql1); - $click_row1 = $click_result1->fetch_assoc(); - $mysql['click_id'] = $db->real_escape_string($click_row1['click_id']); + $click_row1 = $click_result1 ? $click_result1->fetch_assoc() : null; + // No prior click for this IP inside the window — leave click_id empty so the + // guard below skips recording, instead of dereferencing a null row. + $mysql['click_id'] = $click_row1 ? $db->real_escape_string((string)$click_row1['click_id']) : ''; } diff --git a/tracking202/static/record_adv.php b/tracking202/static/record_adv.php index 8a57dafe..e371c835 100755 --- a/tracking202/static/record_adv.php +++ b/tracking202/static/record_adv.php @@ -126,51 +126,51 @@ case "bidded": #try to get the bidded keyword first if ($_GET['OVKEY']) { //if this is a Y! keyword - $keyword = $db->real_escape_string((string)$_GET['OVKEY']); + $keyword = (string)$_GET['OVKEY']; } elseif ($_GET['t202kw']) { - $keyword = $db->real_escape_string((string)$_GET['t202kw']); + $keyword = (string)$_GET['t202kw']; } elseif ($_GET['target_passthrough']) { //if this is a mediatraffic! keyword - $keyword = $db->real_escape_string((string)$_GET['target_passthrough']); + $keyword = (string)$_GET['target_passthrough']; } else { //if this is a zango, or more keyword - $keyword = $db->real_escape_string((string)$_GET['keyword']); + $keyword = (string)$_GET['keyword']; } break; case "searched": #try to get the searched keyword if (!empty($referer_query['q'])) { - $keyword = $db->real_escape_string($referer_query['q']); + $keyword = $referer_query['q']; } elseif ($_GET['OVRAW']) { //if this is a Y! keyword - $keyword = $db->real_escape_string((string)$_GET['OVRAW']); + $keyword = (string)$_GET['OVRAW']; } elseif ($_GET['target_passthrough']) { //if this is a mediatraffic! keyword - $keyword = $db->real_escape_string((string)$_GET['target_passthrough']); + $keyword = (string)$_GET['target_passthrough']; } elseif ($_GET['keyword']) { //if this is a zango, or more keyword - $keyword = $db->real_escape_string((string)$_GET['keyword']); + $keyword = (string)$_GET['keyword']; } elseif ($_GET['search_word']) { //if this is a eniro, or more keyword - $keyword = $db->real_escape_string((string)$_GET['search_word']); + $keyword = (string)$_GET['search_word']; } elseif ($_GET['query']) { //if this is a naver, or more keyword - $keyword = $db->real_escape_string((string)$_GET['query']); + $keyword = (string)$_GET['query']; } elseif ($_GET['encquery']) { //if this is a aol, or more keyword - $keyword = $db->real_escape_string((string)$_GET['encquery']); + $keyword = (string)$_GET['encquery']; } elseif ($_GET['terms']) { //if this is a about.com, or more keyword - $keyword = $db->real_escape_string((string)$_GET['terms']); + $keyword = (string)$_GET['terms']; } elseif ($_GET['rdata']) { //if this is a viola, or more keyword - $keyword = $db->real_escape_string((string)$_GET['rdata']); + $keyword = (string)$_GET['rdata']; } elseif ($_GET['qs']) { //if this is a virgilio, or more keyword - $keyword = $db->real_escape_string((string)$_GET['qs']); + $keyword = (string)$_GET['qs']; } elseif ($_GET['wd']) { //if this is a baidu, or more keyword - $keyword = $db->real_escape_string((string)$_GET['wd']); + $keyword = (string)$_GET['wd']; } elseif ($_GET['text']) { //if this is a yandex, or more keyword - $keyword = $db->real_escape_string((string)$_GET['text']); + $keyword = (string)$_GET['text']; } elseif ($_GET['szukaj']) { //if this is a wp.pl, or more keyword - $keyword = $db->real_escape_string((string)$_GET['szukaj']); + $keyword = (string)$_GET['szukaj']; } elseif ($_GET['qt']) { //if this is a O*net, or more keyword - $keyword = $db->real_escape_string((string)$_GET['qt']); + $keyword = (string)$_GET['qt']; } elseif ($_GET['k']) { //if this is a yam, or more keyword - $keyword = $db->real_escape_string((string)$_GET['k']); + $keyword = (string)$_GET['k']; } elseif ($_GET['words']) { //if this is a Rambler, or more keyword - $keyword = $db->real_escape_string((string)$_GET['words']); + $keyword = (string)$_GET['words']; } else { - $keyword = $db->real_escape_string((string)$_GET['t202kw']); + $keyword = (string)$_GET['t202kw']; } break; } @@ -179,7 +179,7 @@ $t202var = substr((string) $keyword, strpos((string) $keyword, "_") + 1); if (isset($_GET[$t202var])) { - $keyword = $db->real_escape_string((string) $_GET[$t202var]); + $keyword = (string) $_GET[$t202var]; } } @@ -187,22 +187,22 @@ $keyword_id = $trackingRepo->findOrCreateKeyword($keyword); $mysql['keyword_id'] = $db->real_escape_string((string) $keyword_id); -$c1 = $db->real_escape_string((string)$_GET['c1']); +$c1 = (string)$_GET['c1']; $c1 = str_replace('%20', ' ', $c1); $c1_id = $trackingRepo->findOrCreateC1($c1); $mysql['c1_id'] = $db->real_escape_string((string) $c1_id); -$c2 = $db->real_escape_string((string)$_GET['c2']); +$c2 = (string)$_GET['c2']; $c2 = str_replace('%20', ' ', $c2); $c2_id = $trackingRepo->findOrCreateC2($c2); $mysql['c2_id'] = $db->real_escape_string((string) $c2_id); -$c3 = $db->real_escape_string((string)$_GET['c3']); +$c3 = (string)$_GET['c3']; $c3 = str_replace('%20', ' ', $c3); $c3_id = $trackingRepo->findOrCreateC3($c3); $mysql['c3_id'] = $db->real_escape_string((string) $c3_id); -$c4 = $db->real_escape_string((string)$_GET['c4']); +$c4 = (string)$_GET['c4']; $c4 = str_replace('%20', ' ', $c4); $c4_id = $trackingRepo->findOrCreateC4($c4); $mysql['c4_id'] = $db->real_escape_string((string) $c4_id); @@ -220,7 +220,7 @@ continue; } - $variable = $db->real_escape_string((string)$_GET[$value]); + $variable = (string)$_GET[$value]; if (isset($variable) && $variable != '') { $variable = str_replace('%20', ' ', $variable); @@ -230,7 +230,7 @@ } //utm_source -$utm_source = $db->real_escape_string((string)$_GET['utm_source']); +$utm_source = (string)$_GET['utm_source']; if (isset($utm_source) && $utm_source != '') { $utm_source = str_replace('%20', ' ', $utm_source); $utm_source_id = $trackingRepo->findOrCreateUtm($utm_source, 'utm_source'); @@ -240,7 +240,7 @@ $mysql['utm_source_id'] = $db->real_escape_string((string) $utm_source_id); //utm_medium -$utm_medium = $db->real_escape_string((string)$_GET['utm_medium']); +$utm_medium = (string)$_GET['utm_medium']; if (isset($utm_medium) && $utm_medium != '') { $utm_medium = str_replace('%20', ' ', $utm_medium); $utm_medium_id = $trackingRepo->findOrCreateUtm($utm_medium, 'utm_medium'); @@ -250,7 +250,7 @@ $mysql['utm_medium_id'] = $db->real_escape_string((string) $utm_medium_id); //utm_campaign -$utm_campaign = $db->real_escape_string((string)$_GET['utm_campaign']); +$utm_campaign = (string)$_GET['utm_campaign']; if (isset($utm_campaign) && $utm_campaign != '') { $utm_campaign = str_replace('%20', ' ', $utm_campaign); $utm_campaign_id = $trackingRepo->findOrCreateUtm($utm_campaign, 'utm_campaign'); @@ -260,7 +260,7 @@ $mysql['utm_campaign_id'] = $db->real_escape_string((string) $utm_campaign_id); //utm_term -$utm_term = $db->real_escape_string((string)$_GET['utm_term']); +$utm_term = (string)$_GET['utm_term']; if (isset($utm_term) && $utm_term != '') { $utm_term = str_replace('%20', ' ', $utm_term); $utm_term_id = $trackingRepo->findOrCreateUtm($utm_term, 'utm_term'); @@ -270,7 +270,7 @@ $mysql['utm_term_id'] = $db->real_escape_string((string) $utm_term_id); //utm_content -$utm_content = $db->real_escape_string((string)$_GET['utm_content']); +$utm_content = (string)$_GET['utm_content']; if (isset($utm_content) && $utm_content != '') { $utm_content = str_replace('%20', ' ', $utm_content); $utm_content_id = $trackingRepo->findOrCreateUtm($utm_content, 'utm_content'); diff --git a/tracking202/static/record_simple.php b/tracking202/static/record_simple.php index d6895700..98ed4677 100755 --- a/tracking202/static/record_simple.php +++ b/tracking202/static/record_simple.php @@ -134,52 +134,52 @@ case "bidded": #try to get the bidded keyword first if ($_GET['OVKEY']) { //if this is a Y! keyword - $keyword = $db->real_escape_string((string)$_GET['OVKEY']); + $keyword = (string)$_GET['OVKEY']; } elseif ($_GET['t202kw']) { - $keyword = $db->real_escape_string((string)$_GET['t202kw']); + $keyword = (string)$_GET['t202kw']; } elseif ($_GET['target_passthrough']) { //if this is a mediatraffic! keyword - $keyword = $db->real_escape_string((string)$_GET['target_passthrough']); + $keyword = (string)$_GET['target_passthrough']; } else { //if this is a zango, or more keyword - $keyword = $db->real_escape_string((string)$_GET['keyword']); + $keyword = (string)$_GET['keyword']; } break; case "searched": #try to get the searched keyword if (!empty($referer_query['q'])) { - $keyword = $db->real_escape_string($referer_query['q']); + $keyword = $referer_query['q']; } elseif ($_GET['OVRAW']) { //if this is a Y! keyword - $keyword = $db->real_escape_string((string)$_GET['OVRAW']); + $keyword = (string)$_GET['OVRAW']; } elseif ($_GET['target_passthrough']) { //if this is a mediatraffic! keyword - $keyword = $db->real_escape_string((string)$_GET['target_passthrough']); + $keyword = (string)$_GET['target_passthrough']; } elseif ($_GET['keyword']) { //if this is a zango, or more keyword - $keyword = $db->real_escape_string((string)$_GET['keyword']); + $keyword = (string)$_GET['keyword']; } elseif ($_GET['search_word']) { //if this is a eniro, or more keyword - $keyword = $db->real_escape_string((string)$_GET['search_word']); + $keyword = (string)$_GET['search_word']; } elseif ($_GET['query']) { //if this is a naver, or more keyword - $keyword = $db->real_escape_string((string)$_GET['query']); + $keyword = (string)$_GET['query']; } elseif ($_GET['encquery']) { //if this is a aol, or more keyword - $keyword = $db->real_escape_string((string)$_GET['encquery']); + $keyword = (string)$_GET['encquery']; } elseif ($_GET['terms']) { //if this is a about.com, or more keyword - $keyword = $db->real_escape_string((string)$_GET['terms']); + $keyword = (string)$_GET['terms']; } elseif ($_GET['rdata']) { //if this is a viola, or more keyword - $keyword = $db->real_escape_string((string)$_GET['rdata']); + $keyword = (string)$_GET['rdata']; } elseif ($_GET['qs']) { //if this is a virgilio, or more keyword - $keyword = $db->real_escape_string((string)$_GET['qs']); + $keyword = (string)$_GET['qs']; } elseif ($_GET['wd']) { //if this is a baidu, or more keyword - $keyword = $db->real_escape_string((string)$_GET['wd']); + $keyword = (string)$_GET['wd']; } elseif ($_GET['text']) { //if this is a yandex, or more keyword - $keyword = $db->real_escape_string((string)$_GET['text']); + $keyword = (string)$_GET['text']; } elseif ($_GET['szukaj']) { //if this is a wp.pl, or more keyword - $keyword = $db->real_escape_string((string)$_GET['szukaj']); + $keyword = (string)$_GET['szukaj']; } elseif ($_GET['qt']) { //if this is a O*net, or more keyword - $keyword = $db->real_escape_string((string)$_GET['qt']); + $keyword = (string)$_GET['qt']; } elseif ($_GET['k']) { //if this is a yam, or more keyword - $keyword = $db->real_escape_string((string)$_GET['k']); + $keyword = (string)$_GET['k']; } elseif ($_GET['words']) { //if this is a Rambler, or more keyword - $keyword = $db->real_escape_string((string)$_GET['words']); + $keyword = (string)$_GET['words']; } else { - $keyword = $db->real_escape_string((string)$_GET['t202kw']); + $keyword = (string)$_GET['t202kw']; } break; } @@ -188,7 +188,7 @@ $t202var = substr((string) $keyword, strpos((string) $keyword, "_") + 1); if (isset($_GET[$t202var])) { - $keyword = $db->real_escape_string((string) $_GET[$t202var]); + $keyword = (string) $_GET[$t202var]; } } @@ -199,22 +199,22 @@ $mysql['gclid'] = $db->real_escape_string((string)$_GET['gclid']); -$c1 = $db->real_escape_string((string)$_GET['c1']); +$c1 = (string)$_GET['c1']; $c1 = str_replace('%20', ' ', $c1); $c1_id = $trackingRepo->findOrCreateC1($c1); $mysql['c1_id'] = $db->real_escape_string((string) $c1_id); -$c2 = $db->real_escape_string((string)$_GET['c2']); +$c2 = (string)$_GET['c2']; $c2 = str_replace('%20', ' ', $c2); $c2_id = $trackingRepo->findOrCreateC2($c2); $mysql['c2_id'] = $db->real_escape_string((string) $c2_id); -$c3 = $db->real_escape_string((string)$_GET['c3']); +$c3 = (string)$_GET['c3']; $c3 = str_replace('%20', ' ', $c3); $c3_id = $trackingRepo->findOrCreateC3($c3); $mysql['c3_id'] = $db->real_escape_string((string) $c3_id); -$c4 = $db->real_escape_string((string)$_GET['c4']); +$c4 = (string)$_GET['c4']; $c4 = str_replace('%20', ' ', $c4); $c4_id = $trackingRepo->findOrCreateC4($c4); $mysql['c4_id'] = $db->real_escape_string((string) $c4_id); @@ -229,7 +229,7 @@ continue; } - $variable = $db->real_escape_string((string)$_GET[$value]); + $variable = (string)$_GET[$value]; if (isset($variable) && $variable != '') { $variable = str_replace('%20', ' ', $variable); @@ -239,7 +239,7 @@ } //utm_source -$utm_source = $db->real_escape_string((string)$_GET['utm_source']); +$utm_source = (string)$_GET['utm_source']; if (isset($utm_source) && $utm_source != '') { $utm_source = str_replace('%20', ' ', $utm_source); $utm_source_id = $trackingRepo->findOrCreateUtm($utm_source, 'utm_source'); @@ -249,7 +249,7 @@ $mysql['utm_source_id'] = $db->real_escape_string((string) $utm_source_id); //utm_medium -$utm_medium = $db->real_escape_string((string)$_GET['utm_medium']); +$utm_medium = (string)$_GET['utm_medium']; if (isset($utm_medium) && $utm_medium != '') { $utm_medium = str_replace('%20', ' ', $utm_medium); $utm_medium_id = $trackingRepo->findOrCreateUtm($utm_medium, 'utm_medium'); @@ -259,7 +259,7 @@ $mysql['utm_medium_id'] = $db->real_escape_string((string) $utm_medium_id); //utm_campaign -$utm_campaign = $db->real_escape_string((string)$_GET['utm_campaign']); +$utm_campaign = (string)$_GET['utm_campaign']; if (isset($utm_campaign) && $utm_campaign != '') { $utm_campaign = str_replace('%20', ' ', $utm_campaign); $utm_campaign_id = $trackingRepo->findOrCreateUtm($utm_campaign, 'utm_campaign'); @@ -269,7 +269,7 @@ $mysql['utm_campaign_id'] = $db->real_escape_string((string) $utm_campaign_id); //utm_term -$utm_term = $db->real_escape_string((string)$_GET['utm_term']); +$utm_term = (string)$_GET['utm_term']; if (isset($utm_term) && $utm_term != '') { $utm_term = str_replace('%20', ' ', $utm_term); $utm_term_id = $trackingRepo->findOrCreateUtm($utm_term, 'utm_term'); @@ -279,7 +279,7 @@ $mysql['utm_term_id'] = $db->real_escape_string((string) $utm_term_id); //utm_content -$utm_content = $db->real_escape_string((string)$_GET['utm_content']); +$utm_content = (string)$_GET['utm_content']; if (isset($utm_content) && $utm_content != '') { $utm_content = str_replace('%20', ' ', $utm_content); $utm_content_id = $trackingRepo->findOrCreateUtm($utm_content, 'utm_content'); diff --git a/tracking202/update/delete-subids.php b/tracking202/update/delete-subids.php index 24d36954..98c177a5 100644 --- a/tracking202/update/delete-subids.php +++ b/tracking202/update/delete-subids.php @@ -15,6 +15,13 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') { + // CSRF check — this endpoint clears lead/filter flags (alters reported + // income); gate it on the session token like the setup/ mutations do. + if (!hash_equals((string)($_SESSION['token'] ?? ''), (string)($_POST['token'] ?? ''))) { + header('location: ' . get_absolute_url() . 'tracking202/update/delete-subids.php'); + die(); + } + $mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']); $subids = $_POST['subids'] ?? ''; @@ -22,6 +29,11 @@ $subids = explode("\r", $subids); $subids = str_replace("\n", '', $subids); + // Optimistic before the loop so a mid-loop failure can flip it false. The + // previous unconditional `$success = true;` AFTER the loop overwrote every + // failure, reporting success even when updates had failed. + $success = true; + foreach ($subids as $click_id) { $mysql['click_id'] = $db->real_escape_string($click_id); @@ -53,10 +65,9 @@ click_id='" . $mysql['click_id'] . "' AND user_id='" . $mysql['user_id'] . "' "; - try { - $update_result = $db->query($update_sql); - } catch (Exception $e) { - error_log("Database query failed: " . $e->getMessage()); + // Return-value check, not try/catch: see the note on the spy update below. + if ($db->query($update_sql) === false) { + error_log("delete-subids clicks update failed: " . $db->error); $success = false; continue; } @@ -70,13 +81,20 @@ click_id='" . $mysql['click_id'] . "' AND user_id='" . $mysql['user_id'] . "' "; - $update_result = $db->query($update_sql) or die($db->error); + // connect.php sets mysqli_report(MYSQLI_REPORT_STRICT) WITHOUT + // MYSQLI_REPORT_ERROR, so a failed query() returns false rather than + // throwing — check the return value, a catch block would never run. + // (Replaces `or die($db->error)`, which leaked the raw MySQL error and + // left 202_clicks updated while 202_clicks_spy was not.) + if ($db->query($update_sql) === false) { + error_log("delete-subids spy update failed: " . $db->error); + $success = false; + continue; + } $de = new DataEngine(); $de->setDirtyHour($mysql['click_id']); } - - $success = true; } //show the template @@ -111,6 +129,7 @@
    +
    diff --git a/tracking202/update/subids.php b/tracking202/update/subids.php index 7ff55df4..1c621964 100644 --- a/tracking202/update/subids.php +++ b/tracking202/update/subids.php @@ -19,6 +19,13 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') { + // CSRF check — this endpoint alters reported income; every setup/ mutation + // gates its writes on the session token, and this one must too. + if (!hash_equals((string)($_SESSION['token'] ?? ''), (string)($_POST['token'] ?? ''))) { + header('location: ' . get_absolute_url() . 'tracking202/update/subids.php'); + die(); + } + $mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']); $mysql['click_update_type'] = 'upload'; $mysql['click_update_time'] = time(); @@ -154,6 +161,7 @@ function (int $lockedClickId, float $payout) use ($applyClickUpdate): void {
    +
    diff --git a/tracking202/update/upload.php b/tracking202/update/upload.php index 98473af4..aecedfd6 100755 --- a/tracking202/update/upload.php +++ b/tracking202/update/upload.php @@ -202,8 +202,15 @@ function about_revenue_upload() { default: - if ($_SERVER['REQUEST_METHOD'] == 'POST') { - + if ($_SERVER['REQUEST_METHOD'] == 'POST') { + + // The form below already submits the session token; verify it here + // rather than rendering a token that is never checked. + if (!hash_equals((string)($_SESSION['token'] ?? ''), (string)($_POST['token'] ?? ''))) { + header('location: '.get_absolute_url().'tracking202/update/upload.php'); + die(); + } + // Initialize error variable $error = false; @@ -276,7 +283,7 @@ function about_revenue_upload() {
    - +