PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / admin / class-metasync-post-meta-setting.php

class-metasync-post-meta-setting.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.1, at admin/class-metasync-post-meta-setting.php

745 lines 33.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * The header and footer code snippets functionality of the plugin.
5 *
6 *
7 * @link https://searchatlas.com
8 * @since 1.0.0
9 * @package Metasync
10 * @subpackage Metasync/admin
11 * @author Engineering Team <[email protected]>
12 */
13
14 // Abort if this file is accessed directly.
15 if (!defined('ABSPATH')) {
16 exit;
17 }
18
19 class Metasync_Post_Meta_Settings
20 {
21 /**
22 * Description written onto every redirection row this meta box owns.
23 *
24 * Rows created here live in the shared redirections table alongside rules
25 * added by hand on the Redirections screen, and carry no other marker. The
26 * prefix is therefore how the redirect engine tells the two apart, so that
27 * switching the Redirection feature off stops these rows without touching
28 * anyone's manually-added rules.
29 */
30 const POST_REDIRECT_DESCRIPTION_PREFIX = 'Post redirection meta box for post #';
31
32 private $common;
33
34 public function __construct()
35 {
36 $this->common = new Metasync_Common();
37
38 add_action('admin_init', [$this, 'add_post_meta_data'], 2);
39 add_action('save_post', [$this, 'common_robots_meta_box_save']);
40 add_action('save_post', [$this, 'advance_robots_meta_box_save']);
41 add_action('save_post', [$this, 'redirection_meta_box_save']);
42 add_action('save_post', [$this, 'canonical_meta_box_save']);
43 add_action('save_post', [$this, 'video_sitemap_meta_box_save']);
44 add_action('save_post', [$this, 'seo_meta_box_save']);
45 }
46
47 public function add_post_meta_data()
48 {
49 // Don't show meta boxes if user's role doesn't have plugin access
50 if (!Metasync::current_user_has_plugin_access()) {
51 return;
52 }
53
54 $plugin_name = Metasync::get_effective_plugin_name();
55 $general_settings = Metasync::get_option('general', []);
56 $post_types = array_values(get_post_types(['public' => true], 'names'));
57 $post_types = array_diff($post_types, ['attachment']);
58
59 // Resolve the current post ID in admin_init context: $_GET['post'] on the
60 // edit screen, $_POST['post_ID'] on save. Mirrors the sidebar enqueue.
61 $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
62
63 // LPS / custom-HTML pages carry their own baked SEO served before wp_head,
64 // so the editable SEO fields here do nothing and mislead editors.
65 // otto_pixel.php (where metasync_is_custom_or_lps_page lives) is only
66 // required conditionally, so the function_exists guard is needed at runtime.
67 $is_custom_or_lps_page = function_exists('metasync_is_custom_or_lps_page') && $post_id > 0 && metasync_is_custom_or_lps_page($post_id);
68
69 // Only add meta boxes if not disabled in settings.
70 // On LPS / custom-HTML pages all of these MetaSync boxes are suppressed: they do
71 // nothing useful there (their output goes into wp_head, which never runs for these
72 // pages — serve_raw_html() exits first — and a redirect would only break the
73 // published page). The single SEO read-only notice below carries the messaging.
74 if (empty($general_settings['disable_common_robots_metabox']) && !$is_custom_or_lps_page) {
75 add_meta_box('common-robots-meta', "Common Robots Meta by $plugin_name", [$this, 'common_robots_meta_box_display'], $post_types, 'normal', 'default');
76 }
77
78 if (empty($general_settings['disable_advance_robots_metabox']) && !$is_custom_or_lps_page) {
79 add_meta_box('advance-robots-meta', "Advance Robots Meta by $plugin_name", [$this, 'advance_robots_meta_box_display'], $post_types, 'normal', 'default');
80 }
81
82 if (empty($general_settings['disable_redirection_metabox']) && !$is_custom_or_lps_page) {
83 add_meta_box('post-redirection-meta', "Redirection by $plugin_name", [$this, 'post_redirection_display'], $post_types, 'normal', 'default');
84 }
85
86 if (empty($general_settings['disable_canonical_metabox']) && !$is_custom_or_lps_page) {
87 add_meta_box('post-canonical-meta', "Canonical by $plugin_name", [$this, 'post_canonical_display'], $post_types, 'normal', 'default');
88 }
89
90 if (empty($general_settings['disable_seo_metabox'])) {
91 if ($is_custom_or_lps_page) {
92 // Replace the editable SEO box with a read-only notice on LPS pages.
93 // __back_compat_meta_box hides it in Gutenberg (the block-editor sidebar
94 // already renders the same notice), so it shows only in the Classic editor.
95 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]);
96 } else {
97 add_meta_box('metasync-seo-meta', "SEO by $plugin_name", [$this, 'seo_meta_box_display'], $post_types, 'normal', 'default', ['__back_compat_meta_box' => true]);
98 }
99 }
100
101 // Video Sitemap meta box — only if video sitemap is enabled
102 $video_settings = get_option('metasync_video_sitemap_settings', []);
103 if (!empty($video_settings['enabled'])) {
104 $video_post_types = !empty($video_settings['post_types']) ? (array) $video_settings['post_types'] : ['post', 'page'];
105 add_meta_box(
106 'metasync-video-sitemap-meta',
107 "Video Sitemap by $plugin_name",
108 [$this, 'video_sitemap_meta_box_display'],
109 $video_post_types,
110 'normal',
111 'default'
112 );
113 }
114 }
115
116 public function common_robots_meta_box_display()
117 {
118 global $post;
119 $post_meta_robots = get_post_meta($post->ID, 'metasync_common_robots', true);
120 // Check new spelling first, fall back to old for backward compatibility
121 $common_meta_robots = Metasync::get_option('common_robots_meta') ?? Metasync::get_option('common_robots_mata') ?? '';
122 $common_robots = $post_meta_robots ? $post_meta_robots : $common_meta_robots;
123 wp_nonce_field('metasync_common_robots_nonce', 'metasync_common_robots_nonce');
124 ?>
125 <ul class="checkbox-list">
126 <li>
127 <input type="checkbox" name="common_robots_meta[noindex]" id="robots_common2" value="noindex" <?php isset($common_robots['noindex']) ? checked('noindex', $common_robots['noindex']) : '' ?>>
128 <label for="robots_common2">No Index </br>
129 <span class="description">
130 <span>Prevents search engines from indexing and displaying these pages in search results.</span>
131 </span>
132 </label>
133 </li>
134 <li>
135 <input type="checkbox" name="common_robots_meta[nofollow]" id="robots_common3" value="nofollow" <?php isset($common_robots['nofollow']) ? checked('nofollow', $common_robots['nofollow']) : '' ?>>
136 <label for="robots_common3">No Follow </br>
137 <span class="description">
138 <span>Prevents search engines from following the links on the pages.</span>
139 </span>
140 </label>
141 </li>
142 <li>
143 <input type="checkbox" name="common_robots_meta[noarchive]" id="robots_common4" value="noarchive" <?php isset($common_robots['noarchive']) ? checked('noarchive', $common_robots['noarchive']) : '' ?>>
144 <label for="robots_common4">No Archive </br>
145 <span class="description">
146 <span>Prevents search engines from showing cached links for pages.</span>
147 </span>
148 </label>
149 </li>
150 <li>
151 <input type="checkbox" name="common_robots_meta[noimageindex]" id="robots_common5" value="noimageindex" <?php isset($common_robots['noimageindex']) ? checked('noimageindex', $common_robots['noimageindex']) : '' ?>>
152 <label for="robots_common5">No Image Index </br>
153 <span class="description">
154 <span>Prevents your pages from appearing as the referring page for images in image search results.</span>
155 </span>
156 </label>
157 </li>
158 <li>
159 <input type="checkbox" name="common_robots_meta[nosnippet]" id="robots_common6" value="nosnippet" <?php isset($common_robots['nosnippet']) ? checked('nosnippet', $common_robots['nosnippet']) : '' ?>>
160 <label for="robots_common6">No Snippet </br>
161 <span class="description">
162 <span>Prevents search engines from showing a snippet in the search results.</span>
163 </span>
164 </label>
165 </li>
166 </ul>
167
168 <?php
169 }
170
171 public function advance_robots_meta_box_display()
172 {
173 global $post;
174 $post_meta_robots = get_post_meta($post->ID, 'metasync_advance_robots', true);
175 // Check new spelling first, fall back to old for backward compatibility
176 $common_meta_robots = Metasync::get_option('advance_robots_meta') ?? Metasync::get_option('advance_robots_mata') ?? '';
177 $advance_robots = $post_meta_robots ? $post_meta_robots : $common_meta_robots;
178 $snippet_advance_robots_enable = $advance_robots['max-snippet']['enable'] ?? '';
179 $snippet_advance_robots_length = $advance_robots['max-snippet']['length'] ?? '';
180 $video_advance_robots_enable = $advance_robots['max-video-preview']['enable'] ?? '';
181 $video_advance_robots_length = $advance_robots['max-video-preview']['length'] ?? '';
182 $image_advance_robots_enable = $advance_robots['max-image-preview']['enable'] ?? '';
183 $image_advance_robots_length = $advance_robots['max-image-preview']['length'] ?? '';
184 wp_nonce_field('metasync_advance_robots_nonce', 'metasync_advance_robots_nonce');
185 ?>
186 <ul class="checkbox-list">
187 <li>
188 <label for="advanced_robots_snippet">
189 <input type="checkbox" name="advanced_robots_meta[max-snippet][enable]" id="advanced_robots_snippet" value="1" <?php checked('1', esc_attr($snippet_advance_robots_enable)) ?>>
190 Snippet </br>
191 <input type="number" class="input-length" name="advanced_robots_meta[max-snippet][length]" id="advanced_robots_snippet_value" value="<?php echo esc_attr($snippet_advance_robots_length); ?>" min="-1"> </br>
192 <span class="description">
193 <span>Add maximum text-length, in characters, of a snippet for your page.</span>
194 </span>
195 </label>
196 </li>
197 <li>
198 <label for="advanced_robots_video">
199 <input type="checkbox" name="advanced_robots_meta[max-video-preview][enable]" id="advanced_robots_video" value="1" <?php checked('1', esc_attr($video_advance_robots_enable)) ?>>
200 Video Preview </br>
201 <input type="number" class="input-length" name="advanced_robots_meta[max-video-preview][length]" id="advanced_robots_video_value" value="<?php echo esc_attr($video_advance_robots_length); ?>" min="-1"> </br>
202 <span class="description">
203 <span>Add maximum duration in seconds of an animated video preview.</span>
204 </span>
205 </label>
206 </li>
207 <li>
208 <label for="advanced_robots_image">
209 <input type="checkbox" name="advanced_robots_meta[max-image-preview][enable]" id="advanced_robots_image" value="1" <?php checked('1', esc_attr($image_advance_robots_enable)) ?>>
210 Image Preview </br>
211 <select class="input-length" name="advanced_robots_meta[max-image-preview][length]" id="advanced_robots_image_value">
212 <option value="large" <?php selected(esc_attr($image_advance_robots_length), 'large'); ?>>Large</option>
213 <option value="standard" <?php selected(esc_attr($image_advance_robots_length), 'standard'); ?>>Standard</option>
214 <option value="none" <?php selected(esc_attr($image_advance_robots_length), 'none'); ?>>None</option>
215 </select>
216 </br>
217 <span class="description">
218 <span>Add maximum size of image preview to show the images on this page.</span>
219 </span>
220 </label>
221 </li>
222 </ul>
223 <?php
224 }
225
226 public function post_redirection_display()
227 {
228 global $post;
229 $post_redirection = get_post_meta($post->ID, 'metasync_post_redirection_meta', true) ?? '';
230 $enable = $post_redirection['enable'] ?? '';
231 $type = $post_redirection['type'] ?? '';
232 $url = $post_redirection['url'] ?? '';
233 wp_nonce_field('metasync_post_redirection_nonce', 'metasync_post_redirection_nonce');
234 ?>
235 <ul class="checkbox-list">
236 <li>
237 <input type="checkbox" name="post_redirect_meta[enable]" id="post_redirection" value="true" <?php checked('true', esc_attr($enable)); ?>>
238 <label for="post_redirection">Redirection</label>
239 </li>
240 <li class="hide"> Redirection Type:
241 <select class="regular-text" name="post_redirect_meta[type]" id="post_redirection_type">
242 <option value="301" <?php selected(esc_attr($type), '301'); ?>>301 Permanent Move</option>
243 <option value="302" <?php selected(esc_attr($type), '302'); ?>>302 Temporary Move</option>
244 <option value="307" <?php selected(esc_attr($type), '307'); ?>>307 Temporary Redirect</option>
245 <option value="410" <?php selected(esc_attr($type), '410'); ?>>410 Content Deleted</option>
246 <option value="451" <?php selected(esc_attr($type), '451'); ?>>451 Content Unavailable</option>
247 </select>
248 </li>
249 <li class="hide" id="post_redirect_url"> Destination URL:
250 <input type="text" class="regular-text" name="post_redirect_meta[url]" id="post_redirect_url_val" value="<?php echo esc_attr($url); ?>">
251 </li>
252 </ul>
253 <?php
254 }
255
256 public function post_canonical_display()
257 {
258 global $post;
259
260 $raw_canonical = get_post_meta($post->ID, 'meta_canonical', true);
261 $post_canonical = Metasync_Canonical_Sanitizer::sanitize($raw_canonical);
262
263 // Repair legacy array rows and rows corrupted to the literal "Array"
264 // A nested array here previously became the string "Array"
265 // via a single reset() + (string) cast, permanently corrupting the row.
266 if (is_array($raw_canonical) || Metasync_Canonical_Sanitizer::is_corrupted($raw_canonical)) {
267 if ($post_canonical !== '') {
268 update_post_meta($post->ID, 'meta_canonical', $post_canonical);
269 } else {
270 delete_post_meta($post->ID, 'meta_canonical');
271 }
272 } elseif ($post_canonical === '' && is_string($raw_canonical) && trim($raw_canonical) !== '') {
273 // Stored value the validator doesn't recognize (and is not a
274 // corruption artifact): show it as-is so a routine post save
275 // doesn't silently wipe it. It is not emitted while invalid.
276 $post_canonical = trim($raw_canonical);
277 }
278 wp_nonce_field('metasync_post_canonical_nonce', 'metasync_post_canonical_nonce');
279 ?>
280 <ul>
281 <li> Canonical URL:
282 <input type="text" class="regular-text" name="post_canonical_url_meta" placeholder="<?php echo get_permalink($post->ID) ?>" value="<?php echo esc_attr($post_canonical); ?>">
283 </li>
284 </ul>
285 <?php
286 }
287
288 public function common_robots_meta_box_save($post_id)
289 {
290 if (!current_user_can('edit_post', $post_id))
291 return;
292
293 // When saving from Gutenberg (REST API context) and the sidebar
294 // JSON exists, skip — the sidebar auto-save is the source of truth.
295 // Classic editor form submits (non-REST) always proceed so classic-only
296 // users can still save via the meta boxes.
297 if (defined('REST_REQUEST') && REST_REQUEST && !empty(get_post_meta($post_id, '_metasync_robots_advanced', true))) {
298 return;
299 }
300
301 $post_data = metasync_sanitize_input_array($_POST); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- wp_verify_nonce() runs before any meta write
302 // Check for new field name first, then old for backward compatibility
303 $field_name = isset($post_data['common_robots_meta']) ? 'common_robots_meta' : 'common_robots_mata';
304
305 // Unchecked checkboxes are omitted from the request. Require the nonce,
306 // but allow an absent checkbox group so a user can clear the last saved
307 // directive (for example, by unticking No Index).
308 if (!isset($post_data['metasync_common_robots_nonce']) || !wp_verify_nonce($post_data['metasync_common_robots_nonce'], 'metasync_common_robots_nonce'))
309 return;
310
311 $old_common_robots = get_post_meta($post_id, 'metasync_common_robots', true);
312
313 $common_robots = [];
314 if (!empty($post_data[$field_name])) {
315 $common_robots = $this->common->sanitize_array($post_data[$field_name]);
316 }
317
318 if (!empty($common_robots))
319 update_post_meta($post_id, 'metasync_common_robots', $common_robots);
320 elseif (empty($common_robots) && $old_common_robots)
321 delete_post_meta($post_id, 'metasync_common_robots', $old_common_robots);
322 }
323
324 public function advance_robots_meta_box_save($post_id)
325 {
326 if (!current_user_can('edit_post', $post_id))
327 return;
328
329 // When saving from Gutenberg (REST API context) and the sidebar
330 // JSON exists, skip — the sidebar auto-save is the source of truth.
331 if (defined('REST_REQUEST') && REST_REQUEST && !empty(get_post_meta($post_id, '_metasync_robots_advanced', true))) {
332 return;
333 }
334
335 $post_data = metasync_sanitize_input_array($_POST); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- wp_verify_nonce() runs before any meta write
336 // Check for new field name first, then old for backward compatibility
337 $field_name = isset($post_data['advanced_robots_meta']) ? 'advanced_robots_meta' : 'advanced_robots_mata';
338
339 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'))
340 return;
341
342 $old_advance_robots = get_post_meta($post_id, 'metasync_advance_robots', true);
343
344 $advance_robots = $this->common->sanitize_array($post_data[$field_name]);
345
346 if (!empty($advance_robots))
347 update_post_meta($post_id, 'metasync_advance_robots', $advance_robots);
348 elseif (empty($advance_robots) && $old_advance_robots)
349 delete_post_meta($post_id, 'metasync_advance_robots', $old_advance_robots);
350 }
351
352 public function redirection_meta_box_save($post_id)
353 {
354 if (!current_user_can('edit_post', $post_id))
355 return;
356
357 $post_data = metasync_sanitize_input_array($_POST); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- wp_verify_nonce() runs before any meta write
358 // Check for new field name first, then old for backward compatibility
359 $field_name = isset($post_data['post_redirect_meta']) ? 'post_redirect_meta' : 'post_redirect_mata';
360
361 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'))
362 return;
363
364 $old_post_redirection_meta = get_post_meta($post_id, 'metasync_post_redirection_meta', true);
365
366 $post_redirection_meta = $this->common->sanitize_array($post_data[$field_name]);
367
368 // Persist the meta box UI state (checkbox / type / destination) so the form repopulates.
369 if (isset($post_redirection_meta['enable']))
370 update_post_meta($post_id, 'metasync_post_redirection_meta', $post_redirection_meta);
371 else
372 delete_post_meta($post_id, 'metasync_post_redirection_meta', $old_post_redirection_meta);
373
374 // sync the actual redirect into the shared redirection table (exact
375 // match) using the same DB layer as ?page=...-redirections&action=add. This
376 // keeps a single redirect code path — the existing handle_template_redirect()
377 // serves it — instead of a duplicate per-post frontend handler.
378 self::sync_post_redirect_rule($post_id, is_array($post_redirection_meta) ? $post_redirection_meta : []);
379 }
380
381 /**
382 * Create, update, or remove a row in the shared redirection table so a redirect
383 * configured on the post-edit "Redirection" meta box is served by the same engine
384 * as manually-added redirections (exact match on the post's own URL). The row id we
385 * create is remembered in post meta so repeated saves update — never duplicate — it,
386 * and disabling the meta box removes it.
387 *
388 * Static: besides the metabox save hook, the REST handlers call this after
389 * writing metasync_post_redirection_meta, because platform/Gutenberg saves
390 * never pass through the classic nonce-gated save path.
391 *
392 * @param int $post_id The post being saved.
393 * @param array $meta Sanitized meta box values (enable/type/url).
394 */
395 /**
396 * Whether a redirection row was created by the per-post Redirection meta box.
397 *
398 * @param object $row Row from the redirections table.
399 * @return bool True for rows this meta box owns.
400 */
401 public static function owns_redirect_row($row)
402 {
403 // A row missing the column, or carrying a non-string, is not ours.
404 // isset() on a non-object is simply false, so no type check is needed.
405 if (!isset($row->description) || !is_string($row->description)) {
406 return false;
407 }
408
409 return strncmp(
410 $row->description,
411 self::POST_REDIRECT_DESCRIPTION_PREFIX,
412 strlen(self::POST_REDIRECT_DESCRIPTION_PREFIX)
413 ) === 0;
414 }
415
416 public static function sync_post_redirect_rule($post_id, $meta)
417 {
418 // Never act on autosaves or revisions — their permalink isn't the public URL.
419 if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id) || !class_exists('Metasync_Redirection_Database')) {
420 return;
421 }
422
423 // Redirection feature switched off. Return before touching the table so a
424 // disabled feature neither creates new rows nor deletes the row a post
425 // already owns — re-enabling must restore the redirect exactly as it was.
426 if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::REDIRECTION)) {
427 return;
428 }
429
430 $db = new Metasync_Redirection_Database();
431 $existing_row_id = (int) get_post_meta($post_id, '_metasync_post_redirect_row_id', true);
432
433 // The metabox posts the literal string 'true', but REST/platform
434 // writes pass through sanitize_text_field() and may arrive as '1',
435 // 'on', or boolean true. Normalize so every spelling behaves alike.
436 $enabled = isset($meta['enable']) && in_array($meta['enable'], ['true', '1', 1, true, 'on', 'yes'], true);
437 $type = isset($meta['type']) ? (int) $meta['type'] : 301;
438 $dest = isset($meta['url']) ? trim((string) $meta['url']) : '';
439 $is_gone = in_array($type, [410, 451], true);
440
441 // Disabled, or a redirect type that needs a destination but has none → drop any
442 // row we previously created for this post.
443 if (!$enabled || (!$is_gone && $dest === '')) {
444 if ($existing_row_id > 0) {
445 $db->delete([$existing_row_id]);
446 delete_post_meta($post_id, '_metasync_post_redirect_row_id');
447 }
448 return;
449 }
450
451 // Source = this post's own relative URL; visiting it triggers the redirect.
452 $permalink = get_permalink($post_id);
453 if (!$permalink) {
454 return;
455 }
456 $parsed = wp_parse_url($permalink);
457 $source_path = isset($parsed['path']) ? $parsed['path'] : '/';
458
459 // Same loop guard as the admin form — creating or updating this post's
460 // rule must not wire its permalink into a cycle (e.g. another rule
461 // already redirects the chosen destination back here). On a loop we
462 // keep the previously stored rule and skip the write. The guard above
463 // already returned when a non-410/451 type had an empty destination,
464 // so $dest is known non-empty here.
465 if (!$is_gone) {
466 // Destination guard — same checks as the admin form and MCP tools.
467 // Backslashes and protocol-relative hosts bypass host validation in
468 // browsers while looking internal to wp_validate_redirect. On a
469 // bad destination we keep the previously stored rule and skip the
470 // write, same skip-write semantics as the loop guard below.
471 if (!class_exists('Metasync_Redirection_Validator')) {
472 require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection-validator.php';
473 }
474 if (!Metasync_Redirection_Validator::is_safe_destination_syntax($dest)
475 || (!get_option('metasync_allow_external_redirects', 0) && wp_validate_redirect($dest, '') !== $dest)) {
476 return;
477 }
478
479 if (!class_exists('Metasync_Redirection')) {
480 require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection.php';
481 }
482 $db_ref = $db;
483 $redirection_helper = new Metasync_Redirection($db_ref);
484 if ($redirection_helper->validate_no_loop($source_path, $dest) !== null) {
485 return;
486 }
487 }
488
489 $data = [
490 'sources_from' => serialize([$source_path => 'exact']),
491 'url_redirect_to' => $is_gone ? '' : $dest,
492 'http_code' => $type,
493 'status' => 'active',
494 'pattern_type' => 'exact',
495 'regex_pattern' => null,
496 'description' => self::POST_REDIRECT_DESCRIPTION_PREFIX . $post_id,
497 ];
498
499 // Update the row we own if it still exists; otherwise create it and remember its id.
500 if ($existing_row_id > 0 && $db->find($existing_row_id)) {
501 $db->update($data, (string) $existing_row_id);
502 } else {
503 $new_id = $db->add($data);
504 if ($new_id) {
505 update_post_meta($post_id, '_metasync_post_redirect_row_id', (int) $new_id);
506 }
507 }
508 }
509
510 public function canonical_meta_box_save($post_id)
511 {
512 if (!current_user_can('edit_post', $post_id))
513 return;
514
515 $post_data = metasync_sanitize_input_array($_POST); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- wp_verify_nonce() runs before any meta write
516 // Check for new field name first, then old for backward compatibility
517 $field_name = isset($post_data['post_canonical_url_meta']) ? 'post_canonical_url_meta' : 'post_canonical_url_mata';
518
519 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'))
520 return;
521
522 $old_post_canonical_meta = get_post_meta($post_id, 'meta_canonical', true);
523
524 // Canonical is a URL string — extract the first scalar from nested
525 // arrays, normalize schemeless input, and validate. Unusable input
526 // (including the corruption literal "Array") clears the meta instead
527 // of persisting garbage like "http://Array".
528 $post_canonical_meta = Metasync_Canonical_Sanitizer::sanitize_for_save($post_data[$field_name]);
529
530 if (!empty($post_canonical_meta))
531 update_post_meta($post_id, 'meta_canonical', $post_canonical_meta);
532 else
533 delete_post_meta($post_id, 'meta_canonical', $old_post_canonical_meta);
534 }
535
536 public function show_top_admin_bar() {
537 if ( Metasync::current_user_has_plugin_access() ) {
538 show_admin_bar( true );
539 }
540 }
541
542 /**
543 * Display the Video Sitemap meta box in post editor.
544 */
545 public function video_sitemap_meta_box_display()
546 {
547 global $post;
548 $video_url = get_post_meta($post->ID, '_metasync_video_url', true);
549 $video_thumbnail = get_post_meta($post->ID, '_metasync_video_thumbnail', true);
550 $video_title = get_post_meta($post->ID, '_metasync_video_title', true);
551 $video_desc = get_post_meta($post->ID, '_metasync_video_description', true);
552 $video_duration = get_post_meta($post->ID, '_metasync_video_duration', true);
553
554 wp_nonce_field('metasync_video_sitemap_meta_nonce', 'metasync_video_sitemap_meta_nonce');
555 ?>
556 <p style="color: #666; margin-bottom: 12px;">
557 <?php esc_html_e('Override auto-detected video data for this post. Leave fields empty to use auto-detection.', 'metasync'); ?>
558 </p>
559 <table class="form-table" style="margin: 0;">
560 <tr>
561 <th scope="row"><label for="metasync_video_url"><?php esc_html_e('Video URL', 'metasync'); ?></label></th>
562 <td><input type="url" id="metasync_video_url" name="metasync_video_url" value="<?php echo esc_attr($video_url); ?>" class="large-text" placeholder="https://www.youtube.com/watch?v=..." /></td>
563 </tr>
564 <tr>
565 <th scope="row"><label for="metasync_video_thumbnail"><?php esc_html_e('Thumbnail URL', 'metasync'); ?></label></th>
566 <td><input type="url" id="metasync_video_thumbnail" name="metasync_video_thumbnail" value="<?php echo esc_attr($video_thumbnail); ?>" class="large-text" placeholder="https://img.youtube.com/vi/.../hqdefault.jpg" /></td>
567 </tr>
568 <tr>
569 <th scope="row"><label for="metasync_video_title"><?php esc_html_e('Video Title', 'metasync'); ?></label></th>
570 <td><input type="text" id="metasync_video_title" name="metasync_video_title" value="<?php echo esc_attr($video_title); ?>" class="large-text" placeholder="<?php esc_attr_e('Defaults to post title', 'metasync'); ?>" /></td>
571 </tr>
572 <tr>
573 <th scope="row"><label for="metasync_video_description"><?php esc_html_e('Video Description', 'metasync'); ?></label></th>
574 <td><textarea id="metasync_video_description" name="metasync_video_description" class="large-text" rows="3" placeholder="<?php esc_attr_e('Defaults to post excerpt', 'metasync'); ?>"><?php echo esc_textarea($video_desc); ?></textarea></td>
575 </tr>
576 <tr>
577 <th scope="row"><label for="metasync_video_duration"><?php esc_html_e('Duration (seconds)', 'metasync'); ?></label></th>
578 <td><input type="number" id="metasync_video_duration" name="metasync_video_duration" value="<?php echo esc_attr($video_duration); ?>" class="small-text" min="0" step="1" placeholder="300" /></td>
579 </tr>
580 </table>
581 <?php
582 }
583
584 /**
585 * Save the Video Sitemap meta box data.
586 *
587 * @param int $post_id The post ID.
588 */
589 public function video_sitemap_meta_box_save($post_id)
590 {
591 if (!isset($_POST['metasync_video_sitemap_meta_nonce']) ||
592 !wp_verify_nonce($_POST['metasync_video_sitemap_meta_nonce'], 'metasync_video_sitemap_meta_nonce')) {
593 return;
594 }
595
596 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
597 return;
598 }
599
600 if (!current_user_can('edit_post', $post_id)) {
601 return;
602 }
603
604 $fields = [
605 'metasync_video_url' => '_metasync_video_url',
606 'metasync_video_thumbnail' => '_metasync_video_thumbnail',
607 'metasync_video_title' => '_metasync_video_title',
608 'metasync_video_description' => '_metasync_video_description',
609 'metasync_video_duration' => '_metasync_video_duration',
610 ];
611
612 foreach ($fields as $form_key => $meta_key) {
613 if (!isset($_POST[$form_key])) {
614 continue;
615 }
616
617 $value = $_POST[$form_key];
618
619 // Sanitize per field type
620 if (in_array($meta_key, ['_metasync_video_url', '_metasync_video_thumbnail'], true)) {
621 $value = esc_url_raw($value);
622 } elseif ($meta_key === '_metasync_video_duration') {
623 $value = absint($value);
624 $value = $value > 0 ? $value : '';
625 } elseif ($meta_key === '_metasync_video_description') {
626 $value = sanitize_textarea_field($value);
627 } else {
628 $value = sanitize_text_field($value);
629 }
630
631 if (!empty($value)) {
632 update_post_meta($post_id, $meta_key, $value);
633 } else {
634 delete_post_meta($post_id, $meta_key);
635 }
636 }
637 }
638
639 /**
640 * Display a read-only notice in place of the SEO meta box on LPS / custom-HTML
641 * pages, whose SEO is baked into their own HTML bundle and managed by WebStudio.
642 * No inputs, nonce, or editable fields are rendered.
643 */
644 public function seo_lps_notice_meta_box_display()
645 {
646 echo '<p><strong>The SEO for this page is managed by WebStudio.</strong></p>';
647 }
648
649 /**
650 * Display the SEO meta box (SEO Title & Meta Description) in the Classic editor.
651 *
652 * Reads/writes the same _metasync_seo_title / _metasync_seo_desc keys used by the
653 * Gutenberg sidebar. OTTO values are used only as placeholder hints so the
654 * custom-value > OTTO priority is preserved.
655 */
656 public function seo_meta_box_display()
657 {
658 global $post;
659 $seo_title = get_post_meta($post->ID, '_metasync_seo_title', true);
660 $seo_desc = get_post_meta($post->ID, '_metasync_seo_desc', true);
661
662 // Values brought in by the external SEO importer apply only where OTTO
663 // has no suggestion, so show them as the placeholder in exactly that
664 // case. Without this the field looks empty after an import and the user
665 // concludes nothing was imported. Which tier fills the placeholder is
666 // Metasync_Seo_Precedence's call, not this screen's.
667 $title_fallback = Metasync_Seo_Precedence::fallback($post->ID, Metasync_Seo_Precedence::FIELD_TITLE);
668 $desc_fallback = Metasync_Seo_Precedence::fallback($post->ID, Metasync_Seo_Precedence::FIELD_DESCRIPTION);
669
670 $title_placeholder = $title_fallback['value'];
671 $desc_placeholder = $desc_fallback['value'];
672
673 $title_is_imported = $title_fallback['source'] === Metasync_Seo_Precedence::SOURCE_IMPORTED;
674
675 wp_nonce_field('metasync_seo_meta_nonce', 'metasync_seo_meta_nonce');
676 ?>
677 <p style="color: #666; margin-bottom: 12px;">
678 <?php /* translators: %s: OTTO name (whitelabel). */ ?>
679 <?php printf(esc_html__('Set the SEO Title and Meta Description for this post. Leave the field blank to use %s suggestion.', 'metasync'), esc_html(Metasync::get_whitelabel_otto_name())); ?>
680 </p>
681 <?php if ($title_is_imported) : ?>
682 <p style="color: #666; margin-bottom: 12px;">
683 <?php /* translators: %s: OTTO name (whitelabel). */ ?>
684 <?php printf(esc_html__('The greyed-out value below was imported from another SEO plugin. It is used only until %s has a suggestion for this page.', 'metasync'), esc_html(Metasync::get_whitelabel_otto_name())); ?>
685 </p>
686 <?php endif; ?>
687 <table class="form-table" style="margin: 0;">
688 <tr>
689 <th scope="row"><label for="metasync_seo_title"><?php esc_html_e('SEO Title', 'metasync'); ?></label></th>
690 <td><input type="text" id="metasync_seo_title" name="metasync_seo_title" value="<?php echo esc_attr($seo_title); ?>" class="large-text" placeholder="<?php echo esc_attr($title_placeholder); ?>" /></td>
691 </tr>
692 <tr>
693 <th scope="row"><label for="metasync_seo_desc"><?php esc_html_e('Meta Description', 'metasync'); ?></label></th>
694 <td><textarea id="metasync_seo_desc" name="metasync_seo_desc" class="large-text" rows="3" placeholder="<?php echo esc_attr($desc_placeholder); ?>"><?php echo esc_textarea($seo_desc); ?></textarea></td>
695 </tr>
696 </table>
697 <?php
698 }
699
700 /**
701 * Save the SEO meta box data.
702 *
703 * The nonce field is never present in Gutenberg REST saves, so the nonce check
704 * alone prevents this handler from running in that context — leaving the sidebar
705 * as the source of truth there.
706 *
707 * @param int $post_id The post ID.
708 */
709 public function seo_meta_box_save($post_id)
710 {
711 if (!isset($_POST['metasync_seo_meta_nonce']) ||
712 !wp_verify_nonce($_POST['metasync_seo_meta_nonce'], 'metasync_seo_meta_nonce')) {
713 return;
714 }
715
716 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
717 return;
718 }
719
720 if (!current_user_can('edit_post', $post_id)) {
721 return;
722 }
723
724 if (isset($_POST['metasync_seo_title'])) {
725 $seo_title = sanitize_text_field(wp_unslash($_POST['metasync_seo_title']));
726 if (!empty($seo_title)) {
727 // update_post_meta() unslashes again, so re-slash to keep
728 // literal backslashes and quotes in the stored value.
729 update_post_meta($post_id, '_metasync_seo_title', wp_slash($seo_title));
730 } else {
731 delete_post_meta($post_id, '_metasync_seo_title');
732 }
733 }
734
735 if (isset($_POST['metasync_seo_desc'])) {
736 $seo_desc = sanitize_textarea_field(wp_unslash($_POST['metasync_seo_desc']));
737 if (!empty($seo_desc)) {
738 update_post_meta($post_id, '_metasync_seo_desc', wp_slash($seo_desc));
739 } else {
740 delete_post_meta($post_id, '_metasync_seo_desc');
741 }
742 }
743 }
744 }
745