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 '
';
esc_html_e('Your pins and map colors were not saved because the editor is out of date or another map save is in progress. The previously saved map was kept. Reload the editor, review the latest map, and reapply your changes. Other post changes may have been saved.', 'max-interactive-maps');
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 '
';
esc_html_e('Pin changes were not saved because the submitted pin data was invalid. Previously saved pins were kept. Other map changes may have been saved. Reload the map editor, check your pins, and try again.', 'max-interactive-maps');
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('/';
}
/**
* Add pin SVG templates to footer
*/
public function add_pin_templates()
{
global $post_type;
if (MXIMAP_Post_Type::POST_TYPE !== $post_type) {
return;
}
?>