PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / trunk
Search Atlas SEO – OTTO AI SEO Automation for WordPress vtrunk
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 2.6.4 2.6.3 All 139 releases
← All changes | admin/class-metasync-html-visual-editor.php +439 -63 2.6.3trunk 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'])) : '';
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,31 @@
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 + esc_attr(sprintf(__('Edit with %s Visual Editor', 'metasync'), $label)),
284 + __('Edit HTML', 'metasync')
285 + );
286 +
90 287 return $actions;
91 288 }
92 289
93 290 /**
@@ -98,9 +295,9 @@
98 295 $label = Metasync::get_whitelabel_company_name() ?: 'SearchAtlas';
99 296 $page_title = sprintf(__('%s HTML Editor', 'metasync'), $label);
100 297
101 298 add_submenu_page(
102 - null, // Hidden from menu
299 + '', // Hidden from menu
103 300 $page_title,
104 301 $page_title,
105 302 'edit_pages',
106 303 Metasync_Admin::$page_slug . '-html-editor',
@@ -114,9 +311,9 @@
114 311 public function render_editor_page()
115 312 {
116 313 // Check permissions
117 314 if (!current_user_can('edit_pages')) {
118 - wp_die(__('You do not have sufficient permissions to access this page.'));
315 + wp_die(__('You do not have sufficient permissions to access this page.', 'metasync'));
119 316 }
120 317
121 318 // Get post ID
122 319 $post_id = isset($_GET['post_id']) ? intval($_GET['post_id']) : 0;
@@ -121,16 +318,20 @@
121 318 // Get post ID
122 319 $post_id = isset($_GET['post_id']) ? intval($_GET['post_id']) : 0;
123 320
124 321 if (!$post_id) {
125 - wp_die(__('Invalid page ID.'));
322 + wp_die(__('Invalid page ID.', 'metasync'));
126 323 }
127 324
325 + if (!current_user_can('edit_post', $post_id)) {
326 + wp_die(__('You do not have sufficient permissions to access this page.', 'metasync'));
327 + }
328 +
128 329 // Get post
129 330 $post = get_post($post_id);
130 331
131 332 if (!$post) {
132 - wp_die(__('Page not found.'));
333 + wp_die(__('Page not found.', 'metasync'));
133 334 }
134 335
135 336 // Check if raw HTML is enabled
136 337 $has_raw_html = get_post_meta($post_id, '_metasync_raw_html_enabled', true);
@@ -135,101 +336,222 @@
135 336 // Check if raw HTML is enabled
136 337 $has_raw_html = get_post_meta($post_id, '_metasync_raw_html_enabled', true);
137 338
138 339 if (!$has_raw_html) {
139 - wp_die(__('This page is not a raw HTML page.'));
340 + wp_die(__('This page is not a raw HTML page.', 'metasync'));
140 341 }
141 342
343 + // LPS pages are owned by the importer and are always complete
344 + // documents, so the canvas is refused outright rather than opened in a
345 + // state where nothing can be saved.
346 + if (self::is_lps_page($post_id)) {
347 + wp_die(
348 + 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')
349 + );
350 + }
351 +
142 352 // Get HTML content
143 353 $html_content = get_post_meta($post_id, '_metasync_raw_html_content', true);
144 354
145 355 if (empty($html_content)) {
146 - $html_content = '<html><body><h1>Start editing...</h1></body></html>';
356 + // A body fragment, not a document: the canvas builds fragments, so
357 + // seeding a page with <html>/<body> would make a brand-new empty
358 + // page register as unsafe and lock the editor against itself.
359 + $html_content = '<h1>Start editing...</h1>';
147 360 }
148 361
149 362 // Get label for branding
150 363 $label = Metasync::get_whitelabel_company_name() ?: 'SearchAtlas AI';
151 364
152 - // Enqueue editor assets
153 - $this->enqueue_editor_assets();
154 -
155 - // Render editor UI
365 + // Render editor UI. Assets are enqueued on admin_enqueue_scripts.
156 366 include plugin_dir_path(__FILE__) . 'partials/metasync-html-editor-page.php';
157 367 }
158 368
159 369 /**
160 370 * Enqueue editor assets (GrapesJS + custom scripts)
371 + *
372 + * The editor runtime is served from the plugin's own bundled copies at
373 + * pinned versions. It used to be pulled from public CDNs, which made any
374 + * blocked, throttled or offline request render the editor as an empty
375 + * canvas with no explanation.
161 376 */
162 377 private function enqueue_editor_assets()
163 378 {
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'
379 + $lib_url = plugin_dir_url(__FILE__) . 'lib/';
380 + $lib_path = plugin_dir_path(__FILE__) . 'lib/';
381 +
382 + // Bundled libraries, in load order. Anything missing from disk is
383 + // reported to the client so the editor can explain itself instead of
384 + // rendering blank. Handles are namespaced so a theme or plugin
385 + // registering a bare "grapesjs" handle cannot collide with ours.
386 + $libraries = array(
387 + 'metasync-grapesjs' => array(
388 + 'version' => self::GRAPESJS_VERSION,
389 + 'style' => 'grapesjs/grapes.min.css',
390 + 'script' => 'grapesjs/grapes.min.js',
391 + ),
392 + 'metasync-grapesjs-blocks-basic' => array(
393 + 'version' => self::GRAPESJS_BLOCKS_BASIC_VERSION,
394 + 'script' => 'grapesjs-blocks-basic/grapesjs-blocks-basic.min.js',
395 + 'deps' => array('metasync-grapesjs'),
396 + ),
170 397 );
171 398
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 - );
399 + $missing = array();
179 400
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 - );
401 + foreach ($libraries as $handle => $library) {
402 + if (file_exists($lib_path . $library['script'])) {
403 + wp_enqueue_script(
404 + $handle,
405 + $lib_url . $library['script'],
406 + isset($library['deps']) ? $library['deps'] : array(),
407 + $library['version'],
408 + true
409 + );
410 + } else {
411 + $missing[] = $handle;
412 + }
187 413
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 - );
414 + if (!isset($library['style'])) {
415 + continue;
416 + }
196 417
197 - // Custom editor JS (with timestamp for cache busting)
418 + if (file_exists($lib_path . $library['style'])) {
419 + wp_enqueue_style(
420 + $handle,
421 + $lib_url . $library['style'],
422 + array(),
423 + $library['version']
424 + );
425 + } else {
426 + $missing[] = $handle;
427 + }
428 + }
429 +
430 + $missing = array_values(array_unique($missing));
431 +
432 + // The editor bundle depends on the bundled core only when that core
433 + // was actually enqueued. When a library file is absent from disk it is
434 + // never registered, and a dependency on an unregistered handle makes
435 + // WordPress suppress the editor bundle itself — including the localized
436 + // `missing` list, so the failure would go back to being a blank canvas
437 + // with no explanation.
438 + $editor_script_deps = array('jquery');
439 + // The editor chrome and the sidebar's panel switcher label their
440 + // buttons with dashicons glyphs, so the stylesheet is a real
441 + // dependency rather than something to inherit from the admin page.
442 + $editor_style_deps = array('dashicons');
443 + if (wp_script_is('metasync-grapesjs', 'registered')) {
444 + $editor_script_deps[] = 'metasync-grapesjs';
445 + $editor_style_deps[] = 'metasync-grapesjs';
446 + }
447 +
198 448 wp_enqueue_script(
199 449 'metasync-html-editor',
200 450 plugins_url('js/metasync-html-editor.js', __FILE__),
201 - array('jquery', 'grapesjs'),
202 - $this->version . '.' . time(),
451 + $editor_script_deps,
452 + $this->version,
203 453 true
204 454 );
205 455
206 - // Custom editor CSS (with timestamp for cache busting)
207 456 wp_enqueue_style(
208 457 'metasync-html-editor',
209 458 plugins_url('css/metasync-html-editor.css', __FILE__),
210 - array('grapesjs'),
211 - $this->version . '.' . time()
459 + $editor_style_deps,
460 + $this->version
212 461 );
213 462
463 + $post_id = isset($_GET['post_id']) ? intval($_GET['post_id']) : 0;
464 +
214 465 // Localize script with data
215 466 wp_localize_script('metasync-html-editor', 'metasyncEditor', array(
216 467 'ajax_url' => admin_url('admin-ajax.php'),
217 468 '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'),
469 + 'post_id' => $post_id,
470 + 'preview_url' => get_permalink($post_id),
471 + // Names of bundled libraries that are absent from disk, so the
472 + // client can name the failing dependency without exposing paths
473 + // or other sensitive detail.
474 + 'missing' => $missing,
475 + // Non-empty when the page's stored HTML cannot survive the canvas,
476 + // so the client can disable saving before the document is parsed
477 + // into it rather than after the user has edited.
478 + 'blocked_reason' => $post_id ? $this->get_blocked_reason($post_id) : '',
479 + // Where the user can edit this page without loss.
480 + 'direct_edit_url' => $post_id ? get_edit_post_link($post_id, 'raw') : '',
221 481 'i18n' => array(
222 482 'saving' => __('Saving...', 'metasync'),
223 483 'saved' => __('Saved!', 'metasync'),
224 484 'error' => __('Error saving', 'metasync'),
485 + 'session_expired' => __('Your session has expired. Copy your work before reloading the page.', 'metasync'),
486 + 'ready' => __('Ready', 'metasync'),
487 + 'unsaved_changes' => __('Unsaved changes', 'metasync'),
225 488 'confirm_exit' => __('You have unsaved changes. Are you sure you want to leave?', 'metasync'),
489 + 'confirm_preview' => __('You have unsaved changes. Preview will show the last saved version. Continue?', 'metasync'),
490 + 'panel_styles' => __('Styles', 'metasync'),
491 + 'panel_settings' => __('Settings', 'metasync'),
492 + 'panel_layers' => __('Layers', 'metasync'),
493 + 'panel_blocks' => __('Blocks', 'metasync'),
494 + 'load_failed_title' => __('The visual editor could not start', 'metasync'),
495 + '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'),
496 + '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'),
497 + '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'),
498 + 'load_failed_detail' => __('Missing component: %s', 'metasync'),
499 + 'reload' => __('Reload page', 'metasync'),
500 + 'dismiss' => __('Dismiss', 'metasync'),
501 + 'save_disabled' => __('Saving is disabled because the editor did not load', 'metasync'),
502 + 'blocked_title' => __('This page cannot be saved from the visual editor', 'metasync'),
503 + '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'),
504 + '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'),
505 + 'blocked_save_disabled' => __('Saving is disabled to protect this page from being rewritten', 'metasync'),
506 + 'open_direct_editor' => __('Edit HTML Directly', 'metasync'),
507 + 'upload_failed' => __('Image upload failed', 'metasync'),
226 508 )
227 509 ));
228 510 }
229 511
230 512 /**
513 + * Why a page cannot be saved through the visual canvas, if it cannot.
514 + *
515 + * Returned to the client so the canvas can refuse before it parses the
516 + * document, rather than letting the user edit a reduced copy and discover
517 + * at save time that the original is unrecoverable.
518 + *
519 + * @param int $post_id Page being opened.
520 + * @return string Machine-readable reason, or '' when the page is editable.
521 + */
522 + private function get_blocked_reason($post_id)
523 + {
524 + if (self::is_lps_page($post_id)) {
525 + return 'lps';
526 + }
527 +
528 + $html = get_post_meta($post_id, '_metasync_raw_html_content', true);
529 +
530 + // An empty page is seeded with a body fragment, so there is nothing
531 + // to lose and the canvas may open normally.
532 + if ('' === (string) $html) {
533 + return '';
534 + }
535 +
536 + return self::is_unsafe_for_visual_editor($html) ? 'full_document' : '';
537 + }
538 +
539 + /**
231 540 * AJAX handler for saving HTML
541 + *
542 + * The payload is stored verbatim, matching the contract of every other
543 + * writer of this meta key: the Custom Pages metabox stores the raw value
544 + * for users who can edit the page, and the front-end renderer echoes it
545 + * as authored. Filtering here with kses would silently strip the very
546 + * elements raw HTML pages exist to carry (doctype, head assets, forms,
547 + * iframes, inline SVG), and re-filtering an already-filtered value is
548 + * what let entity-encoded markup re-materialize as live tags.
549 + *
550 + * Storing verbatim is also why the guards below refuse whole saves rather
551 + * than trying to repair a payload: by the time it arrives, the canvas has
552 + * already discarded whatever it could not represent, and nothing here can
553 + * tell a deliberate deletion from a parser casualty.
232 554 */
233 555 public function ajax_save_html()
234 556 {
235 557 // Check nonce
@@ -236,19 +558,57 @@
236 558 check_ajax_referer('metasync_html_editor', 'nonce');
237 559
238 560 // Check permissions
239 561 if (!current_user_can('edit_pages')) {
240 - wp_send_json_error(array('message' => __('Permission denied', 'metasync')));
562 + wp_send_json_error(array('message' => __('Permission denied', 'metasync')), 403);
241 563 }
242 564
243 565 // Get data
244 566 $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
245 - $html_content = isset($_POST['html']) ? wp_kses_post($_POST['html']) : '';
246 567
247 - if (!$post_id || empty($html_content)) {
248 - wp_send_json_error(array('message' => __('Invalid data', 'metasync')));
568 + if (!$post_id) {
569 + wp_send_json_error(array('message' => __('No page selected', 'metasync')));
249 570 }
250 571
572 + if (!current_user_can('edit_post', $post_id)) {
573 + wp_send_json_error(array('message' => __('Permission denied', 'metasync')), 403);
574 + }
575 +
576 + // The client disables saving for these pages before the canvas parses
577 + // them, but the endpoint is reachable on its own, and the decision is
578 + // about what the stored document can survive rather than about what
579 + // the browser did. Both checks read the value already in the database,
580 + // not the payload: what matters is whether the page being overwritten
581 + // is one the canvas could have represented faithfully.
582 + if (self::is_lps_page($post_id)) {
583 + wp_send_json_error(
584 + array('message' => __('This page was imported from Website Studio and cannot be saved from the visual editor.', 'metasync')),
585 + 409
586 + );
587 + }
588 +
589 + $stored = get_post_meta($post_id, '_metasync_raw_html_content', true);
590 +
591 + if (self::is_unsafe_for_visual_editor($stored)) {
592 + wp_send_json_error(
593 + 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')),
594 + 409
595 + );
596 + }
597 +
598 + $html_content = isset($_POST['html']) ? wp_unslash($_POST['html']) : '';
599 +
600 + if (empty($html_content)) {
601 + wp_send_json_error(array('message' => __('Nothing to save', 'metasync')));
602 + }
603 +
604 + // Keep the value being replaced so a save that mangles the page can
605 + // be undone; postmeta is not revisioned, so this is the only undo.
606 + $previous = get_post_meta($post_id, '_metasync_raw_html_content', true);
607 + if ('' !== $previous) {
608 + update_post_meta($post_id, '_metasync_raw_html_content_previous', $previous);
609 + }
610 +
251 611 // Save HTML content
252 612 update_post_meta($post_id, '_metasync_raw_html_content', $html_content);
253 613
254 614 // Update modified date
@@ -265,8 +625,13 @@
265 625 }
266 626
267 627 /**
268 628 * AJAX handler for uploading images
629 + *
630 + * File validation is delegated entirely to the WordPress media pipeline
631 + * (wp_handle_upload + wp_check_filetype_and_ext via media_handle_upload),
632 + * the same chain the core media uploader uses; the capability gate
633 + * matches core's async-upload endpoint (upload_files).
269 634 */
270 635 public function ajax_upload_image()
271 636 {
272 637 // Check nonce
@@ -273,11 +638,17 @@
273 638 check_ajax_referer('metasync_html_editor', 'nonce');
274 639
275 640 // Check permissions
276 641 if (!current_user_can('upload_files')) {
277 - wp_send_json_error(array('message' => __('Permission denied', 'metasync')));
642 + wp_send_json_error(array('message' => __('Permission denied', 'metasync')), 403);
278 643 }
279 644
645 + // Tie the upload to the page being edited, when the editor names one.
646 + $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
647 + if ($post_id && !current_user_can('edit_post', $post_id)) {
648 + wp_send_json_error(array('message' => __('Permission denied', 'metasync')), 403);
649 + }
650 +
280 651 // Handle file upload
281 652 if (!isset($_FILES['file'])) {
282 653 wp_send_json_error(array('message' => __('No file uploaded', 'metasync')));
283 654 }
@@ -293,10 +664,15 @@
293 664 }
294 665
295 666 $image_url = wp_get_attachment_url($attachment_id);
296 667
668 + // The editor's asset manager adds response.data to the asset list
669 + // directly, and an asset's source attribute is called `src`. The
670 + // attachment is exposed as `attachment_id` rather than `id` — `id` is
671 + // the Backbone collection's identity key, so reusing it would make
672 + // repeated uploads of the same attachment silently dedupe.
297 673 wp_send_json_success(array(
298 - 'url' => $image_url,
299 - 'id' => $attachment_id
674 + 'src' => $image_url,
675 + 'attachment_id' => $attachment_id
300 676 ));
301 677 }
302 678 }