| @@ -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 | } |