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
metasync / admin / class-metasync-html-visual-editor.php

class-metasync-html-visual-editor.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at admin/class-metasync-html-visual-editor.php

687 lines 27.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MetaSync HTML Visual Editor
4 *
5 * Provides a visual editing interface for raw HTML pages with:
6 * - Click to edit text
7 * - Color picker for backgrounds/colors
8 * - Image uploader
9 * - Drag and drop reordering
10 * - Live preview
11 *
12 * @package Metasync
13 * @subpackage Metasync/admin
14 * @since 2.0.0
15 */
16
17 if (!defined('ABSPATH')) {
18 exit;
19 }
20
21 class Metasync_HTML_Visual_Editor
22 {
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 /**
56 * Plugin name
57 *
58 * @var string
59 */
60 private $plugin_name;
61
62 /**
63 * Plugin version
64 *
65 * @var string
66 */
67 private $version;
68
69 /**
70 * Initialize the class
71 *
72 * @param string $plugin_name Plugin name
73 * @param string $version Plugin version
74 */
75 public function __construct($plugin_name, $version)
76 {
77 $this->plugin_name = $plugin_name;
78 $this->version = $version;
79 }
80
81 /**
82 * Register hooks
83 */
84 public function init()
85 {
86 // Add "Edit HTML" button to post row actions
87 add_filter('post_row_actions', array($this, 'add_edit_html_button'), 10, 2);
88 add_filter('page_row_actions', array($this, 'add_edit_html_button'), 10, 2);
89
90 // Add admin menu page for the editor
91 add_action('admin_menu', array($this, 'add_editor_page'));
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
97 // Register AJAX handlers
98 add_action('wp_ajax_metasync_save_html', array($this, 'ajax_save_html'));
99 add_action('wp_ajax_metasync_upload_image', array($this, 'ajax_upload_image'));
100 }
101
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 /**
253 * Add "Edit HTML" button to row actions
254 *
255 * @param array $actions Row actions
256 * @param WP_Post $post Post object
257 * @return array Modified actions
258 */
259 public function add_edit_html_button($actions, $post)
260 {
261 // Check if this is a raw HTML page
262 $has_raw_html = get_post_meta($post->ID, '_metasync_raw_html_enabled', true);
263
264 if (!$has_raw_html) {
265 return $actions;
266 }
267
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;
275 }
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
288 return $actions;
289 }
290
291 /**
292 * Add editor admin page
293 */
294 public function add_editor_page()
295 {
296 $label = Metasync::get_whitelabel_company_name() ?: 'SearchAtlas';
297 /* translators: %s: company name (whitelabel-aware). */
298 $page_title = sprintf(__('%s HTML Editor', 'metasync'), $label);
299
300 add_submenu_page(
301 '', // Hidden from menu
302 $page_title,
303 $page_title,
304 'edit_pages',
305 Metasync_Admin::$page_slug . '-html-editor',
306 array($this, 'render_editor_page')
307 );
308 }
309
310 /**
311 * Render the visual editor page
312 */
313 public function render_editor_page()
314 {
315 // Check permissions
316 if (!current_user_can('edit_pages')) {
317 wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'metasync'));
318 }
319
320 // Get post ID
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
322
323 if (!$post_id) {
324 wp_die(esc_html__('Invalid page ID.', 'metasync'));
325 }
326
327 if (!current_user_can('edit_post', $post_id)) {
328 wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'metasync'));
329 }
330
331 // Get post
332 $post = get_post($post_id);
333
334 if (!$post) {
335 wp_die(esc_html__('Page not found.', 'metasync'));
336 }
337
338 // Check if raw HTML is enabled
339 $has_raw_html = get_post_meta($post_id, '_metasync_raw_html_enabled', true);
340
341 if (!$has_raw_html) {
342 wp_die(esc_html__('This page is not a raw HTML page.', 'metasync'));
343 }
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
354 // Get HTML content
355 $html_content = get_post_meta($post_id, '_metasync_raw_html_content', true);
356
357 if (empty($html_content)) {
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>';
362 }
363
364 // Get label for branding
365 $label = Metasync::get_whitelabel_company_name() ?: 'SearchAtlas AI';
366
367 // Render editor UI. Assets are enqueued on admin_enqueue_scripts.
368 include plugin_dir_path(__FILE__) . 'partials/metasync-html-editor-page.php';
369 }
370
371 /**
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.
378 */
379 private function enqueue_editor_assets()
380 {
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 ),
399 );
400
401 $missing = array();
402
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 }
415
416 if (!isset($library['style'])) {
417 continue;
418 }
419
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
450 wp_enqueue_script(
451 'metasync-html-editor',
452 plugins_url('js/metasync-html-editor.js', __FILE__),
453 $editor_script_deps,
454 $this->version,
455 true
456 );
457
458 wp_enqueue_style(
459 'metasync-html-editor',
460 plugins_url('css/metasync-html-editor.css', __FILE__),
461 $editor_style_deps,
462 $this->version
463 );
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
467 // Localize script with data
468 wp_localize_script('metasync-html-editor', 'metasyncEditor', array(
469 'ajax_url' => admin_url('admin-ajax.php'),
470 'nonce' => wp_create_nonce('metasync_html_editor'),
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') : '',
483 'i18n' => array(
484 'saving' => __('Saving...', 'metasync'),
485 'saved' => __('Saved!', 'metasync'),
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'),
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'),
511 )
512 ));
513 }
514
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 /**
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.
557 */
558 public function ajax_save_html()
559 {
560 // Check nonce
561 check_ajax_referer('metasync_html_editor', 'nonce');
562
563 // Check permissions
564 if (!current_user_can('edit_pages')) {
565 wp_send_json_error(array('message' => __('Permission denied', 'metasync')), 403);
566 }
567
568 // Get data
569 $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
570
571 if (!$post_id) {
572 wp_send_json_error(array('message' => __('No page selected', 'metasync')));
573 }
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 Metasync_PostHog::rollup('visual_editor_saved', array('blocked_lps' => 1));
587 wp_send_json_error(
588 array('message' => __('This page was imported from Website Studio and cannot be saved from the visual editor.', 'metasync')),
589 409
590 );
591 }
592
593 $stored = get_post_meta($post_id, '_metasync_raw_html_content', true);
594
595 if (self::is_unsafe_for_visual_editor($stored)) {
596 Metasync_PostHog::rollup('visual_editor_saved', array('blocked_full_document' => 1));
597 wp_send_json_error(
598 array('message' => __('This page is a complete HTML document, so saving it from the visual editor would discard its doctype, head and scripts. Use Edit HTML Directly on the page editor instead.', 'metasync')),
599 409
600 );
601 }
602
603 $html_content = isset($_POST['html']) ? wp_unslash($_POST['html']) : '';
604
605 if (empty($html_content)) {
606 wp_send_json_error(array('message' => __('Nothing to save', 'metasync')));
607 }
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
616 // Save HTML content
617 update_post_meta($post_id, '_metasync_raw_html_content', $html_content);
618
619 // Update modified date
620 wp_update_post(array(
621 'ID' => $post_id,
622 'post_modified' => current_time('mysql'),
623 'post_modified_gmt' => current_time('mysql', 1)
624 ));
625
626 // Consent-gated analytics: daily counts, not one event per save.
627 Metasync_PostHog::rollup('visual_editor_saved', array('saved' => 1));
628
629 wp_send_json_success(array(
630 'message' => __('Page saved successfully', 'metasync'),
631 'preview_url' => get_permalink($post_id)
632 ));
633 }
634
635 /**
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).
642 */
643 public function ajax_upload_image()
644 {
645 // Check nonce
646 check_ajax_referer('metasync_html_editor', 'nonce');
647
648 // Check permissions
649 if (!current_user_can('upload_files')) {
650 wp_send_json_error(array('message' => __('Permission denied', 'metasync')), 403);
651 }
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
659 // Handle file upload
660 if (!isset($_FILES['file'])) {
661 wp_send_json_error(array('message' => __('No file uploaded', 'metasync')));
662 }
663
664 require_once(ABSPATH . 'wp-admin/includes/image.php');
665 require_once(ABSPATH . 'wp-admin/includes/file.php');
666 require_once(ABSPATH . 'wp-admin/includes/media.php');
667
668 $attachment_id = media_handle_upload('file', 0);
669
670 if (is_wp_error($attachment_id)) {
671 wp_send_json_error(array('message' => $attachment_id->get_error_message()));
672 }
673
674 $image_url = wp_get_attachment_url($attachment_id);
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.
681 wp_send_json_success(array(
682 'src' => $image_url,
683 'attachment_id' => $attachment_id
684 ));
685 }
686 }
687