Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/metasync/404-monitor/class-metasync-404-monitor-list-table.php
+++ b/metasync/404-monitor/class-metasync-404-monitor-list-table.php
@@ -544,24 +544,54 @@
if (empty($post_data['item'])) return;
+ $action = $this->current_action();
+
+ // Only the state-changing bulk actions need (and emit) a nonce. Returning
+ // early for anything else keeps normal page loads from tripping the check.
+ if (!in_array($action, ['delete_bulk', 'empty'], true)) {
+ return;
+ }
+
+ // Verify the bulk-action nonce emitted by WP_List_Table::display_tablenav()
+ // (plural === 'items' → 'bulk-items') and confirm plugin access before any
+ // delete / clear-logs. Guards against CSRF on the bulk path.
+ check_admin_referer('bulk-items');
+
+ if (!Metasync::current_user_has_plugin_access()) {
+ return;
+ }
+
// Detect when bulk delete action is being triggered.
- if ('delete_bulk' === $this->current_action()) {
+ if ('delete_bulk' === $action) {
$this->database->delete($items);
}
- if ('empty' === $this->current_action()) {
+ if ('empty' === $action) {
$this->database->clear_logs();
}
}
protected function process_row_action()
{
+ // Only the delete row action changes state; bail on anything else so a
+ // normal page load never triggers a nonce failure.
+ if ('delete' !== $this->current_action()) {
+ return;
+ }
+
$get_data = metasync_sanitize_input_array($_GET);
$item = isset($get_data['id']) ? sanitize_text_field($get_data['id']) : '';
- // Detect when row action is being triggered.
- if ('delete' === $this->current_action()) {
- $this->database->delete([$item]);
+ // Verify the per-item nonce already attached to the Delete link in
+ // column_uri() (wp_nonce_url(..., 'deleteid_' . $item['id'])) and confirm
+ // plugin access before deleting. The link runs via GET, so without this it
+ // is open to CSRF.
+ check_admin_referer('deleteid_' . $item);
+
+ if (!Metasync::current_user_has_plugin_access()) {
+ return;
}
+
+ $this->database->delete([$item]);
}
function prepare_items()
--- a/metasync/admin/class-metasync-admin.php
+++ b/metasync/admin/class-metasync-admin.php
@@ -199,11 +199,16 @@
// Set menu title using the effective title (includes whitelabel company name if available)
$this->menu_title = $this->get_effective_menu_title();
- if(!isset( $data['white_label_plugin_menu_slug'])){
- self::$page_slug = "searchatlas";
- }else{
- self::$page_slug = $data['white_label_plugin_menu_slug']=="" ? "searchatlas":$data['white_label_plugin_menu_slug'];
- }
+ $raw_slug = isset($data['white_label_plugin_menu_slug']) ? $data['white_label_plugin_menu_slug'] : '';
+ $clean_slug = sanitize_title($raw_slug);
+ # Self-heal a legacy URL-shaped slug (WP-413): persist the sanitized value so every
+ # reader that builds admin links from the stored option gets a valid WP menu slug.
+ if ($raw_slug !== '' && $clean_slug !== $raw_slug) {
+ $options = Metasync::get_option();
+ $options['general']['white_label_plugin_menu_slug'] = $clean_slug;
+ Metasync::set_option($options);
+ }
+ self::$page_slug = $clean_slug === '' ? 'searchatlas' : $clean_slug;
add_action('admin_menu', array($this, 'add_plugin_settings_page'));
add_action('admin_menu', array($this, 'add_import_external_data_page'));
@@ -462,7 +467,9 @@
$data= Metasync::get_option('general');
# Get white label menu slug
- $menu_slug = empty($data['white_label_plugin_menu_slug']) ? self::$page_slug : $data['white_label_plugin_menu_slug'];
+ # Sanitize so a URL-shaped legacy value still yields a valid WP menu slug (WP-413)
+ $menu_slug = empty($data['white_label_plugin_menu_slug']) ? self::$page_slug : sanitize_title($data['white_label_plugin_menu_slug']);
+ $menu_slug = $menu_slug === '' ? self::$page_slug : $menu_slug;
?>
<style>
@@ -1135,6 +1142,9 @@
$this->version,
true
);
+ wp_localize_script($this->plugin_name . '-bing-console', 'metasyncBingConsoleData', array(
+ 'nonce' => wp_create_nonce('metasync_nonce'),
+ ));
}
// Add redirection form
@@ -3780,17 +3790,18 @@
</div>
<div class="sync-log-status" style="display:flex;align-items:center;gap:8px;">
- <?php if ($record->status === 'published' || $record->status === 'publish'): ?>
- <span class="sync-status-badge sync-status-published">
- <span class="sync-status-icon">✓</span>
- Published
- </span>
- <?php else: ?>
- <span class="sync-status-badge sync-status-draft">
- <span class="sync-status-icon">i</span>
- Draft
- </span>
- <?php endif; ?>
+ <?php
+ $st = (string) $record->status;
+ $b_label = ucfirst($st); $b_bg = '#6b7280'; $b_icon = 'dashicons-info-outline';
+ if ($st === 'published' || $st === 'publish' || $st === 'success' || $st === 'partial') { $b_label = 'Published'; $b_bg = '#16a34a'; $b_icon = 'dashicons-yes'; }
+ elseif ($st === 'updated') { $b_label = 'Updated'; $b_bg = '#0d9488'; $b_icon = 'dashicons-update'; }
+ elseif ($st === 'failed' || $st === 'conflict' || $st === 'locked') { $b_label = 'Not imported'; $b_bg = '#64748b'; $b_icon = 'dashicons-minus'; }
+ elseif ($st === 'draft') { $b_label = 'Draft'; $b_bg = '#6b7280'; $b_icon = 'dashicons-info-outline'; }
+ ?>
+ <span class="sync-status-badge" style="display:inline-flex;align-items:center;gap:4px;background:<?php echo esc_attr($b_bg); ?>;color:#fff;padding:3px 10px;border-radius:12px;font-size:12px;font-weight:600;line-height:1;">
+ <span class="dashicons <?php echo esc_attr($b_icon); ?>" style="font-size:14px;width:14px;height:14px;line-height:14px;"></span>
+ <?php echo esc_html($b_label); ?>
+ </span>
<?php if ($record->source === 'MCP Client'): ?>
<button type="button"
class="metasync-rollback-btn"
@@ -6081,6 +6092,12 @@
*/
public function ajax_send_giapi()
{
+ check_ajax_referer('metasync_nonce', 'nonce');
+
+ if (!Metasync::current_user_has_plugin_access()) {
+ wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
+ }
+
$post_data = metasync_sanitize_input_array($_POST);
if (!isset($post_data['metasync_giapi_url'])) {
return;
@@ -6148,6 +6165,12 @@
*/
public function ajax_send_bing_indexnow()
{
+ check_ajax_referer('metasync_nonce', 'nonce');
+
+ if (!Metasync::current_user_has_plugin_access()) {
+ wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
+ }
+
require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
$bing_instant_index = new Metasync_Bing_Instant_Index();
$bing_instant_index->send();
--- a/metasync/custom-pages/class-metasync-custom-pages-api.php
+++ b/metasync/custom-pages/class-metasync-custom-pages-api.php
@@ -531,11 +531,19 @@
}
/**
- * Delete a custom HTML page
+ * Delete a custom HTML page and clean up its on-disk assets.
+ *
+ * Shared implementation behind both the REST endpoint and the MCP delete tool
+ * so the two paths cannot drift. Performs: page validation, front-page reset
+ * (when the page is the static front page), permanent deletion, and
+ * reference-counted removal of the LPS asset folder.
+ *
+ * @param int $page_id
+ * @return array|WP_Error Result payload on success, WP_Error (with status) on failure.
*/
- public function delete_custom_html_page($request)
+ public function delete_custom_page_with_cleanup($page_id)
{
- $page_id = intval($request->get_param('page_id'));
+ $page_id = intval($page_id);
// Verify page exists
$page = get_post($page_id);
@@ -565,6 +573,11 @@
$assets_folder = $page->post_name;
}
+ // Capture whether this page is the static front page BEFORE deleting it -
+ // WordPress/plugins may clear page_on_front during wp_delete_post(), so a
+ // post-delete read could miss it (WP-443).
+ $was_front_page = ((int) get_option('page_on_front') === $page_id);
+
// Delete the page permanently
$result = wp_delete_post($page_id, true);
@@ -576,6 +589,16 @@
);
}
+ // Reset the WordPress static front page when this page WAS it (captured
+ // before the delete; some setups clear page_on_front during wp_delete_post(),
+ // so reading it afterwards would miss the match). Idempotent + only on success.
+ $front_page_reset = false;
+ if ($was_front_page) {
+ update_option('show_on_front', 'posts');
+ delete_option('page_on_front');
+ $front_page_reset = true;
+ }
+
// Remove the LPS asset folder associated with this page — but only if no
// other page still references it. Multi-page imports share one folder across
// many pages, so deleting one page must not wipe assets the remaining pages
@@ -617,15 +640,35 @@
}
}
+ return array(
+ 'page_id' => $page_id,
+ 'title' => $page->post_title,
+ 'assets_removed' => $assets_removed,
+ 'assets_folder_still_referenced' => $assets_still_referenced,
+ 'front_page_reset' => $front_page_reset,
+ );
+ }
+
+ /**
+ * Delete a custom HTML page (REST endpoint).
+ *
+ * Delegates to the shared delete_custom_page_with_cleanup() implementation.
+ */
+ public function delete_custom_html_page($request)
+ {
+ $page_id = intval($request->get_param('page_id'));
+
+ $result = $this->delete_custom_page_with_cleanup($page_id);
+ if (is_wp_error($result)) {
+ // Return the WP_Error directly so the REST framework maps its embedded
+ // status (404/400/500) to the HTTP status code.
+ return $result;
+ }
+
return rest_ensure_response(array(
'success' => true,
'message' => 'Custom HTML page deleted successfully',
- 'data' => array(
- 'page_id' => $page_id,
- 'title' => $page->post_title,
- 'assets_removed' => $assets_removed,
- 'assets_folder_still_referenced' => $assets_still_referenced
- )
+ 'data' => $result
));
}
@@ -669,6 +712,12 @@
'required' => false,
'type' => 'string',
'description' => 'On-disk folder name the bundle is extracted to (under uploads/metasync-pages/). This is the path LPS bakes into the asset URLs at build time and is intentionally separate from the page slug. Defaults to the slug when omitted.'
+ ),
+ 'external_ref' => array(
+ 'required' => false,
+ 'type' => 'string',
+ 'description' => 'LPS project UUID used as the stable per-project home dedup key.',
+ 'sanitize_callback' => 'sanitize_text_field'
)
);
}
@@ -680,125 +729,219 @@
*/
public function import_lps_page($request)
{
- $raw_slug = $request->get_param('slug');
- if (is_string($raw_slug) && (strpos($raw_slug, '..') !== false || strpos($raw_slug, '/') !== false || strpos($raw_slug, '\') !== false)) {
- return new WP_Error(
- 'invalid_slug_path',
- 'Slug must not contain path separators or parent references.',
- array('status' => 400)
- );
- }
-
- $params = $request->get_params();
- $slug = isset($params['slug']) ? sanitize_title($params['slug']) : '';
- $title = isset($params['title']) ? sanitize_text_field($params['title']) : '';
- $status = isset($params['status']) ? sanitize_text_field($params['status']) : 'publish';
- $overwrite = isset($params['overwrite']) ? (bool) $params['overwrite'] : true;
- $download_url = isset($params['download_url']) ? $params['download_url'] : '';
- $assets_folder = isset($params['assets_folder']) ? $params['assets_folder'] : '';
-
- $zip_path = '';
- $tmp_file = null;
+ // --- Audit tracking (one persistent record is written on every exit path) ---
+ $_lps_start_ms = (int) round(microtime(true) * 1000);
+ $_lps_result = null; // WP_Error|array — set before each return
+ $_lps_http_st = 500; // updated before each return
+ $_lps_input = array(
+ 'source_type' => 'unknown',
+ 'zip_size_bytes' => 0,
+ 'assets_folder' => '',
+ 'pages_in_manifest' => null,
+ );
- $max_zip_bytes = 50 * 1024 * 1024;
+ // Re-derive the auth method + masked key prefix using the same precedence as
+ // validate_api_key() (Bearer header, then x-api-key header, then apikey param).
+ $_lps_auth_method = '';
+ $_lps_api_key_prefix = '';
+ $_lps_matched_key = '';
+ $_lps_auth_header = $request->get_header('authorization');
+ if (!empty($_lps_auth_header) && preg_match('/^Bearers+(.+)$/i', $_lps_auth_header, $_lps_m)) {
+ $_lps_auth_method = 'bearer';
+ $_lps_matched_key = sanitize_text_field($_lps_m[1]);
+ }
+ if ($_lps_matched_key === '') {
+ $_lps_hk = $request->get_header('x-api-key');
+ if (!empty($_lps_hk)) {
+ $_lps_auth_method = 'x-api-key';
+ $_lps_matched_key = sanitize_text_field($_lps_hk);
+ }
+ }
+ if ($_lps_matched_key === '') {
+ $_lps_pk = $request->get_param('apikey');
+ if (!empty($_lps_pk)) {
+ $_lps_auth_method = 'apikey-query';
+ $_lps_matched_key = sanitize_text_field($_lps_pk);
+ }
+ }
+ if (strlen($_lps_matched_key) >= 8) {
+ $_lps_api_key_prefix = substr($_lps_matched_key, 0, 8) . '...';
+ }
try {
- if (!empty($download_url)) {
- $tmp_file = wp_tempnam('lps_import_');
- if (!$tmp_file) {
- return new WP_Error('tmp_file_failed', 'Failed to create temporary file.', array('status' => 500));
- }
-
- $response = wp_safe_remote_get($download_url, array(
- 'timeout' => 60,
- 'stream' => true,
- 'filename' => $tmp_file,
- ));
-
- if (is_wp_error($response)) {
- return new WP_Error('zip_download_failed', 'Failed to download ZIP: ' . $response->get_error_message(), array('status' => 400));
- }
-
- $code = wp_remote_retrieve_response_code($response);
- if ((int) $code !== 200) {
- return new WP_Error('zip_download_http_error', 'ZIP download returned HTTP ' . intval($code), array('status' => 400));
- }
+ $raw_slug = $request->get_param('slug');
+ if (is_string($raw_slug) && (strpos($raw_slug, '..') !== false || strpos($raw_slug, '/') !== false || strpos($raw_slug, '\') !== false)) {
+ $_lps_http_st = 400;
+ $_lps_result = new WP_Error(
+ 'invalid_slug_path',
+ 'Slug must not contain path separators or parent references.',
+ array('status' => 400)
+ );
+ return $_lps_result;
+ }
- $content_type = strtolower((string) wp_remote_retrieve_header($response, 'content-type'));
- if ($content_type !== '' && strpos($content_type, 'zip') === false && strpos($content_type, 'octet-stream') === false) {
- return new WP_Error('invalid_zip_content_type', 'Downloaded file is not a ZIP (Content-Type: ' . sanitize_text_field($content_type) . ').', array('status' => 400));
- }
+ $params = $request->get_params();
+ $slug = isset($params['slug']) ? sanitize_title($params['slug']) : '';
+ $title = isset($params['title']) ? sanitize_text_field($params['title']) : '';
+ $status = isset($params['status']) ? sanitize_text_field($params['status']) : 'publish';
+ $overwrite = isset($params['overwrite']) ? (bool) $params['overwrite'] : true;
+ $download_url = isset($params['download_url']) ? $params['download_url'] : '';
+ $assets_folder = isset($params['assets_folder']) ? $params['assets_folder'] : '';
+ $_lps_input['assets_folder'] = $assets_folder;
+ $external_ref = isset($params['external_ref']) ? sanitize_text_field($params['external_ref']) : '';
+ $_lps_input['external_ref'] = $external_ref;
+
+ $zip_path = '';
+ $tmp_file = null;
+
+ $max_zip_bytes = 50 * 1024 * 1024;
+
+ try {
+ if (!empty($download_url)) {
+ $_lps_input['source_type'] = 'download_url';
+ // wp_tempnam() lives in wp-admin/includes/file.php, which is NOT loaded
+ // during a normal REST request — load it so this works in any context.
+ if (!function_exists('wp_tempnam')) {
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ }
+ $tmp_file = wp_tempnam('lps_import_');
+ if (!$tmp_file) {
+ $_lps_http_st = 500;
+ $_lps_result = new WP_Error('tmp_file_failed', 'Failed to create temporary file.', array('status' => 500));
+ return $_lps_result;
+ }
- $downloaded_size = file_exists($tmp_file) ? filesize($tmp_file) : 0;
- if ($downloaded_size === 0) {
- return new WP_Error('zip_download_empty', 'Downloaded ZIP file is empty.', array('status' => 400));
- }
- if ($downloaded_size > $max_zip_bytes) {
- return new WP_Error('zip_too_large', 'Downloaded ZIP exceeds the maximum allowed size (' . intval($max_zip_bytes / 1024 / 1024) . ' MB).', array('status' => 413));
- }
+ $response = wp_safe_remote_get($download_url, array(
+ 'timeout' => 60,
+ 'stream' => true,
+ 'filename' => $tmp_file,
+ ));
+
+ if (is_wp_error($response)) {
+ $_lps_http_st = 400;
+ $_lps_result = new WP_Error('zip_download_failed', 'Failed to download ZIP: ' . $response->get_error_message(), array('status' => 400));
+ return $_lps_result;
+ }
- $zip_path = $tmp_file;
+ $code = wp_remote_retrieve_response_code($response);
+ if ((int) $code !== 200) {
+ $_lps_http_st = 400;
+ $_lps_result = new WP_Error('zip_download_http_error', 'ZIP download returned HTTP ' . intval($code), array('status' => 400));
+ return $_lps_result;
+ }
- } elseif (!empty($_FILES['zip_file']) && is_array($_FILES['zip_file'])) {
- $file = $_FILES['zip_file'];
+ $content_type = strtolower((string) wp_remote_retrieve_header($response, 'content-type'));
+ if ($content_type !== '' && strpos($content_type, 'zip') === false && strpos($content_type, 'octet-stream') === false) {
+ $_lps_http_st = 400;
+ $_lps_result = new WP_Error('invalid_zip_content_type', 'Downloaded file is not a ZIP (Content-Type: ' . sanitize_text_field($content_type) . ').', array('status' => 400));
+ return $_lps_result;
+ }
- if (!isset($file['error']) || $file['error'] !== UPLOAD_ERR_OK) {
- return new WP_Error('zip_upload_error', 'Uploaded ZIP file has an error code: ' . (isset($file['error']) ? intval($file['error']) : 'unknown'), array('status' => 400));
- }
+ $downloaded_size = file_exists($tmp_file) ? filesize($tmp_file) : 0;
+ $_lps_input['zip_size_bytes'] = $downloaded_size;
+ if ($downloaded_size === 0) {
+ $_lps_http_st = 400;
+ $_lps_result = new WP_Error('zip_download_empty', 'Downloaded ZIP file is empty.', array('status' => 400));
+ return $_lps_result;
+ }
+ if ($downloaded_size > $max_zip_bytes) {
+ $_lps_http_st = 413;
+ $_lps_result = new WP_Error('zip_too_large', 'Downloaded ZIP exceeds the maximum allowed size (' . intval($max_zip_bytes / 1024 / 1024) . ' MB).', array('status' => 413));
+ return $_lps_result;
+ }
- if (empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
- return new WP_Error('invalid_zip_upload', 'Invalid ZIP upload.', array('status' => 400));
- }
+ $zip_path = $tmp_file;
- $allowed_mime_types = array('application/zip', 'application/octet-stream', 'application/x-zip-compressed', 'multipart/x-zip');
- if (!empty($file['type']) && !in_array($file['type'], $allowed_mime_types, true)) {
- return new WP_Error('invalid_zip_mime', 'Uploaded file is not a ZIP archive (type: ' . sanitize_text_field($file['type']) . ').', array('status' => 400));
- }
+ } elseif (!empty($_FILES['zip_file']) && is_array($_FILES['zip_file'])) {
+ $file = $_FILES['zip_file'];
+ $_lps_input['source_type'] = 'upload';
+
+ if (!isset($file['error']) || $file['error'] !== UPLOAD_ERR_OK) {
+ $_lps_http_st = 400;
+ $_lps_result = new WP_Error('zip_upload_error', 'Uploaded ZIP file has an error code: ' . (isset($file['error']) ? intval($file['error']) : 'unknown'), array('status' => 400));
+ return $_lps_result;
+ }
- $uploaded_size = isset($file['size']) ? (int) $file['size'] : 0;
- if ($uploaded_size > $max_zip_bytes) {
- return new WP_Error('zip_too_large', 'Uploaded ZIP exceeds the maximum allowed size (' . intval($max_zip_bytes / 1024 / 1024) . ' MB).', array('status' => 413));
- }
+ if (empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
+ $_lps_http_st = 400;
+ $_lps_result = new WP_Error('invalid_zip_upload', 'Invalid ZIP upload.', array('status' => 400));
+ return $_lps_result;
+ }
- $zip_path = $file['tmp_name'];
+ $allowed_mime_types = array('application/zip', 'application/octet-stream', 'application/x-zip-compressed', 'multipart/x-zip');
+ if (!empty($file['type']) && !in_array($file['type'], $allowed_mime_types, true)) {
+ $_lps_http_st = 400;
+ $_lps_result = new WP_Error('invalid_zip_mime', 'Uploaded file is not a ZIP archive (type: ' . sanitize_text_field($file['type']) . ').', array('status' => 400));
+ return $_lps_result;
+ }
- } else {
- return new WP_Error('missing_zip_source', 'Provide either a download_url or a multipart zip_file upload.', array('status' => 400));
- }
+ $uploaded_size = isset($file['size']) ? (int) $file['size'] : 0;
+ $_lps_input['zip_size_bytes'] = $uploaded_size;
+ if ($uploaded_size > $max_zip_bytes) {
+ $_lps_http_st = 413;
+ $_lps_result = new WP_Error('zip_too_large', 'Uploaded ZIP exceeds the maximum allowed size (' . intval($max_zip_bytes / 1024 / 1024) . ' MB).', array('status' => 413));
+ return $_lps_result;
+ }
- $result = $this->extract_and_create_lps_page($zip_path, $slug, $title, $status, $overwrite, $assets_folder);
+ $zip_path = $file['tmp_name'];
- if (is_wp_error($result)) {
- return $result;
- }
+ } else {
+ $_lps_http_st = 400;
+ $_lps_result = new WP_Error('missing_zip_source', 'Provide either a download_url or a multipart zip_file upload.', array('status' => 400));
+ return $_lps_result;
+ }
+
+ $result = $this->extract_and_create_lps_page($zip_path, $slug, $title, $status, $overwrite, $assets_folder, $external_ref);
+ $_lps_result = $result;
+
+ if (is_wp_error($result)) {
+ $_err_data = $result->get_error_data();
+ $_lps_http_st = (int) ((is_array($_err_data) && isset($_err_data['status'])) ? $_err_data['status'] : 500);
+ return $result;
+ }
+
+ // Map the import outcome to an HTTP status:
+ // 200 — all pages created/updated
+ // 207 — partial success (some pages failed)
+ // 422 — every page failed
+ // (Single-page slug conflicts already return 409 as a WP_Error above.)
+ $status_code = 200;
+ $data = (isset($result['data']) && is_array($result['data'])) ? $result['data'] : array();
+ if (isset($data['mode']) && $data['mode'] === 'multi') {
+ $_lps_input['pages_in_manifest'] = isset($data['pages_total']) ? (int) $data['pages_total'] : null;
+ $succeeded = count(isset($data['created']) ? $data['created'] : array())
+ + count(isset($data['updated']) ? $data['updated'] : array());
+ $failed = count(isset($data['failed']) ? $data['failed'] : array());
+ if ($failed > 0 && $succeeded > 0) {
+ $status_code = 207;
+ } elseif ($failed > 0 && $succeeded === 0) {
+ $status_code = 422;
+ $result['success'] = false;
+ $_lps_result = $result;
+ }
+ } elseif (isset($data['mode']) && $data['mode'] === 'single') {
+ $_lps_input['pages_in_manifest'] = 1;
+ }
- // Map the import outcome to an HTTP status:
- // 200 — all pages created/updated
- // 207 — partial success (some pages failed)
- // 422 — every page failed
- // (Single-page slug conflicts already return 409 as a WP_Error above.)
- $status_code = 200;
- $data = (isset($result['data']) && is_array($result['data'])) ? $result['data'] : array();
- if (isset($data['mode']) && $data['mode'] === 'multi') {
- $succeeded = count(isset($data['created']) ? $data['created'] : array())
- + count(isset($data['updated']) ? $data['updated'] : array());
- $failed = count(isset($data['failed']) ? $data['failed'] : array());
- if ($failed > 0 && $succeeded > 0) {
- $status_code = 207;
- } elseif ($failed > 0 && $succeeded === 0) {
- $status_code = 422;
- $result['success'] = false;
+ $_lps_http_st = $status_code;
+ $response = rest_ensure_response($result);
+ $response->set_status($status_code);
+ return $response;
+
+ } finally {
+ if (!empty($tmp_file) && file_exists($tmp_file)) {
+ @unlink($tmp_file);
}
}
-
- $response = rest_ensure_response($result);
- $response->set_status($status_code);
- return $response;
-
} finally {
- if (!empty($tmp_file) && file_exists($tmp_file)) {
- @unlink($tmp_file);
- }
+ self::write_lps_import_audit(array(
+ 'result' => $_lps_result,
+ 'http_status' => $_lps_http_st,
+ 'input' => $_lps_input,
+ 'start_ms' => $_lps_start_ms,
+ 'auth_method' => $_lps_auth_method,
+ 'api_key_prefix' => $_lps_api_key_prefix,
+ ));
}
}
@@ -815,9 +958,11 @@
* @param string $assets_folder On-disk folder name to extract into (under metasync-pages/).
* Defaults to the slug when empty. Must match the path LPS baked
* into the bundle's asset URLs at build time.
+ * @param string $external_ref Stable per-project LPS UUID. When non-empty it is stored as
+ * post meta and used as the primary home-page dedup key.
* @return array|WP_Error Response array on success, WP_Error on failure.
*/
- public function extract_and_create_lps_page($zip_path, $slug, $title, $status = 'publish', $overwrite = true, $assets_folder = '')
+ public function extract_and_create_lps_page($zip_path, $slug, $title, $status = 'publish', $overwrite = true, $assets_folder = '', $external_ref = '')
{
if (!class_exists('ZipArchive')) {
return new WP_Error(
@@ -1023,7 +1168,7 @@
if (is_wp_error($swap)) {
return $swap;
}
- return $this->create_pages_from_manifest($manifest['pages'], $target_dir, $assets_folder, $assets_dir_url, $status, $overwrite);
+ return $this->create_pages_from_manifest($manifest['pages'], $target_dir, $assets_folder, $assets_dir_url, $status, $overwrite, $external_ref);
}
// ---- Single-page import -------------------------------------------------
@@ -1113,6 +1258,9 @@
update_post_meta($page_id, Metasync_Custom_Pages::META_CREATED_VIA_API, '1');
update_post_meta($page_id, Metasync_Custom_Pages::META_LPS_IMPORT, '1');
update_post_meta($page_id, Metasync_Custom_Pages::META_ASSETS_FOLDER, $assets_folder);
+ if ($external_ref !== '') {
+ update_post_meta($page_id, Metasync_Custom_Pages::META_LPS_PROJECT_REF, $external_ref);
+ }
update_post_meta($page_id, Metasync_Custom_Pages::META_HTML_ENABLED, '1');
update_post_meta($page_id, Metasync_Custom_Pages::META_HTML_CONTENT, wp_unslash($html));
update_post_meta($page_id, Metasync_Custom_Pages::META_HTML_FILENAME, 'index.html');
@@ -1125,6 +1273,7 @@
'message' => 'LPS ZIP imported successfully',
'data' => array(
'mode' => 'single',
+ 'was_update' => (bool) $existing_page,
'page_id' => $page_id,
'slug' => get_post_field('post_name', $page_id),
'url' => get_permalink($page_id),
@@ -1213,6 +1362,57 @@
}
/**
+ * Find this project's existing LPS home page by a stable per-project marker,
+ * independent of its slug, so a re-publish updates the same home instead of
+ * creating a duplicate.
+ *
+ * When $external_ref (the LPS project UUID) is supplied it is the primary key:
+ * matched on META_LPS_PROJECT_REF, robust to assets_folder/slug changes between
+ * publishes. When absent (i.e. before CA forwards external_ref) it falls back to
+ * the legacy META_LPS_HOME == assets_folder lookup — no regression.
+ *
+ * @param string $assets_folder Legacy per-project key (fallback).
+ * @param string $external_ref LPS project UUID (preferred key when present).
+ * @return int Page ID, or 0 if none.
+ */
+ private function find_lps_home_page($assets_folder, $external_ref = '')
+ {
+ if ($external_ref !== '') {
+ // Match the project's HOME specifically: the page carrying this project's
+ // UUID (written on every page) AND flagged as a home (META_LPS_HOME is
+ // written only on the home). Without the home clause this matched any page.
+ $ref_ids = get_posts(array(
+ 'post_type' => 'page',
+ 'post_status' => array('publish', 'future', 'draft', 'pending', 'private'),
+ 'numberposts' => 1,
+ 'fields' => 'ids',
+ 'no_found_rows' => true,
+ 'suppress_filters' => true,
+ 'meta_query' => array(
+ 'relation' => 'AND',
+ array('key' => Metasync_Custom_Pages::META_LPS_PROJECT_REF, 'value' => $external_ref),
+ array('key' => Metasync_Custom_Pages::META_LPS_HOME, 'compare' => 'EXISTS'),
+ ),
+ ));
+ if (!empty($ref_ids)) {
+ return (int) $ref_ids[0];
+ }
+ }
+
+ $ids = get_posts(array(
+ 'post_type' => 'page',
+ 'post_status' => array('publish', 'future', 'draft', 'pending', 'private'),
+ 'numberposts' => 1,
+ 'fields' => 'ids',
+ 'no_found_rows' => true,
+ 'suppress_filters' => true,
+ 'meta_key' => Metasync_Custom_Pages::META_LPS_HOME,
+ 'meta_value' => $assets_folder,
+ ));
+ return !empty($ids) ? (int) $ids[0] : 0;
+ }
+
+ /**
* Create or update one WordPress page per entry in pages.manifest.json.
*
* All pages share the single $assets_folder. Slugs are created shallowest-first
@@ -1225,7 +1425,7 @@
* before the staging→live swap).
* @return array Response with created/updated/failed arrays.
*/
- private function create_pages_from_manifest($pages, $target_dir, $assets_folder, $assets_dir_url, $status, $overwrite)
+ private function create_pages_from_manifest($pages, $target_dir, $assets_folder, $assets_dir_url, $status, $overwrite, $external_ref = '')
{
// Shallowest slugs first so parents are created before their children.
usort($pages, function ($a, $b) {
@@ -1250,8 +1450,8 @@
$is_home = !empty($entry['isHome']);
$raw_slug = isset($entry['slug']) ? trim((string) $entry['slug'], '/') : '';
- // Home page (empty slug) maps to a dedicated 'home' slug. We deliberately
- // do NOT change the site's front page — that stays a manual decision.
+ // Home page (isHome / empty slug) is handled specially below: it is
+ // created and set as the WordPress static front page so it serves at "/".
$effective_slug = ($is_home && $raw_slug === '') ? 'home' : $raw_slug;
// Normalize each segment exactly as WordPress will persist it, so the
@@ -1290,6 +1490,70 @@
continue;
}
+ // --- Home page: create it and set it as the WordPress static front page
+ // so it serves at "/" (the LPS app's router expects home at the root).
+ // The page is identified per-project via the META_LPS_HOME marker (value =
+ // assets_folder), so re-publishing the SAME project updates its own home
+ // instead of creating duplicates — even if its slug was renamed. A DIFFERENT
+ // project gets its own new home and takes over the front page; the previous
+ // project's home is left in place (non-destructive). Home conflicts are NOT
+ // reported to LPS — a taken 'home' slug just falls back to 'home-2'.
+ // Home dedup keys on external_ref (the LPS project UUID) when present,
+ // falling back to assets_folder for pre-external_ref imports (WP-449).
+ if ($is_home) {
+ $home_id = $this->find_lps_home_page($assets_folder, $external_ref);
+ $home_existing = $home_id > 0;
+ if ($home_existing) {
+ $res = wp_update_post(array('ID' => $home_id, 'post_title' => $title, 'post_status' => 'publish'), true);
+ if (is_wp_error($res)) {
+ $failed[] = array('slug' => 'home', 'code' => 'home_update_failed', 'message' => $res->get_error_message());
+ continue;
+ }
+ } else {
+ // 'home' if free; otherwise next available (home-2, ...). Front-page
+ // makes the slug invisible, so any free slug is fine.
+ $home_slug = get_page_by_path('home', OBJECT, 'page') ? $this->suggest_available_slug('home') : 'home';
+ $home_id = wp_insert_post(array(
+ 'post_title' => $title,
+ 'post_name' => $home_slug,
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ 'post_content' => ''
+ ), true);
+ if (is_wp_error($home_id)) {
+ $failed[] = array('slug' => 'home', 'code' => 'home_insert_failed', 'message' => $home_id->get_error_message());
+ continue;
+ }
+ }
+
+ update_post_meta($home_id, Metasync_Custom_Pages::META_IS_CUSTOM_HTML_PAGE, '1');
+ update_post_meta($home_id, Metasync_Custom_Pages::META_CREATED_VIA_API, '1');
+ update_post_meta($home_id, Metasync_Custom_Pages::META_LPS_IMPORT, '1');
+ update_post_meta($home_id, Metasync_Custom_Pages::META_ASSETS_FOLDER, $assets_folder);
+ update_post_meta($home_id, Metasync_Custom_Pages::META_LPS_HOME, $assets_folder);
+ if ($external_ref !== '') {
+ update_post_meta($home_id, Metasync_Custom_Pages::META_LPS_PROJECT_REF, $external_ref);
+ }
+ update_post_meta($home_id, Metasync_Custom_Pages::META_HTML_ENABLED, '1');
+ update_post_meta($home_id, Metasync_Custom_Pages::META_HTML_CONTENT, wp_unslash($html));
+ update_post_meta($home_id, Metasync_Custom_Pages::META_HTML_FILENAME, $rel);
+
+ // Make it the site's static front page (serves at "/").
+ update_option('show_on_front', 'page');
+ update_option('page_on_front', $home_id);
+
+ wp_cache_delete($home_id, 'posts');
+ wp_cache_delete($home_id, 'post_meta');
+
+ $rec = array('slug' => get_post_field('post_name', $home_id), 'page_id' => $home_id, 'url' => get_permalink($home_id), 'is_front_page' => true);
+ if ($home_existing) {
+ $updated[] = $rec;
+ } else {
+ $created[] = $rec;
+ }
+ continue;
+ }
+
// Split slug into ancestors + leaf; resolve/create the parent chain.
$segments = explode('/', $effective_slug);
$leaf = sanitize_title(array_pop($segments));
@@ -1331,6 +1595,9 @@
}
update_post_meta($new_parent, Metasync_Custom_Pages::META_LPS_IMPORT, '1');
update_post_meta($new_parent, Metasync_Custom_Pages::META_ASSETS_FOLDER, $assets_folder);
+ if ($external_ref !== '') {
+ update_post_meta($new_parent, Metasync_Custom_Pages::META_LPS_PROJECT_REF, $external_ref);
+ }
$parent_id = $new_parent;
}
$slug_to_id[$ancestor_path] = $parent_id;
@@ -1393,6 +1660,9 @@
update_post_meta($page_id, Metasync_Custom_Pages::META_CREATED_VIA_API, '1');
update_post_meta($page_id, Metasync_Custom_Pages::META_LPS_IMPORT, '1');
update_post_meta($page_id, Metasync_Custom_Pages::META_ASSETS_FOLDER, $assets_folder);
+ if ($external_ref !== '') {
+ update_post_meta($page_id, Metasync_Custom_Pages::META_LPS_PROJECT_REF, $external_ref);
+ }
update_post_meta($page_id, Metasync_Custom_Pages::META_HTML_ENABLED, '1');
update_post_meta($page_id, Metasync_Custom_Pages::META_HTML_CONTENT, wp_unslash($html));
update_post_meta($page_id, Metasync_Custom_Pages::META_HTML_FILENAME, $rel);
@@ -1484,4 +1754,179 @@
return @rmdir($dir);
}
+
+ /**
+ * Write exactly one persistent audit record for an LPS ZIP import.
+ *
+ * Called from both the REST handler (import_lps_page) and the MCP tool on
+ * every exit path — success or failure. The record always lands in the
+ * existing metasync_sync_history table (covered by its bounded cleanup),
+ * independent of WP_DEBUG. Neither the ZIP bytes nor the page HTML are
+ * stored: only sizes, counts, slugs and short reasons.
+ *
+ * @param array $ctx {
+ * @type WP_Error|array|null $result Helper result (or WP_Error on failure).
+ * @type int $http_status Mapped HTTP status (200/207/422/409/423/...).
+ * @type array $input source_type, zip_size_bytes, assets_folder, pages_in_manifest.
+ * @type int $start_ms Start time in ms for duration (0 to skip).
+ * @type string $auth_method bearer|x-api-key|apikey-query|mcp-capability.
+ * @type string $api_key_prefix Masked key prefix (NEVER the full key).
+ * @type string $error_message Optional externally-supplied error message.
+ * }
+ * @return void
+ */
+ public static function write_lps_import_audit(array $ctx)
+ {
+ try {
+ require_once dirname(__FILE__, 2) . '/sync-history/class-metasync-sync-history-database.php';
+
+ $result = isset($ctx['result']) ? $ctx['result'] : null;
+ $http_status = isset($ctx['http_status']) ? (int) $ctx['http_status'] : 500;
+ $input = (isset($ctx['input']) && is_array($ctx['input'])) ? $ctx['input'] : array();
+ $start_ms = isset($ctx['start_ms']) ? (int) $ctx['start_ms'] : 0;
+ $auth_method = isset($ctx['auth_method']) ? (string) $ctx['auth_method'] : '';
+ $api_key_prefix = isset($ctx['api_key_prefix']) ? (string) $ctx['api_key_prefix'] : '';
+ $ext_error = isset($ctx['error_message']) ? (string) $ctx['error_message'] : '';
+ $duration_ms = $start_ms > 0 ? max(0, (int) round(microtime(true) * 1000) - $start_ms) : null;
+
+ $per_page = array();
+ $counts = array('created' => 0, 'updated' => 0, 'failed' => 0, 'skipped' => 0);
+ $front_page = array('set' => false, 'page_id' => null);
+ $error_msg = $ext_error;
+
+ if ($result instanceof WP_Error) {
+ $error_msg = $result->get_error_message();
+ $counts['failed'] = 1;
+ } elseif (is_array($result) && isset($result['data']) && is_array($result['data'])) {
+ $data = $result['data'];
+ $mode = isset($data['mode']) ? $data['mode'] : '';
+
+ if ($mode === 'multi') {
+ foreach ((isset($data['created']) && is_array($data['created'])) ? $data['created'] : array() as $p) {
+ $per_page[] = array(
+ 'slug' => isset($p['slug']) ? $p['slug'] : '',
+ 'action' => 'created',
+ 'page_id' => isset($p['page_id']) ? $p['page_id'] : null,
+ 'reason' => null,
+ );
+ $counts['created']++;
+ if (!empty($p['is_front_page'])) {
+ $front_page = array('set' => true, 'page_id' => isset($p['page_id']) ? $p['page_id'] : null);
+ }
+ }
+ foreach ((isset($data['updated']) && is_array($data['updated'])) ? $data['updated'] : array() as $p) {
+ $per_page[] = array(
+ 'slug' => isset($p['slug']) ? $p['slug'] : '',
+ 'action' => 'updated',
+ 'page_id' => isset($p['page_id']) ? $p['page_id'] : null,
+ 'reason' => null,
+ );
+ $counts['updated']++;
+ if (!empty($p['is_front_page'])) {
+ $front_page = array('set' => true, 'page_id' => isset($p['page_id']) ? $p['page_id'] : null);
+ }
+ }
+ foreach ((isset($data['failed']) && is_array($data['failed'])) ? $data['failed'] : array() as $p) {
+ $reason = isset($p['code']) ? $p['code'] : '';
+ if (isset($p['message'])) {
+ $reason .= ($reason !== '' ? ': ' : '') . $p['message'];
+ }
+ $per_page[] = array(
+ 'slug' => isset($p['slug']) ? $p['slug'] : '',
+ 'action' => 'failed',
+ 'page_id' => null,
+ 'reason' => $reason !== '' ? $reason : 'failed',
+ );
+ $counts['failed']++;
+ }
+ } elseif ($mode === 'single') {
+ $single_action = !empty($data['was_update']) ? 'updated' : 'created';
+ $counts[$single_action] = 1;
+ $per_page[] = array(
+ 'slug' => isset($data['slug']) ? $data['slug'] : '',
+ 'action' => $single_action,
+ 'page_id' => isset($data['page_id']) ? $data['page_id'] : null,
+ 'reason' => null,
+ );
+ }
+ } elseif ($result === null) {
+ // Never reached the extraction helper (early validation failure).
+ $counts['failed'] = 1;
+ }
+
+ if ($http_status === 200) {
+ $overall_status = 'success';
+ } elseif ($http_status === 207) {
+ $overall_status = 'partial';
+ } elseif ($http_status === 409) {
+ $overall_status = 'conflict';
+ } elseif ($http_status === 423) {
+ $overall_status = 'locked';
+ } else {
+ $overall_status = 'failed';
+ }
+
+ $assets_folder = isset($input['assets_folder']) ? $input['assets_folder'] : '';
+ $title_slug = $assets_folder !== '' ? $assets_folder : (isset($per_page[0]['slug']) && $per_page[0]['slug'] !== '' ? $per_page[0]['slug'] : 'unknown');
+
+ $site_url = function_exists('get_site_url') ? get_site_url() : '';
+
+ $meta_payload = array(
+ 'timestamp' => function_exists('current_time') ? current_time('mysql') : date('Y-m-d H:i:s'),
+ 'site_url' => $site_url,
+ 'api_key_prefix' => $api_key_prefix,
+ 'plugin_version' => defined('METASYNC_VERSION') ? METASYNC_VERSION : '',
+ 'auth_method' => $auth_method,
+ 'source_type' => isset($input['source_type']) ? $input['source_type'] : 'unknown',
+ 'assets_folder' => $assets_folder,
+ 'external_ref' => isset($input['external_ref']) ? (string) $input['external_ref'] : '',
+ 'zip_size_bytes' => isset($input['zip_size_bytes']) ? (int) $input['zip_size_bytes'] : 0,
+ 'pages_in_manifest' => isset($input['pages_in_manifest']) ? $input['pages_in_manifest'] : null,
+ 'counts' => $counts,
+ 'front_page' => $front_page,
+ 'per_page' => $per_page,
+ 'http_status' => $http_status,
+ 'overall_status' => $overall_status,
+ 'error_message' => $error_msg !== '' ? substr($error_msg, 0, 500) : '',
+ 'duration_ms' => $duration_ms,
+ );
+
+ // User-facing badge status: first import -> Published, re-sync -> Updated,
+ // nothing imported -> failed. Per-page conflicts are intentionally NOT
+ // surfaced here (Website Studio notifies the customer to republish).
+ if ($counts['created'] > 0) {
+ $db_status = 'published';
+ } elseif ($counts['updated'] > 0) {
+ $db_status = 'updated';
+ } else {
+ $db_status = 'failed';
+ }
+ $db = new Metasync_Sync_History_Database();
+ $db->add(array(
+ 'title' => 'Website Studio (LPS) import: ' . $title_slug,
+ 'source' => 'Website Studio (LPS)',
+ 'status' => $db_status,
+ 'content_type' => 'lps_import',
+ 'url' => $site_url,
+ 'meta_data' => json_encode($meta_payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
+ ));
+
+ // Optional deep-debug breadcrumb — the DB write above always happens
+ // regardless of WP_DEBUG; this line is purely supplementary.
+ if (defined('WP_DEBUG') && WP_DEBUG) {
+ error_log(sprintf(
+ 'Metasync LPS import audit: status=%s http=%d created=%d updated=%d failed=%d',
+ $overall_status,
+ $http_status,
+ $counts['created'],
+ $counts['updated'],
+ $counts['failed']
+ ));
+ }
+ } catch (Throwable $e) {
+ if (defined('WP_DEBUG') && WP_DEBUG) {
+ error_log('Metasync LPS audit log failed: ' . $e->getMessage());
+ }
+ }
+ }
}
--- a/metasync/custom-pages/class-metasync-custom-pages.php
+++ b/metasync/custom-pages/class-metasync-custom-pages.php
@@ -66,6 +66,17 @@
const META_ASSETS_FOLDER = '_metasync_lps_assets_folder';
/**
+ * Meta key marking a page as the LPS home page that is set as the site's
+ * static front page. Its value is the project's assets_folder, so a
+ * re-publish of the same project finds and updates its own home (instead of
+ * creating a duplicate), regardless of the page's slug.
+ */
+ const META_LPS_HOME = '_metasync_lps_home';
+
+ // Stable per-project LPS UUID; primary key for home-page dedup when external_ref is present.
+ const META_LPS_PROJECT_REF = '_metasync_lps_project_ref';
+
+ /**
* Initialize the custom pages functionality
*/
public function __construct()
--- a/metasync/customer-sync-requests/class-metasync-sync-requests.php
+++ b/metasync/customer-sync-requests/class-metasync-sync-requests.php
@@ -40,8 +40,16 @@
/**
* Data or Response received from HeartBeat API for admin area.
+ *
+ * @param string|null $token Legacy auth token (unused by the request itself).
+ * @param string $context 'manual' for the Settings "Sync Now" button,
+ * 'heartbeat' for JS heartbeat ticks, '' for
+ * system callers (settings-save verification,
+ * cron connectivity test, category CRUD, REST).
+ * Only 'manual' consumes/stamps the manual
+ * cooldown (WP-426).
*/
- public function SyncCustomerParams($token = null)
+ public function SyncCustomerParams($token = null, $context = '')
{
$categories_sync_limit = 1000;
$users_sync_limit = 1000;
@@ -75,7 +83,7 @@
$last_hb_request_time = $_throttle['last_heart_beat'] ?? 0;
# PR3: Throttle depends on state — burst (KEY_PENDING within 30 min) allows 30s; else 5 min
- $is_heartbeat = filter_var($_POST['is_heart_beat'] ?? false, FILTER_VALIDATE_BOOLEAN);
+ $is_heartbeat = ($context === 'heartbeat') || filter_var($_POST['is_heart_beat'] ?? false, FILTER_VALIDATE_BOOLEAN);
$is_burst = !empty($_POST['is_burst']);
$heartbeat_state = $metasync_options['general']['heartbeat_state'] ?? '';
$state_changed_at = (int) ($metasync_options['general']['heartbeat_state_changed_at'] ?? 0);
@@ -96,10 +104,16 @@
];
}
Metasync::set_heartbeat_throttle(['last_heart_beat' => time()]);
- } else {
+ } elseif ($context === 'manual') {
# Manual "Sync Now" path: track throttle via a dedicated option key so
# heartbeat ticks (which update last_heart_beat every ~15s) cannot keep
# the manual cooldown alive indefinitely.
+ # WP-426: only the Settings "Sync Now" button takes this branch. System
+ # callers (API-key save verification, cron connectivity test, category
+ # CRUD, REST sync) must neither be rejected by the manual cooldown —
+ # a throttled object reads as a failed verification and reverts a
+ # freshly saved API key — nor stamp it, which would lock the button
+ # for 5 minutes after every settings save.
$last_manual_sync_time = (int) ($metasync_options['general']['last_manual_sync'] ?? 0);
$manual_min_interval_sec = 60 * 5; // 5 minutes
if (($last_manual_sync_time + $manual_min_interval_sec) > time()) {
--- a/metasync/google-index/class-google-index-admin.php
+++ b/metasync/google-index/class-google-index-admin.php
@@ -253,6 +253,9 @@
{
// Only show notices on MetaSync settings pages
$page_slug = $this->get_metasync_page_slug();
+ if ( ! function_exists( 'get_current_screen' ) ) {
+ return;
+ }
$current_screen = get_current_screen();
if (!$current_screen || strpos($current_screen->id, $page_slug) === false) {
--- a/metasync/includes/class-metasync-admin-ajax.php
+++ b/metasync/includes/class-metasync-admin-ajax.php
@@ -44,6 +44,7 @@
$type = isset($_POST['type']) ? sanitize_text_field($_POST['type']) : '';
$plugin = isset($_POST['plugin']) ? sanitize_text_field($_POST['plugin']) : '';
+ $offset = isset($_POST['offset']) ? intval($_POST['offset']) : 0;
if (empty($type) || empty($plugin)) {
wp_send_json_error(['message' => 'Missing required parameters.']);
@@ -64,7 +65,7 @@
$result = $importer->import_robots($plugin);
break;
case 'indexation':
- $result = $importer->import_indexation($plugin);
+ $result = $importer->import_indexation($plugin, ['batch_size' => 50, 'offset' => $offset]);
break;
case 'schema':
$result = $importer->import_schema($plugin);
@@ -140,8 +141,12 @@
$general_options = Metasync::get_option('general') ?? [];
$token = $general_options['apikey'] ?? null;
+ # WP-426: declare the call context explicitly so only the Settings
+ # "Sync Now" button consumes/stamps the 5-minute manual cooldown.
+ $is_heartbeat_tick = filter_var($_POST['is_heart_beat'] ?? false, FILTER_VALIDATE_BOOLEAN);
+
# get the response
- $response = $sync_request->SyncCustomerParams($token);
+ $response = $sync_request->SyncCustomerParams($token, $is_heartbeat_tick ? 'heartbeat' : 'manual');
// Check if response is a throttling error object
if (is_object($response) && isset($response->throttled) && $response->throttled === true) {
--- a/metasync/includes/class-metasync-admin-navigation.php
+++ b/metasync/includes/class-metasync-admin-navigation.php
@@ -647,7 +647,9 @@
$plugin_name = Metasync::get_effective_plugin_name();
$menu_name = $plugin_name;
$menu_title = $plugin_name;
- $menu_slug = !isset($data['white_label_plugin_menu_slug']) || $data['white_label_plugin_menu_slug'] == "" ? Metasync_Admin::$page_slug : $data['white_label_plugin_menu_slug'];
+ // Sanitize so a URL-shaped legacy value still yields a valid WP menu slug (WP-413)
+ $menu_slug = !isset($data['white_label_plugin_menu_slug']) || $data['white_label_plugin_menu_slug'] == "" ? Metasync_Admin::$page_slug : sanitize_title($data['white_label_plugin_menu_slug']);
+ $menu_slug = $menu_slug === '' ? Metasync_Admin::$page_slug : $menu_slug;
$menu_icon = !isset($data['white_label_plugin_menu_icon']) || $data['white_label_plugin_menu_icon'] == "" ? 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzQiIHZpZXdCb3g9IjAgMCAzMiAzNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzE4NzlfMTcyNzcpIj4KPHBhdGggZD0iTTI5LjAyMTUgMi4xNjc2NkwzMC4wMTAxIDIuMTIyNDFMMjkuMTYyNyA4LjcyNzA1TDI5LjExNTcgOC44MTc0M0w1LjI5NTA0IDMzLjI0NTJMNC43MzAxMiAzMy4yOTA1TDQuMDcxMDQgMzMuOTY5MUgxLjk1MjYyTDIuMjM1MDggMzIuMjk1M0wyLjA5Mzg0IDMyLjM0MDZMMi4xODggMzEuNjYySDEuNjIzMDhDMS41NzYgMzEuNjYyIDEuNDgxODUgMzEuNjYyIDEuNDgxODUgMzEuNjE2N0MxLjQzNDc4IDMxLjU3MTYgMS4zODc3IDMxLjQ4MSAxLjM4NzcgMzEuNDM1OEwyLjUxNzU0IDI1LjEwMjdDMi41MTc1NCAyNS4wNTc1IDIuNTY0NiAyNS4wMTIyIDIuNTY0NiAyNS4wMTIyTDI2LjE5NjkgMC42Mjk2MDdDMjYuMjQ0IDAuNTg0MzY2IDI2LjI5MTEgMC41MzkxMjUgMjYuMzM4MSAwLjUzOTEyNUgyOC4zNjI1QzI4LjQwOTUgMC40OTM4ODQgMjguNDU2NiAwLjUzOTEyNSAyOC41MDM3IDAuNTg0MzY2QzI4LjU1MDcgMC42Mjk2MDcgMjguNTk3OSAwLjcyMDA3MSAyOC41OTc5IDAuNzY1MzExTDI4LjUwMzcgMS4zNTMzOUgyOS4xMTU3TDI5LjAyMTUgMi4xNjc2NlpNMS45MDU1NCAzMS4yMDk2SDIuMjgyMTZMMy4yMjM2OCAyNS43ODEySDMuMjcwNzZMNi44MDE1IDIyLjI1MjhMNi44NDg1MSAyMi4yOTgxTDIwLjIxODMgOC40NTU1N0wyNi45MDMxIDEuMzUzMzlIMjguMDMyOUwyOC4wNzk5IDAuOTkxNDk5SDI2LjQ3OTNMMi45ODgzIDI1LjIzODRMMS45MDU1NCAzMS4yMDk2Wk0yOC42OTE5IDguNTQ1OTVMMjkuNDQ1MiAyLjYyMDAxSDI4Ljk3NDVMMjguMzE1MyA3Ljc3Njk2TDI4LjI2ODMgNy44MjIyNEwyOC4xMjcxIDcuOTU3ODlMMjcuOTM4NyA5LjMxNTExTDI4LjY5MTkgOC41NDU5NVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0zMS41NTQ0IDE4LjE5MzdDMzEuNzM0OSAxOC42NjYyIDMxLjgwMjggMTkuMjA2NCAzMS42ODk4IDE5Ljc2OUwyOS42NTgyIDMyLjEwMTNIMjkuMDkzOUwyOS4wMjYyIDMyLjQzODhIMjUuOTMzNkwyNi4wNjkxIDMxLjYwNjJIMjYuMDAxM0wyNi4wNjkxIDMxLjI2ODZIMjUuNzk4MUMyNS43NTMgMzEuMjY4NiAyNS43MzA1IDMxLjI2ODYgMjUuNzA3OSAzMS4yNDYxQzI1LjY4NTIgMzEuMjIzNyAyNS42ODUyIDMxLjE3ODYgMjUuNjg1MiAzMS4xNTZMMjYuMzYyNCAyNy4zOThIMTguNDE2N0wxNy40MjM0IDMyLjA3ODdIMTYuODM2NUwxNi43Njg3IDMyLjQxNjNIMTMuNjk4OEwxMy45MDE5IDMxLjU4MzZIMTMuODM0M0wxMy45MDE5IDMxLjI2ODZIMTMuNjMxQzEzLjYwODQgMzEuMjY4NiAxMy41NjMzIDMxLjI2ODYgMTMuNTQwOCAzMS4yNDYxQzEzLjU0MDggMzEuMjAxMSAxMy41MTgyIDMxLjE3ODYgMTMuNTQwOCAzMS4xMzM2TDE2LjM2MjQgMTguOTEzOEMxNi40OTc5IDE4LjM1MTIgMTYuNzQ2MiAxNy44MzM2IDE3LjEyOTkgMTcuMzM4NUMxNy40OTEyIDE2Ljg2NiAxNy45NDI2IDE2LjQ4MzUgMTguNDYxOCAxNi4yMTM0QzE4Ljk4MSAxNS45MjA3IDE5LjUwMDIgMTUuNzg1OCAyMC4wNDE5IDE1Ljc4NThIMjguNTk3MkMyOS4xMzkgMTUuNzg1OCAyOS42MTMxIDE1LjkyMDcgMzAuMDE5MyAxNi4yMTM0QzMwLjM1NzkgMTYuNDYwOSAzMC42Mjg5IDE2Ljc5ODUgMzAuODA5NSAxNy4xODFDMzEuMTI1NSAxNy40NTExIDMxLjM5NjMgMTcuNzg4NiAzMS41NTQ0IDE4LjE5MzdaTTI3LjQ5MTIgMjMuMzY5N0wyOC4wNTU1IDIwLjI2NDFDMjguMDMyOSAyMC4yNDE1IDI4LjAzMjkgMjAuMjE5MSAyOC4wMTA0IDIwLjIxOTFIMjcuODk3NUwyNy4zNzgzIDIzLjA5OTZDMjcuMzc4MyAyMy4xNDQ3IDI3LjMxMDYgMjMuMTg5NiAyNy4yNjU1IDIzLjE4OTZIMTkuMzQyMUwxOS4yOTcgMjMuMzY5N0gyNy40OTEyWk0xOS4wOTM5IDIzLjE4OTZIMTguODQ1NUwxOC44MjI5IDIzLjM2OTdIMTkuMDcxM0wxOS4wOTM5IDIzLjE4OTZaTTE5LjUwMDIgMjAuMjY0MUwxOC45MTMzIDIyLjk2NDZIMTkuMTYxNUwxOS43NDg0IDIwLjIxOTFIMTkuNTQ1M0MxOS41NDUzIDIwLjIxOTEgMTkuNTIyNyAyMC4yNDE1IDE5LjUwMDIgMjAuMjY0MVpNMjcuNjcxOCAyMC4yMTkxSDE5Ljk3NDJMMTkuMzg3MiAyMi45NjQ2SDI3LjE3NTFMMjcuNjcxOCAyMC4yMTkxWk0xMy43ODkgMzEuMDQzNkgxMy45NDdMMTUuMDk4NCAyNi4xMTUySDE1LjE2NkwxNi43MjM2IDE5LjI5NjRDMTYuODU5MSAxOC43NTYzIDE3LjEwNzMgMTguMjM4OCAxNy40Njg1IDE3Ljc2NjFDMTcuODI5NiAxNy4zMTYxIDE4LjI4MTIgMTYuOTMzNCAxOC43Nzc4IDE2LjY2MzNDMTkuMDI2MSAxNi41Mjg0IDE5LjI3NDUgMTYuNDM4NCAxOS41MjI3IDE2LjM0ODNMMTkuNTAwMiAxNi4zMDM0QzE5Ljc0ODQgMTYuMjEzNCAyMC4wMTkzIDE2LjE5MDggMjAuMjkwMyAxNi4xOTA4SDI4Ljg2ODJDMjkuMjI5NCAxNi4xOTA4IDI5LjU5MDUgMTYuMjU4MyAyOS44ODQgMTYuMzkzNEMyOS41MjI5IDE2LjEyMzMgMjkuMDkzOSAxNi4wMTA4IDI4LjU5NzIgMTYuMDEwOEgyMC4wNDE5QzE5LjU0NTMgMTYuMDEwOCAxOS4wNDg2IDE2LjE0NTkgMTguNTc0NyAxNi4zOTM0QzE4LjA3OCAxNi42NjMzIDE3LjY0OTEgMTcuMDIzNSAxNy4zMTA0IDE3LjQ5NkMxNi45NDkzIDE3Ljk0NjIgMTYuNzAxIDE4LjQ0MTIgMTYuNTg4MSAxOC45NTg5TDEzLjc4OSAzMS4wNDM2Wk0xNy4yMjAyIDMxLjg1MzdMMTguMTkxIDI3LjM5OEgxNy45NDI2TDE3LjAxNzEgMzEuNTgzNkgxNi45NDkzTDE2LjkwNDIgMzEuODUzN0gxNy4yMjAyWk0yNS45MzM2IDMxLjA0MzZIMjYuMTE0MkwyNi43Njg5IDI3LjM5OEgyNi41ODgzTDI1LjkzMzYgMzEuMDQzNlpNMzEuNDg2NyAxOS43NDY0QzMxLjU3NjkgMTkuMjA2NCAzMS41MDkzIDE4LjcxMTMgMzEuMzI4NyAxOC4yNjEyQzMxLjI4MzYgMTguMTQ4OCAzMS4yMTU4IDE4LjAzNjIgMzEuMTQ4MSAxNy45MjM2QzMxLjI4MzYgMTguMzUxMiAzMS4zMjg3IDE4LjgwMTQgMzEuMjM4MyAxOS4yOTY0TDMwLjA4NzIgMjYuMTE1MkwzMC4xNTQ4IDI2LjEzNzdMMjkuMjUxOSAzMS42MDYySDI5LjE4NDNMMjkuMTM5IDMxLjg3NjNIMjkuNDU1TDMxLjQ4NjcgMTkuNzQ2NFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0xNy42ODQ3IDIuNDI3MTdIMTcuNjYxOEMxNy44NjgyIDIuODk5MTIgMTcuOTE0IDMuNDM4NDkgMTcuODIyMiA0LjAwMDMzTDE3LjU5MjkgNS4zOTM3SDE3LjA0MjNMMTYuOTczNSA1LjczMDhIMTMuOTY4NEwxNC4xMjkxIDQuODk5MjhIMTQuMDM3M0wxNC4xMDYyIDQuNTg0NjRIMTMuODUzOEMxMy44MDggNC41ODQ2NCAxMy43ODUxIDQuNTYyMTcgMTMuNzYyIDQuNTM5NjlDMTMuNzM5MSA0LjUxNzIzIDEzLjczOTEgNC40OTQ3NSAxMy43MzkxIDQuNDQ5OEg1LjkxNjg2TDUuNTQ5ODcgNi4wOTAzOUgxMy41Nzg1QzE0LjEyOTEgNi4wOTAzOSAxNC42MzM3IDYuMjQ3NzEgMTUuMDQ2NyA2LjUzOTg3QzE1LjM2NzggNi43NDIxMyAxNS41OTcxIDcuMDU2NzcgMTUuODAzNiA3LjM3MTQxQzE1Ljg0OTUgNy40Mzg4OSAxNS44NzI0IDcuNDgzODIgMTUuOTE4NCA3LjU1MTEyQzE2LjIxNjYgNy43OTgzMiAxNi40Njg4IDguMTEyOTkgMTYuNjI5NSA4LjQ5NTE0QzE2LjgzNTkgOC45NjY5OCAxNi45MDQ4IDkuNTA2NDcgMTYuNzkwMSAxMC4wOTA3TDE2LjI4NTMgMTMuMTY5NkMxNi4xOTM1IDEzLjczMTUgMTUuOTQxMyAxNC4yNDg0IDE1LjU3NDMgMTQuNzQyOEMxNS4yMDcyIDE1LjIxNDcgMTQuNzQ4NCAxNS41OTY4IDE0LjIyMDggMTUuODg4OUMxNC4xNTIgMTUuOTExNSAxNC4wNjAyIDE1Ljk1NjQgMTMuOTkxNSAxNS45Nzg4QzEzLjg3NjcgMTYuMDY4OCAxMy43NjIgMTYuMTU4NyAxMy42MjQ1IDE2LjIyNkMxMy4wOTY4IDE2LjQ5NTcgMTIuNTY5MiAxNi42NTMxIDExLjk5NTcgMTYuNjUzMUgyLjYzNjYxQzIuMDg2MDcgMTYuNjUzMSAxLjU4MTQxIDE2LjQ5NTcgMS4xNjg1IDE2LjIyNkMwLjc1NTYxIDE1Ljk1NjQgMC40ODAzMzEgMTUuNTc0MyAwLjMxOTc1IDE1LjEyNDhDMC4xODIxMiAxNC43NjUyIDAuMTU5MTg3IDE0LjQwNTggMC4xODIxMTkgMTQuMDAxMkMwLjE4MjExOSAxMy45NTYxIDAuMTM2MjU0IDEzLjkzMzggMC4xMzYyNTQgMTMuODg4OEMtMC4wMjQzMjYxIDEzLjQxNjggLTAuMDQ3MjU4MSAxMi44Nzc1IDAuMDkwMzcyNSAxMi4zMTU2TDAuMzg4NTgzIDExLjAxMjJDMC4zODg1ODMgMTAuOTY3MyAwLjQ1NzM5OCAxMC45MjIzIDAuNTAzMjY0IDEwLjkyMjNIMy41NTQxN0MzLjU3NzExIDEwLjkyMjMgMy42MDAwNCAxMC45NDQ3IDMuNjIyOTkgMTAuOTY3M0MzLjY0NTkyIDEwLjk4OTYgMy42Njg4NyAxMS4wMzQ2IDMuNjQ1OTIgMTEuMDU3MUwzLjYwMDA0IDExLjMyNjlIMy44OTgyNUwzLjgwNjUgMTEuNzMxNEg0LjMxMTE2TDQuMjE5MzkgMTIuMjAzMkgxMi4yNzFDMTIuMjcxIDEyLjIwMzIgMTIuMjcxIDEyLjIwMzIgMTIuMjkzOSAxMi4xODA4QzEyLjI5MzkgMTIuMTU4MyAxMi4zMTY4IDEyLjE1ODMgMTIuMzE2OCAxMi4xNTgzTDEyLjU5MjEgMTAuNTYyN0gzLjk5MDAyQzMuNDM5NDggMTAuNTYyNyAyLjk1Nzc1IDEwLjQyNzggMi41Njc3OSAxMC4xMzU2QzIuMTU0ODggOS44NjU5IDEuODc5NjIgOS41MDY0NyAxLjcxOTA0IDkuMDM0NDZDMS42MDQzNCA4LjY5NzQxIDEuNTU4NDYgOC4zMzc4MSAxLjYwNDM0IDcuOTMzMjhDMS42MDQzNCA3Ljg4ODM1IDEuNTU4NDYgNy44NjU4IDEuNTU4NDYgNy44MjA4N0MxLjM5NzkgNy4zNDg4NiAxLjM3NDk3IDYuODA5NTYgMS41MTI2IDYuMjI1MjNMMi4yNDY2NSAzLjE0NjM0QzIuMzg0MjggMi41ODQ0OSAyLjYzNjYxIDIuMDY3NTggMy4wMDM2MyAxLjU3MzE2QzMuMzcwNjYgMS4xMDEyMiAzLjgyOTQ0IDAuNzE5MTUyIDQuMzU3MDQgMC40NDk0NzZDNC44ODQ2MyAwLjE1NzMxOSA1LjQxMjI0IDAgNS45NjI4MyAwSDE0LjcwMjZDMTUuMjMwMSAwIDE1LjcxMTggMC4xNTczMTkgMTYuMTI0OCAwLjQ0OTQ3NkMxNi40NDU5IDAuNjc0MjA2IDE2LjY3NTMgMC45NjYzOCAxNi44NTg4IDEuMzAzNDhDMTYuOTA0OCAxLjM0ODQzIDE2LjkyNzcgMS4zOTMzNyAxNi45NzM1IDEuNDYwOEMxNy4yNzE4IDEuNzMwNDggMTcuNTI0IDIuMDIyNjQgMTcuNjg0NyAyLjQyNzE3Wk0zLjY0NTkyIDEyLjU4NTRWMTIuNjA3OEgzLjg5ODI1TDMuOTIxMiAxMi40MjhIMy42Njg4N0wzLjY0NTkyIDEyLjU2MjhDMy42MjI5OSAxMi41NjI4IDMuNjIyOTkgMTIuNTg1NCAzLjY0NTkyIDEyLjU4NTRaTTQuOTk5MzMgNi41NjIzNUg1LjIwNTc4TDUuMjc0NjEgNi4zMTUxNEg0Ljk1MzQ1TDQuOTA3NTggNi40NDk5N0M0LjkwNzU4IDYuNDk0OTIgNC45MDc1OCA2LjUxNzQgNC45MzA1MSA2LjUzOTg3QzQuOTUzNDUgNi41NjIzNSA0Ljk3NjQgNi41NjIzNSA0Ljk5OTMzIDYuNTYyMzVaTTEuNzQxOTcgNi4yNzAxN0MxLjY1MDIzIDYuNjc0NyAxLjY1MDIzIDcuMDU2NzcgMS43MTkwNCA3LjM5Mzc5TDEuNzQxOTcgNy4yMzY1NUMxLjc2NDkyIDcuMDExODEgMS43ODc4NiA2LjgwOTU2IDEuODMzNzQgNi41ODQ4MUwyLjU0NDg0IDMuNTI4MzlDMi42ODI0OSAyLjk2NjU0IDIuOTM0ODIgMi40NDk2NSAzLjMwMTg1IDEuOTc3NjlDMy4zNDc3MSAxLjkzMjc0IDMuMzcwNjYgMS44ODc4IDMuMzkzNTkgMS44NDI4NUwzLjQ2MjQxIDEuOTEwMjhDMy44MDY1IDEuNDgzMjcgNC4xOTY0NiAxLjE0NjE2IDQuNjc4MTkgMC44OTg5NTNDNS4xODI4NCAwLjYyOTI2IDUuNjg3NSAwLjQ5NDQyMyA2LjIxNTA2IDAuNDk0NDIzSDE0Ljk3NzlDMTUuMTg0MyAwLjQ5NDQyMyAxNS4zOTA3IDAuNTE2OTA0IDE1LjU5NzEgMC41NjE4NUwxNS42MiAwLjQ5NDQyM0MxNS43MzQ5IDAuNTE2OTA0IDE1Ljg0OTUgMC41NjE4NSAxNS45NjQyIDAuNjA2Nzk2QzE1LjU5NzEgMC4zNTk1ODUgMTUuMTg0MyAwLjI0NzIxMSAxNC43MDI2IDAuMjQ3MjExSDUuOTYyODNDNS40NTgxMiAwLjI0NzIxMSA0Ljk1MzQ1IDAuMzU5NTg0IDQuNDcxNzQgMC42MjkyNkMzLjk2NzA3IDAuODk4OTUzIDMuNTU0MTcgMS4yNTg1NCAzLjE4NzE1IDEuNzA4MDFDMi44NDMwNSAyLjE3OTk1IDIuNTkwNzIgMi42NzQzOCAyLjQ3NjAzIDMuMTkxMjhMMS43NDE5NyA2LjI3MDE3Wk0xMi4yNzEgMTIuNDI4SDQuMTczNTNMNC4xMjc2NSAxMi42MDc4SDcuNzI5MVYxMi42NzUySDEyLjU2OTJDMTIuNTkyMSAxMi42NzUyIDEyLjYxNSAxMi42NTI3IDEyLjY2MSAxMi42MzAzQzEyLjY4MzkgMTIuNjA3OCAxMi43MDY4IDEyLjU2MjggMTIuNzA2OCAxMi41NDAzTDEyLjg0NDUgMTEuODIxMkwxMy4wNTEgMTAuNjc1QzEzLjA3MzkgMTAuNjMgMTMuMDUxIDEwLjYwNzcgMTMuMDI4MSAxMC41ODUxQzEzLjAwNSAxMC41NjI3IDEyLjk4MjEgMTAuNTYyNyAxMi45NTkyIDEwLjU2MjdIMTIuODQ0NUwxMi41MjMzIDEyLjIwMzJDMTIuNTIzMyAxMi4yNDgyIDEyLjUwMDQgMTIuMzE1NiAxMi40MzE3IDEyLjM2MDZDMTIuMzg1NyAxMi40MDU1IDEyLjMxNjggMTIuNDI4IDEyLjI3MSAxMi40MjhaTTQuMDM1OSAxMS45NTZIMy43NjA2MkwzLjcxNDc0IDEyLjIwMzJIMy45NjcwN0w0LjAzNTkgMTEuOTU2Wk0wLjI5NjgxNyAxMi4zNjA2QzAuMjA1MDY5IDEyLjc2NTEgMC4yMDUwNyAxMy4xNjk2IDAuMjczODg2IDEzLjUyOTJMMC4zMTk3NSAxMy4zM