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
← All changes | admin/class-metasync-html-visual-editor.php +442 -63 2.6.19 → 2.7.1 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,23 +313,27 @@
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(__('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(__('Invalid page ID.', 'metasync'));
126 325 }
127 326
327 + if (!current_user_can('edit_post', $post_id)) {
328 + wp_die(__('You do not have sufficient permissions to access this page.', 'metasync'));
329 + }
330 +
128 331 // Get post
129 332 $post = get_post($post_id);
130 333
131 334 if (!$post) {
132 - wp_die(__('Page not found.'));
335 + wp_die(__('Page not found.', 'metasync'));
133 336 }
134 337
135 338 // Check if raw HTML is enabled
136 339 $has_raw_html = get_post_meta($post_id, '_metasync_raw_html_enabled', true);
@@ -135,101 +338,223 @@
135 338 // Check if raw HTML is enabled
136 339 $has_raw_html = get_post_meta($post_id, '_metasync_raw_html_enabled', true);
137 340
138 341 if (!$has_raw_html) {
139 - wp_die(__('This page is not a raw HTML page.'));
342 + wp_die(__('This page is not a raw HTML page.', 'metasync'));
140 343 }
141 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 +
142 354 // Get HTML content
143 355 $html_content = get_post_meta($post_id, '_metasync_raw_html_content', true);
144 356
145 357 if (empty($html_content)) {
146 - $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>';
147 362 }
148 363
149 364 // Get label for branding
150 365 $label = Metasync::get_whitelabel_company_name() ?: 'SearchAtlas AI';
151 366
152 - // Enqueue editor assets
153 - $this->enqueue_editor_assets();
154 -
155 - // Render editor UI
367 + // Render editor UI. Assets are enqueued on admin_enqueue_scripts.
156 368 include plugin_dir_path(__FILE__) . 'partials/metasync-html-editor-page.php';
157 369 }
158 370
159 371 /**
160 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.
161 378 */
162 379 private function enqueue_editor_assets()
163 380 {
164 - // Font Awesome for icons
165 - wp_enqueue_style(
166 - 'font-awesome',
167 - 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css',
168 - array(),
169 - '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 + ),
170 399 );
171 400
172 - // GrapesJS core
173 - wp_enqueue_style(
174 - 'grapesjs',
175 - 'https://unpkg.com/[email protected]/dist/css/grapes.min.css',
176 - array(),
177 - '0.21.7'
178 - );
401 + $missing = array();
179 402
180 - wp_enqueue_script(
181 - 'grapesjs',
182 - 'https://unpkg.com/[email protected]/dist/grapes.min.js',
183 - array(),
184 - '0.21.7',
185 - true
186 - );
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 + }
187 415
188 - // GrapesJS Plugins
189 - wp_enqueue_script(
190 - 'grapesjs-blocks-basic',
191 - 'https://unpkg.com/grapesjs-blocks-basic',
192 - array('grapesjs'),
193 - null,
194 - true
195 - );
416 + if (!isset($library['style'])) {
417 + continue;
418 + }
196 419
197 - // 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 +
198 450 wp_enqueue_script(
199 451 'metasync-html-editor',
200 452 plugins_url('js/metasync-html-editor.js', __FILE__),
201 - array('jquery', 'grapesjs'),
202 - $this->version . '.' . time(),
453 + $editor_script_deps,
454 + $this->version,
203 455 true
204 456 );
205 457
206 - // Custom editor CSS (with timestamp for cache busting)
207 458 wp_enqueue_style(
208 459 'metasync-html-editor',
209 460 plugins_url('css/metasync-html-editor.css', __FILE__),
210 - array('grapesjs'),
211 - $this->version . '.' . time()
461 + $editor_style_deps,
462 + $this->version
212 463 );
213 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 +
214 467 // Localize script with data
215 468 wp_localize_script('metasync-html-editor', 'metasyncEditor', array(
216 469 'ajax_url' => admin_url('admin-ajax.php'),
217 470 'nonce' => wp_create_nonce('metasync_html_editor'),
218 - 'post_id' => isset($_GET['post_id']) ? intval($_GET['post_id']) : 0,
219 - 'preview_url' => get_permalink(isset($_GET['post_id']) ? intval($_GET['post_id']) : 0),
220 - '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') : '',
221 483 'i18n' => array(
222 484 'saving' => __('Saving...', 'metasync'),
223 485 'saved' => __('Saved!', 'metasync'),
224 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'),
225 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'),
226 511 )
227 512 ));
228 513 }
229 514
230 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 + /**
231 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.
232 557 */
233 558 public function ajax_save_html()
234 559 {
235 560 // Check nonce
@@ -236,19 +561,57 @@
236 561 check_ajax_referer('metasync_html_editor', 'nonce');
237 562
238 563 // Check permissions
239 564 if (!current_user_can('edit_pages')) {
240 - wp_send_json_error(array('message' => __('Permission denied', 'metasync')));
565 + wp_send_json_error(array('message' => __('Permission denied', 'metasync')), 403);
241 566 }
242 567
243 568 // Get data
244 569 $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
245 - $html_content = isset($_POST['html']) ? wp_kses_post($_POST['html']) : '';
246 570
247 - if (!$post_id || empty($html_content)) {
248 - wp_send_json_error(array('message' => __('Invalid data', 'metasync')));
571 + if (!$post_id) {
572 + wp_send_json_error(array('message' => __('No page selected', 'metasync')));
249 573 }
250 574
575 + if (!current_user_can('edit_post', $post_id)) {
576 + wp_send_json_error(array('message' => __('Permission denied', 'metasync')), 403);
577 + }
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 + wp_send_json_error(
587 + array('message' => __('This page was imported from Website Studio and cannot be saved from the visual editor.', 'metasync')),
588 + 409
589 + );
590 + }
591 +
592 + $stored = get_post_meta($post_id, '_metasync_raw_html_content', true);
593 +
594 + if (self::is_unsafe_for_visual_editor($stored)) {
595 + wp_send_json_error(
596 + 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')),
597 + 409
598 + );
599 + }
600 +
601 + $html_content = isset($_POST['html']) ? wp_unslash($_POST['html']) : '';
602 +
603 + if (empty($html_content)) {
604 + wp_send_json_error(array('message' => __('Nothing to save', 'metasync')));
605 + }
606 +
607 + // Keep the value being replaced so a save that mangles the page can
608 + // be undone; postmeta is not revisioned, so this is the only undo.
609 + $previous = get_post_meta($post_id, '_metasync_raw_html_content', true);
610 + if ('' !== $previous) {
611 + update_post_meta($post_id, '_metasync_raw_html_content_previous', $previous);
612 + }
613 +
251 614 // Save HTML content
252 615 update_post_meta($post_id, '_metasync_raw_html_content', $html_content);
253 616
254 617 // Update modified date
@@ -265,8 +628,13 @@
265 628 }
266 629
267 630 /**
268 631 * AJAX handler for uploading images
632 + *
633 + * File validation is delegated entirely to the WordPress media pipeline
634 + * (wp_handle_upload + wp_check_filetype_and_ext via media_handle_upload),
635 + * the same chain the core media uploader uses; the capability gate
636 + * matches core's async-upload endpoint (upload_files).
269 637 */
270 638 public function ajax_upload_image()
271 639 {
272 640 // Check nonce
@@ -273,11 +641,17 @@
273 641 check_ajax_referer('metasync_html_editor', 'nonce');
274 642
275 643 // Check permissions
276 644 if (!current_user_can('upload_files')) {
277 - wp_send_json_error(array('message' => __('Permission denied', 'metasync')));
645 + wp_send_json_error(array('message' => __('Permission denied', 'metasync')), 403);
278 646 }
279 647
648 + // Tie the upload to the page being edited, when the editor names one.
649 + $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
650 + if ($post_id && !current_user_can('edit_post', $post_id)) {
651 + wp_send_json_error(array('message' => __('Permission denied', 'metasync')), 403);
652 + }
653 +
280 654 // Handle file upload
281 655 if (!isset($_FILES['file'])) {
282 656 wp_send_json_error(array('message' => __('No file uploaded', 'metasync')));
283 657 }
@@ -293,10 +667,15 @@
293 667 }
294 668
295 669 $image_url = wp_get_attachment_url($attachment_id);
296 670
671 + // The editor's asset manager adds response.data to the asset list
672 + // directly, and an asset's source attribute is called `src`. The
673 + // attachment is exposed as `attachment_id` rather than `id` — `id` is
674 + // the Backbone collection's identity key, so reusing it would make
675 + // repeated uploads of the same attachment silently dedupe.
297 676 wp_send_json_success(array(
298 - 'url' => $image_url,
299 - 'id' => $attachment_id
677 + 'src' => $image_url,
678 + 'attachment_id' => $attachment_id
300 679 ));
301 680 }
302 681 }