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 +435 -64 2.6.24 → 2.7.2 View file →
@@ -20,8 +20,40 @@
20 20
21 21 class Metasync_HTML_Visual_Editor
22 22 {
23 23 /**
24 + * Pinned version of the bundled GrapesJS core.
25 + *
26 + * @var string
27 + */
28 + const GRAPESJS_VERSION = '0.21.7';
29 +
30 + /**
31 + * Pinned version of the bundled grapesjs-blocks-basic plugin.
32 + *
33 + * Releases before 1.0.0 registered themselves by calling
34 + * grapesjs.plugins.add('gjs-blocks-basic'); 1.0.x only exposes a UMD
35 + * export, so the editor passes the plugin by reference rather than by
36 + * that legacy global name.
37 + *
38 + * @var string
39 + */
40 + const GRAPESJS_BLOCKS_BASIC_VERSION = '1.0.2';
41 +
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 + /**
24 56 * Plugin name
25 57 *
26 58 * @var string
27 59 */
@@ -57,8 +89,12 @@
57 89
58 90 // Add admin menu page for the editor
59 91 add_action('admin_menu', array($this, 'add_editor_page'));
60 92
93 + // Enqueue editor assets during the normal asset phase so stylesheets
94 + // land in <head> rather than being flushed late from the page body.
95 + add_action('admin_enqueue_scripts', array($this, 'maybe_enqueue_editor_assets'));
96 +
61 97 // Register AJAX handlers
62 98 add_action('wp_ajax_metasync_save_html', array($this, 'ajax_save_html'));
63 99 add_action('wp_ajax_metasync_upload_image', array($this, 'ajax_upload_image'));
64 100 }
@@ -63,8 +99,158 @@
63 99 add_action('wp_ajax_metasync_upload_image', array($this, 'ajax_upload_image'));
64 100 }
65 101
66 102 /**
103 + * Admin page slug for the visual editor.
104 + *
105 + * @return string
106 + */
107 + private function get_editor_page_slug()
108 + {
109 + return Metasync_Admin::$page_slug . '-html-editor';
110 + }
111 +
112 + /**
113 + * Enqueue the editor assets when the current request is the editor page.
114 + *
115 + * Keyed on the request rather than on the hook suffix because the editor is
116 + * registered as a hidden submenu page, so its generated suffix is not
117 + * stable to match against.
118 + */
119 + public function maybe_enqueue_editor_assets()
120 + {
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
122 +
123 + if ($page !== $this->get_editor_page_slug()) {
124 + return;
125 + }
126 +
127 + if (!current_user_can('edit_pages')) {
128 + return;
129 + }
130 +
131 + $this->enqueue_editor_assets();
132 + }
133 +
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 + /**
67 253 * Add "Edit HTML" button to row actions
68 254 *
69 255 * @param array $actions Row actions
70 256 * @param WP_Post $post Post object
@@ -74,20 +260,32 @@
74 260 {
75 261 // Check if this is a raw HTML page
76 262 $has_raw_html = get_post_meta($post->ID, '_metasync_raw_html_enabled', true);
77 263
78 - if ($has_raw_html) {
79 - $edit_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-html-editor&post_id=' . $post->ID);
80 - $label = Metasync::get_whitelabel_company_name() ?: 'SearchAtlas';
264 + if (!$has_raw_html) {
265 + return $actions;
266 + }
81 267
82 - $actions['edit_html'] = sprintf(
83 - '<a href="%s" title="%s">%s</a>',
84 - esc_url($edit_url),
85 - esc_attr(sprintf(__('Edit with %s Visual Editor', 'metasync'), $label)),
86 - __('Edit HTML', 'metasync')
87 - );
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;
88 275 }
89 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 +
90 288 return $actions;
91 289 }
92 290
93 291 /**
@@ -95,8 +293,9 @@
95 293 */
96 294 public function add_editor_page()
97 295 {
98 296 $label = Metasync::get_whitelabel_company_name() ?: 'SearchAtlas';
297 + /* translators: %s: company name (whitelabel-aware). */
99 298 $page_title = sprintf(__('%s HTML Editor', 'metasync'), $label);
100 299
101 300 add_submenu_page(
102 301 '', // Hidden from menu
@@ -114,20 +313,20 @@
114 313 public function render_editor_page()
115 314 {
116 315 // Check permissions
117 316 if (!current_user_can('edit_pages')) {
118 - wp_die(__('You do not have sufficient permissions to access this page.'));
317 + wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'metasync'));
119 318 }
120 319
121 320 // Get post ID
122 - $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
123 322
124 323 if (!$post_id) {
125 - wp_die(__('Invalid page ID.'));
324 + wp_die(esc_html__('Invalid page ID.', 'metasync'));
126 325 }
127 326
128 327 if (!current_user_can('edit_post', $post_id)) {
129 - wp_die(__('You do not have sufficient permissions to access this page.'));
328 + wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'metasync'));
130 329 }
131 330
132 331 // Get post
133 332 $post = get_post($post_id);
@@ -132,9 +331,9 @@
132 331 // Get post
133 332 $post = get_post($post_id);
134 333
135 334 if (!$post) {
136 - wp_die(__('Page not found.'));
335 + wp_die(esc_html__('Page not found.', 'metasync'));
137 336 }
138 337
139 338 // Check if raw HTML is enabled
140 339 $has_raw_html = get_post_meta($post_id, '_metasync_raw_html_enabled', true);
@@ -139,101 +338,223 @@
139 338 // Check if raw HTML is enabled
140 339 $has_raw_html = get_post_meta($post_id, '_metasync_raw_html_enabled', true);
141 340
142 341 if (!$has_raw_html) {
143 - wp_die(__('This page is not a raw HTML page.'));
342 + wp_die(esc_html__('This page is not a raw HTML page.', 'metasync'));
144 343 }
145 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 +
146 354 // Get HTML content
147 355 $html_content = get_post_meta($post_id, '_metasync_raw_html_content', true);
148 356
149 357 if (empty($html_content)) {
150 - $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>';
151 362 }
152 363
153 364 // Get label for branding
154 365 $label = Metasync::get_whitelabel_company_name() ?: 'SearchAtlas AI';
155 366
156 - // Enqueue editor assets
157 - $this->enqueue_editor_assets();
158 -
159 - // Render editor UI
367 + // Render editor UI. Assets are enqueued on admin_enqueue_scripts.
160 368 include plugin_dir_path(__FILE__) . 'partials/metasync-html-editor-page.php';
161 369 }
162 370
163 371 /**
164 372 * Enqueue editor assets (GrapesJS + custom scripts)
373 + *
374 + * The editor runtime is served from the plugin's own bundled copies at
375 + * pinned versions. It used to be pulled from public CDNs, which made any
376 + * blocked, throttled or offline request render the editor as an empty
377 + * canvas with no explanation.
165 378 */
166 379 private function enqueue_editor_assets()
167 380 {
168 - // Font Awesome for icons
169 - wp_enqueue_style(
170 - 'font-awesome',
171 - 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css',
172 - array(),
173 - '6.4.0'
381 + $lib_url = plugin_dir_url(__FILE__) . 'lib/';
382 + $lib_path = plugin_dir_path(__FILE__) . 'lib/';
383 +
384 + // Bundled libraries, in load order. Anything missing from disk is
385 + // reported to the client so the editor can explain itself instead of
386 + // rendering blank. Handles are namespaced so a theme or plugin
387 + // registering a bare "grapesjs" handle cannot collide with ours.
388 + $libraries = array(
389 + 'metasync-grapesjs' => array(
390 + 'version' => self::GRAPESJS_VERSION,
391 + 'style' => 'grapesjs/grapes.min.css',
392 + 'script' => 'grapesjs/grapes.min.js',
393 + ),
394 + 'metasync-grapesjs-blocks-basic' => array(
395 + 'version' => self::GRAPESJS_BLOCKS_BASIC_VERSION,
396 + 'script' => 'grapesjs-blocks-basic/grapesjs-blocks-basic.min.js',
397 + 'deps' => array('metasync-grapesjs'),
398 + ),
174 399 );
175 400
176 - // GrapesJS core
177 - wp_enqueue_style(
178 - 'grapesjs',
179 - 'https://unpkg.com/[email protected]/dist/css/grapes.min.css',
180 - array(),
181 - '0.21.7'
182 - );
401 + $missing = array();
183 402
184 - wp_enqueue_script(
185 - 'grapesjs',
186 - 'https://unpkg.com/[email protected]/dist/grapes.min.js',
187 - array(),
188 - '0.21.7',
189 - true
190 - );
403 + foreach ($libraries as $handle => $library) {
404 + if (file_exists($lib_path . $library['script'])) {
405 + wp_enqueue_script(
406 + $handle,
407 + $lib_url . $library['script'],
408 + isset($library['deps']) ? $library['deps'] : array(),
409 + $library['version'],
410 + true
411 + );
412 + } else {
413 + $missing[] = $handle;
414 + }
191 415
192 - // GrapesJS Plugins
193 - wp_enqueue_script(
194 - 'grapesjs-blocks-basic',
195 - 'https://unpkg.com/grapesjs-blocks-basic',
196 - array('grapesjs'),
197 - null,
198 - true
199 - );
416 + if (!isset($library['style'])) {
417 + continue;
418 + }
200 419
201 - // Custom editor JS (with timestamp for cache busting)
420 + if (file_exists($lib_path . $library['style'])) {
421 + wp_enqueue_style(
422 + $handle,
423 + $lib_url . $library['style'],
424 + array(),
425 + $library['version']
426 + );
427 + } else {
428 + $missing[] = $handle;
429 + }
430 + }
431 +
432 + $missing = array_values(array_unique($missing));
433 +
434 + // The editor bundle depends on the bundled core only when that core
435 + // was actually enqueued. When a library file is absent from disk it is
436 + // never registered, and a dependency on an unregistered handle makes
437 + // WordPress suppress the editor bundle itself — including the localized
438 + // `missing` list, so the failure would go back to being a blank canvas
439 + // with no explanation.
440 + $editor_script_deps = array('jquery');
441 + // The editor chrome and the sidebar's panel switcher label their
442 + // buttons with dashicons glyphs, so the stylesheet is a real
443 + // dependency rather than something to inherit from the admin page.
444 + $editor_style_deps = array('dashicons');
445 + if (wp_script_is('metasync-grapesjs', 'registered')) {
446 + $editor_script_deps[] = 'metasync-grapesjs';
447 + $editor_style_deps[] = 'metasync-grapesjs';
448 + }
449 +
202 450 wp_enqueue_script(
203 451 'metasync-html-editor',
204 452 plugins_url('js/metasync-html-editor.js', __FILE__),
205 - array('jquery', 'grapesjs'),
206 - $this->version . '.' . time(),
453 + $editor_script_deps,
454 + $this->version,
207 455 true
208 456 );
209 457
210 - // Custom editor CSS (with timestamp for cache busting)
211 458 wp_enqueue_style(
212 459 'metasync-html-editor',
213 460 plugins_url('css/metasync-html-editor.css', __FILE__),
214 - array('grapesjs'),
215 - $this->version . '.' . time()
461 + $editor_style_deps,
462 + $this->version
216 463 );
217 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 +
218 467 // Localize script with data
219 468 wp_localize_script('metasync-html-editor', 'metasyncEditor', array(
220 469 'ajax_url' => admin_url('admin-ajax.php'),
221 470 'nonce' => wp_create_nonce('metasync_html_editor'),
222 - 'post_id' => isset($_GET['post_id']) ? intval($_GET['post_id']) : 0,
223 - 'preview_url' => get_permalink(isset($_GET['post_id']) ? intval($_GET['post_id']) : 0),
224 - 'back_url' => admin_url('edit.php?post_type=page'),
471 + 'post_id' => $post_id,
472 + 'preview_url' => get_permalink($post_id),
473 + // Names of bundled libraries that are absent from disk, so the
474 + // client can name the failing dependency without exposing paths
475 + // or other sensitive detail.
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') : '',
225 483 'i18n' => array(
226 484 'saving' => __('Saving...', 'metasync'),
227 485 'saved' => __('Saved!', 'metasync'),
228 486 'error' => __('Error saving', 'metasync'),
487 + 'session_expired' => __('Your session has expired. Copy your work before reloading the page.', 'metasync'),
488 + 'ready' => __('Ready', 'metasync'),
489 + 'unsaved_changes' => __('Unsaved changes', 'metasync'),
229 490 'confirm_exit' => __('You have unsaved changes. Are you sure you want to leave?', 'metasync'),
491 + 'confirm_preview' => __('You have unsaved changes. Preview will show the last saved version. Continue?', 'metasync'),
492 + 'panel_styles' => __('Styles', 'metasync'),
493 + 'panel_settings' => __('Settings', 'metasync'),
494 + 'panel_layers' => __('Layers', 'metasync'),
495 + 'panel_blocks' => __('Blocks', 'metasync'),
496 + 'load_failed_title' => __('The visual editor could not start', 'metasync'),
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'),
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'),
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. */
501 + 'load_failed_detail' => __('Missing component: %s', 'metasync'),
502 + 'reload' => __('Reload page', 'metasync'),
503 + 'dismiss' => __('Dismiss', 'metasync'),
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'),
510 + 'upload_failed' => __('Image upload failed', 'metasync'),
230 511 )
231 512 ));
232 513 }
233 514
234 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 + /**
235 543 * AJAX handler for saving HTML
544 + *
545 + * The payload is stored verbatim, matching the contract of every other
546 + * writer of this meta key: the Custom Pages metabox stores the raw value
547 + * for users who can edit the page, and the front-end renderer echoes it
548 + * as authored. Filtering here with kses would silently strip the very
549 + * elements raw HTML pages exist to carry (doctype, head assets, forms,
550 + * iframes, inline SVG), and re-filtering an already-filtered value is
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.
236 557 */
237 558 public function ajax_save_html()
238 559 {
239 560 // Check nonce
@@ -240,9 +561,9 @@
240 561 check_ajax_referer('metasync_html_editor', 'nonce');
241 562
242 563 // Check permissions
243 564 if (!current_user_can('edit_pages')) {
244 - wp_send_json_error(array('message' => __('Permission denied', 'metasync')));
565 + wp_send_json_error(array('message' => __('Permission denied', 'metasync')), 403);
245 566 }
246 567
247 568 // Get data
248 569 $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
@@ -247,9 +568,9 @@
247 568 // Get data
248 569 $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
249 570
250 571 if (!$post_id) {
251 - wp_send_json_error(array('message' => __('Invalid data', 'metasync')));
572 + wp_send_json_error(array('message' => __('No page selected', 'metasync')));
252 573 }
253 574
254 575 if (!current_user_can('edit_post', $post_id)) {
255 576 wp_send_json_error(array('message' => __('Permission denied', 'metasync')), 403);
@@ -254,14 +575,45 @@
254 575 if (!current_user_can('edit_post', $post_id)) {
255 576 wp_send_json_error(array('message' => __('Permission denied', 'metasync')), 403);
256 577 }
257 578
258 - $html_content = isset($_POST['html']) ? wp_kses_post($_POST['html']) : '';
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 + }
259 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 +
603 + $html_content = isset($_POST['html']) ? wp_unslash($_POST['html']) : '';
604 +
260 605 if (empty($html_content)) {
261 - wp_send_json_error(array('message' => __('Invalid data', 'metasync')));
606 + wp_send_json_error(array('message' => __('Nothing to save', 'metasync')));
262 607 }
263 608
609 + // Keep the value being replaced so a save that mangles the page can
610 + // be undone; postmeta is not revisioned, so this is the only undo.
611 + $previous = get_post_meta($post_id, '_metasync_raw_html_content', true);
612 + if ('' !== $previous) {
613 + update_post_meta($post_id, '_metasync_raw_html_content_previous', $previous);
614 + }
615 +
264 616 // Save HTML content
265 617 update_post_meta($post_id, '_metasync_raw_html_content', $html_content);
266 618
267 619 // Update modified date
@@ -270,8 +622,11 @@
270 622 'post_modified' => current_time('mysql'),
271 623 'post_modified_gmt' => current_time('mysql', 1)
272 624 ));
273 625
626 + // Consent-gated analytics: daily counts, not one event per save.
627 + Metasync_PostHog::rollup('visual_editor_saved', array('saved' => 1));
628 +
274 629 wp_send_json_success(array(
275 630 'message' => __('Page saved successfully', 'metasync'),
276 631 'preview_url' => get_permalink($post_id)
277 632 ));
@@ -278,8 +633,13 @@
278 633 }
279 634
280 635 /**
281 636 * AJAX handler for uploading images
637 + *
638 + * File validation is delegated entirely to the WordPress media pipeline
639 + * (wp_handle_upload + wp_check_filetype_and_ext via media_handle_upload),
640 + * the same chain the core media uploader uses; the capability gate
641 + * matches core's async-upload endpoint (upload_files).
282 642 */
283 643 public function ajax_upload_image()
284 644 {
285 645 // Check nonce
@@ -286,11 +646,17 @@
286 646 check_ajax_referer('metasync_html_editor', 'nonce');
287 647
288 648 // Check permissions
289 649 if (!current_user_can('upload_files')) {
290 - wp_send_json_error(array('message' => __('Permission denied', 'metasync')));
650 + wp_send_json_error(array('message' => __('Permission denied', 'metasync')), 403);
291 651 }
292 652
653 + // Tie the upload to the page being edited, when the editor names one.
654 + $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
655 + if ($post_id && !current_user_can('edit_post', $post_id)) {
656 + wp_send_json_error(array('message' => __('Permission denied', 'metasync')), 403);
657 + }
658 +
293 659 // Handle file upload
294 660 if (!isset($_FILES['file'])) {
295 661 wp_send_json_error(array('message' => __('No file uploaded', 'metasync')));
296 662 }
@@ -306,10 +672,15 @@
306 672 }
307 673
308 674 $image_url = wp_get_attachment_url($attachment_id);
309 675
676 + // The editor's asset manager adds response.data to the asset list
677 + // directly, and an asset's source attribute is called `src`. The
678 + // attachment is exposed as `attachment_id` rather than `id` — `id` is
679 + // the Backbone collection's identity key, so reusing it would make
680 + // repeated uploads of the same attachment silently dedupe.
310 681 wp_send_json_success(array(
311 - 'url' => $image_url,
312 - 'id' => $attachment_id
682 + 'src' => $image_url,
683 + 'attachment_id' => $attachment_id
313 684 ));
314 685 }
315 686 }