| @@ -129,9 +129,9 @@ | ||
| 129 | 129 | * Uses the same authentication as otto_crawl_notify endpoint |
| 130 | 130 | */ |
| 131 | 131 | public function validate_api_key($request) |
| 132 | 132 | { |
| 133 | - // Get API key from Authorization: Bearer header (preferred), x-api-key header, or apikey query parameter | |
| 133 | + // Get API key from Authorization: Bearer header, x-api-key header, or apikey query parameter | |
| 134 | 134 | $api_key = ''; |
| 135 | 135 | |
| 136 | 136 | $auth_header = $request->get_header('authorization'); |
| 137 | 137 | if (!empty($auth_header) && preg_match('/^Bearer\s+(.+)$/i', $auth_header, $matches)) { |
| @@ -616,9 +616,9 @@ | ||
| 616 | 616 | 'post_status' => array('any', 'trash'), |
| 617 | 617 | 'numberposts' => 1, |
| 618 | 618 | 'fields' => 'ids', |
| 619 | 619 | 'no_found_rows' => true, |
| 620 | - 'suppress_filters' => true, | |
| 620 | + 'suppress_filters' => true, // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- identity lookup for the duplicate-assets guard; query-filter plugins (WPML/Polylang) must not narrow it | |
| 621 | 621 | 'exclude' => array($page_id), |
| 622 | 622 | 'meta_key' => Metasync_Custom_Pages::META_ASSETS_FOLDER, |
| 623 | 623 | 'meta_value' => $assets_folder, |
| 624 | 624 | )); |
| @@ -851,10 +851,10 @@ | ||
| 851 | 851 | } |
| 852 | 852 | |
| 853 | 853 | $zip_path = $tmp_file; |
| 854 | 854 | |
| 855 | - } elseif (!empty($_FILES['zip_file']) && is_array($_FILES['zip_file'])) { | |
| 856 | - $file = $_FILES['zip_file']; | |
| 855 | + } elseif (!empty($_FILES['zip_file']) && is_array($_FILES['zip_file'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- REST route authenticated by API key, not cookies | |
| 856 | + $file = $_FILES['zip_file']; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- REST route authenticated by API key, not cookies | |
| 857 | 857 | $_lps_input['source_type'] = 'upload'; |
| 858 | 858 | |
| 859 | 859 | if (!isset($file['error']) || $file['error'] !== UPLOAD_ERR_OK) { |
| 860 | 860 | $_lps_http_st = 400; |
| @@ -929,9 +929,9 @@ | ||
| 929 | 929 | return $response; |
| 930 | 930 | |
| 931 | 931 | } finally { |
| 932 | 932 | if (!empty($tmp_file) && file_exists($tmp_file)) { |
| 933 | - @unlink($tmp_file); | |
| 933 | + @wp_delete_file($tmp_file); | |
| 934 | 934 | } |
| 935 | 935 | } |
| 936 | 936 | } finally { |
| 937 | 937 | self::write_lps_import_audit(array( |
| @@ -941,12 +941,60 @@ | ||
| 941 | 941 | 'start_ms' => $_lps_start_ms, |
| 942 | 942 | 'auth_method' => $_lps_auth_method, |
| 943 | 943 | 'api_key_prefix' => $_lps_api_key_prefix, |
| 944 | 944 | )); |
| 945 | + self::track_lps_import($_lps_result, $_lps_http_st, $_lps_input); | |
| 945 | 946 | } |
| 946 | 947 | } |
| 947 | 948 | |
| 948 | 949 | /** |
| 950 | + * Consent-gated analytics for Website Studio imports. | |
| 951 | + * | |
| 952 | + * Successes are rolled up hourly (a multi-page publish is one burst of | |
| 953 | + * work, not N events). Failures and partial imports send one event per | |
| 954 | + * error code per hour so a retry loop on the platform cannot flood it. | |
| 955 | + * Hooked here, not in extract_and_create_lps_page(), because the MCP tool | |
| 956 | + * also calls that and is already counted by the MCP rollup. | |
| 957 | + * | |
| 958 | + * @param WP_Error|array|null $result Final import result. | |
| 959 | + * @param int $http_status HTTP status returned. | |
| 960 | + * @param array $input Audit input (pages_in_manifest). | |
| 961 | + */ | |
| 962 | + private static function track_lps_import($result, $http_status, array $input) { | |
| 963 | + $data = (is_array($result) && isset($result['data']) && is_array($result['data'])) ? $result['data'] : array(); | |
| 964 | + $mode = (isset($data['mode']) && in_array($data['mode'], array('single', 'multi'), true)) ? $data['mode'] : 'unknown'; | |
| 965 | + $pages = isset($input['pages_in_manifest']) ? (int) $input['pages_in_manifest'] : 0; | |
| 966 | + | |
| 967 | + if ((int) $http_status === 200) { | |
| 968 | + Metasync_PostHog::rollup('lps_page_imported', array( | |
| 969 | + 'imports_succeeded' => 1, | |
| 970 | + 'imports_' . $mode => 1, | |
| 971 | + 'pages_total' => $pages, | |
| 972 | + ), 'hour'); | |
| 973 | + return; | |
| 974 | + } | |
| 975 | + | |
| 976 | + if (is_wp_error($result)) { | |
| 977 | + $error_code = (string) $result->get_error_code(); | |
| 978 | + } elseif ((int) $http_status === 207) { | |
| 979 | + $error_code = 'partial_failure'; | |
| 980 | + } elseif (is_array($result)) { | |
| 981 | + $error_code = 'all_pages_failed'; | |
| 982 | + } else { | |
| 983 | + // The import threw before producing a result. | |
| 984 | + $error_code = 'unexpected_error'; | |
| 985 | + } | |
| 986 | + | |
| 987 | + Metasync_PostHog::feature_once('lps_page_imported', array( | |
| 988 | + 'outcome' => (int) $http_status === 207 ? 'partial' : 'failed', | |
| 989 | + 'mode' => $mode, | |
| 990 | + 'pages_total' => $pages, | |
| 991 | + 'error_code' => substr(sanitize_key($error_code), 0, 60), | |
| 992 | + 'http_status' => (int) $http_status, | |
| 993 | + ), $error_code, HOUR_IN_SECONDS); | |
| 994 | + } | |
| 995 | + | |
| 996 | + /** | |
| 949 | 997 | * Shared helper: extract a ZIP archive and create or update a custom HTML page. |
| 950 | 998 | * |
| 951 | 999 | * Made public so the MCP tool (in a different class) can invoke it directly. |
| 952 | 1000 | * |
| @@ -1290,9 +1338,9 @@ | ||
| 1290 | 1338 | |
| 1291 | 1339 | } finally { |
| 1292 | 1340 | if (is_resource($lps_import_lock)) { |
| 1293 | 1341 | @flock($lps_import_lock, LOCK_UN); |
| 1294 | - @fclose($lps_import_lock); | |
| 1342 | + @fclose($lps_import_lock); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- operates on a native stream handle | |
| 1295 | 1343 | } |
| 1296 | 1344 | } |
| 1297 | 1345 | } |
| 1298 | 1346 | |
| @@ -1316,14 +1364,14 @@ | ||
| 1316 | 1364 | wp_mkdir_p($lock_dir); |
| 1317 | 1365 | } |
| 1318 | 1366 | $lock_path = trailingslashit($lock_dir) . md5($assets_folder) . '.lock'; |
| 1319 | 1367 | |
| 1320 | - $handle = @fopen($lock_path, 'c'); | |
| 1368 | + $handle = @fopen($lock_path, 'c'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- streaming file I/O; WP_Filesystem cannot return a raw stream handle | |
| 1321 | 1369 | if ($handle === false) { |
| 1322 | 1370 | return new WP_Error('lock_unavailable', 'Could not open the import lock file.', array('status' => 500)); |
| 1323 | 1371 | } |
| 1324 | 1372 | if (!flock($handle, LOCK_EX | LOCK_NB)) { |
| 1325 | - fclose($handle); | |
| 1373 | + fclose($handle); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- operates on a native stream handle | |
| 1326 | 1374 | return new WP_Error( |
| 1327 | 1375 | 'import_in_progress', |
| 1328 | 1376 | 'Another import for this assets_folder is already in progress. Retry shortly.', |
| 1329 | 1377 | array('status' => 423) |
| @@ -1344,9 +1392,9 @@ | ||
| 1344 | 1392 | { |
| 1345 | 1393 | if (file_exists($target_dir)) { |
| 1346 | 1394 | $this->recursive_rmdir($target_dir); |
| 1347 | 1395 | } |
| 1348 | - if (!@rename($staging_dir, $target_dir)) { | |
| 1396 | + if (!@rename($staging_dir, $target_dir)) { // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- atomic same-filesystem replace; WP_Filesystem::move() needs credential bootstrapping on non-direct hosts | |
| 1349 | 1397 | $this->recursive_rmdir($staging_dir); |
| 1350 | 1398 | return new WP_Error( |
| 1351 | 1399 | 'assets_swap_failed', |
| 1352 | 1400 | 'Failed to move the extracted assets into place.', |
| @@ -1425,9 +1473,9 @@ | ||
| 1425 | 1473 | if (class_exists('Metasync_Edge_Cache_Purge')) { |
| 1426 | 1474 | Metasync_Edge_Cache_Purge::purge($urls); |
| 1427 | 1475 | } |
| 1428 | 1476 | } catch (\Throwable $e) { |
| 1429 | - error_log('MetaSync: LPS import cache purge failed - ' . $e->getMessage()); | |
| 1477 | + error_log('MetaSync: LPS import cache purge failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets | |
| 1430 | 1478 | } |
| 1431 | 1479 | } |
| 1432 | 1480 | |
| 1433 | 1481 | /** |
| @@ -1455,9 +1503,9 @@ | ||
| 1455 | 1503 | 'post_status' => array('publish', 'future', 'draft', 'pending', 'private'), |
| 1456 | 1504 | 'numberposts' => 1, |
| 1457 | 1505 | 'fields' => 'ids', |
| 1458 | 1506 | 'no_found_rows' => true, |
| 1459 | - 'suppress_filters' => true, | |
| 1507 | + 'suppress_filters' => true, // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- LPS home-page identity lookup; language/query filters must not hide the tagged page | |
| 1460 | 1508 | 'meta_query' => array( |
| 1461 | 1509 | 'relation' => 'AND', |
| 1462 | 1510 | array('key' => Metasync_Custom_Pages::META_LPS_PROJECT_REF, 'value' => $external_ref), |
| 1463 | 1511 | array('key' => Metasync_Custom_Pages::META_LPS_HOME, 'compare' => 'EXISTS'), |
| @@ -1473,9 +1521,9 @@ | ||
| 1473 | 1521 | 'post_status' => array('publish', 'future', 'draft', 'pending', 'private'), |
| 1474 | 1522 | 'numberposts' => 1, |
| 1475 | 1523 | 'fields' => 'ids', |
| 1476 | 1524 | 'no_found_rows' => true, |
| 1477 | - 'suppress_filters' => true, | |
| 1525 | + 'suppress_filters' => true, // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- LPS home-page identity lookup; language/query filters must not hide the tagged page | |
| 1478 | 1526 | 'meta_key' => Metasync_Custom_Pages::META_LPS_HOME, |
| 1479 | 1527 | 'meta_value' => $assets_folder, |
| 1480 | 1528 | )); |
| 1481 | 1529 | return !empty($ids) ? (int) $ids[0] : 0; |
| @@ -1908,9 +1956,9 @@ | ||
| 1908 | 1956 | 'post_type' => 'page', |
| 1909 | 1957 | 'post_status' => array('publish', 'future', 'draft', 'pending', 'private'), |
| 1910 | 1958 | 'posts_per_page' => 1, |
| 1911 | 1959 | 'fields' => 'ids', |
| 1912 | - 'suppress_filters' => true, | |
| 1960 | + 'suppress_filters' => true, // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- identity lookup mirroring find_lps_home_page(); query hooks must not alter the match | |
| 1913 | 1961 | 'no_found_rows' => true, |
| 1914 | 1962 | 'meta_query' => array( |
| 1915 | 1963 | 'relation' => 'AND', |
| 1916 | 1964 | $project_clause, |
| @@ -1964,9 +2012,9 @@ | ||
| 1964 | 2012 | return true; |
| 1965 | 2013 | } |
| 1966 | 2014 | |
| 1967 | 2015 | if (!is_dir($dir)) { |
| 1968 | - return @unlink($dir); | |
| 2016 | + return @unlink($dir); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- boolean return value is consumed | |
| 1969 | 2017 | } |
| 1970 | 2018 | |
| 1971 | 2019 | $iterator = new RecursiveIteratorIterator( |
| 1972 | 2020 | new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), |
| @@ -1974,15 +2022,15 @@ | ||
| 1974 | 2022 | ); |
| 1975 | 2023 | |
| 1976 | 2024 | foreach ($iterator as $item) { |
| 1977 | 2025 | if ($item->isDir()) { |
| 1978 | - @rmdir($item->getPathname()); | |
| 2026 | + @rmdir($item->getPathname()); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- WP_Filesystem would prompt for FTP credentials on non-direct hosts | |
| 1979 | 2027 | } else { |
| 1980 | - @unlink($item->getPathname()); | |
| 2028 | + @wp_delete_file($item->getPathname()); | |
| 1981 | 2029 | } |
| 1982 | 2030 | } |
| 1983 | 2031 | |
| 1984 | - return @rmdir($dir); | |
| 2032 | + return @rmdir($dir); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- WP_Filesystem would prompt for FTP credentials on non-direct hosts | |
| 1985 | 2033 | } |
| 1986 | 2034 | |
| 1987 | 2035 | /** |
| 1988 | 2036 | * Write exactly one persistent audit record for an LPS ZIP import. |
| @@ -2100,9 +2148,9 @@ | ||
| 2100 | 2148 | |
| 2101 | 2149 | $site_url = function_exists('get_site_url') ? get_site_url() : ''; |
| 2102 | 2150 | |
| 2103 | 2151 | $meta_payload = array( |
| 2104 | - 'timestamp' => function_exists('current_time') ? current_time('mysql') : date('Y-m-d H:i:s'), | |
| 2152 | + 'timestamp' => function_exists('current_time') ? current_time('mysql') : gmdate('Y-m-d H:i:s'), | |
| 2105 | 2153 | 'site_url' => $site_url, |
| 2106 | 2154 | 'api_key_prefix' => $api_key_prefix, |
| 2107 | 2155 | 'plugin_version' => defined('METASYNC_VERSION') ? METASYNC_VERSION : '', |
| 2108 | 2156 | 'auth_method' => $auth_method, |
| @@ -2142,9 +2190,9 @@ | ||
| 2142 | 2190 | |
| 2143 | 2191 | // Optional deep-debug breadcrumb — the DB write above always happens |
| 2144 | 2192 | // regardless of WP_DEBUG; this line is purely supplementary. |
| 2145 | 2193 | if (defined('WP_DEBUG') && WP_DEBUG) { |
| 2146 | - error_log(sprintf( | |
| 2194 | + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets | |
| 2147 | 2195 | 'Metasync LPS import audit: status=%s http=%d created=%d updated=%d failed=%d', |
| 2148 | 2196 | $overall_status, |
| 2149 | 2197 | $http_status, |
| 2150 | 2198 | $counts['created'], |
| @@ -2153,9 +2201,9 @@ | ||
| 2153 | 2201 | )); |
| 2154 | 2202 | } |
| 2155 | 2203 | } catch (\Throwable $e) { |
| 2156 | 2204 | if (defined('WP_DEBUG') && WP_DEBUG) { |
| 2157 | - error_log('Metasync LPS audit log failed: ' . $e->getMessage()); | |
| 2205 | + error_log('Metasync LPS audit log failed: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets | |
| 2158 | 2206 | } |
| 2159 | 2207 | } |
| 2160 | 2208 | } |
| 2161 | 2209 | } |