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 / custom-pages / class-metasync-custom-pages-api.php

class-metasync-custom-pages-api.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at custom-pages/class-metasync-custom-pages-api.php

2,210 lines 83.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // If this file is called directly, abort.
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7
8 /**
9 * Custom HTML Pages REST API Handler
10 *
11 * Provides REST API endpoints for creating and managing custom HTML pages
12 *
13 * @package Metasync
14 * @subpackage Metasync/custom-pages
15 * @since 2.5.10
16 */
17
18 class Metasync_Custom_Pages_API
19 {
20 /**
21 * REST API namespace
22 */
23 private const NAMESPACE = 'metasync/v1';
24
25 /**
26 * Initialize the API handler
27 */
28 public function __construct()
29 {
30 add_action('rest_api_init', array($this, 'register_routes'));
31 }
32
33 /**
34 * Register REST API routes
35 */
36 public function register_routes()
37 {
38 // Create custom HTML page
39 register_rest_route(
40 self::NAMESPACE,
41 '/custom-pages/create',
42 array(
43 'methods' => 'POST',
44 'callback' => array($this, 'create_custom_html_page'),
45 'permission_callback' => array($this, 'validate_api_key'),
46 'args' => $this->get_create_page_args()
47 )
48 );
49
50 // Update custom HTML page
51 register_rest_route(
52 self::NAMESPACE,
53 '/custom-pages/update',
54 array(
55 'methods' => 'POST',
56 'callback' => array($this, 'update_custom_html_page'),
57 'permission_callback' => array($this, 'validate_api_key'),
58 'args' => $this->get_update_page_args()
59 )
60 );
61
62 // Get custom HTML page
63 register_rest_route(
64 self::NAMESPACE,
65 '/custom-pages/get',
66 array(
67 'methods' => 'GET',
68 'callback' => array($this, 'get_custom_html_page'),
69 'permission_callback' => array($this, 'validate_api_key'),
70 'args' => array(
71 'page_id' => array(
72 'required' => false,
73 'type' => 'integer',
74 'description' => 'Page ID'
75 ),
76 'slug' => array(
77 'required' => false,
78 'type' => 'string',
79 'description' => 'Page slug'
80 )
81 )
82 )
83 );
84
85 // List all custom HTML pages
86 register_rest_route(
87 self::NAMESPACE,
88 '/custom-pages/list',
89 array(
90 'methods' => 'GET',
91 'callback' => array($this, 'list_custom_html_pages'),
92 'permission_callback' => array($this, 'validate_api_key')
93 )
94 );
95
96 // Delete custom HTML page
97 register_rest_route(
98 self::NAMESPACE,
99 '/custom-pages/delete',
100 array(
101 'methods' => 'DELETE',
102 'callback' => array($this, 'delete_custom_html_page'),
103 'permission_callback' => array($this, 'validate_api_key'),
104 'args' => array(
105 'page_id' => array(
106 'required' => true,
107 'type' => 'integer',
108 'description' => 'Page ID to delete'
109 )
110 )
111 )
112 );
113
114 // Import LPS ZIP as custom HTML page
115 register_rest_route(
116 self::NAMESPACE,
117 '/custom-pages/import-zip',
118 array(
119 'methods' => 'POST',
120 'callback' => array($this, 'import_lps_page'),
121 'permission_callback' => array($this, 'validate_api_key'),
122 'args' => $this->get_import_zip_args()
123 )
124 );
125 }
126
127 /**
128 * Validate API key from request
129 * Uses the same authentication as otto_crawl_notify endpoint
130 */
131 public function validate_api_key($request)
132 {
133 // Get API key from Authorization: Bearer header, x-api-key header, or apikey query parameter
134 $api_key = '';
135
136 $auth_header = $request->get_header('authorization');
137 if (!empty($auth_header) && preg_match('/^Bearer\s+(.+)$/i', $auth_header, $matches)) {
138 $api_key = sanitize_text_field($matches[1]);
139 }
140
141 if (empty($api_key)) {
142 $header_key = $request->get_header('x-api-key');
143 if (!empty($header_key)) {
144 $api_key = sanitize_text_field($header_key);
145 }
146 }
147
148 if (empty($api_key)) {
149 $param_key = $request->get_param('apikey');
150 if (!empty($param_key)) {
151 $api_key = sanitize_text_field($param_key);
152 // Deprecated: ?apikey= query-param auth — switch callers to Authorization: Bearer
153 }
154 }
155
156 if (empty($api_key)) {
157 return new WP_Error(
158 'missing_api_key',
159 'API key is required. Provide it via Authorization: Bearer header, x-api-key header, or apikey query parameter.',
160 array('status' => 401)
161 );
162 }
163
164 // Get stored API key from settings
165 $settings = Metasync::get_option('general');
166 $stored_api_key = $settings['apikey'] ?? null;
167
168 if (empty($stored_api_key)) {
169 return new WP_Error(
170 'api_key_not_configured',
171 'API key is not configured in MetaSync settings.',
172 array('status' => 500)
173 );
174 }
175
176 // Validate API key
177 if (!hash_equals($stored_api_key, $api_key)) {
178 return new WP_Error(
179 'invalid_api_key',
180 'Invalid API key provided.',
181 array('status' => 403)
182 );
183 }
184
185 return true;
186 }
187
188 /**
189 * Get arguments for create page endpoint
190 */
191 private function get_create_page_args()
192 {
193 return array(
194 'title' => array(
195 'required' => true,
196 'type' => 'string',
197 'description' => 'Page title',
198 'sanitize_callback' => 'sanitize_text_field'
199 ),
200 'slug' => array(
201 'required' => false,
202 'type' => 'string',
203 'description' => 'Page slug (URL-friendly name)',
204 'sanitize_callback' => 'sanitize_title'
205 ),
206 'html_content' => array(
207 'required' => true,
208 'type' => 'string',
209 'description' => 'Raw HTML content'
210 ),
211 'status' => array(
212 'required' => false,
213 'type' => 'string',
214 'default' => 'publish',
215 'enum' => array('publish', 'draft', 'pending'),
216 'description' => 'Page status'
217 ),
218 'enable_raw_html' => array(
219 'required' => false,
220 'type' => 'boolean',
221 'default' => true,
222 'description' => 'Enable raw HTML mode (bypass theme)'
223 ),
224 'filename' => array(
225 'required' => false,
226 'type' => 'string',
227 'description' => 'Original HTML filename (for reference)',
228 'sanitize_callback' => 'sanitize_file_name'
229 )
230 );
231 }
232
233 /**
234 * Get arguments for update page endpoint
235 */
236 private function get_update_page_args()
237 {
238 $args = $this->get_create_page_args();
239
240 // Add page_id as required for updates
241 $args['page_id'] = array(
242 'required' => true,
243 'type' => 'integer',
244 'description' => 'Page ID to update'
245 );
246
247 // Make other fields optional for updates
248 $args['title']['required'] = false;
249 $args['html_content']['required'] = false;
250
251 return $args;
252 }
253
254 /**
255 * Create a new custom HTML page
256 */
257 public function create_custom_html_page($request)
258 {
259 $params = $request->get_params();
260
261 // Prepare page data
262 $page_data = array(
263 'post_title' => sanitize_text_field($params['title']),
264 'post_type' => 'page',
265 'post_status' => sanitize_text_field($params['status'] ?? 'publish'),
266 'post_content' => '', // We'll store HTML in meta
267 );
268
269 // Set slug if provided
270 if (!empty($params['slug'])) {
271 $page_data['post_name'] = sanitize_title($params['slug']);
272 }
273
274 // Check if page with same slug already exists
275 if (!empty($page_data['post_name'])) {
276 $existing_page = get_page_by_path($page_data['post_name'], OBJECT, 'page');
277 if ($existing_page) {
278 return new WP_Error(
279 'page_exists',
280 'A page with this slug already exists. Use the update endpoint instead.',
281 array('status' => 409)
282 );
283 }
284 }
285
286 // Insert the page
287 $page_id = wp_insert_post($page_data, true);
288
289 if (is_wp_error($page_id)) {
290 return new WP_Error(
291 'page_creation_failed',
292 'Failed to create page: ' . $page_id->get_error_message(),
293 array('status' => 500)
294 );
295 }
296
297 // Mark as custom HTML page
298 update_post_meta($page_id, Metasync_Custom_Pages::META_IS_CUSTOM_HTML_PAGE, '1');
299
300 // Mark as created via API
301 update_post_meta($page_id, Metasync_Custom_Pages::META_CREATED_VIA_API, '1');
302
303 // Enable raw HTML mode
304 $enable_raw_html = isset($params['enable_raw_html']) ? (bool) $params['enable_raw_html'] : true;
305 update_post_meta($page_id, Metasync_Custom_Pages::META_HTML_ENABLED, $enable_raw_html ? '1' : '0');
306
307 // Store HTML content
308 if (!empty($params['html_content'])) {
309 // No sanitization for HTML content - admin users are trusted
310 update_post_meta($page_id, Metasync_Custom_Pages::META_HTML_CONTENT, wp_unslash($params['html_content']));
311 }
312
313 // Store filename if provided
314 if (!empty($params['filename'])) {
315 update_post_meta($page_id, Metasync_Custom_Pages::META_HTML_FILENAME, sanitize_file_name($params['filename']));
316 }
317
318 // Clear cache
319 wp_cache_delete($page_id, 'posts');
320 wp_cache_delete($page_id, 'post_meta');
321
322 // Return success response
323 return rest_ensure_response(array(
324 'success' => true,
325 'message' => 'Custom HTML page created successfully',
326 'data' => array(
327 'page_id' => $page_id,
328 'title' => get_the_title($page_id),
329 'slug' => get_post_field('post_name', $page_id),
330 'url' => get_permalink($page_id),
331 'edit_url' => get_edit_post_link($page_id, 'raw'),
332 'status' => get_post_status($page_id),
333 'raw_html_enabled' => $enable_raw_html,
334 'html_length' => strlen($params['html_content'] ?? ''),
335 'created_at' => get_post_field('post_date', $page_id)
336 )
337 ));
338 }
339
340 /**
341 * Update an existing custom HTML page
342 */
343 public function update_custom_html_page($request)
344 {
345 $params = $request->get_params();
346 $page_id = intval($params['page_id']);
347
348 // Verify page exists
349 $page = get_post($page_id);
350 if (!$page || $page->post_type !== 'page') {
351 return new WP_Error(
352 'page_not_found',
353 'Page not found with ID: ' . $page_id,
354 array('status' => 404)
355 );
356 }
357
358 // Verify it's a custom HTML page
359 $is_custom_html_page = get_post_meta($page_id, Metasync_Custom_Pages::META_IS_CUSTOM_HTML_PAGE, true);
360 if ($is_custom_html_page !== '1') {
361 return new WP_Error(
362 'not_custom_html_page',
363 'This page is not a custom HTML page.',
364 array('status' => 400)
365 );
366 }
367
368 // Update page data if provided
369 $update_data = array('ID' => $page_id);
370
371 if (isset($params['title'])) {
372 $update_data['post_title'] = sanitize_text_field($params['title']);
373 }
374
375 if (isset($params['slug'])) {
376 $update_data['post_name'] = sanitize_title($params['slug']);
377 }
378
379 if (isset($params['status'])) {
380 $update_data['post_status'] = sanitize_text_field($params['status']);
381 }
382
383 // Update page if there are changes
384 if (count($update_data) > 1) {
385 $result = wp_update_post($update_data, true);
386 if (is_wp_error($result)) {
387 return new WP_Error(
388 'page_update_failed',
389 'Failed to update page: ' . $result->get_error_message(),
390 array('status' => 500)
391 );
392 }
393 }
394
395 // Update HTML content if provided
396 if (isset($params['html_content'])) {
397 update_post_meta($page_id, Metasync_Custom_Pages::META_HTML_CONTENT, wp_unslash($params['html_content']));
398 }
399
400 // Update raw HTML mode if provided
401 if (isset($params['enable_raw_html'])) {
402 $enable_raw_html = (bool) $params['enable_raw_html'];
403 update_post_meta($page_id, Metasync_Custom_Pages::META_HTML_ENABLED, $enable_raw_html ? '1' : '0');
404 }
405
406 // Update filename if provided
407 if (isset($params['filename'])) {
408 update_post_meta($page_id, Metasync_Custom_Pages::META_HTML_FILENAME, sanitize_file_name($params['filename']));
409 }
410
411 // Clear cache
412 wp_cache_delete($page_id, 'posts');
413 wp_cache_delete($page_id, 'post_meta');
414
415 // Return success response
416 return rest_ensure_response(array(
417 'success' => true,
418 'message' => 'Custom HTML page updated successfully',
419 'data' => array(
420 'page_id' => $page_id,
421 'title' => get_the_title($page_id),
422 'slug' => get_post_field('post_name', $page_id),
423 'url' => get_permalink($page_id),
424 'edit_url' => get_edit_post_link($page_id, 'raw'),
425 'status' => get_post_status($page_id),
426 'raw_html_enabled' => get_post_meta($page_id, Metasync_Custom_Pages::META_HTML_ENABLED, true) === '1',
427 'html_length' => strlen(get_post_meta($page_id, Metasync_Custom_Pages::META_HTML_CONTENT, true)),
428 'updated_at' => get_post_field('post_modified', $page_id)
429 )
430 ));
431 }
432
433 /**
434 * Get a custom HTML page
435 */
436 public function get_custom_html_page($request)
437 {
438 $page_id = $request->get_param('page_id');
439 $slug = $request->get_param('slug');
440
441 if (empty($page_id) && empty($slug)) {
442 return new WP_Error(
443 'missing_identifier',
444 'Either page_id or slug must be provided.',
445 array('status' => 400)
446 );
447 }
448
449 // Get page by ID or slug
450 if (!empty($page_id)) {
451 $page = get_post(intval($page_id));
452 } else {
453 $page = get_page_by_path($slug, OBJECT, 'page');
454 }
455
456 if (!$page || $page->post_type !== 'page') {
457 return new WP_Error(
458 'page_not_found',
459 'Page not found.',
460 array('status' => 404)
461 );
462 }
463
464 // Verify it's a custom HTML page
465 $is_custom_html_page = get_post_meta($page->ID, Metasync_Custom_Pages::META_IS_CUSTOM_HTML_PAGE, true);
466 if ($is_custom_html_page !== '1') {
467 return new WP_Error(
468 'not_custom_html_page',
469 'This page is not a custom HTML page.',
470 array('status' => 400)
471 );
472 }
473
474 // Get HTML content
475 $html_content = get_post_meta($page->ID, Metasync_Custom_Pages::META_HTML_CONTENT, true);
476 $html_enabled = get_post_meta($page->ID, Metasync_Custom_Pages::META_HTML_ENABLED, true);
477 $html_filename = get_post_meta($page->ID, Metasync_Custom_Pages::META_HTML_FILENAME, true);
478
479 return rest_ensure_response(array(
480 'success' => true,
481 'data' => array(
482 'page_id' => $page->ID,
483 'title' => $page->post_title,
484 'slug' => $page->post_name,
485 'url' => get_permalink($page->ID),
486 'edit_url' => get_edit_post_link($page->ID, 'raw'),
487 'status' => $page->post_status,
488 'raw_html_enabled' => $html_enabled === '1',
489 'html_content' => $html_content,
490 'html_filename' => $html_filename,
491 'html_length' => strlen($html_content),
492 'created_at' => $page->post_date,
493 'updated_at' => $page->post_modified
494 )
495 ));
496 }
497
498 /**
499 * List all custom HTML pages
500 */
501 public function list_custom_html_pages($request)
502 {
503 $pages = Metasync_Custom_Pages::get_custom_pages();
504
505 $pages_data = array();
506 foreach ($pages as $page) {
507 $html_enabled = get_post_meta($page->ID, Metasync_Custom_Pages::META_HTML_ENABLED, true);
508 $html_content = get_post_meta($page->ID, Metasync_Custom_Pages::META_HTML_CONTENT, true);
509 $html_filename = get_post_meta($page->ID, Metasync_Custom_Pages::META_HTML_FILENAME, true);
510
511 $pages_data[] = array(
512 'page_id' => $page->ID,
513 'title' => $page->post_title,
514 'slug' => $page->post_name,
515 'url' => get_permalink($page->ID),
516 'edit_url' => get_edit_post_link($page->ID, 'raw'),
517 'status' => $page->post_status,
518 'raw_html_enabled' => $html_enabled === '1',
519 'html_filename' => $html_filename,
520 'html_length' => strlen($html_content),
521 'created_at' => $page->post_date,
522 'updated_at' => $page->post_modified
523 );
524 }
525
526 return rest_ensure_response(array(
527 'success' => true,
528 'count' => count($pages_data),
529 'data' => $pages_data
530 ));
531 }
532
533 /**
534 * Delete a custom HTML page and clean up its on-disk assets.
535 *
536 * Shared implementation behind both the REST endpoint and the MCP delete tool
537 * so the two paths cannot drift. Performs: page validation, front-page reset
538 * (when the page is the static front page), permanent deletion, and
539 * reference-counted removal of the LPS asset folder.
540 *
541 * @param int $page_id
542 * @return array|WP_Error Result payload on success, WP_Error (with status) on failure.
543 */
544 public function delete_custom_page_with_cleanup($page_id)
545 {
546 $page_id = intval($page_id);
547
548 // Verify page exists
549 $page = get_post($page_id);
550 if (!$page || $page->post_type !== 'page') {
551 return new WP_Error(
552 'page_not_found',
553 'Page not found with ID: ' . $page_id,
554 array('status' => 404)
555 );
556 }
557
558 // Verify it's a custom HTML page
559 $is_custom_html_page = get_post_meta($page_id, Metasync_Custom_Pages::META_IS_CUSTOM_HTML_PAGE, true);
560 if ($is_custom_html_page !== '1') {
561 return new WP_Error(
562 'not_custom_html_page',
563 'This page is not a custom HTML page.',
564 array('status' => 400)
565 );
566 }
567
568 // Capture the on-disk assets folder BEFORE deleting the post — its meta is
569 // gone afterwards. Prefer the recorded folder (it can differ from the slug);
570 // fall back to the slug for pages imported before this was tracked.
571 $assets_folder = get_post_meta($page_id, Metasync_Custom_Pages::META_ASSETS_FOLDER, true);
572 if (empty($assets_folder)) {
573 $assets_folder = $page->post_name;
574 }
575
576 // Capture whether this page is the static front page BEFORE deleting it -
577 // WordPress/plugins may clear page_on_front during wp_delete_post(), so a
578 // post-delete read could miss it.
579 $was_front_page = ((int) get_option('page_on_front') === $page_id);
580
581 // Delete the page permanently
582 $result = wp_delete_post($page_id, true);
583
584 if (!$result) {
585 return new WP_Error(
586 'delete_failed',
587 'Failed to delete page.',
588 array('status' => 500)
589 );
590 }
591
592 // Reset the WordPress static front page when this page WAS it (captured
593 // before the delete; some setups clear page_on_front during wp_delete_post(),
594 // so reading it afterwards would miss the match). Idempotent + only on success.
595 $front_page_reset = false;
596 if ($was_front_page) {
597 update_option('show_on_front', 'posts');
598 delete_option('page_on_front');
599 $front_page_reset = true;
600 }
601
602 // Remove the LPS asset folder associated with this page — but only if no
603 // other page still references it. Multi-page imports share one folder across
604 // many pages, so deleting one page must not wipe assets the remaining pages
605 // still need (reference counting).
606 $assets_removed = false;
607 $assets_still_referenced = false;
608 if (!empty($assets_folder) && strpos($assets_folder, '..') === false && strpos($assets_folder, '/') === false && strpos($assets_folder, '\\') === false) {
609 $others = get_posts(array(
610 'post_type' => 'page',
611 // 'any' + 'trash': bare 'any' excludes trashed posts, so a trashed
612 // sibling sharing this folder would be missed and its assets wiped
613 // (breaking it on restore). Listing 'trash' alongside 'any' removes it
614 // from the exclusion set while still matching every other status —
615 // including custom plugin statuses, which a hardcoded list would drop.
616 'post_status' => array('any', 'trash'),
617 'numberposts' => 1,
618 'fields' => 'ids',
619 'no_found_rows' => true,
620 'suppress_filters' => true, // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- identity lookup for the duplicate-assets guard; query-filter plugins (WPML/Polylang) must not narrow it
621 'exclude' => array($page_id),
622 'meta_key' => Metasync_Custom_Pages::META_ASSETS_FOLDER,
623 'meta_value' => $assets_folder,
624 ));
625 if (!empty($others)) {
626 // Other pages still use this folder — keep it.
627 $assets_still_referenced = true;
628 } else {
629 $upload_dir = wp_upload_dir();
630 if (empty($upload_dir['error'])) {
631 $base_dir = trailingslashit($upload_dir['basedir']) . 'metasync-pages';
632 $target_dir = trailingslashit($base_dir) . $assets_folder;
633 $base_real = realpath($base_dir);
634 $target_real = realpath($target_dir);
635 if ($base_real !== false && $target_real !== false && strpos($target_real, $base_real) === 0) {
636 $this->recursive_rmdir($target_real);
637 $assets_removed = !file_exists($target_real);
638 }
639 }
640 }
641 }
642
643 return array(
644 'page_id' => $page_id,
645 'title' => $page->post_title,
646 'assets_removed' => $assets_removed,
647 'assets_folder_still_referenced' => $assets_still_referenced,
648 'front_page_reset' => $front_page_reset,
649 );
650 }
651
652 /**
653 * Delete a custom HTML page (REST endpoint).
654 *
655 * Delegates to the shared delete_custom_page_with_cleanup() implementation.
656 */
657 public function delete_custom_html_page($request)
658 {
659 $page_id = intval($request->get_param('page_id'));
660
661 $result = $this->delete_custom_page_with_cleanup($page_id);
662 if (is_wp_error($result)) {
663 // Return the WP_Error directly so the REST framework maps its embedded
664 // status (404/400/500) to the HTTP status code.
665 return $result;
666 }
667
668 return rest_ensure_response(array(
669 'success' => true,
670 'message' => 'Custom HTML page deleted successfully',
671 'data' => $result
672 ));
673 }
674
675 /**
676 * Get arguments for the import-zip endpoint
677 */
678 private function get_import_zip_args()
679 {
680 return array(
681 'slug' => array(
682 'required' => false,
683 'type' => 'string',
684 'description' => 'Page slug (single-page imports only). Required when the ZIP has no pages.manifest.json; ignored for multi-page bundles, which take slugs from the manifest.'
685 ),
686 'title' => array(
687 'required' => false,
688 'type' => 'string',
689 'description' => 'Page title (single-page imports only). Ignored for multi-page bundles, which take titles from the manifest.',
690 'sanitize_callback' => 'sanitize_text_field'
691 ),
692 'status' => array(
693 'required' => false,
694 'type' => 'string',
695 'default' => 'publish',
696 'enum' => array('publish', 'draft', 'pending'),
697 'description' => 'Page status'
698 ),
699 'overwrite' => array(
700 'required' => false,
701 'type' => 'boolean',
702 'default' => true,
703 'description' => 'When true, an existing page with the same slug is overwritten.'
704 ),
705 'download_url' => array(
706 'required' => false,
707 'type' => 'string',
708 'description' => 'URL to download the LPS ZIP from. If provided, the ZIP is fetched server-side instead of requiring a multipart upload.',
709 'sanitize_callback' => 'esc_url_raw'
710 ),
711 'assets_folder' => array(
712 'required' => false,
713 'type' => 'string',
714 'description' => 'On-disk folder name the bundle is extracted to (under uploads/metasync-pages/). This is the path LPS bakes into the asset URLs at build time and is intentionally separate from the page slug. Defaults to the slug when omitted.'
715 ),
716 'external_ref' => array(
717 'required' => false,
718 'type' => 'string',
719 'description' => 'LPS project UUID used as the stable per-project home dedup key.',
720 'sanitize_callback' => 'sanitize_text_field'
721 )
722 );
723 }
724
725 /**
726 * REST callback: import LPS ZIP as a custom HTML page.
727 *
728 * Accepts either a JSON body with download_url (preferred) or a multipart zip_file upload.
729 */
730 public function import_lps_page($request)
731 {
732 // --- Audit tracking (one persistent record is written on every exit path) ---
733 $_lps_start_ms = (int) round(microtime(true) * 1000);
734 $_lps_result = null; // WP_Error|array — set before each return
735 $_lps_http_st = 500; // updated before each return
736 $_lps_input = array(
737 'source_type' => 'unknown',
738 'zip_size_bytes' => 0,
739 'assets_folder' => '',
740 'pages_in_manifest' => null,
741 );
742
743 // Re-derive the auth method + masked key prefix using the same precedence as
744 // validate_api_key() (Bearer header, then x-api-key header, then apikey param).
745 $_lps_auth_method = '';
746 $_lps_api_key_prefix = '';
747 $_lps_matched_key = '';
748 $_lps_auth_header = $request->get_header('authorization');
749 if (!empty($_lps_auth_header) && preg_match('/^Bearer\s+(.+)$/i', $_lps_auth_header, $_lps_m)) {
750 $_lps_auth_method = 'bearer';
751 $_lps_matched_key = sanitize_text_field($_lps_m[1]);
752 }
753 if ($_lps_matched_key === '') {
754 $_lps_hk = $request->get_header('x-api-key');
755 if (!empty($_lps_hk)) {
756 $_lps_auth_method = 'x-api-key';
757 $_lps_matched_key = sanitize_text_field($_lps_hk);
758 }
759 }
760 if ($_lps_matched_key === '') {
761 $_lps_pk = $request->get_param('apikey');
762 if (!empty($_lps_pk)) {
763 $_lps_auth_method = 'apikey-query';
764 $_lps_matched_key = sanitize_text_field($_lps_pk);
765 }
766 }
767 if (strlen($_lps_matched_key) >= 8) {
768 $_lps_api_key_prefix = substr($_lps_matched_key, 0, 8) . '...';
769 }
770
771 try {
772 $raw_slug = $request->get_param('slug');
773 if (is_string($raw_slug) && (strpos($raw_slug, '..') !== false || strpos($raw_slug, '/') !== false || strpos($raw_slug, '\\') !== false)) {
774 $_lps_http_st = 400;
775 $_lps_result = new WP_Error(
776 'invalid_slug_path',
777 'Slug must not contain path separators or parent references.',
778 array('status' => 400)
779 );
780 return $_lps_result;
781 }
782
783 $params = $request->get_params();
784 $slug = isset($params['slug']) ? sanitize_title($params['slug']) : '';
785 $title = isset($params['title']) ? sanitize_text_field($params['title']) : '';
786 $status = isset($params['status']) ? sanitize_text_field($params['status']) : 'publish';
787 $overwrite = isset($params['overwrite']) ? (bool) $params['overwrite'] : true;
788 $download_url = isset($params['download_url']) ? $params['download_url'] : '';
789 $assets_folder = isset($params['assets_folder']) ? $params['assets_folder'] : '';
790 $_lps_input['assets_folder'] = $assets_folder;
791 $external_ref = isset($params['external_ref']) ? sanitize_text_field($params['external_ref']) : '';
792 $_lps_input['external_ref'] = $external_ref;
793
794 $zip_path = '';
795 $tmp_file = null;
796
797 $max_zip_bytes = 50 * 1024 * 1024;
798
799 try {
800 if (!empty($download_url)) {
801 $_lps_input['source_type'] = 'download_url';
802 // wp_tempnam() lives in wp-admin/includes/file.php, which is NOT loaded
803 // during a normal REST request — load it so this works in any context.
804 if (!function_exists('wp_tempnam')) {
805 require_once ABSPATH . 'wp-admin/includes/file.php';
806 }
807 $tmp_file = wp_tempnam('lps_import_');
808 if (!$tmp_file) {
809 $_lps_http_st = 500;
810 $_lps_result = new WP_Error('tmp_file_failed', 'Failed to create temporary file.', array('status' => 500));
811 return $_lps_result;
812 }
813
814 $response = wp_safe_remote_get($download_url, array(
815 'timeout' => 60,
816 'stream' => true,
817 'filename' => $tmp_file,
818 ));
819
820 if (is_wp_error($response)) {
821 $_lps_http_st = 400;
822 $_lps_result = new WP_Error('zip_download_failed', 'Failed to download ZIP: ' . $response->get_error_message(), array('status' => 400));
823 return $_lps_result;
824 }
825
826 $code = wp_remote_retrieve_response_code($response);
827 if ((int) $code !== 200) {
828 $_lps_http_st = 400;
829 $_lps_result = new WP_Error('zip_download_http_error', 'ZIP download returned HTTP ' . intval($code), array('status' => 400));
830 return $_lps_result;
831 }
832
833 $content_type = strtolower((string) wp_remote_retrieve_header($response, 'content-type'));
834 if ($content_type !== '' && strpos($content_type, 'zip') === false && strpos($content_type, 'octet-stream') === false) {
835 $_lps_http_st = 400;
836 $_lps_result = new WP_Error('invalid_zip_content_type', 'Downloaded file is not a ZIP (Content-Type: ' . sanitize_text_field($content_type) . ').', array('status' => 400));
837 return $_lps_result;
838 }
839
840 $downloaded_size = file_exists($tmp_file) ? filesize($tmp_file) : 0;
841 $_lps_input['zip_size_bytes'] = $downloaded_size;
842 if ($downloaded_size === 0) {
843 $_lps_http_st = 400;
844 $_lps_result = new WP_Error('zip_download_empty', 'Downloaded ZIP file is empty.', array('status' => 400));
845 return $_lps_result;
846 }
847 if ($downloaded_size > $max_zip_bytes) {
848 $_lps_http_st = 413;
849 $_lps_result = new WP_Error('zip_too_large', 'Downloaded ZIP exceeds the maximum allowed size (' . intval($max_zip_bytes / 1024 / 1024) . ' MB).', array('status' => 413));
850 return $_lps_result;
851 }
852
853 $zip_path = $tmp_file;
854
855 } elseif (!empty($_FILES['zip_file']) && is_array($_FILES['zip_file'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- REST route authenticated by API key, not cookies
856 $file = $_FILES['zip_file']; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- REST route authenticated by API key, not cookies
857 $_lps_input['source_type'] = 'upload';
858
859 if (!isset($file['error']) || $file['error'] !== UPLOAD_ERR_OK) {
860 $_lps_http_st = 400;
861 $_lps_result = new WP_Error('zip_upload_error', 'Uploaded ZIP file has an error code: ' . (isset($file['error']) ? intval($file['error']) : 'unknown'), array('status' => 400));
862 return $_lps_result;
863 }
864
865 if (empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
866 $_lps_http_st = 400;
867 $_lps_result = new WP_Error('invalid_zip_upload', 'Invalid ZIP upload.', array('status' => 400));
868 return $_lps_result;
869 }
870
871 $allowed_mime_types = array('application/zip', 'application/octet-stream', 'application/x-zip-compressed', 'multipart/x-zip');
872 if (!empty($file['type']) && !in_array($file['type'], $allowed_mime_types, true)) {
873 $_lps_http_st = 400;
874 $_lps_result = new WP_Error('invalid_zip_mime', 'Uploaded file is not a ZIP archive (type: ' . sanitize_text_field($file['type']) . ').', array('status' => 400));
875 return $_lps_result;
876 }
877
878 $uploaded_size = isset($file['size']) ? (int) $file['size'] : 0;
879 $_lps_input['zip_size_bytes'] = $uploaded_size;
880 if ($uploaded_size > $max_zip_bytes) {
881 $_lps_http_st = 413;
882 $_lps_result = new WP_Error('zip_too_large', 'Uploaded ZIP exceeds the maximum allowed size (' . intval($max_zip_bytes / 1024 / 1024) . ' MB).', array('status' => 413));
883 return $_lps_result;
884 }
885
886 $zip_path = $file['tmp_name'];
887
888 } else {
889 $_lps_http_st = 400;
890 $_lps_result = new WP_Error('missing_zip_source', 'Provide either a download_url or a multipart zip_file upload.', array('status' => 400));
891 return $_lps_result;
892 }
893
894 $result = $this->extract_and_create_lps_page($zip_path, $slug, $title, $status, $overwrite, $assets_folder, $external_ref);
895 $_lps_result = $result;
896
897 if (is_wp_error($result)) {
898 $_err_data = $result->get_error_data();
899 $_lps_http_st = (int) ((is_array($_err_data) && isset($_err_data['status'])) ? $_err_data['status'] : 500);
900 return $result;
901 }
902
903 // Map the import outcome to an HTTP status:
904 // 200 — all pages created/updated
905 // 207 — partial success (some pages failed)
906 // 422 — every page failed
907 // (Single-page slug conflicts already return 409 as a WP_Error above.)
908 $status_code = 200;
909 $data = (isset($result['data']) && is_array($result['data'])) ? $result['data'] : array();
910 if (isset($data['mode']) && $data['mode'] === 'multi') {
911 $_lps_input['pages_in_manifest'] = isset($data['pages_total']) ? (int) $data['pages_total'] : null;
912 $succeeded = count(isset($data['created']) ? $data['created'] : array())
913 + count(isset($data['updated']) ? $data['updated'] : array());
914 $failed = count(isset($data['failed']) ? $data['failed'] : array());
915 if ($failed > 0 && $succeeded > 0) {
916 $status_code = 207;
917 } elseif ($failed > 0 && $succeeded === 0) {
918 $status_code = 422;
919 $result['success'] = false;
920 $_lps_result = $result;
921 }
922 } elseif (isset($data['mode']) && $data['mode'] === 'single') {
923 $_lps_input['pages_in_manifest'] = 1;
924 }
925
926 $_lps_http_st = $status_code;
927 $response = rest_ensure_response($result);
928 $response->set_status($status_code);
929 return $response;
930
931 } finally {
932 if (!empty($tmp_file) && file_exists($tmp_file)) {
933 @wp_delete_file($tmp_file);
934 }
935 }
936 } finally {
937 self::write_lps_import_audit(array(
938 'result' => $_lps_result,
939 'http_status' => $_lps_http_st,
940 'input' => $_lps_input,
941 'start_ms' => $_lps_start_ms,
942 'auth_method' => $_lps_auth_method,
943 'api_key_prefix' => $_lps_api_key_prefix,
944 ));
945 self::track_lps_import($_lps_result, $_lps_http_st, $_lps_input);
946 }
947 }
948
949 /**
950 * Consent-gated analytics for Website Studio imports.
951 *
952 * Successes are rolled up hourly (a multi-page publish is one burst of
953 * work, not N events). Failures and partial imports send one event per
954 * error code per hour so a retry loop on the platform cannot flood it.
955 * Hooked here, not in extract_and_create_lps_page(), because the MCP tool
956 * also calls that and is already counted by the MCP rollup.
957 *
958 * @param WP_Error|array|null $result Final import result.
959 * @param int $http_status HTTP status returned.
960 * @param array $input Audit input (pages_in_manifest).
961 */
962 private static function track_lps_import($result, $http_status, array $input) {
963 $data = (is_array($result) && isset($result['data']) && is_array($result['data'])) ? $result['data'] : array();
964 $mode = (isset($data['mode']) && in_array($data['mode'], array('single', 'multi'), true)) ? $data['mode'] : 'unknown';
965 $pages = isset($input['pages_in_manifest']) ? (int) $input['pages_in_manifest'] : 0;
966
967 if ((int) $http_status === 200) {
968 Metasync_PostHog::rollup('lps_page_imported', array(
969 'imports_succeeded' => 1,
970 'imports_' . $mode => 1,
971 'pages_total' => $pages,
972 ), 'hour');
973 return;
974 }
975
976 if (is_wp_error($result)) {
977 $error_code = (string) $result->get_error_code();
978 } elseif ((int) $http_status === 207) {
979 $error_code = 'partial_failure';
980 } elseif (is_array($result)) {
981 $error_code = 'all_pages_failed';
982 } else {
983 // The import threw before producing a result.
984 $error_code = 'unexpected_error';
985 }
986
987 Metasync_PostHog::feature_once('lps_page_imported', array(
988 'outcome' => (int) $http_status === 207 ? 'partial' : 'failed',
989 'mode' => $mode,
990 'pages_total' => $pages,
991 'error_code' => substr(sanitize_key($error_code), 0, 60),
992 'http_status' => (int) $http_status,
993 ), $error_code, HOUR_IN_SECONDS);
994 }
995
996 /**
997 * Shared helper: extract a ZIP archive and create or update a custom HTML page.
998 *
999 * Made public so the MCP tool (in a different class) can invoke it directly.
1000 *
1001 * @param string $zip_path Filesystem path to the ZIP file.
1002 * @param string $slug Page slug (will be re-sanitized).
1003 * @param string $title Page title.
1004 * @param string $status Page status (publish|draft|pending).
1005 * @param bool $overwrite If true, existing page+assets with same slug are overwritten.
1006 * @param string $assets_folder On-disk folder name to extract into (under metasync-pages/).
1007 * Defaults to the slug when empty. Must match the path LPS baked
1008 * into the bundle's asset URLs at build time.
1009 * @param string $external_ref Stable per-project LPS UUID. When non-empty it is stored as
1010 * post meta and used as the primary home-page dedup key.
1011 * @return array|WP_Error Response array on success, WP_Error on failure.
1012 */
1013 public function extract_and_create_lps_page($zip_path, $slug, $title, $status = 'publish', $overwrite = true, $assets_folder = '', $external_ref = '')
1014 {
1015 if (!class_exists('ZipArchive')) {
1016 return new WP_Error(
1017 'zip_unavailable',
1018 'ZipArchive PHP extension is not available on this server.',
1019 array('status' => 500)
1020 );
1021 }
1022
1023 // Basic slug sanity (single-page slugs never contain path separators). Empty
1024 // is allowed here because multi-page imports take their slugs from the manifest.
1025 if (is_string($slug) && $slug !== '' && (strpos($slug, '..') !== false || strpos($slug, '/') !== false || strpos($slug, '\\') !== false)) {
1026 return new WP_Error(
1027 'invalid_slug_path',
1028 'Slug must not contain path separators or parent references.',
1029 array('status' => 400)
1030 );
1031 }
1032 $slug = is_string($slug) ? sanitize_title($slug) : '';
1033
1034 $upload_dir = wp_upload_dir();
1035 if (!empty($upload_dir['error'])) {
1036 return new WP_Error(
1037 'upload_dir_error',
1038 'WordPress uploads directory error: ' . $upload_dir['error'],
1039 array('status' => 500)
1040 );
1041 }
1042
1043 $base_dir = trailingslashit($upload_dir['basedir']) . 'metasync-pages';
1044
1045 // Resolve the on-disk assets folder. LPS bakes this path into the bundle's
1046 // asset URLs (Vite `base`) at build time, so it is intentionally separate
1047 // from the page slug. Fall back to the slug for single-page imports that do
1048 // not send one. Do NOT transform the value (no sanitize_title) — it must
1049 // match exactly what LPS baked in — only reject anything unsafe.
1050 $assets_folder = is_string($assets_folder) ? trim($assets_folder) : '';
1051 if ($assets_folder === '') {
1052 $assets_folder = $slug;
1053 }
1054 if ($assets_folder === '' || !preg_match('#^[A-Za-z0-9._-]+$#', $assets_folder) || strpos($assets_folder, '..') !== false || $assets_folder[0] === '.') {
1055 return new WP_Error(
1056 'invalid_assets_folder',
1057 'A valid assets_folder (or a slug to derive it from) is required; it may contain only letters, numbers, dot, underscore and hyphen, must not start with a dot, and must not contain "..".',
1058 array('status' => 400)
1059 );
1060 }
1061
1062 $target_dir = trailingslashit($base_dir) . $assets_folder;
1063
1064 // Ensure the parent metasync-pages directory exists, then resolve it for
1065 // containment checks against the staging and target directories.
1066 if (!file_exists($base_dir)) {
1067 wp_mkdir_p($base_dir);
1068 }
1069 $base_real = realpath($base_dir);
1070 if ($base_real === false) {
1071 return new WP_Error(
1072 'uploads_dir_unwritable',
1073 'Could not create or resolve the metasync-pages uploads directory.',
1074 array('status' => 500)
1075 );
1076 }
1077
1078 // Concurrency guard: serialize imports that target the same assets_folder so
1079 // two simultaneous requests cannot race on the same extract/swap. The lock is
1080 // an flock() on a per-folder lock file, explicitly released in the finally
1081 // block below; the OS also drops it if the request dies, so a crash can never
1082 // leave a stale lock behind.
1083 $lps_import_lock = $this->acquire_import_lock($assets_folder, $base_dir);
1084 if (is_wp_error($lps_import_lock)) {
1085 return $lps_import_lock;
1086 }
1087
1088 // Everything below runs under the import lock; the finally block guarantees
1089 // the lock is explicitly released no matter which return path is taken.
1090 try {
1091
1092 // Extract into a staging directory first. The live assets are only removed
1093 // and replaced once the new bundle is fully extracted AND validated, so a
1094 // failed or partial extraction can never destroy the currently-served page.
1095 $staging_dir = trailingslashit($base_dir) . '.import-tmp-' . $assets_folder . '-' . uniqid();
1096 if (!wp_mkdir_p($staging_dir)) {
1097 return new WP_Error(
1098 'staging_mkdir_failed',
1099 'Failed to create a staging directory for extraction.',
1100 array('status' => 500)
1101 );
1102 }
1103 $staging_real = realpath($staging_dir);
1104 if ($staging_real === false || strpos($staging_real, $base_real) !== 0) {
1105 $this->recursive_rmdir($staging_dir);
1106 return new WP_Error(
1107 'path_traversal_detected',
1108 'Resolved staging directory escapes the metasync-pages base.',
1109 array('status' => 400)
1110 );
1111 }
1112
1113 $zip = new ZipArchive();
1114 $open_result = $zip->open($zip_path);
1115 if ($open_result !== true) {
1116 $this->recursive_rmdir($staging_dir);
1117 return new WP_Error(
1118 'zip_open_failed',
1119 'Failed to open ZIP archive (code: ' . intval($open_result) . ').',
1120 array('status' => 400)
1121 );
1122 }
1123
1124 // ZIP Slip guard: validate every entry name before extraction.
1125 // Reject absolute paths, parent traversal, and disallow .php files entirely.
1126 for ($i = 0; $i < $zip->numFiles; $i++) {
1127 $entry = $zip->getNameIndex($i);
1128 if ($entry === false || $entry === '') {
1129 $zip->close();
1130 $this->recursive_rmdir($staging_dir);
1131 return new WP_Error('zip_invalid_entry', 'ZIP contains an invalid entry name.', array('status' => 400));
1132 }
1133 $normalized = str_replace('\\', '/', $entry);
1134 if ($normalized[0] === '/' || preg_match('#(^|/)\.\.(/|$)#', $normalized)) {
1135 $zip->close();
1136 $this->recursive_rmdir($staging_dir);
1137 return new WP_Error('zip_slip_detected', 'ZIP contains an entry with an unsafe path: ' . sanitize_text_field($entry), array('status' => 400));
1138 }
1139 $ext = strtolower(pathinfo($normalized, PATHINFO_EXTENSION));
1140 $blocked_exts = array('php', 'phtml', 'php3', 'php4', 'php5', 'php7', 'phar', 'pl', 'py', 'jsp', 'asp', 'aspx', 'cgi', 'sh', 'htaccess');
1141 if (in_array($ext, $blocked_exts, true) || basename($normalized) === '.htaccess') {
1142 $zip->close();
1143 $this->recursive_rmdir($staging_dir);
1144 return new WP_Error('zip_unsafe_entry', 'ZIP contains a disallowed file type: ' . sanitize_text_field($entry), array('status' => 400));
1145 }
1146 }
1147
1148 $extracted = $zip->extractTo($staging_dir);
1149 $zip->close();
1150
1151 if (!$extracted) {
1152 $this->recursive_rmdir($staging_dir);
1153 return new WP_Error(
1154 'zip_extract_failed',
1155 'Failed to extract ZIP archive contents.',
1156 array('status' => 500)
1157 );
1158 }
1159
1160 // Drop a hardening .htaccess to block any script execution from the directory.
1161 // (Apache-only; on Nginx the pre-extraction extension blocklist above is the
1162 // real guard.) Written into staging so it travels with the swap.
1163 $htaccess_content = "# Auto-generated by MetaSync LPS import — do not edit.\n"
1164 . "<FilesMatch \"\\.(php|phtml|php3|php4|php5|php7|phar|pl|py|jsp|asp|aspx|cgi|sh)$\">\n"
1165 . " Require all denied\n"
1166 . " Deny from all\n"
1167 . "</FilesMatch>\n"
1168 . "Options -ExecCGI\n"
1169 . "RemoveHandler .php .phtml .php3 .php4 .php5 .php7 .phar\n"
1170 . "RemoveType .php .phtml .php3 .php4 .php5 .php7 .phar\n";
1171 @file_put_contents(trailingslashit($staging_dir) . '.htaccess', $htaccess_content);
1172
1173 $assets_dir_url = trailingslashit($upload_dir['baseurl']) . 'metasync-pages/' . $assets_folder;
1174
1175 // Decide mode: a pages.manifest.json at the bundle root means multi-page.
1176 $has_manifest = file_exists(trailingslashit($staging_dir) . 'pages.manifest.json');
1177
1178 // ---- Multi-page import (manifest-driven) --------------------------------
1179 if ($has_manifest) {
1180 // Parse and validate the manifest while it is still only in staging, so a
1181 // broken manifest can neither leave an orphan folder nor destroy a prior
1182 // import — the live assets stay untouched until we know the bundle is good.
1183 $manifest_raw = file_get_contents(trailingslashit($staging_dir) . 'pages.manifest.json');
1184 if ($manifest_raw === false) {
1185 $this->recursive_rmdir($staging_dir);
1186 return new WP_Error('manifest_unreadable', 'Could not read pages.manifest.json.', array('status' => 500));
1187 }
1188 $manifest = json_decode($manifest_raw, true);
1189 if (!is_array($manifest) || empty($manifest['pages']) || !is_array($manifest['pages'])) {
1190 $this->recursive_rmdir($staging_dir);
1191 return new WP_Error('invalid_manifest', 'pages.manifest.json is missing a non-empty "pages" array.', array('status' => 422));
1192 }
1193
1194 // Verify every page's pre-rendered HTML is present in staging BEFORE the
1195 // swap. A structurally incomplete bundle must be rejected here — otherwise
1196 // the swap would wipe a prior import's live assets and only then discover
1197 // the missing files, leaving the existing pages broken. (Slug conflicts are
1198 // different: they are reported per-page; a missing HTML file is a bad bundle.)
1199 foreach ($manifest['pages'] as $manifest_page) {
1200 if (!is_array($manifest_page)) {
1201 continue;
1202 }
1203 $mp_slug = isset($manifest_page['slug']) ? trim((string) $manifest_page['slug'], '/') : '';
1204 $mp_rel = ($mp_slug === '') ? 'index.html' : $mp_slug . '/index.html';
1205 if (!file_exists(trailingslashit($staging_dir) . $mp_rel)) {
1206 $this->recursive_rmdir($staging_dir);
1207 return new WP_Error(
1208 'incomplete_bundle',
1209 'Bundle is missing index.html for page "' . sanitize_text_field($mp_slug === '' ? '(home)' : $mp_slug) . '"; aborting before touching live assets.',
1210 array('status' => 422)
1211 );
1212 }
1213 }
1214
1215 $swap = $this->swap_staging_into_place($staging_dir, $target_dir, $base_real);
1216 if (is_wp_error($swap)) {
1217 return $swap;
1218 }
1219 return $this->create_pages_from_manifest($manifest['pages'], $target_dir, $assets_folder, $assets_dir_url, $status, $overwrite, $external_ref);
1220 }
1221
1222 // ---- Single-page import -------------------------------------------------
1223 if (empty($slug)) {
1224 $this->recursive_rmdir($staging_dir);
1225 return new WP_Error('invalid_slug', 'A non-empty slug is required for a single-page import.', array('status' => 400));
1226 }
1227 if (empty($title)) {
1228 $this->recursive_rmdir($staging_dir);
1229 return new WP_Error('missing_title', 'A non-empty title is required for a single-page import.', array('status' => 400));
1230 }
1231
1232 // Validate the bundle in staging BEFORE touching the live directory.
1233 $index_path = trailingslashit($staging_dir) . 'index.html';
1234 if (!file_exists($index_path)) {
1235 $this->recursive_rmdir($staging_dir);
1236 return new WP_Error('missing_index_html', 'index.html was not found at the root of the extracted ZIP.', array('status' => 422));
1237 }
1238 $html = file_get_contents($index_path);
1239 if ($html === false) {
1240 $this->recursive_rmdir($staging_dir);
1241 return new WP_Error('index_html_unreadable', 'Could not read index.html from the extracted ZIP.', array('status' => 500));
1242 }
1243
1244 // Ownership gate — checked while the new bundle is still only in staging, so
1245 // rejecting here leaves the live page and its assets completely untouched.
1246 // Only pages we previously created via LPS import may be overwritten.
1247 $existing_page = get_page_by_path($slug, OBJECT, 'page');
1248 if ($existing_page) {
1249 $is_lps_page = get_post_meta($existing_page->ID, Metasync_Custom_Pages::META_LPS_IMPORT, true) === '1';
1250 if (!$is_lps_page) {
1251 $this->recursive_rmdir($staging_dir);
1252 return new WP_Error(
1253 'slug_taken_by_existing_content',
1254 'A page already exists at this slug and was not created by LPS, so it will not be overwritten.',
1255 array(
1256 'status' => 409,
1257 'conflict_with' => array(
1258 'post_id' => (int) $existing_page->ID,
1259 'post_type' => $existing_page->post_type,
1260 'title' => $existing_page->post_title,
1261 ),
1262 'suggested_slug' => $this->suggest_available_slug($slug),
1263 )
1264 );
1265 }
1266 if (!$overwrite) {
1267 $this->recursive_rmdir($staging_dir);
1268 return new WP_Error(
1269 'page_slug_exists',
1270 'An LPS page already exists at this slug. Pass overwrite=true to replace it.',
1271 array('status' => 409, 'suggested_slug' => $this->suggest_available_slug($slug))
1272 );
1273 }
1274 }
1275
1276 // Bundle validated and ownership confirmed — swap into place.
1277 $swap = $this->swap_staging_into_place($staging_dir, $target_dir, $base_real);
1278 if (is_wp_error($swap)) {
1279 return $swap;
1280 }
1281
1282 if ($existing_page) {
1283 $update_result = wp_update_post(array(
1284 'ID' => $existing_page->ID,
1285 'post_title' => $title,
1286 'post_status' => $status
1287 ), true);
1288 if (is_wp_error($update_result)) {
1289 return new WP_Error('page_update_failed', 'Failed to update existing page: ' . $update_result->get_error_message(), array('status' => 500));
1290 }
1291 $page_id = $existing_page->ID;
1292 } else {
1293 $page_id = wp_insert_post(array(
1294 'post_title' => $title,
1295 'post_name' => $slug,
1296 'post_type' => 'page',
1297 'post_status' => $status,
1298 'post_content' => ''
1299 ), true);
1300 if (is_wp_error($page_id)) {
1301 return new WP_Error('page_insert_failed', 'Failed to create page: ' . $page_id->get_error_message(), array('status' => 500));
1302 }
1303 }
1304
1305 update_post_meta($page_id, Metasync_Custom_Pages::META_IS_CUSTOM_HTML_PAGE, '1');
1306 update_post_meta($page_id, Metasync_Custom_Pages::META_CREATED_VIA_API, '1');
1307 update_post_meta($page_id, Metasync_Custom_Pages::META_LPS_IMPORT, '1');
1308 update_post_meta($page_id, Metasync_Custom_Pages::META_ASSETS_FOLDER, $assets_folder);
1309 if ($external_ref !== '') {
1310 update_post_meta($page_id, Metasync_Custom_Pages::META_LPS_PROJECT_REF, $external_ref);
1311 }
1312 update_post_meta($page_id, Metasync_Custom_Pages::META_HTML_ENABLED, '1');
1313 update_post_meta($page_id, Metasync_Custom_Pages::META_HTML_CONTENT, wp_unslash($html));
1314 update_post_meta($page_id, Metasync_Custom_Pages::META_HTML_FILENAME, 'index.html');
1315
1316 wp_cache_delete($page_id, 'posts');
1317 wp_cache_delete($page_id, 'post_meta');
1318
1319 // Purge page + edge caches so the re-published HTML references the new asset hashes.
1320 $this->purge_lps_page_caches(array(array('page_id' => $page_id, 'url' => get_permalink($page_id))));
1321
1322 return array(
1323 'success' => true,
1324 'message' => 'LPS ZIP imported successfully',
1325 'data' => array(
1326 'mode' => 'single',
1327 'was_update' => (bool) $existing_page,
1328 'page_id' => $page_id,
1329 'slug' => get_post_field('post_name', $page_id),
1330 'url' => get_permalink($page_id),
1331 'edit_url' => get_edit_post_link($page_id, ''),
1332 'status' => get_post_status($page_id),
1333 'html_length' => strlen($html),
1334 'assets_folder' => $assets_folder,
1335 'assets_dir_url' => $assets_dir_url
1336 )
1337 );
1338
1339 } finally {
1340 if (is_resource($lps_import_lock)) {
1341 @flock($lps_import_lock, LOCK_UN);
1342 @fclose($lps_import_lock); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- operates on a native stream handle
1343 }
1344 }
1345 }
1346
1347 /**
1348 * Acquire an exclusive, non-blocking lock for an assets_folder import.
1349 *
1350 * Uses flock() on a per-folder lock file under metasync-pages/.locks/. The
1351 * returned handle must stay referenced for the duration of the import; the lock
1352 * releases when the handle closes (on return / request end), so a crash cannot
1353 * leave a stale lock. Returns a 423 WP_Error if another import for the same
1354 * folder is already running.
1355 *
1356 * @param string $assets_folder Folder key to lock on.
1357 * @param string $base_dir The metasync-pages base directory.
1358 * @return resource|WP_Error Open file handle holding the lock, or WP_Error.
1359 */
1360 private function acquire_import_lock($assets_folder, $base_dir)
1361 {
1362 $lock_dir = trailingslashit($base_dir) . '.locks';
1363 if (!file_exists($lock_dir)) {
1364 wp_mkdir_p($lock_dir);
1365 }
1366 $lock_path = trailingslashit($lock_dir) . md5($assets_folder) . '.lock';
1367
1368 $handle = @fopen($lock_path, 'c'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- streaming file I/O; WP_Filesystem cannot return a raw stream handle
1369 if ($handle === false) {
1370 return new WP_Error('lock_unavailable', 'Could not open the import lock file.', array('status' => 500));
1371 }
1372 if (!flock($handle, LOCK_EX | LOCK_NB)) {
1373 fclose($handle); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- operates on a native stream handle
1374 return new WP_Error(
1375 'import_in_progress',
1376 'Another import for this assets_folder is already in progress. Retry shortly.',
1377 array('status' => 423)
1378 );
1379 }
1380 return $handle;
1381 }
1382
1383 /**
1384 * Move a validated staging directory into its final place atomically.
1385 *
1386 * Removes any existing target dir, renames staging over it, then re-checks
1387 * containment under the metasync-pages base.
1388 *
1389 * @return true|WP_Error
1390 */
1391 private function swap_staging_into_place($staging_dir, $target_dir, $base_real)
1392 {
1393 if (file_exists($target_dir)) {
1394 $this->recursive_rmdir($target_dir);
1395 }
1396 if (!@rename($staging_dir, $target_dir)) { // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- atomic same-filesystem replace; WP_Filesystem::move() needs credential bootstrapping on non-direct hosts
1397 $this->recursive_rmdir($staging_dir);
1398 return new WP_Error(
1399 'assets_swap_failed',
1400 'Failed to move the extracted assets into place.',
1401 array('status' => 500)
1402 );
1403 }
1404 $target_real = realpath($target_dir);
1405 if ($target_real === false || strpos($target_real, $base_real) !== 0) {
1406 return new WP_Error(
1407 'path_traversal_detected',
1408 'Resolved target directory escapes the metasync-pages base.',
1409 array('status' => 400)
1410 );
1411 }
1412 return true;
1413 }
1414
1415 /**
1416 * Purge full-page and edge/CDN caches for imported LPS pages.
1417 *
1418 * LPS bundles use content-hashed asset filenames. A re-publish writes new
1419 * hashes and deletes the old bundle, but a page-cache plugin or CDN may keep
1420 * serving the OLD HTML — which references the now-deleted assets (404 →
1421 * unstyled / white screen). This clears those layers for every created/updated
1422 * page (and the front page when one was set/updated) so the served HTML always
1423 * references the current asset hashes. Reuses the plugin's existing
1424 * Metasync_Cache_Purge (page-cache plugins) and Metasync_Edge_Cache_Purge (CDN)
1425 * subsystems, with the edge purge batched into a single call.
1426 *
1427 * Wrapped in a \Throwable guard so a purge failure can never abort or error the
1428 * import (same pattern as the audit logger).
1429 *
1430 * @param array $page_records Records carrying 'page_id', 'url', and optional 'is_front_page'.
1431 */
1432 private function purge_lps_page_caches(array $page_records)
1433 {
1434 try {
1435 $urls = array();
1436 $front_page_seen = false;
1437
1438 foreach ($page_records as $rec) {
1439 if (!empty($rec['is_front_page'])) {
1440 $front_page_seen = true;
1441 }
1442
1443 $url = !empty($rec['url']) ? $rec['url'] : '';
1444 if (empty($url) && !empty($rec['page_id'])) {
1445 $url = get_permalink((int) $rec['page_id']);
1446 }
1447
1448 if (!empty($url) && !in_array($url, $urls, true)) {
1449 $urls[] = $url;
1450 }
1451 }
1452
1453 // A front page is served at "/" — purge that URL too.
1454 if ($front_page_seen) {
1455 $home = home_url('/');
1456 if (!in_array($home, $urls, true)) {
1457 $urls[] = $home;
1458 }
1459 }
1460
1461 if (empty($urls)) {
1462 return;
1463 }
1464
1465 // Page-cache plugins: purge each URL individually.
1466 if (class_exists('Metasync_Cache_Purge')) {
1467 foreach ($urls as $url) {
1468 Metasync_Cache_Purge::purge_single_url($url);
1469 }
1470 }
1471
1472 // Edge/CDN caches: one batched call for all URLs (avoids N API calls).
1473 if (class_exists('Metasync_Edge_Cache_Purge')) {
1474 Metasync_Edge_Cache_Purge::purge($urls);
1475 }
1476 } catch (\Throwable $e) {
1477 error_log('MetaSync: LPS import cache purge failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1478 }
1479 }
1480
1481 /**
1482 * Find this project's existing LPS home page by a stable per-project marker,
1483 * independent of its slug, so a re-publish updates the same home instead of
1484 * creating a duplicate.
1485 *
1486 * When $external_ref (the LPS project UUID) is supplied it is the primary key:
1487 * matched on META_LPS_PROJECT_REF, robust to assets_folder/slug changes between
1488 * publishes. When absent (i.e. before CA forwards external_ref) it falls back to
1489 * the legacy META_LPS_HOME == assets_folder lookup — no regression.
1490 *
1491 * @param string $assets_folder Legacy per-project key (fallback).
1492 * @param string $external_ref LPS project UUID (preferred key when present).
1493 * @return int Page ID, or 0 if none.
1494 */
1495 private function find_lps_home_page($assets_folder, $external_ref = '')
1496 {
1497 if ($external_ref !== '') {
1498 // Match the project's HOME specifically: the page carrying this project's
1499 // UUID (written on every page) AND flagged as a home (META_LPS_HOME is
1500 // written only on the home). Without the home clause this matched any page.
1501 $ref_ids = get_posts(array(
1502 'post_type' => 'page',
1503 'post_status' => array('publish', 'future', 'draft', 'pending', 'private'),
1504 'numberposts' => 1,
1505 'fields' => 'ids',
1506 'no_found_rows' => true,
1507 'suppress_filters' => true, // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- LPS home-page identity lookup; language/query filters must not hide the tagged page
1508 'meta_query' => array(
1509 'relation' => 'AND',
1510 array('key' => Metasync_Custom_Pages::META_LPS_PROJECT_REF, 'value' => $external_ref),
1511 array('key' => Metasync_Custom_Pages::META_LPS_HOME, 'compare' => 'EXISTS'),
1512 ),
1513 ));
1514 if (!empty($ref_ids)) {
1515 return (int) $ref_ids[0];
1516 }
1517 }
1518
1519 $ids = get_posts(array(
1520 'post_type' => 'page',
1521 'post_status' => array('publish', 'future', 'draft', 'pending', 'private'),
1522 'numberposts' => 1,
1523 'fields' => 'ids',
1524 'no_found_rows' => true,
1525 'suppress_filters' => true, // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- LPS home-page identity lookup; language/query filters must not hide the tagged page
1526 'meta_key' => Metasync_Custom_Pages::META_LPS_HOME,
1527 'meta_value' => $assets_folder,
1528 ));
1529 return !empty($ids) ? (int) $ids[0] : 0;
1530 }
1531
1532 /**
1533 * Create or update one WordPress page per entry in pages.manifest.json.
1534 *
1535 * All pages share the single $assets_folder. Slugs are created shallowest-first
1536 * so parent pages exist before their children (e.g. "departments" before
1537 * "departments/cardiology"); missing ancestors are auto-created as LPS-owned
1538 * placeholder pages. Per-page conflicts with non-LPS content are skipped and
1539 * reported with a suggested_slug rather than overwriting the user's content.
1540 *
1541 * @param array $pages Validated manifest "pages" array (parsed by the caller
1542 * before the staging→live swap).
1543 * @return array Response with created/updated/failed arrays.
1544 */
1545 private function create_pages_from_manifest($pages, $target_dir, $assets_folder, $assets_dir_url, $status, $overwrite, $external_ref = '')
1546 {
1547 // Shallowest slugs first so parents are created before their children.
1548 usort($pages, function ($a, $b) {
1549 $da = isset($a['slug']) ? substr_count(trim((string) $a['slug'], '/'), '/') : 0;
1550 $db = isset($b['slug']) ? substr_count(trim((string) $b['slug'], '/'), '/') : 0;
1551 if ($da === $db) {
1552 return 0;
1553 }
1554 return ($da < $db) ? -1 : 1;
1555 });
1556
1557 $created = array();
1558 $updated = array();
1559 $failed = array();
1560 $slug_to_id = array(); // full slug path => page ID, for parent resolution
1561
1562 // Resolve the bundle root once before the loop so we can verify every
1563 // per-page HTML path stays inside it. The string blocklist above rejects
1564 // literal "." and backslashes, but cannot catch symlinks in the bundle or
1565 // encoding tricks. realpath() gives the canonical on-disk path, making
1566 // containment provable regardless of how the path was constructed.
1567 $target_real = realpath($target_dir);
1568 if ($target_real === false) {
1569 // $target_dir must exist at this point (the swap already ran); if
1570 // realpath fails anyway, fail safe and bail out entirely.
1571 return array(
1572 'success' => false,
1573 'message' => 'Could not resolve the bundle directory path.',
1574 'data' => array('mode' => 'multi', 'assets_folder' => $assets_folder, 'assets_dir_url' => $assets_dir_url, 'pages_total' => count($pages), 'created' => array(), 'updated' => array(), 'failed' => array()),
1575 );
1576 }
1577 // Ensure the prefix check below cannot match a sibling directory that
1578 // starts with the same characters as $target_real (e.g. /foo/bar vs
1579 // /foo/bar-extra). Adding DIRECTORY_SEPARATOR guarantees we match a real
1580 // path boundary.
1581 $target_real_prefix = rtrim($target_real, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
1582
1583 foreach ($pages as $entry) {
1584 if (!is_array($entry)) {
1585 continue;
1586 }
1587
1588 $is_home = !empty($entry['isHome']);
1589 $raw_slug = isset($entry['slug']) ? trim((string) $entry['slug'], '/') : '';
1590
1591 // Reject path-traversal slugs before $raw_slug is used to build the
1592 // on-disk HTML path below. A crafted manifest slug (e.g.
1593 // "././././some-dir") could otherwise escape $target_dir and have
1594 // file_get_contents() read an index.html from outside the bundle. The
1595 // raw value is checked as-is, before any normalization.
1596 if (strpos($raw_slug, '..') !== false || strpos($raw_slug, '\\') !== false) {
1597 $failed[] = array(
1598 'slug' => $raw_slug,
1599 'code' => 'invalid_slug_path',
1600 'message' => 'Slug contains unsafe path characters.',
1601 );
1602 continue;
1603 }
1604
1605 // Home page (isHome / empty slug) is handled specially below: it is
1606 // created and set as the WordPress static front page so it serves at "/".
1607 $effective_slug = ($is_home && $raw_slug === '') ? 'home' : $raw_slug;
1608
1609 // Normalize each segment exactly as WordPress will persist it, so the
1610 // get_page_by_path() lookups and stored records match what wp_insert_post
1611 // creates (a raw mixed-case slug would desync the object cache). The raw
1612 // slug is still used for the on-disk HTML path, which mirrors the bundle.
1613 $effective_slug = implode('/', array_map('sanitize_title', explode('/', $effective_slug)));
1614
1615 if ($effective_slug === '') {
1616 $failed[] = array('slug' => '(home)', 'code' => 'invalid_slug', 'message' => 'Page has an empty slug.');
1617 continue;
1618 }
1619
1620 // Per-page title from the manifest (name, then seo.title).
1621 $seo = (isset($entry['seo']) && is_array($entry['seo'])) ? $entry['seo'] : array();
1622 $title = '';
1623 if (!empty($entry['name'])) {
1624 $title = sanitize_text_field($entry['name']);
1625 } elseif (!empty($seo['title'])) {
1626 $title = sanitize_text_field($seo['title']);
1627 }
1628 if ($title === '') {
1629 $title = $effective_slug;
1630 }
1631
1632 // Locate this page's pre-rendered HTML inside the bundle.
1633 $rel = ($raw_slug === '') ? 'index.html' : $raw_slug . '/index.html';
1634 $html_file = trailingslashit($target_dir) . $rel;
1635 if (!file_exists($html_file)) {
1636 $failed[] = array('slug' => $effective_slug, 'code' => 'missing_html', 'message' => 'No index.html for this page in the bundle.');
1637 continue;
1638 }
1639 // Containment check: verify the resolved, canonical path stays under
1640 // $target_dir. The string blocklist above catches literal "." and
1641 // backslashes, but realpath() is the authoritative guard — it resolves
1642 // any symlinks in the bundle and proves the final on-disk read is
1643 // inside the bundle directory regardless of how the path was built.
1644 // (Same pattern used at lines 1055-1056 for staging and 1353-1354 for
1645 // the post-swap target.)
1646 $html_real = realpath($html_file);
1647 if ($html_real === false || strpos($html_real, $target_real_prefix) !== 0) {
1648 $failed[] = array(
1649 'slug' => $effective_slug,
1650 'code' => 'invalid_slug_path',
1651 'message' => 'Resolved HTML path escapes the bundle directory.',
1652 );
1653 continue;
1654 }
1655 $html = file_get_contents($html_file);
1656 if ($html === false) {
1657 $failed[] = array('slug' => $effective_slug, 'code' => 'html_unreadable', 'message' => 'Could not read this page\'s index.html.');
1658 continue;
1659 }
1660
1661 // --- Home page: create it and set it as the WordPress static front page
1662 // so it serves at "/" (the LPS app's router expects home at the root).
1663 // The page is identified per-project via the META_LPS_HOME marker (value =
1664 // assets_folder), so re-publishing the SAME project updates its own home
1665 // instead of creating duplicates — even if its slug was renamed. A DIFFERENT
1666 // project gets its own new home and takes over the front page; the previous
1667 // project's home is left in place (non-destructive). Home conflicts are NOT
1668 // reported to LPS — a taken 'home' slug just falls back to 'home-2'.
1669 // Home dedup keys on external_ref (the LPS project UUID) when present,
1670 // falling back to assets_folder for pre-external_ref imports.
1671 if ($is_home) {
1672 $home_id = $this->find_lps_home_page($assets_folder, $external_ref);
1673 $home_existing = $home_id > 0;
1674 if ($home_existing) {
1675 $res = wp_update_post(array('ID' => $home_id, 'post_title' => $title, 'post_status' => 'publish'), true);
1676 if (is_wp_error($res)) {
1677 $failed[] = array('slug' => 'home', 'code' => 'home_update_failed', 'message' => $res->get_error_message());
1678 continue;
1679 }
1680 } else {
1681 // 'home' if free; otherwise next available (home-2, ...). Front-page
1682 // makes the slug invisible, so any free slug is fine.
1683 $home_slug = get_page_by_path('home', OBJECT, 'page') ? $this->suggest_available_slug('home') : 'home';
1684 $home_id = wp_insert_post(array(
1685 'post_title' => $title,
1686 'post_name' => $home_slug,
1687 'post_type' => 'page',
1688 'post_status' => 'publish',
1689 'post_content' => ''
1690 ), true);
1691 if (is_wp_error($home_id)) {
1692 $failed[] = array('slug' => 'home', 'code' => 'home_insert_failed', 'message' => $home_id->get_error_message());
1693 continue;
1694 }
1695 }
1696
1697 update_post_meta($home_id, Metasync_Custom_Pages::META_IS_CUSTOM_HTML_PAGE, '1');
1698 update_post_meta($home_id, Metasync_Custom_Pages::META_CREATED_VIA_API, '1');
1699 update_post_meta($home_id, Metasync_Custom_Pages::META_LPS_IMPORT, '1');
1700 update_post_meta($home_id, Metasync_Custom_Pages::META_ASSETS_FOLDER, $assets_folder);
1701 update_post_meta($home_id, Metasync_Custom_Pages::META_LPS_HOME, $assets_folder);
1702 if ($external_ref !== '') {
1703 update_post_meta($home_id, Metasync_Custom_Pages::META_LPS_PROJECT_REF, $external_ref);
1704 }
1705 update_post_meta($home_id, Metasync_Custom_Pages::META_HTML_ENABLED, '1');
1706 update_post_meta($home_id, Metasync_Custom_Pages::META_HTML_CONTENT, wp_unslash($html));
1707 update_post_meta($home_id, Metasync_Custom_Pages::META_HTML_FILENAME, $rel);
1708
1709 // Make it the site's static front page (serves at "/").
1710 update_option('show_on_front', 'page');
1711 update_option('page_on_front', $home_id);
1712
1713 wp_cache_delete($home_id, 'posts');
1714 wp_cache_delete($home_id, 'post_meta');
1715
1716 $rec = array('slug' => get_post_field('post_name', $home_id), 'page_id' => $home_id, 'url' => get_permalink($home_id), 'is_front_page' => true);
1717 if ($home_existing) {
1718 $updated[] = $rec;
1719 } else {
1720 $created[] = $rec;
1721 }
1722 continue;
1723 }
1724
1725 // Split slug into ancestors + leaf; resolve/create the parent chain.
1726 $segments = explode('/', $effective_slug);
1727 $leaf = sanitize_title(array_pop($segments));
1728 if ($leaf === '') {
1729 $failed[] = array('slug' => $effective_slug, 'code' => 'invalid_slug', 'message' => 'Slug sanitized to empty.');
1730 continue;
1731 }
1732
1733 $parent_id = 0;
1734 $parent_ok = true;
1735 $parent_fail_code = 'parent_failed';
1736 $parent_fail_extra = array();
1737 $ancestor_path = '';
1738 foreach ($segments as $seg) {
1739 $seg = sanitize_title($seg);
1740 if ($seg === '') {
1741 $parent_ok = false;
1742 break;
1743 }
1744 $ancestor_path = ($ancestor_path === '') ? $seg : $ancestor_path . '/' . $seg;
1745 if (isset($slug_to_id[$ancestor_path])) {
1746 $parent_id = $slug_to_id[$ancestor_path];
1747 continue;
1748 }
1749 // find our previously-created ancestor by stable tag (project +
1750 // path) first, so a re-import reuses it even if WordPress renamed its slug
1751 // on a collision — instead of auto-creating another placeholder each time.
1752 $tagged_ancestor = $this->find_lps_page_by_path($assets_folder, $external_ref, $ancestor_path);
1753 if ($tagged_ancestor > 0) {
1754 $parent_id = $tagged_ancestor;
1755 $slug_to_id[$ancestor_path] = $parent_id;
1756 continue;
1757 }
1758 $ancestor_page = get_page_by_path($ancestor_path, OBJECT, 'page');
1759 if ($ancestor_page) {
1760 // only nest under ancestors we own. If an existing page on this
1761 // path was NOT created by LPS, refuse to graft LPS content under the
1762 // user's content — consistent with the leaf ownership gate below.
1763 if (get_post_meta($ancestor_page->ID, Metasync_Custom_Pages::META_LPS_IMPORT, true) !== '1') {
1764 $parent_ok = false;
1765 $parent_fail_code = 'parent_not_owned';
1766 $parent_fail_extra = array(
1767 'conflict_with' => array(
1768 'post_id' => (int) $ancestor_page->ID,
1769 'post_type' => $ancestor_page->post_type,
1770 'title' => $ancestor_page->post_title,
1771 ),
1772 // Suggest an alternate for the CONFLICTING ANCESTOR (the actual blocker),
1773 // not the leaf — renaming the leaf wouldn't resolve an owned-ancestor clash.
1774 'suggested_slug' => $this->suggest_available_slug($ancestor_path),
1775 );
1776 break;
1777 }
1778 $parent_id = $ancestor_page->ID;
1779 } else {
1780 // Auto-create a minimal LPS-owned placeholder so the URL nests.
1781 $new_parent = wp_insert_post(array(
1782 'post_title' => $seg,
1783 'post_name' => $seg,
1784 'post_type' => 'page',
1785 'post_status' => $status,
1786 'post_parent' => $parent_id,
1787 'post_content' => ''
1788 ), true);
1789 if (is_wp_error($new_parent)) {
1790 $parent_ok = false;
1791 break;
1792 }
1793 update_post_meta($new_parent, Metasync_Custom_Pages::META_LPS_IMPORT, '1');
1794 update_post_meta($new_parent, Metasync_Custom_Pages::META_ASSETS_FOLDER, $assets_folder);
1795 if ($external_ref !== '') {
1796 update_post_meta($new_parent, Metasync_Custom_Pages::META_LPS_PROJECT_REF, $external_ref);
1797 }
1798 // tag the placeholder with its manifest path so re-imports find it.
1799 update_post_meta($new_parent, Metasync_Custom_Pages::META_LPS_PAGE_PATH, $ancestor_path);
1800 $parent_id = $new_parent;
1801 }
1802 $slug_to_id[$ancestor_path] = $parent_id;
1803 }
1804 if (!$parent_ok) {
1805 $failed[] = array_merge(
1806 array(
1807 'slug' => $effective_slug,
1808 'code' => $parent_fail_code,
1809 'message' => ($parent_fail_code === 'parent_not_owned')
1810 ? 'A parent page on this path is owned by existing non-LPS content; LPS will not nest under it.'
1811 : 'Could not resolve the parent page hierarchy.',
1812 ),
1813 $parent_fail_extra
1814 );
1815 continue;
1816 }
1817
1818 // find our previously-imported page by its stable tag (project +
1819 // manifest path) first, so a re-import updates the SAME page even if WordPress
1820 // renamed its slug on a collision — instead of creating a duplicate. Only when
1821 // no tagged page exists do we fall back to the intended slug path, which is
1822 // also where a non-LPS ownership conflict is detected (a user page sitting on
1823 // the slug we want).
1824 $existing = null;
1825 $tagged_id = $this->find_lps_page_by_path($assets_folder, $external_ref, $effective_slug);
1826 if ($tagged_id > 0) {
1827 $existing = get_post($tagged_id);
1828 }
1829 if (!$existing) {
1830 $existing = get_page_by_path($effective_slug, OBJECT, 'page');
1831 }
1832 if ($existing) {
1833 $is_lps = get_post_meta($existing->ID, Metasync_Custom_Pages::META_LPS_IMPORT, true) === '1';
1834 if (!$is_lps) {
1835 $failed[] = array(
1836 'slug' => $effective_slug,
1837 'code' => 'slug_taken_by_existing_content',
1838 'conflict_with' => array('post_id' => (int) $existing->ID, 'post_type' => $existing->post_type, 'title' => $existing->post_title),
1839 'suggested_slug' => $this->suggest_available_slug($effective_slug),
1840 );
1841 continue;
1842 }
1843 if (!$overwrite) {
1844 $failed[] = array('slug' => $effective_slug, 'code' => 'page_slug_exists', 'suggested_slug' => $this->suggest_available_slug($effective_slug));
1845 continue;
1846 }
1847 }
1848
1849 // Create or update.
1850 if ($existing) {
1851 $res = wp_update_post(array(
1852 'ID' => $existing->ID,
1853 'post_title' => $title,
1854 'post_status' => $status,
1855 'post_parent' => $parent_id
1856 ), true);
1857 if (is_wp_error($res)) {
1858 $failed[] = array('slug' => $effective_slug, 'code' => 'page_update_failed', 'message' => $res->get_error_message());
1859 continue;
1860 }
1861 $page_id = $existing->ID;
1862 $was_update = true;
1863 } else {
1864 $page_id = wp_insert_post(array(
1865 'post_title' => $title,
1866 'post_name' => $leaf,
1867 'post_type' => 'page',
1868 'post_status' => $status,
1869 'post_parent' => $parent_id,
1870 'post_content' => ''
1871 ), true);
1872 if (is_wp_error($page_id)) {
1873 $failed[] = array('slug' => $effective_slug, 'code' => 'page_insert_failed', 'message' => $page_id->get_error_message());
1874 continue;
1875 }
1876 $was_update = false;
1877 }
1878
1879 update_post_meta($page_id, Metasync_Custom_Pages::META_IS_CUSTOM_HTML_PAGE, '1');
1880 update_post_meta($page_id, Metasync_Custom_Pages::META_CREATED_VIA_API, '1');
1881 update_post_meta($page_id, Metasync_Custom_Pages::META_LPS_IMPORT, '1');
1882 update_post_meta($page_id, Metasync_Custom_Pages::META_ASSETS_FOLDER, $assets_folder);
1883 if ($external_ref !== '') {
1884 update_post_meta($page_id, Metasync_Custom_Pages::META_LPS_PROJECT_REF, $external_ref);
1885 }
1886 // tag the page with its manifest path so re-imports find it by identity, not slug.
1887 update_post_meta($page_id, Metasync_Custom_Pages::META_LPS_PAGE_PATH, $effective_slug);
1888 update_post_meta($page_id, Metasync_Custom_Pages::META_HTML_ENABLED, '1');
1889 update_post_meta($page_id, Metasync_Custom_Pages::META_HTML_CONTENT, wp_unslash($html));
1890 update_post_meta($page_id, Metasync_Custom_Pages::META_HTML_FILENAME, $rel);
1891 wp_cache_delete($page_id, 'posts');
1892 wp_cache_delete($page_id, 'post_meta');
1893
1894 $slug_to_id[$effective_slug] = $page_id;
1895 $record = array('slug' => $effective_slug, 'page_id' => $page_id, 'url' => get_permalink($page_id));
1896 if ($was_update) {
1897 $updated[] = $record;
1898 } else {
1899 $created[] = $record;
1900 }
1901 }
1902
1903 // Purge page + edge caches so the re-published HTML references the new asset hashes.
1904 $this->purge_lps_page_caches(array_merge($created, $updated));
1905
1906 return array(
1907 'success' => true,
1908 'message' => sprintf('LPS multi-page import complete: %d created, %d updated, %d failed.', count($created), count($updated), count($failed)),
1909 'data' => array(
1910 'mode' => 'multi',
1911 'assets_folder' => $assets_folder,
1912 'assets_dir_url' => $assets_dir_url,
1913 'pages_total' => count($pages),
1914 'created' => $created,
1915 'updated' => $updated,
1916 'failed' => $failed,
1917 )
1918 );
1919 }
1920
1921 /**
1922 * Find a previously-imported LPS page by its stable per-page identity
1923 * (project key + manifest path), independent of the slug WordPress actually
1924 * stored. This makes re-imports idempotent even when a slug was renamed on a
1925 * collision (e.g. "2026" -> "2026-2"): we match on META_LPS_PAGE_PATH within
1926 * the same project, so the existing page is updated instead of duplicated.
1927 *
1928 * The project is keyed on external_ref (the LPS project UUID) when present,
1929 * falling back to assets_folder for pre-external_ref imports — mirroring
1930 * find_lps_home_page().
1931 *
1932 * @param string $assets_folder Per-project key (fallback).
1933 * @param string $external_ref LPS project UUID (preferred key when present).
1934 * @param string $page_path Manifest path of the page (e.g. "blog/2026/launch").
1935 * @return int Post ID of the matching LPS page, or 0 when none exists.
1936 */
1937 private function find_lps_page_by_path($assets_folder, $external_ref, $page_path)
1938 {
1939 $page_path = (string) $page_path;
1940 if ($page_path === '') {
1941 return 0;
1942 }
1943
1944 // Prefer the stable project UUID; fall back to assets_folder.
1945 if ($external_ref !== '') {
1946 $project_clause = array('key' => Metasync_Custom_Pages::META_LPS_PROJECT_REF, 'value' => $external_ref);
1947 } else {
1948 $project_clause = array('key' => Metasync_Custom_Pages::META_ASSETS_FOLDER, 'value' => $assets_folder);
1949 }
1950
1951 // suppress_filters + explicit statuses mirror find_lps_home_page() so query
1952 // hooks (e.g. WPML/Polylang on pre_get_posts) cannot silently alter this
1953 // identity lookup and cause a missed match -> duplicate. The META_LPS_IMPORT
1954 // clause enforces the "tagged == LPS-owned" invariant rather than assuming it.
1955 $ids = get_posts(array(
1956 'post_type' => 'page',
1957 'post_status' => array('publish', 'future', 'draft', 'pending', 'private'),
1958 'posts_per_page' => 1,
1959 'fields' => 'ids',
1960 'suppress_filters' => true, // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- identity lookup mirroring find_lps_home_page(); query hooks must not alter the match
1961 'no_found_rows' => true,
1962 'meta_query' => array(
1963 'relation' => 'AND',
1964 $project_clause,
1965 array('key' => Metasync_Custom_Pages::META_LPS_PAGE_PATH, 'value' => $page_path),
1966 array('key' => Metasync_Custom_Pages::META_LPS_IMPORT, 'value' => '1'),
1967 ),
1968 ));
1969
1970 return !empty($ids) ? (int) $ids[0] : 0;
1971 }
1972
1973 /**
1974 * Find the next available page path by appending -2, -3, ... to the LEAF
1975 * segment, when the requested slug is taken by content we may not overwrite.
1976 *
1977 * Accepts a full slug path. For a nested slug the suffix is applied to the
1978 * last segment and the candidate is checked at its real position under the
1979 * parent (e.g. "departments/cardiology" -> "departments/cardiology-2"), not
1980 * at the site root.
1981 *
1982 * @param string $slug The desired (already sanitized) slug, possibly nested.
1983 * @return string A full slug path that no existing page currently uses.
1984 */
1985 private function suggest_available_slug($slug)
1986 {
1987 $slug = trim((string) $slug, '/');
1988 $pos = strrpos($slug, '/');
1989 $parent = ($pos === false) ? '' : substr($slug, 0, $pos + 1); // keeps trailing slash
1990 $leaf = ($pos === false) ? $slug : substr($slug, $pos + 1);
1991
1992 for ($i = 2; $i <= 100; $i++) {
1993 $candidate = $parent . $leaf . '-' . $i;
1994 if (!get_page_by_path($candidate, OBJECT, 'page')) {
1995 return $candidate;
1996 }
1997 }
1998
1999 // Extremely unlikely fallback: append a random suffix.
2000 return $parent . $leaf . '-' . wp_rand(1000, 9999);
2001 }
2002
2003 /**
2004 * Recursively delete a directory and its contents.
2005 *
2006 * Safe to call only on paths under wp-content/uploads/metasync-pages/ —
2007 * callers are responsible for that containment check.
2008 */
2009 private function recursive_rmdir($dir)
2010 {
2011 if (!file_exists($dir)) {
2012 return true;
2013 }
2014
2015 if (!is_dir($dir)) {
2016 return @unlink($dir); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- boolean return value is consumed
2017 }
2018
2019 $iterator = new RecursiveIteratorIterator(
2020 new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
2021 RecursiveIteratorIterator::CHILD_FIRST
2022 );
2023
2024 foreach ($iterator as $item) {
2025 if ($item->isDir()) {
2026 @rmdir($item->getPathname()); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- WP_Filesystem would prompt for FTP credentials on non-direct hosts
2027 } else {
2028 @wp_delete_file($item->getPathname());
2029 }
2030 }
2031
2032 return @rmdir($dir); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- WP_Filesystem would prompt for FTP credentials on non-direct hosts
2033 }
2034
2035 /**
2036 * Write exactly one persistent audit record for an LPS ZIP import.
2037 *
2038 * Called from both the REST handler (import_lps_page) and the MCP tool on
2039 * every exit path — success or failure. The record always lands in the
2040 * existing metasync_sync_history table (covered by its bounded cleanup),
2041 * independent of WP_DEBUG. Neither the ZIP bytes nor the page HTML are
2042 * stored: only sizes, counts, slugs and short reasons.
2043 *
2044 * @param array $ctx {
2045 * @type WP_Error|array|null $result Helper result (or WP_Error on failure).
2046 * @type int $http_status Mapped HTTP status (200/207/422/409/423/...).
2047 * @type array $input source_type, zip_size_bytes, assets_folder, pages_in_manifest.
2048 * @type int $start_ms Start time in ms for duration (0 to skip).
2049 * @type string $auth_method bearer|x-api-key|apikey-query|mcp-capability.
2050 * @type string $api_key_prefix Masked key prefix (NEVER the full key).
2051 * @type string $error_message Optional externally-supplied error message.
2052 * }
2053 * @return void
2054 */
2055 public static function write_lps_import_audit(array $ctx)
2056 {
2057 try {
2058 require_once dirname(__FILE__, 2) . '/sync-history/class-metasync-sync-history-database.php';
2059
2060 $result = isset($ctx['result']) ? $ctx['result'] : null;
2061 $http_status = isset($ctx['http_status']) ? (int) $ctx['http_status'] : 500;
2062 $input = (isset($ctx['input']) && is_array($ctx['input'])) ? $ctx['input'] : array();
2063 $start_ms = isset($ctx['start_ms']) ? (int) $ctx['start_ms'] : 0;
2064 $auth_method = isset($ctx['auth_method']) ? (string) $ctx['auth_method'] : '';
2065 $api_key_prefix = isset($ctx['api_key_prefix']) ? (string) $ctx['api_key_prefix'] : '';
2066 $ext_error = isset($ctx['error_message']) ? (string) $ctx['error_message'] : '';
2067 $duration_ms = $start_ms > 0 ? max(0, (int) round(microtime(true) * 1000) - $start_ms) : null;
2068
2069 $per_page = array();
2070 $counts = array('created' => 0, 'updated' => 0, 'failed' => 0, 'skipped' => 0);
2071 $front_page = array('set' => false, 'page_id' => null);
2072 $error_msg = $ext_error;
2073
2074 if ($result instanceof WP_Error) {
2075 $error_msg = $result->get_error_message();
2076 $counts['failed'] = 1;
2077 } elseif (is_array($result) && isset($result['data']) && is_array($result['data'])) {
2078 $data = $result['data'];
2079 $mode = isset($data['mode']) ? $data['mode'] : '';
2080
2081 if ($mode === 'multi') {
2082 foreach ((isset($data['created']) && is_array($data['created'])) ? $data['created'] : array() as $p) {
2083 $per_page[] = array(
2084 'slug' => isset($p['slug']) ? $p['slug'] : '',
2085 'action' => 'created',
2086 'page_id' => isset($p['page_id']) ? $p['page_id'] : null,
2087 'reason' => null,
2088 );
2089 $counts['created']++;
2090 if (!empty($p['is_front_page'])) {
2091 $front_page = array('set' => true, 'page_id' => isset($p['page_id']) ? $p['page_id'] : null);
2092 }
2093 }
2094 foreach ((isset($data['updated']) && is_array($data['updated'])) ? $data['updated'] : array() as $p) {
2095 $per_page[] = array(
2096 'slug' => isset($p['slug']) ? $p['slug'] : '',
2097 'action' => 'updated',
2098 'page_id' => isset($p['page_id']) ? $p['page_id'] : null,
2099 'reason' => null,
2100 );
2101 $counts['updated']++;
2102 if (!empty($p['is_front_page'])) {
2103 $front_page = array('set' => true, 'page_id' => isset($p['page_id']) ? $p['page_id'] : null);
2104 }
2105 }
2106 foreach ((isset($data['failed']) && is_array($data['failed'])) ? $data['failed'] : array() as $p) {
2107 $reason = isset($p['code']) ? $p['code'] : '';
2108 if (isset($p['message'])) {
2109 $reason .= ($reason !== '' ? ': ' : '') . $p['message'];
2110 }
2111 $per_page[] = array(
2112 'slug' => isset($p['slug']) ? $p['slug'] : '',
2113 'action' => 'failed',
2114 'page_id' => null,
2115 'reason' => $reason !== '' ? $reason : 'failed',
2116 );
2117 $counts['failed']++;
2118 }
2119 } elseif ($mode === 'single') {
2120 $single_action = !empty($data['was_update']) ? 'updated' : 'created';
2121 $counts[$single_action] = 1;
2122 $per_page[] = array(
2123 'slug' => isset($data['slug']) ? $data['slug'] : '',
2124 'action' => $single_action,
2125 'page_id' => isset($data['page_id']) ? $data['page_id'] : null,
2126 'reason' => null,
2127 );
2128 }
2129 } elseif ($result === null) {
2130 // Never reached the extraction helper (early validation failure).
2131 $counts['failed'] = 1;
2132 }
2133
2134 if ($http_status === 200) {
2135 $overall_status = 'success';
2136 } elseif ($http_status === 207) {
2137 $overall_status = 'partial';
2138 } elseif ($http_status === 409) {
2139 $overall_status = 'conflict';
2140 } elseif ($http_status === 423) {
2141 $overall_status = 'locked';
2142 } else {
2143 $overall_status = 'failed';
2144 }
2145
2146 $assets_folder = isset($input['assets_folder']) ? $input['assets_folder'] : '';
2147 $title_slug = $assets_folder !== '' ? $assets_folder : (isset($per_page[0]['slug']) && $per_page[0]['slug'] !== '' ? $per_page[0]['slug'] : 'unknown');
2148
2149 $site_url = function_exists('get_site_url') ? get_site_url() : '';
2150
2151 $meta_payload = array(
2152 'timestamp' => function_exists('current_time') ? current_time('mysql') : gmdate('Y-m-d H:i:s'),
2153 'site_url' => $site_url,
2154 'api_key_prefix' => $api_key_prefix,
2155 'plugin_version' => defined('METASYNC_VERSION') ? METASYNC_VERSION : '',
2156 'auth_method' => $auth_method,
2157 'source_type' => isset($input['source_type']) ? $input['source_type'] : 'unknown',
2158 'assets_folder' => $assets_folder,
2159 'external_ref' => isset($input['external_ref']) ? (string) $input['external_ref'] : '',
2160 'zip_size_bytes' => isset($input['zip_size_bytes']) ? (int) $input['zip_size_bytes'] : 0,
2161 'pages_in_manifest' => isset($input['pages_in_manifest']) ? $input['pages_in_manifest'] : null,
2162 'counts' => $counts,
2163 'front_page' => $front_page,
2164 'per_page' => $per_page,
2165 'http_status' => $http_status,
2166 'overall_status' => $overall_status,
2167 'error_message' => $error_msg !== '' ? substr($error_msg, 0, 500) : '',
2168 'duration_ms' => $duration_ms,
2169 );
2170
2171 // User-facing badge status: first import -> Published, re-sync -> Updated,
2172 // nothing imported -> failed. Per-page conflicts are intentionally NOT
2173 // surfaced here (Website Studio notifies the customer to republish).
2174 if ($counts['created'] > 0) {
2175 $db_status = 'published';
2176 } elseif ($counts['updated'] > 0) {
2177 $db_status = 'updated';
2178 } else {
2179 $db_status = 'failed';
2180 }
2181 $db = new Metasync_Sync_History_Database();
2182 $db->add(array(
2183 'title' => 'Website Studio (LPS) import: ' . $title_slug,
2184 'source' => 'Website Studio (LPS)',
2185 'status' => $db_status,
2186 'content_type' => 'lps_import',
2187 'url' => $site_url,
2188 'meta_data' => json_encode($meta_payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
2189 ));
2190
2191 // Optional deep-debug breadcrumb — the DB write above always happens
2192 // regardless of WP_DEBUG; this line is purely supplementary.
2193 if (defined('WP_DEBUG') && WP_DEBUG) {
2194 error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
2195 'Metasync LPS import audit: status=%s http=%d created=%d updated=%d failed=%d',
2196 $overall_status,
2197 $http_status,
2198 $counts['created'],
2199 $counts['updated'],
2200 $counts['failed']
2201 ));
2202 }
2203 } catch (\Throwable $e) {
2204 if (defined('WP_DEBUG') && WP_DEBUG) {
2205 error_log('Metasync LPS audit log failed: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
2206 }
2207 }
2208 }
2209 }
2210