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