*/ // Abort if this file is accessed directly. if (!defined('ABSPATH')) { exit; } class Metasync_Post_Meta_Settings { /** * Description written onto every redirection row this meta box owns. * * Rows created here live in the shared redirections table alongside rules * added by hand on the Redirections screen, and carry no other marker. The * prefix is therefore how the redirect engine tells the two apart, so that * switching the Redirection feature off stops these rows without touching * anyone's manually-added rules. */ const POST_REDIRECT_DESCRIPTION_PREFIX = 'Post redirection meta box for post #'; private $common; public function __construct() { $this->common = new Metasync_Common(); add_action('admin_init', [$this, 'add_post_meta_data'], 2); add_action('save_post', [$this, 'common_robots_meta_box_save']); add_action('save_post', [$this, 'advance_robots_meta_box_save']); add_action('save_post', [$this, 'redirection_meta_box_save']); add_action('save_post', [$this, 'canonical_meta_box_save']); add_action('save_post', [$this, 'video_sitemap_meta_box_save']); add_action('save_post', [$this, 'seo_meta_box_save']); } public function add_post_meta_data() { // Don't show meta boxes if user's role doesn't have plugin access if (!Metasync::current_user_has_plugin_access()) { return; } $plugin_name = Metasync::get_effective_plugin_name(); $general_settings = Metasync::get_option('general', []); $post_types = array_values(get_post_types(['public' => true], 'names')); $post_types = array_diff($post_types, ['attachment']); // Resolve the current post ID in admin_init context: $_GET['post'] on the // edit screen, $_POST['post_ID'] on save. Mirrors the sidebar enqueue. $post_id = isset($_GET['post']) ? intval($_GET['post']) : (isset($_POST['post_ID']) ? intval($_POST['post_ID']) : 0); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended -- read-only post ID to pick meta boxes // LPS / custom-HTML pages carry their own baked SEO served before wp_head, // so the editable SEO fields here do nothing and mislead editors. // otto_pixel.php (where metasync_is_custom_or_lps_page lives) is only // required conditionally, so the function_exists guard is needed at runtime. $is_custom_or_lps_page = function_exists('metasync_is_custom_or_lps_page') && $post_id > 0 && metasync_is_custom_or_lps_page($post_id); // Only add meta boxes if not disabled in settings. // On LPS / custom-HTML pages all of these MetaSync boxes are suppressed: they do // nothing useful there (their output goes into wp_head, which never runs for these // pages — serve_raw_html() exits first — and a redirect would only break the // published page). The single SEO read-only notice below carries the messaging. if (empty($general_settings['disable_common_robots_metabox']) && !$is_custom_or_lps_page) { add_meta_box('common-robots-meta', "Common Robots Meta by $plugin_name", [$this, 'common_robots_meta_box_display'], $post_types, 'normal', 'default'); } if (empty($general_settings['disable_advance_robots_metabox']) && !$is_custom_or_lps_page) { add_meta_box('advance-robots-meta', "Advance Robots Meta by $plugin_name", [$this, 'advance_robots_meta_box_display'], $post_types, 'normal', 'default'); } if (empty($general_settings['disable_redirection_metabox']) && !$is_custom_or_lps_page) { add_meta_box('post-redirection-meta', "Redirection by $plugin_name", [$this, 'post_redirection_display'], $post_types, 'normal', 'default'); } if (empty($general_settings['disable_canonical_metabox']) && !$is_custom_or_lps_page) { add_meta_box('post-canonical-meta', "Canonical by $plugin_name", [$this, 'post_canonical_display'], $post_types, 'normal', 'default'); } if (empty($general_settings['disable_seo_metabox'])) { if ($is_custom_or_lps_page) { // Replace the editable SEO box with a read-only notice on LPS pages. // __back_compat_meta_box hides it in Gutenberg (the block-editor sidebar // already renders the same notice), so it shows only in the Classic editor. add_meta_box('metasync-seo-lps-notice', "SEO by $plugin_name", [$this, 'seo_lps_notice_meta_box_display'], $post_types, 'normal', 'default', ['__back_compat_meta_box' => true]); } else { add_meta_box('metasync-seo-meta', "SEO by $plugin_name", [$this, 'seo_meta_box_display'], $post_types, 'normal', 'default', ['__back_compat_meta_box' => true]); } } // Video Sitemap meta box — only if video sitemap is enabled $video_settings = get_option('metasync_video_sitemap_settings', []); if (!empty($video_settings['enabled'])) { $video_post_types = !empty($video_settings['post_types']) ? (array) $video_settings['post_types'] : ['post', 'page']; add_meta_box( 'metasync-video-sitemap-meta', "Video Sitemap by $plugin_name", [$this, 'video_sitemap_meta_box_display'], $video_post_types, 'normal', 'default' ); } } public function common_robots_meta_box_display() { global $post; $post_meta_robots = get_post_meta($post->ID, 'metasync_common_robots', true); // Check new spelling first, fall back to old for backward compatibility $common_meta_robots = Metasync::get_option('common_robots_meta') ?? Metasync::get_option('common_robots_mata') ?? ''; $common_robots = $post_meta_robots ? $post_meta_robots : $common_meta_robots; wp_nonce_field('metasync_common_robots_nonce', 'metasync_common_robots_nonce'); ?> ID, 'metasync_advance_robots', true); // Check new spelling first, fall back to old for backward compatibility $common_meta_robots = Metasync::get_option('advance_robots_meta') ?? Metasync::get_option('advance_robots_mata') ?? ''; $advance_robots = $post_meta_robots ? $post_meta_robots : $common_meta_robots; $snippet_advance_robots_enable = $advance_robots['max-snippet']['enable'] ?? ''; $snippet_advance_robots_length = $advance_robots['max-snippet']['length'] ?? ''; $video_advance_robots_enable = $advance_robots['max-video-preview']['enable'] ?? ''; $video_advance_robots_length = $advance_robots['max-video-preview']['length'] ?? ''; $image_advance_robots_enable = $advance_robots['max-image-preview']['enable'] ?? ''; $image_advance_robots_length = $advance_robots['max-image-preview']['length'] ?? ''; wp_nonce_field('metasync_advance_robots_nonce', 'metasync_advance_robots_nonce'); ?> ID, 'metasync_post_redirection_meta', true) ?? ''; $enable = $post_redirection['enable'] ?? ''; $type = $post_redirection['type'] ?? ''; $url = $post_redirection['url'] ?? ''; wp_nonce_field('metasync_post_redirection_nonce', 'metasync_post_redirection_nonce'); ?> ID, 'meta_canonical', true); $post_canonical = Metasync_Canonical_Sanitizer::sanitize($raw_canonical); // Repair legacy array rows and rows corrupted to the literal "Array" // A nested array here previously became the string "Array" // via a single reset() + (string) cast, permanently corrupting the row. if (is_array($raw_canonical) || Metasync_Canonical_Sanitizer::is_corrupted($raw_canonical)) { if ($post_canonical !== '') { update_post_meta($post->ID, 'meta_canonical', $post_canonical); } else { delete_post_meta($post->ID, 'meta_canonical'); } } elseif ($post_canonical === '' && is_string($raw_canonical) && trim($raw_canonical) !== '') { // Stored value the validator doesn't recognize (and is not a // corruption artifact): show it as-is so a routine post save // doesn't silently wipe it. It is not emitted while invalid. $post_canonical = trim($raw_canonical); } wp_nonce_field('metasync_post_canonical_nonce', 'metasync_post_canonical_nonce'); ?> common->sanitize_array($post_data[$field_name]); } if (!empty($common_robots)) update_post_meta($post_id, 'metasync_common_robots', $common_robots); elseif (empty($common_robots) && $old_common_robots) delete_post_meta($post_id, 'metasync_common_robots', $old_common_robots); } public function advance_robots_meta_box_save($post_id) { if (!current_user_can('edit_post', $post_id)) return; // When saving from Gutenberg (REST API context) and the sidebar // JSON exists, skip — the sidebar auto-save is the source of truth. if (defined('REST_REQUEST') && REST_REQUEST && !empty(get_post_meta($post_id, '_metasync_robots_advanced', true))) { return; } $post_data = metasync_sanitize_input_array($_POST); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- wp_verify_nonce() runs before any meta write // Check for new field name first, then old for backward compatibility $field_name = isset($post_data['advanced_robots_meta']) ? 'advanced_robots_meta' : 'advanced_robots_mata'; if (!isset($post_data['metasync_advance_robots_nonce'], $post_data[$field_name]) || !wp_verify_nonce($post_data['metasync_advance_robots_nonce'], 'metasync_advance_robots_nonce')) return; $old_advance_robots = get_post_meta($post_id, 'metasync_advance_robots', true); $advance_robots = $this->common->sanitize_array($post_data[$field_name]); if (!empty($advance_robots)) update_post_meta($post_id, 'metasync_advance_robots', $advance_robots); elseif (empty($advance_robots) && $old_advance_robots) delete_post_meta($post_id, 'metasync_advance_robots', $old_advance_robots); } public function redirection_meta_box_save($post_id) { if (!current_user_can('edit_post', $post_id)) return; $post_data = metasync_sanitize_input_array($_POST); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- wp_verify_nonce() runs before any meta write // Check for new field name first, then old for backward compatibility $field_name = isset($post_data['post_redirect_meta']) ? 'post_redirect_meta' : 'post_redirect_mata'; if (!isset($post_data['metasync_post_redirection_nonce'], $post_data[$field_name]) || !wp_verify_nonce($post_data['metasync_post_redirection_nonce'], 'metasync_post_redirection_nonce')) return; $old_post_redirection_meta = get_post_meta($post_id, 'metasync_post_redirection_meta', true); $post_redirection_meta = $this->common->sanitize_array($post_data[$field_name]); // Persist the meta box UI state (checkbox / type / destination) so the form repopulates. if (isset($post_redirection_meta['enable'])) update_post_meta($post_id, 'metasync_post_redirection_meta', $post_redirection_meta); else delete_post_meta($post_id, 'metasync_post_redirection_meta', $old_post_redirection_meta); // sync the actual redirect into the shared redirection table (exact // match) using the same DB layer as ?page=...-redirections&action=add. This // keeps a single redirect code path — the existing handle_template_redirect() // serves it — instead of a duplicate per-post frontend handler. self::sync_post_redirect_rule($post_id, is_array($post_redirection_meta) ? $post_redirection_meta : []); } /** * Create, update, or remove a row in the shared redirection table so a redirect * configured on the post-edit "Redirection" meta box is served by the same engine * as manually-added redirections (exact match on the post's own URL). The row id we * create is remembered in post meta so repeated saves update — never duplicate — it, * and disabling the meta box removes it. * * Static: besides the metabox save hook, the REST handlers call this after * writing metasync_post_redirection_meta, because platform/Gutenberg saves * never pass through the classic nonce-gated save path. * * @param int $post_id The post being saved. * @param array $meta Sanitized meta box values (enable/type/url). */ /** * Whether a redirection row was created by the per-post Redirection meta box. * * @param object $row Row from the redirections table. * @return bool True for rows this meta box owns. */ public static function owns_redirect_row($row) { // A row missing the column, or carrying a non-string, is not ours. // isset() on a non-object is simply false, so no type check is needed. if (!isset($row->description) || !is_string($row->description)) { return false; } return strncmp( $row->description, self::POST_REDIRECT_DESCRIPTION_PREFIX, strlen(self::POST_REDIRECT_DESCRIPTION_PREFIX) ) === 0; } public static function sync_post_redirect_rule($post_id, $meta) { // Never act on autosaves or revisions — their permalink isn't the public URL. if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id) || !class_exists('Metasync_Redirection_Database')) { return; } // Redirection feature switched off. Return before touching the table so a // disabled feature neither creates new rows nor deletes the row a post // already owns — re-enabling must restore the redirect exactly as it was. if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::REDIRECTION)) { return; } $db = new Metasync_Redirection_Database(); $existing_row_id = (int) get_post_meta($post_id, '_metasync_post_redirect_row_id', true); // The metabox posts the literal string 'true', but REST/platform // writes pass through sanitize_text_field() and may arrive as '1', // 'on', or boolean true. Normalize so every spelling behaves alike. $enabled = isset($meta['enable']) && in_array($meta['enable'], ['true', '1', 1, true, 'on', 'yes'], true); $type = isset($meta['type']) ? (int) $meta['type'] : 301; $dest = isset($meta['url']) ? trim((string) $meta['url']) : ''; $is_gone = in_array($type, [410, 451], true); // Disabled, or a redirect type that needs a destination but has none → drop any // row we previously created for this post. if (!$enabled || (!$is_gone && $dest === '')) { if ($existing_row_id > 0) { $db->delete([$existing_row_id]); delete_post_meta($post_id, '_metasync_post_redirect_row_id'); } return; } // Source = this post's own relative URL; visiting it triggers the redirect. $permalink = get_permalink($post_id); if (!$permalink) { return; } $parsed = wp_parse_url($permalink); $source_path = isset($parsed['path']) ? $parsed['path'] : '/'; // Same loop guard as the admin form — creating or updating this post's // rule must not wire its permalink into a cycle (e.g. another rule // already redirects the chosen destination back here). On a loop we // keep the previously stored rule and skip the write. The guard above // already returned when a non-410/451 type had an empty destination, // so $dest is known non-empty here. if (!$is_gone) { // Destination guard — same checks as the admin form and MCP tools. // Backslashes and protocol-relative hosts bypass host validation in // browsers while looking internal to wp_validate_redirect. On a // bad destination we keep the previously stored rule and skip the // write, same skip-write semantics as the loop guard below. if (!class_exists('Metasync_Redirection_Validator')) { require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection-validator.php'; } if (!Metasync_Redirection_Validator::is_safe_destination_syntax($dest) || (!get_option('metasync_allow_external_redirects', 0) && wp_validate_redirect($dest, '') !== $dest)) { return; } if (!class_exists('Metasync_Redirection')) { require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection.php'; } $db_ref = $db; $redirection_helper = new Metasync_Redirection($db_ref); if ($redirection_helper->validate_no_loop($source_path, $dest) !== null) { return; } } $data = [ 'sources_from' => serialize([$source_path => 'exact']), 'url_redirect_to' => $is_gone ? '' : $dest, 'http_code' => $type, 'status' => 'active', 'pattern_type' => 'exact', 'regex_pattern' => null, 'description' => self::POST_REDIRECT_DESCRIPTION_PREFIX . $post_id, ]; // Update the row we own if it still exists; otherwise create it and remember its id. if ($existing_row_id > 0 && $db->find($existing_row_id)) { $db->update($data, (string) $existing_row_id); } else { $new_id = $db->add($data); if ($new_id) { update_post_meta($post_id, '_metasync_post_redirect_row_id', (int) $new_id); } } } public function canonical_meta_box_save($post_id) { if (!current_user_can('edit_post', $post_id)) return; $post_data = metasync_sanitize_input_array($_POST); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- wp_verify_nonce() runs before any meta write // Check for new field name first, then old for backward compatibility $field_name = isset($post_data['post_canonical_url_meta']) ? 'post_canonical_url_meta' : 'post_canonical_url_mata'; if (!isset($post_data['metasync_post_canonical_nonce'], $post_data[$field_name]) || !wp_verify_nonce($post_data['metasync_post_canonical_nonce'], 'metasync_post_canonical_nonce')) return; $old_post_canonical_meta = get_post_meta($post_id, 'meta_canonical', true); // Canonical is a URL string — extract the first scalar from nested // arrays, normalize schemeless input, and validate. Unusable input // (including the corruption literal "Array") clears the meta instead // of persisting garbage like "http://Array". $post_canonical_meta = Metasync_Canonical_Sanitizer::sanitize_for_save($post_data[$field_name]); if (!empty($post_canonical_meta)) update_post_meta($post_id, 'meta_canonical', $post_canonical_meta); else delete_post_meta($post_id, 'meta_canonical', $old_post_canonical_meta); } public function show_top_admin_bar() { if ( Metasync::current_user_has_plugin_access() ) { show_admin_bar( true ); } } /** * Display the Video Sitemap meta box in post editor. */ public function video_sitemap_meta_box_display() { global $post; $video_url = get_post_meta($post->ID, '_metasync_video_url', true); $video_thumbnail = get_post_meta($post->ID, '_metasync_video_thumbnail', true); $video_title = get_post_meta($post->ID, '_metasync_video_title', true); $video_desc = get_post_meta($post->ID, '_metasync_video_description', true); $video_duration = get_post_meta($post->ID, '_metasync_video_duration', true); wp_nonce_field('metasync_video_sitemap_meta_nonce', 'metasync_video_sitemap_meta_nonce'); ?>

