PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
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
← All changes | admin/class-metasync-html-visual-editor.php +248 -19 2.6.26 → 2.7.2 View file →
@@ -39,8 +39,21 @@
39 39 */
40 40 const GRAPESJS_BLOCKS_BASIC_VERSION = '1.0.2';
41 41
42 42 /**
43 + * Meta key marking a page as a Landing Page Studio (LPS) ZIP import.
44 + *
45 + * Mirrors Metasync_Custom_Pages::META_LPS_IMPORT. The literal is used here
46 + * for the same reason includes/class-metasync-seo-suite.php does: this
47 + * class is loaded by the admin bootstrap, which does not guarantee the
48 + * Custom Pages class is present, and a guard that reads the marker must
49 + * never be skipped just because a class failed to load.
50 + *
51 + * @var string
52 + */
53 + const META_LPS_IMPORT = '_metasync_lps_import';
54 +
55 + /**
43 56 * Plugin name
44 57 *
45 58 * @var string
46 59 */
@@ -104,9 +117,9 @@
104 117 * stable to match against.
105 118 */
106 119 public function maybe_enqueue_editor_assets()
107 120 {
108 - $page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : '';
121 + $page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to enqueue editor assets
109 122
110 123 if ($page !== $this->get_editor_page_slug()) {
111 124 return;
112 125 }
@@ -118,8 +131,126 @@
118 131 $this->enqueue_editor_assets();
119 132 }
120 133
121 134 /**
135 + * Whether a page is a Landing Page Studio import.
136 + *
137 + * LPS pages are owned by the importer: a re-import replaces their stored
138 + * HTML wholesale, and they are always complete documents. Visual editing
139 + * them would both destroy the document and put the page out of sync with
140 + * the project it was imported from, so they are excluded from this editor
141 + * entirely rather than merely blocked at save.
142 + *
143 + * @param int $post_id Page being inspected.
144 + * @return bool
145 + */
146 + public static function is_lps_page($post_id)
147 + {
148 + return get_post_meta($post_id, self::META_LPS_IMPORT, true) === '1';
149 + }
150 +
151 + /**
152 + * Whether stored HTML carries content the visual canvas cannot round-trip.
153 + *
154 + * GrapesJS builds body fragments. Handing it anything document-level makes
155 + * it parse what it can and discard the rest, and the editor then saves that
156 + * reduced result over the original — so the loss happens when the page is
157 + * parsed into the canvas, before the user edits anything.
158 + *
159 + * Two classes of content are unsafe:
160 + *
161 + * - Document structure (doctype, <html>, <head>): dropped outright,
162 + * because a fragment builder has nowhere to put it.
163 + * - Scripts: dropped even inside an otherwise ordinary body fragment, so a
164 + * check limited to document structure would still let a scripted page
165 + * lose its behaviour silently.
166 + *
167 + * <body> is the exception that has to be judged on its attributes. The
168 + * canvas emits a bare <body> wrapper around everything it serializes, so
169 + * treating the element itself as unsafe would make every page this editor
170 + * saves lock itself out of the editor on the next visit. What the canvas
171 + * does destroy is the element's attributes — a class, inline style, a data
172 + * attribute a script or stylesheet depends on — so an opening <body> tag
173 + * is unsafe when it carries any.
174 + *
175 + * With one exception of its own: styling the body in the Styles panel
176 + * makes GrapesJS mint an id for it (`<body id="ir4h">`) purely so its
177 + * generated stylesheet has something to target. That id is the editor's
178 + * own bookkeeping, not authored content, and refusing it would lock a page
179 + * out of the editor the first time anyone edited a style — the very thing
180 + * the bare-wrapper allowance exists to prevent.
181 + *
182 + * Detection is deliberately a scan for opening tags rather than a parse:
183 + * the question is only whether unsupported constructs are present, and a
184 + * malformed document must answer yes as readily as a well-formed one.
185 + *
186 + * @param mixed $html Stored page HTML. Typed loosely because post meta can
187 + * come back as false when absent, or as an array from a
188 + * corrupted row, and neither is an unsafe document.
189 + * @return bool True when the document must not be edited visually.
190 + */
191 + public static function is_unsafe_for_visual_editor($html)
192 + {
193 + if (!is_string($html) || '' === trim($html)) {
194 + return false;
195 + }
196 +
197 + // Opening tags only: a stray "</head>" without its opening tag is not
198 + // evidence of a document, and matching bare words would misfire on
199 + // ordinary prose ("the html spec").
200 + $patterns = array(
201 + '/<!doctype\b/i',
202 + '/<html[\s>]/i',
203 + '/<head[\s>]/i',
204 + '/<script[\s>]/i',
205 + );
206 +
207 + foreach ($patterns as $pattern) {
208 + if (preg_match($pattern, $html)) {
209 + return true;
210 + }
211 + }
212 +
213 + if (!preg_match('/<body(\s[^>]*)?>/i', $html, $match)) {
214 + return false;
215 + }
216 +
217 + $attributes = isset($match[1]) ? trim($match[1]) : '';
218 +
219 + // A bare <body> is the canvas's own wrapper and round-trips safely.
220 + if ('' === $attributes) {
221 + return false;
222 + }
223 +
224 + // So is one carrying nothing but the id GrapesJS generates for itself.
225 + // Its ids are an "i" prefix followed by a short base-36 token — `ir4h`,
226 + // `igcb`, `ip75` — a shape sampled from the bundled runtime rather
227 + // than assumed, because a mismatch here silently locks pages out of
228 + // the editor. The digits are incidental: better than a third of
229 + // generated ids contain none, so only the prefix, charset and length
230 + // are relied on. An author writing a hook worth preserving writes a
231 + // word — `top`, `main-content` — which this does not match.
232 + return !preg_match('/^id=(["\'])i[a-z0-9]{2,7}\1$/', $attributes);
233 + }
234 +
235 + /**
236 + * Whether a page may be edited through the visual canvas.
237 + *
238 + * @param int $post_id Page being inspected.
239 + * @return bool
240 + */
241 + public static function can_edit_visually($post_id)
242 + {
243 + if (self::is_lps_page($post_id)) {
244 + return false;
245 + }
246 +
247 + $html = get_post_meta($post_id, '_metasync_raw_html_content', true);
248 +
249 + return !self::is_unsafe_for_visual_editor($html);
250 + }
251 +
252 + /**
122 253 * Add "Edit HTML" button to row actions
123 254 *
124 255 * @param array $actions Row actions
125 256 * @param WP_Post $post Post object
@@ -129,20 +260,32 @@
129 260 {
130 261 // Check if this is a raw HTML page
131 262 $has_raw_html = get_post_meta($post->ID, '_metasync_raw_html_enabled', true);
132 263
133 - if ($has_raw_html) {
134 - $edit_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-html-editor&post_id=' . $post->ID);
135 - $label = Metasync::get_whitelabel_company_name() ?: 'SearchAtlas';
264 + if (!$has_raw_html) {
265 + return $actions;
266 + }
136 267
137 - $actions['edit_html'] = sprintf(
138 - '<a href="%s" title="%s">%s</a>',
139 - esc_url($edit_url),
140 - esc_attr(sprintf(__('Edit with %s Visual Editor', 'metasync'), $label)),
141 - __('Edit HTML', 'metasync')
142 - );
268 + // LPS pages are never editable here; the importer owns their content.
269 + // Non-LPS pages keep the action even when their HTML is a complete
270 + // document: the editor page explains why it cannot save and points at
271 + // the lossless editor, which is more useful than a button that simply
272 + // vanishes with no explanation.
273 + if (self::is_lps_page($post->ID)) {
274 + return $actions;
143 275 }
144 276
277 + $edit_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-html-editor&post_id=' . $post->ID);
278 + $label = Metasync::get_whitelabel_company_name() ?: 'SearchAtlas';
279 +
280 + $actions['edit_html'] = sprintf(
281 + '<a href="%s" title="%s">%s</a>',
282 + esc_url($edit_url),
283 + /* translators: %s: company name (whitelabel-aware). */
284 + esc_attr(sprintf(__('Edit with %s Visual Editor', 'metasync'), $label)),
285 + __('Edit HTML', 'metasync')
286 + );
287 +
145 288 return $actions;
146 289 }
147 290
148 291 /**
@@ -150,8 +293,9 @@
150 293 */
151 294 public function add_editor_page()
152 295 {
153 296 $label = Metasync::get_whitelabel_company_name() ?: 'SearchAtlas';
297 + /* translators: %s: company name (whitelabel-aware). */
154 298 $page_title = sprintf(__('%s HTML Editor', 'metasync'), $label);
155 299
156 300 add_submenu_page(
157 301 '', // Hidden from menu
@@ -169,20 +313,20 @@
169 313 public function render_editor_page()
170 314 {
171 315 // Check permissions
172 316 if (!current_user_can('edit_pages')) {
173 - wp_die(__('You do not have sufficient permissions to access this page.', 'metasync'));
317 + wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'metasync'));
174 318 }
175 319
176 320 // Get post ID
177 - $post_id = isset($_GET['post_id']) ? intval($_GET['post_id']) : 0;
321 + $post_id = isset($_GET['post_id']) ? intval($_GET['post_id']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only post ID, capability checked, view only
178 322
179 323 if (!$post_id) {
180 - wp_die(__('Invalid page ID.', 'metasync'));
324 + wp_die(esc_html__('Invalid page ID.', 'metasync'));
181 325 }
182 326
183 327 if (!current_user_can('edit_post', $post_id)) {
184 - wp_die(__('You do not have sufficient permissions to access this page.', 'metasync'));
328 + wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'metasync'));
185 329 }
186 330
187 331 // Get post
188 332 $post = get_post($post_id);
@@ -187,9 +331,9 @@
187 331 // Get post
188 332 $post = get_post($post_id);
189 333
190 334 if (!$post) {
191 - wp_die(__('Page not found.', 'metasync'));
335 + wp_die(esc_html__('Page not found.', 'metasync'));
192 336 }
193 337
194 338 // Check if raw HTML is enabled
195 339 $has_raw_html = get_post_meta($post_id, '_metasync_raw_html_enabled', true);
@@ -194,16 +338,28 @@
194 338 // Check if raw HTML is enabled
195 339 $has_raw_html = get_post_meta($post_id, '_metasync_raw_html_enabled', true);
196 340
197 341 if (!$has_raw_html) {
198 - wp_die(__('This page is not a raw HTML page.', 'metasync'));
342 + wp_die(esc_html__('This page is not a raw HTML page.', 'metasync'));
199 343 }
200 344
345 + // LPS pages are owned by the importer and are always complete
346 + // documents, so the canvas is refused outright rather than opened in a
347 + // state where nothing can be saved.
348 + if (self::is_lps_page($post_id)) {
349 + wp_die(
350 + esc_html__('This page was imported from Website Studio and cannot be edited with the visual editor. Edit it from the page editor, or re-publish it from Website Studio.', 'metasync')
351 + );
352 + }
353 +
201 354 // Get HTML content
202 355 $html_content = get_post_meta($post_id, '_metasync_raw_html_content', true);
203 356
204 357 if (empty($html_content)) {
205 - $html_content = '<html><body><h1>Start editing...</h1></body></html>';
358 + // A body fragment, not a document: the canvas builds fragments, so
359 + // seeding a page with <html>/<body> would make a brand-new empty
360 + // page register as unsafe and lock the editor against itself.
361 + $html_content = '<h1>Start editing...</h1>';
206 362 }
207 363
208 364 // Get label for branding
209 365 $label = Metasync::get_whitelabel_company_name() ?: 'SearchAtlas AI';
@@ -305,18 +461,26 @@
305 461 $editor_style_deps,
306 462 $this->version
307 463 );
308 464
465 + $post_id = isset($_GET['post_id']) ? intval($_GET['post_id']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only post ID passed to script data
466 +
309 467 // Localize script with data
310 468 wp_localize_script('metasync-html-editor', 'metasyncEditor', array(
311 469 'ajax_url' => admin_url('admin-ajax.php'),
312 470 'nonce' => wp_create_nonce('metasync_html_editor'),
313 - 'post_id' => isset($_GET['post_id']) ? intval($_GET['post_id']) : 0,
314 - 'preview_url' => get_permalink(isset($_GET['post_id']) ? intval($_GET['post_id']) : 0),
471 + 'post_id' => $post_id,
472 + 'preview_url' => get_permalink($post_id),
315 473 // Names of bundled libraries that are absent from disk, so the
316 474 // client can name the failing dependency without exposing paths
317 475 // or other sensitive detail.
318 476 'missing' => $missing,
477 + // Non-empty when the page's stored HTML cannot survive the canvas,
478 + // so the client can disable saving before the document is parsed
479 + // into it rather than after the user has edited.
480 + 'blocked_reason' => $post_id ? $this->get_blocked_reason($post_id) : '',
481 + // Where the user can edit this page without loss.
482 + 'direct_edit_url' => $post_id ? get_edit_post_link($post_id, 'raw') : '',
319 483 'i18n' => array(
320 484 'saving' => __('Saving...', 'metasync'),
321 485 'saved' => __('Saved!', 'metasync'),
322 486 'error' => __('Error saving', 'metasync'),
@@ -332,12 +496,18 @@
332 496 'load_failed_title' => __('The visual editor could not start', 'metasync'),
333 497 'load_failed_core' => __('The visual editor library could not be loaded, so this page cannot be edited visually. Reload the page, and if the problem continues check whether a browser extension, proxy or content security policy is blocking plugin scripts.', 'metasync'),
334 498 'load_failed_blocks' => __('The editor loaded, but its extra block library is unavailable, so the Blocks panel only offers the built-in blocks. Existing page content can still be edited and saved normally.', 'metasync'),
335 499 'load_failed_init' => __('The visual editor failed to start while loading this page. Reload to try again; the saved page content has not been changed.', 'metasync'),
500 + /* translators: %s: name of the missing component. */
336 501 'load_failed_detail' => __('Missing component: %s', 'metasync'),
337 502 'reload' => __('Reload page', 'metasync'),
338 503 'dismiss' => __('Dismiss', 'metasync'),
339 504 'save_disabled' => __('Saving is disabled because the editor did not load', 'metasync'),
505 + 'blocked_title' => __('This page cannot be saved from the visual editor', 'metasync'),
506 + 'blocked_full_document' => __('This page is a complete HTML document. The visual editor rebuilds pages from their body content, so saving here would discard the doctype, head and scripts. Use Edit HTML Directly on the page editor to change it without losing anything.', 'metasync'),
507 + 'blocked_lps' => __('This page was imported from Website Studio, which owns its content. Re-publish it from Website Studio, or use Edit HTML Directly on the page editor.', 'metasync'),
508 + 'blocked_save_disabled' => __('Saving is disabled to protect this page from being rewritten', 'metasync'),
509 + 'open_direct_editor' => __('Edit HTML Directly', 'metasync'),
340 510 'upload_failed' => __('Image upload failed', 'metasync'),
341 511 )
342 512 ));
343 513 }
@@ -342,8 +512,35 @@
342 512 ));
343 513 }
344 514
345 515 /**
516 + * Why a page cannot be saved through the visual canvas, if it cannot.
517 + *
518 + * Returned to the client so the canvas can refuse before it parses the
519 + * document, rather than letting the user edit a reduced copy and discover
520 + * at save time that the original is unrecoverable.
521 + *
522 + * @param int $post_id Page being opened.
523 + * @return string Machine-readable reason, or '' when the page is editable.
524 + */
525 + private function get_blocked_reason($post_id)
526 + {
527 + if (self::is_lps_page($post_id)) {
528 + return 'lps';
529 + }
530 +
531 + $html = get_post_meta($post_id, '_metasync_raw_html_content', true);
532 +
533 + // An empty page is seeded with a body fragment, so there is nothing
534 + // to lose and the canvas may open normally.
535 + if ('' === (string) $html) {
536 + return '';
537 + }
538 +
539 + return self::is_unsafe_for_visual_editor($html) ? 'full_document' : '';
540 + }
541 +
542 + /**
346 543 * AJAX handler for saving HTML
347 544 *
348 545 * The payload is stored verbatim, matching the contract of every other
349 546 * writer of this meta key: the Custom Pages metabox stores the raw value
@@ -351,8 +548,13 @@
351 548 * as authored. Filtering here with kses would silently strip the very
352 549 * elements raw HTML pages exist to carry (doctype, head assets, forms,
353 550 * iframes, inline SVG), and re-filtering an already-filtered value is
354 551 * what let entity-encoded markup re-materialize as live tags.
552 + *
553 + * Storing verbatim is also why the guards below refuse whole saves rather
554 + * than trying to repair a payload: by the time it arrives, the canvas has
555 + * already discarded whatever it could not represent, and nothing here can
556 + * tell a deliberate deletion from a parser casualty.
355 557 */
356 558 public function ajax_save_html()
357 559 {
358 560 // Check nonce
@@ -373,8 +575,32 @@
373 575 if (!current_user_can('edit_post', $post_id)) {
374 576 wp_send_json_error(array('message' => __('Permission denied', 'metasync')), 403);
375 577 }
376 578
579 + // The client disables saving for these pages before the canvas parses
580 + // them, but the endpoint is reachable on its own, and the decision is
581 + // about what the stored document can survive rather than about what
582 + // the browser did. Both checks read the value already in the database,
583 + // not the payload: what matters is whether the page being overwritten
584 + // is one the canvas could have represented faithfully.
585 + if (self::is_lps_page($post_id)) {
586 + Metasync_PostHog::rollup('visual_editor_saved', array('blocked_lps' => 1));
587 + wp_send_json_error(
588 + array('message' => __('This page was imported from Website Studio and cannot be saved from the visual editor.', 'metasync')),
589 + 409
590 + );
591 + }
592 +
593 + $stored = get_post_meta($post_id, '_metasync_raw_html_content', true);
594 +
595 + if (self::is_unsafe_for_visual_editor($stored)) {
596 + Metasync_PostHog::rollup('visual_editor_saved', array('blocked_full_document' => 1));
597 + wp_send_json_error(
598 + array('message' => __('This page is a complete HTML document, so saving it from the visual editor would discard its doctype, head and scripts. Use Edit HTML Directly on the page editor instead.', 'metasync')),
599 + 409
600 + );
601 + }
602 +
377 603 $html_content = isset($_POST['html']) ? wp_unslash($_POST['html']) : '';
378 604
379 605 if (empty($html_content)) {
380 606 wp_send_json_error(array('message' => __('Nothing to save', 'metasync')));
@@ -395,8 +621,11 @@
395 621 'ID' => $post_id,
396 622 'post_modified' => current_time('mysql'),
397 623 'post_modified_gmt' => current_time('mysql', 1)
398 624 ));
625 +
626 + // Consent-gated analytics: daily counts, not one event per save.
627 + Metasync_PostHog::rollup('visual_editor_saved', array('saved' => 1));
399 628
400 629 wp_send_json_success(array(
401 630 'message' => __('Page saved successfully', 'metasync'),
402 631 'preview_url' => get_permalink($post_id)