| @@ -466,20 +466,8 @@ | ||
| 466 | 466 | } |
| 467 | 467 | |
| 468 | 468 | return false; |
| 469 | 469 | } |
| 470 | - public static function remove_old_notice(){ | |
| 471 | - global $wp_filter; | |
| 472 | - if( isset( $wp_filter['admin_notices']->callbacks[10] ) && is_array( $wp_filter['admin_notices']->callbacks[10] ) ) { | |
| 473 | - foreach( $wp_filter['admin_notices']->callbacks[10] as $hash => $callbacks ) { | |
| 474 | - if( is_array( $callbacks['function'] ) && ! empty( $callbacks['function'][0] ) && is_object( $callbacks['function'][0] ) && $callbacks['function'][0] instanceof \NotificationX_Licensing ) { | |
| 475 | - remove_action( 'admin_notices', $hash ); | |
| 476 | - break; | |
| 477 | - } | |
| 478 | - } | |
| 479 | - } | |
| 480 | - } | |
| 481 | - | |
| 482 | 470 | public static function remote_get($url, $args = array(), $raw = false, $assoc = null) { |
| 483 | 471 | $defaults = array( |
| 484 | 472 | 'timeout' => 20, |
| 485 | 473 | 'redirection' => 5, |
| @@ -1106,8 +1094,16 @@ | ||
| 1106 | 1094 | } |
| 1107 | 1095 | } |
| 1108 | 1096 | |
| 1109 | 1097 | public static function nx_get_visitor_country_code() { |
| 1098 | + // Country targeting now applies to ALL notification types, so a single page | |
| 1099 | + // load can evaluate several country-targeted notifications. Resolve the | |
| 1100 | + // visitor's country once per request and reuse it. We key the memo on the IP | |
| 1101 | + // and read it with array_key_exists (not isset) so an empty/failed result is | |
| 1102 | + // remembered too and never re-triggers the header/API lookups below within the | |
| 1103 | + // same request. An unknown country is always represented as an empty string. | |
| 1104 | + static $memo = []; | |
| 1105 | + | |
| 1110 | 1106 | $ip = ''; |
| 1111 | 1107 | if (!empty($_SERVER['HTTP_CLIENT_IP'])) { |
| 1112 | 1108 | $ip = sanitize_text_field(wp_unslash($_SERVER['HTTP_CLIENT_IP'])); |
| 1113 | 1109 | } elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { |
| @@ -1114,23 +1110,77 @@ | ||
| 1114 | 1110 | $ip = explode(',', sanitize_text_field(wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR'])))[0]; |
| 1115 | 1111 | } else { |
| 1116 | 1112 | $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : ''; |
| 1117 | 1113 | } |
| 1118 | - | |
| 1119 | - // Prevent localhost IP from erroring | |
| 1120 | - if ($ip === '127.0.0.1' || $ip === '::1') { | |
| 1121 | - return 'all'; // default fallback for local testing | |
| 1114 | + $ip = trim($ip); | |
| 1115 | + | |
| 1116 | + // No usable IP (CLI/cron) or localhost: return an empty code. Callers treat an | |
| 1117 | + // unknown country as "don't filter" (fail-open), so country-targeted | |
| 1118 | + // notifications still show during local testing instead of silently | |
| 1119 | + // disappearing. Bailing here also avoids requesting | |
| 1120 | + // http://ip-api.com/json/?fields=countryCode with an empty IP, which would | |
| 1121 | + // resolve to the SERVER's country and cache nothing. | |
| 1122 | + if ($ip === '' || $ip === '127.0.0.1' || $ip === '::1') { | |
| 1123 | + return apply_filters('nx_visitor_country_code', '', $ip); | |
| 1122 | 1124 | } |
| 1123 | 1125 | |
| 1124 | - $response = wp_remote_get("http://ip-api.com/json/{$ip}?fields=countryCode"); | |
| 1126 | + if (array_key_exists($ip, $memo)) { | |
| 1127 | + return apply_filters('nx_visitor_country_code', $memo[$ip], $ip); | |
| 1128 | + } | |
| 1125 | 1129 | |
| 1126 | - if (is_wp_error($response)) { | |
| 1127 | - return null; | |
| 1130 | + // Let a host/CDN or custom resolver short-circuit the external lookup entirely. | |
| 1131 | + // e.g. Cloudflare sends CF-IPCountry; many hosts expose GEOIP_COUNTRY_CODE. | |
| 1132 | + $header_country = ''; | |
| 1133 | + foreach (['HTTP_CF_IPCOUNTRY', 'GEOIP_COUNTRY_CODE', 'HTTP_X_COUNTRY_CODE'] as $h) { | |
| 1134 | + if (!empty($_SERVER[$h])) { | |
| 1135 | + $header_country = strtoupper(sanitize_text_field(wp_unslash($_SERVER[$h]))); | |
| 1136 | + break; | |
| 1137 | + } | |
| 1128 | 1138 | } |
| 1139 | + // 'XX'/'T1' are Cloudflare's "unknown"/Tor placeholders — ignore them. | |
| 1140 | + if ($header_country !== '' && !in_array($header_country, ['XX', 'T1'], true)) { | |
| 1141 | + $memo[$ip] = $header_country; | |
| 1142 | + return apply_filters('nx_visitor_country_code', $header_country, $ip); | |
| 1143 | + } | |
| 1129 | 1144 | |
| 1130 | - $data = json_decode(wp_remote_retrieve_body($response), true); | |
| 1145 | + // Per-IP cache so we don't hit the external API on every page load. A | |
| 1146 | + // transient is used deliberately: WordPress serves transients from a | |
| 1147 | + // persistent object cache (Redis/Memcached) when one is available — so those | |
| 1148 | + // sites add zero wp_options rows — and transparently falls back to the options | |
| 1149 | + // table otherwise, so the country stays cached across requests on plain sites | |
| 1150 | + // too (which is what keeps us under ip-api's 45 req/min limit). A cached empty | |
| 1151 | + // string is a remembered "lookup failed" marker, distinct from a miss (false). | |
| 1152 | + $cache_key = 'nx_geo_' . md5($ip); | |
| 1153 | + $cached = get_transient($cache_key); | |
| 1154 | + if (false !== $cached) { | |
| 1155 | + $memo[$ip] = $cached; | |
| 1156 | + return apply_filters('nx_visitor_country_code', $cached, $ip); | |
| 1157 | + } | |
| 1131 | 1158 | |
| 1132 | - return isset($data['countryCode']) ? $data['countryCode'] : null; | |
| 1159 | + $country = ''; | |
| 1160 | + $api_endpoint = apply_filters('nx_visitor_country_api', "http://ip-api.com/json/{$ip}?fields=countryCode", $ip); | |
| 1161 | + $response = wp_remote_get($api_endpoint, ['timeout' => 3]); | |
| 1162 | + | |
| 1163 | + if (!is_wp_error($response) && (int) wp_remote_retrieve_response_code($response) === 200) { | |
| 1164 | + $data = json_decode(wp_remote_retrieve_body($response), true); | |
| 1165 | + if (isset($data['countryCode']) && $data['countryCode'] !== '') { | |
| 1166 | + $country = $data['countryCode']; | |
| 1167 | + } | |
| 1168 | + } | |
| 1169 | + | |
| 1170 | + // Cache successes for the full TTL; negative-cache failures for a short window | |
| 1171 | + // so a rate-limit/timeout (ip-api's free tier is ~45 req/min keyed by the | |
| 1172 | + // SERVER IP, shared across all visitors) doesn't re-hit the API on every | |
| 1173 | + // subsequent page load while it recovers. | |
| 1174 | + $ttl = '' !== $country | |
| 1175 | + ? (int) apply_filters('nx_visitor_country_cache_ttl', 12 * HOUR_IN_SECONDS) | |
| 1176 | + : (int) apply_filters('nx_visitor_country_failed_cache_ttl', 5 * MINUTE_IN_SECONDS); | |
| 1177 | + if ($ttl > 0) { | |
| 1178 | + set_transient($cache_key, $country, $ttl); | |
| 1179 | + } | |
| 1180 | + | |
| 1181 | + $memo[$ip] = $country; | |
| 1182 | + return apply_filters('nx_visitor_country_code', $country, $ip); | |
| 1133 | 1183 | } |
| 1134 | 1184 | |
| 1135 | 1185 | public static function nx_get_all_country($search = '') { |
| 1136 | 1186 | $countries = [ |
| @@ -1330,8 +1380,45 @@ | ||
| 1330 | 1380 | return strpos(strtolower($name), $search) !== false; |
| 1331 | 1381 | }); |
| 1332 | 1382 | } |
| 1333 | 1383 | return $countries; |
| 1384 | + } | |
| 1385 | + | |
| 1386 | + | |
| 1387 | + /** | |
| 1388 | + * Delete a design document that NotificationX itself owns. | |
| 1389 | + * | |
| 1390 | + * The ID reaching the callers of this method arrives in a REST payload, so | |
| 1391 | + * it is attacker-controlled. Without a post-type check, any user holding | |
| 1392 | + * `edit_notificationx` could pass an arbitrary ID and force-delete any post | |
| 1393 | + * on the site -- pages, products, orders -- with no trash to recover from. | |
| 1394 | + * Only documents of a post type NotificationX creates may be removed here. | |
| 1395 | + * | |
| 1396 | + * A `current_user_can( 'delete_post' )` check is deliberately NOT applied. | |
| 1397 | + * These post types register with `capability_type => 'post'`, so that meta | |
| 1398 | + * cap resolves to the primitive `delete_posts`. A custom role delegated only | |
| 1399 | + * "Who Can Create Notification?" does not hold `delete_posts`, and gating on | |
| 1400 | + * it would stop that role from removing its own designs -- breaking exactly | |
| 1401 | + * the delegated workflow this boundary exists to support. Actor authority is | |
| 1402 | + * already established by the route's `edit_notificationx` permission | |
| 1403 | + * callback; what was missing, and what this restores, is object authority. | |
| 1404 | + * | |
| 1405 | + * @param int|string $post_id Candidate post ID, untrusted. | |
| 1406 | + * @param string $expected_type Post type NotificationX owns. | |
| 1407 | + * @return bool True when a post was deleted. | |
| 1408 | + */ | |
| 1409 | + public static function delete_owned_post( $post_id, $expected_type ) { | |
| 1410 | + $post_id = absint( $post_id ); | |
| 1411 | + if ( ! $post_id ) { | |
| 1412 | + return false; | |
| 1413 | + } | |
| 1414 | + | |
| 1415 | + $post = get_post( $post_id ); | |
| 1416 | + if ( ! $post || $expected_type !== $post->post_type ) { | |
| 1417 | + return false; | |
| 1418 | + } | |
| 1419 | + | |
| 1420 | + return (bool) wp_delete_post( $post_id, true ); | |
| 1334 | 1421 | } |
| 1335 | 1422 | |
| 1336 | 1423 | |
| 1337 | 1424 | } |