admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('mximap_admin_nonce'), 'pinStyles' => $this->get_pin_styles(), 'defaultPin' => MXIMAP_Post_Type::get_default_pin(), 'i18n' => array( 'addPin' => __('Add Pin', 'max-interactive-maps'), 'deletePin' => __('Delete', 'max-interactive-maps'), 'editPin' => __('Edit Pin Settings', 'max-interactive-maps'), /* translators: %s: pin label. */ 'editNamedPin' => __('Edit pin: %s', 'max-interactive-maps'), /* translators: %d: pin number. */ 'editNumberedPin' => __('Edit pin %d', 'max-interactive-maps'), /* translators: 1: horizontal percentage, 2: vertical percentage. */ 'pinPosition' => __('Position: %1$s percent from left, %2$s percent from top.', 'max-interactive-maps'), 'pinUrl' => __('Target URL', 'max-interactive-maps'), 'pinLabel' => __('Label (Tooltip)', 'max-interactive-maps'), 'pinStyle' => __('Icon Style', 'max-interactive-maps'), 'pinColor' => __('Pin Color', 'max-interactive-maps'), 'save' => __('Save', 'max-interactive-maps'), 'cancel' => __('Cancel', 'max-interactive-maps'), 'confirmDelete' => __('Are you sure you want to delete this pin?', 'max-interactive-maps'), ), )); } } /** * Get available pin styles */ private function get_pin_styles() { return array( 'dot' => __('Simple Dot', 'max-interactive-maps'), 'teardrop' => __('Teardrop', 'max-interactive-maps'), 'star' => __('Star', 'max-interactive-maps'), 'flag' => __('Flag', 'max-interactive-maps'), 'pushpin' => __('Pushpin', 'max-interactive-maps'), ); } /** * Add meta boxes */ public function add_meta_boxes($post_type = null, $post = null) { if ($post instanceof WP_Post && MXIMAP_Post_Type::POST_TYPE === $post_type) { // All map controls and their signature must describe the same read. $this->editor_states[$post->ID] = $this->read_map_state($post->ID); } // Map Builder Meta Box add_meta_box( 'mximap_map_builder', __('Map Builder', 'max-interactive-maps'), array($this, 'render_map_builder'), MXIMAP_Post_Type::POST_TYPE, 'normal', 'high' ); // Shortcode Display Meta Box add_meta_box( 'mximap_shortcode', __('Shortcode', 'max-interactive-maps'), array($this, 'render_shortcode_box'), MXIMAP_Post_Type::POST_TYPE, 'side', 'high' ); // Map Settings Meta Box add_meta_box( 'mximap_map_settings', __('Map Colors', 'max-interactive-maps'), array($this, 'render_settings_box'), MXIMAP_Post_Type::POST_TYPE, 'side', 'default' ); } /** * Render the Map Builder interface */ public function render_map_builder($post) { // Get saved data $state = $this->editor_states[$post->ID] ?? $this->read_map_state($post->ID); $pins = $state['pins']; $pins_editable = $this->can_edit_pins($state); $pins = $pins_editable && is_array($pins) ? $pins : array(); // Nonce field wp_nonce_field('mximap_save_map', 'mximap_map_nonce'); echo ''; ?>

get_world_map_svg(); ?>
>
post_status) { $shortcode = '[mximap_map id="' . $post->ID . '"]'; ?>

editor_states[$post->ID] ?? $this->read_map_state($post->ID); $settings = $state['settings']; $settings_editable = count($state['settings_rows']) <= 1; $settings = MXIMAP_Post_Type::resolve_settings($settings); ?>
>
>
>
rejected_pin_saves[$post_id]); unset($this->conflicted_map_saves[$post_id], $this->editor_states[$post_id]); // Verify nonce if (!isset($_POST['mximap_map_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['mximap_map_nonce'])), 'mximap_save_map')) { return; } // Check autosave if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) { return; } // Check permissions if (!current_user_can('edit_post', $post_id)) { return; } if (!isset($_POST['mximap_pins_data']) && !(isset($_POST['mximap_settings']) && is_array($_POST['mximap_settings']))) { return; } // A content comparison alone races with a second request. Serialize this // plugin's writes first, then read uncached metadata and compare the form. $lock = $this->acquire_map_save_lock($post_id); if (!$lock) { $this->conflicted_map_saves[$post_id] = true; return; } try { wp_cache_delete($post_id, 'post_meta'); $state = $this->read_map_state($post_id); $version = $_POST['mximap_map_version'] ?? null; if (!is_string($version) || !hash_equals($this->sign_map_state($post_id, $state), wp_unslash($version))) { $this->conflicted_map_saves[$post_id] = true; return; } $this->save_map_fields($post_id, $state); } finally { $this->release_map_save_lock($post_id, $lock); } } /** Existing field validation/sanitization; called only after the save guard. */ private function save_map_fields($post_id, $state) { // Save pins data if (isset($_POST['mximap_pins_data']) && $this->can_edit_pins($state)) { // Decode before field sanitization so encoded URLs are not text-filtered. // Keep objects distinct from lists: only a JSON list of pin objects is valid. $pins = is_string($_POST['mximap_pins_data']) ? json_decode(wp_unslash($_POST['mximap_pins_data'])) : null; $valid_pins = false; if (is_array($pins)) { // Sanitize each pin $sanitized_pins = array(); $valid_pins = true; foreach ($pins as $pin) { if (!is_object($pin)) { $valid_pins = false; break; } $pin = (array) $pin; foreach (array('id', 'x', 'y', 'url', 'style', 'color', 'label', 'fontStyle', 'fontSize', 'latitude', 'longitude') as $field) { if (isset($pin[$field]) && !is_scalar($pin[$field])) { // Reject the whole list rather than partially replacing saved pins. $valid_pins = false; break 2; } } $sanitized_pins[] = array( 'id' => sanitize_text_field($pin['id'] ?? ''), 'x' => floatval($pin['x'] ?? 50), 'y' => floatval($pin['y'] ?? 50), 'url' => esc_url_raw($pin['url'] ?? ''), 'style' => sanitize_key($pin['style'] ?? 'dot'), 'color' => sanitize_hex_color($pin['color'] ?? '#e74c3c'), 'label' => sanitize_text_field($pin['label'] ?? ''), 'fontStyle' => sanitize_key($pin['fontStyle'] ?? 'normal'), 'fontSize' => sanitize_key($pin['fontSize'] ?? 'medium'), 'latitude' => floatval($pin['latitude'] ?? 0), 'longitude' => floatval($pin['longitude'] ?? 0), ); } if ($valid_pins) { // WordPress unslashes meta values; protect literal backslashes in text. update_post_meta($post_id, '_mximap_pins', wp_slash($sanitized_pins)); } } if (!$valid_pins) { $this->rejected_pin_saves[$post_id] = true; } } // Save settings if (isset($_POST['mximap_settings']) && is_array($_POST['mximap_settings']) && count($state['settings_rows']) <= 1) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized below $raw_settings = wp_unslash($_POST['mximap_settings']); $settings = array( 'land_color' => sanitize_hex_color($raw_settings['land_color'] ?? ''), 'water_color' => sanitize_hex_color($raw_settings['water_color'] ?? ''), 'border_color' => sanitize_hex_color($raw_settings['border_color'] ?? ''), ); update_post_meta($post_id, '_mximap_settings', $settings); } } private function read_map_state($post_id) { $pins = get_post_meta($post_id, '_mximap_pins', false); $settings = get_post_meta($post_id, '_mximap_settings', false); // Include every row in the signature: a second record must not be hidden // by get_post_meta(..., true), then overwritten by update_post_meta(). return array('pins' => $pins ? $pins[0] : '', 'settings' => $settings ? $settings[0] : '', 'pin_rows' => $pins, 'settings_rows' => $settings); } private function can_edit_pins($state) { return empty($state['pin_rows']) || (1 === count($state['pin_rows']) && MXIMAP_Post_Type::pins_are_editable($state['pins'])); } private function sign_map_state($post_id, $state) { return hash_hmac('sha256', serialize(array((int) $post_id, get_current_user_id(), $state)), wp_salt('nonce')); } /** * Atomic private option-row mutex, like WordPress's upgrader lock. Do not * use a cached transient for mutual exclusion. No saved map fields are added. */ private function acquire_map_save_lock($post_id) { global $wpdb; $name = 'mximap_save_lock_' . (int) $post_id; $old = $wpdb->get_var($wpdb->prepare("SELECT option_value FROM $wpdb->options WHERE option_name = %s", $name)); if (is_string($old) && preg_match('/^([0-9]+):[a-f0-9]{32}$/D', $old, $match) && (int) $match[1] < time() - 10 * MINUTE_IN_SECONDS) { // Reclaim only the exact abandoned lease we observed, never a successor. $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->options WHERE option_name = %s AND option_value = %s", $name, $old)); } $value = time() . ':' . bin2hex(random_bytes(16)); $inserted = $wpdb->query($wpdb->prepare("INSERT IGNORE INTO $wpdb->options (option_name, option_value, autoload) VALUES (%s, %s, 'no')", $name, $value)); return 1 === $inserted ? $value : false; } private function release_map_save_lock($post_id, $value) { global $wpdb; $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->options WHERE option_name = %s AND option_value = %s", 'mximap_save_lock_' . (int) $post_id, $value)); } public function add_map_conflict_to_redirect($location, $post_id) { if (MXIMAP_Post_Type::POST_TYPE !== get_post_type($post_id)) { return $location; } $location = remove_query_arg('mximap_map_conflict', $location); if (empty($this->conflicted_map_saves[$post_id]) || !current_user_can('edit_post', $post_id)) { return $location; } unset($this->conflicted_map_saves[$post_id]); $token = wp_generate_password(32, false, false); $key = 'mximap_map_conflict_' . get_current_user_id() . '_' . $post_id . '_' . $token; if (!set_transient($key, 'conflict', 5 * MINUTE_IN_SECONDS)) { return $location; } return add_query_arg('mximap_map_conflict', $token, $location); } public function render_map_conflict() { global $post; $screen = get_current_screen(); if (!$screen || 'post' !== $screen->base || MXIMAP_Post_Type::POST_TYPE !== $screen->post_type || !($post instanceof WP_Post) || MXIMAP_Post_Type::POST_TYPE !== $post->post_type || !current_user_can('edit_post', $post->ID) || !isset($_GET['mximap_map_conflict']) || !is_string($_GET['mximap_map_conflict'])) { return; } $token = wp_unslash($_GET['mximap_map_conflict']); if (!preg_match('/^[A-Za-z0-9]{32}$/D', $token)) { return; } $key = 'mximap_map_conflict_' . get_current_user_id() . '_' . $post->ID . '_' . $token; if ('conflict' !== get_transient($key)) { return; } delete_transient($key); echo ''; } /** * Carry a guarded rejection to the submitting user's map editor once. */ public function add_pin_save_warning_to_redirect($location, $post_id) { if (MXIMAP_Post_Type::POST_TYPE !== get_post_type($post_id)) { return $location; } $location = remove_query_arg('mximap_pins_rejected', $location); if (empty($this->rejected_pin_saves[$post_id]) || !current_user_can('edit_post', $post_id)) { return $location; } unset($this->rejected_pin_saves[$post_id]); $token = wp_generate_password(32, false, false); if (!set_transient($this->get_pin_save_notice_key($post_id, $token), 'rejected', 5 * MINUTE_IN_SECONDS)) { return $location; } return add_query_arg('mximap_pins_rejected', $token, $location); } /** * Show feedback only on the matching authorized editor, without pin data. */ public function render_pin_save_warning() { global $post; $screen = get_current_screen(); if (!$screen || 'post' !== $screen->base || MXIMAP_Post_Type::POST_TYPE !== $screen->post_type || !($post instanceof WP_Post) || MXIMAP_Post_Type::POST_TYPE !== $post->post_type || !current_user_can('edit_post', $post->ID) || !isset($_GET['mximap_pins_rejected']) || !is_string($_GET['mximap_pins_rejected'])) { return; } $token = wp_unslash($_GET['mximap_pins_rejected']); if (!preg_match('/^[A-Za-z0-9]{32}$/D', $token)) { return; } $key = $this->get_pin_save_notice_key($post->ID, $token); if ('rejected' !== get_transient($key)) { return; } delete_transient($key); echo ''; } /** * Keep independent save events separate without writing map metadata. */ private function get_pin_save_notice_key($post_id, $token) { return 'mximap_pin_rejected_' . get_current_user_id() . '_' . $post_id . '_' . $token; } /** * Get the World Map SVG */ private function get_world_map_svg() { $svg_path = MXIMAP_PLUGIN_DIR . 'assets/svg/world-map.svg'; if (file_exists($svg_path)) { return preg_replace('/