'_metasync_video_url', 'metasync_video_thumbnail' => '_metasync_video_thumbnail', 'metasync_video_title' => '_metasync_video_title', 'metasync_video_description' => '_metasync_video_description', 'metasync_video_duration' => '_metasync_video_duration', ]; foreach ($fields as $form_key => $meta_key) { if (!isset($_POST[$form_key])) { continue; } $value = $_POST[$form_key]; // Sanitize per field type if (in_array($meta_key, ['_metasync_video_url', '_metasync_video_thumbnail'], true)) { $value = esc_url_raw($value); } elseif ($meta_key === '_metasync_video_duration') { $value = absint($value); $value = $value > 0 ? $value : ''; } elseif ($meta_key === '_metasync_video_description') { $value = sanitize_textarea_field($value); } else { $value = sanitize_text_field($value); } if (!empty($value)) { update_post_meta($post_id, $meta_key, $value); } else { delete_post_meta($post_id, $meta_key); } } } /** * Display a read-only notice in place of the SEO meta box on LPS / custom-HTML * pages, whose SEO is baked into their own HTML bundle and managed by WebStudio. * No inputs, nonce, or editable fields are rendered. */ public function seo_lps_notice_meta_box_display() { echo '

The SEO for this page is managed by WebStudio.

'; } /** * Display the SEO meta box (SEO Title & Meta Description) in the Classic editor. * * Reads/writes the same _metasync_seo_title / _metasync_seo_desc keys used by the * Gutenberg sidebar. OTTO values are used only as placeholder hints so the * custom-value > OTTO priority is preserved. */ public function seo_meta_box_display() { global $post; $seo_title = get_post_meta($post->ID, '_metasync_seo_title', true); $seo_desc = get_post_meta($post->ID, '_metasync_seo_desc', true); // Values brought in by the external SEO importer apply only where OTTO // has no suggestion, so show them as the placeholder in exactly that // case. Without this the field looks empty after an import and the user // concludes nothing was imported. Which tier fills the placeholder is // Metasync_Seo_Precedence's call, not this screen's. $title_fallback = Metasync_Seo_Precedence::fallback($post->ID, Metasync_Seo_Precedence::FIELD_TITLE); $desc_fallback = Metasync_Seo_Precedence::fallback($post->ID, Metasync_Seo_Precedence::FIELD_DESCRIPTION); $title_placeholder = $title_fallback['value']; $desc_placeholder = $desc_fallback['value']; $title_is_imported = $title_fallback['source'] === Metasync_Seo_Precedence::SOURCE_IMPORTED; wp_nonce_field('metasync_seo_meta_nonce', 'metasync_seo_meta_nonce'); ?>