PluginProbe
Extendify / 3.1.0
Extendify v3.1.0
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / app / QuickEdit / Controllers / SaveController.php

SaveController.php in Extendify 3.1.0, at app/QuickEdit/Controllers/SaveController.php

625 lines 25.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Extendify\QuickEdit\Controllers;
4
5 defined('ABSPATH') || die('No direct access.');
6
7 use Extendify\Agent\TagBlocks;
8 use Extendify\Agent\TemplatePartBlockFinder;
9 use Extendify\Config;
10 use Extendify\QuickEdit\Schemas\Registry;
11 use Extendify\QuickEdit\Services\BlockFingerprint;
12 use Extendify\QuickEdit\Services\TranslatedContext;
13
14 class SaveController
15 {
16 public static function init()
17 {
18 add_action('rest_api_init', [self::class, 'registerRoutes']);
19 }
20
21 public static function registerRoutes()
22 {
23 register_rest_route('extendify/v1', '/quick-edit/save', [
24 'methods' => 'POST',
25 'permission_callback' => [self::class, 'permissionCallback'],
26 'callback' => [self::class, 'handleSave'],
27 ]);
28
29 register_rest_route('extendify/v1', '/quick-edit/schemas', [
30 'methods' => 'GET',
31 'permission_callback' => [self::class, 'permissionCallback'],
32 'callback' => function () {
33 return new \WP_REST_Response(Registry::describe());
34 },
35 ]);
36 }
37
38 public static function permissionCallback(): bool
39 {
40 return current_user_can(Config::$requiredCapability);
41 }
42
43 public static function handleSave(\WP_REST_Request $req)
44 {
45 $body = $req->get_json_params() ?: [];
46 $source = $body['source'] ?? null;
47 $blockId = isset($body['blockId']) ? (int) $body['blockId'] : 0;
48 $blockType = isset($body['blockType']) ? (string) $body['blockType'] : '';
49 $patches = is_array($body['patches'] ?? null) ? $body['patches'] : [];
50 // patches: schema-driven (image/cover). rawBlock: serialized block markup
51 // from the BlockEditor text editor; bypasses schema apply.
52 $rawBlock = isset($body['rawBlock']) ? (string) $body['rawBlock'] : '';
53
54 if (!is_array($source) || !$blockId || !$blockType || (!$patches && !$rawBlock)) {
55 return new \WP_REST_Response(
56 ['error' => 'source, blockId, blockType + (patches OR rawBlock) required'],
57 400
58 );
59 }
60
61 $schema = Registry::get($blockType);
62 if (!$rawBlock && !$schema) {
63 return new \WP_REST_Response(
64 ['error' => 'no schema registered for block type', 'blockType' => $blockType],
65 400
66 );
67 }
68
69 // rawBlock bypasses schema apply and is spliced in whole, so restrict it
70 // to the text blocks BlockTextEditor actually emits — otherwise an
71 // arbitrary block type could be smuggled through this path.
72 $rawBlockAllowed = ['core/paragraph', 'core/heading', 'core/button'];
73 if ($rawBlock !== '' && !in_array($blockType, $rawBlockAllowed, true)) {
74 return new \WP_REST_Response(
75 ['error' => 'rawBlock not supported for this block type'],
76 400
77 );
78 }
79
80 // A text save rewrites the source post_content, but on a non-default-
81 // language render the user is looking at the translation — writing here
82 // would overwrite the source with the wrong language. The client
83 // suppresses the text editor on those pages; refuse here too so the path
84 // fails closed even when no fingerprint is sent. Image / layout /
85 // schema-patch saves touch shared (untranslated) source and stay allowed.
86 if (self::writesText($rawBlock, $patches) && self::isTranslatedRender($body)) {
87 return new \WP_REST_Response([
88 'error' => 'translated_content',
89 'message' => "Quick Edit can't edit translated content from a non-default-language page.",
90 ], 409);
91 }
92
93 $sourcePost = self::resolveSourcePost($source);
94 if (is_wp_error($sourcePost)) {
95 return new \WP_REST_Response(['error' => $sourcePost->get_error_message()], 404);
96 }
97
98 // edit_posts is a coarse gate; per-source check enforces edit_theme_options
99 // for template parts and edit_post on this specific id for posts.
100 if (!self::userCanEditSource($sourcePost)) {
101 return new \WP_REST_Response(['error' => 'forbidden for this source'], 403);
102 }
103
104 $blocks = parse_blocks($sourcePost->post_content);
105 // TagBlocks counts top-level blocks only (post-content), TagTemplateParts
106 // counts every nested block in preorder — keep the two ID spaces apart
107 // here or `findBlock`'s top-level walk never reaches inner template-part
108 // blockIds (e.g. social-link inside core/social-links in a header).
109 $maxCounter = 0;
110 $visited = [];
111 $found = (($source['kind'] ?? '') === 'template-part')
112 ? TemplatePartBlockFinder::find($blocks, $blockId, $maxCounter, $visited)
113 : self::findBlock($blocks, $blockId);
114 $fingerprint = is_array($body['fingerprint'] ?? null) ? $body['fingerprint'] : [];
115
116 // Block ids are best-effort: TagBlocks numbers at render time while
117 // findBlock re-derives at parse time, and the two diverge on synced
118 // patterns, nested navs, and dynamic expansion. Accept the count-resolved
119 // block only when it's the right type AND carries the clicked block's
120 // fingerprint — matched against the raw markup, then the rendered block
121 // so shortcodes / wptexturize line up with what the client read.
122 $countOk = $found !== null
123 && ($found['block']['blockName'] ?? '') === $blockType
124 && (
125 !$fingerprint
126 || BlockFingerprint::matches($found['block'], $fingerprint)
127 || BlockFingerprint::matches(
128 $found['block'],
129 $fingerprint,
130 self::renderBlockHtml($found['block'], $sourcePost)
131 )
132 );
133
134 if (!$countOk && $fingerprint) {
135 // The count missed or landed on the wrong block; recover by identity
136 // and edit the unique block that carries the fingerprint. Ambiguous
137 // (or absent) → refuse rather than overwrite an unintended block.
138 $matches = self::findBlocksByFingerprint($blocks, $blockType, $fingerprint, $sourcePost);
139 if (count($matches) !== 1) {
140 $resp = [
141 'error' => 'block fingerprint mismatch',
142 'blockId' => $blockId,
143 'candidates' => count($matches),
144 ];
145 // Devmode-only: surface what the post actually holds so a
146 // candidates:0 (text not in storage) vs ambiguous mismatch can be
147 // diagnosed straight from the response.
148 if (defined('EXTENDIFY_DEVMODE') && EXTENDIFY_DEVMODE) {
149 $resp['debug'] = [
150 'wanted' => $fingerprint,
151 'countLanded' => $found ? [
152 'name' => $found['block']['blockName'] ?? null,
153 'text' => self::debugSnippet($found['block']),
154 ] : null,
155 'sameTypeInPost' => self::collectTextsByType($blocks, $blockType),
156 ];
157 }
158 return new \WP_REST_Response($resp, 409);
159 }
160 $found = $matches[0];
161 } elseif (!$countOk) {
162 // No fingerprint to recover with — surface the original count failure.
163 if ($found === null) {
164 return new \WP_REST_Response([
165 'error' => 'block not found in source',
166 'blockId' => $blockId,
167 'maxCounter' => $maxCounter,
168 'sourceKind' => $source['kind'] ?? null,
169 'partSlug' => $source['partSlug'] ?? null,
170 'visited' => $visited,
171 ], 404);
172 }
173 return new \WP_REST_Response([
174 'error' => 'block type mismatch',
175 'expected' => $blockType,
176 'actual' => $found['block']['blockName'] ?? null,
177 ], 409);
178 }
179
180 $targetBlock = $found['block'];
181
182 if ($rawBlock !== '') {
183 $parsed = parse_blocks(wp_unslash($rawBlock));
184 $parsed = array_values(array_filter(
185 $parsed,
186 static fn ($b) => is_array($b) && !empty($b['blockName'])
187 ));
188 if (count($parsed) !== 1) {
189 return new \WP_REST_Response([
190 'error' => 'rawBlock must parse to exactly one block',
191 'parsed_count' => count($parsed),
192 ], 400);
193 }
194 if ($parsed[0]['blockName'] !== $blockType) {
195 return new \WP_REST_Response([
196 'error' => 'rawBlock type does not match blockType',
197 'expected' => $blockType,
198 'got' => $parsed[0]['blockName'],
199 ], 400);
200 }
201 // kses the parsed innerHTML in place so both the persisted content
202 // and the re-rendered HTML echoed back below are sanitized, not just
203 // what wp_update_post stores for non-unfiltered_html users.
204 $inner = wp_kses_post($parsed[0]['innerHTML'] ?? '');
205 if ($blockType === 'core/paragraph') {
206 $inner = self::syncTelLink($inner);
207 }
208 $parsed[0]['innerHTML'] = $inner;
209 $parsed[0]['innerContent'] = [$inner];
210 $targetBlock = $parsed[0];
211 } else {
212 // Patch order matters: some schemas cross-refer to innerHTML
213 // so text-then-align differs from align-then-text.
214 foreach ($patches as $patch) {
215 if (!is_array($patch)) {
216 continue;
217 }
218 $fieldKey = (string) ($patch['fieldKey'] ?? '');
219 if ($fieldKey === '') {
220 continue;
221 }
222 $targetBlock = $schema->apply($targetBlock, $fieldKey, $patch['value'] ?? null);
223 }
224 }
225
226 $blocks = self::replaceBlockAtPath($blocks, $found['path'], $targetBlock);
227 $newContent = serialize_blocks($blocks);
228
229 $update = wp_update_post([
230 'ID' => $sourcePost->ID,
231 'post_content' => wp_slash($newContent),
232 ], true);
233 if (is_wp_error($update)) {
234 return new \WP_REST_Response(['error' => $update->get_error_message()], 500);
235 }
236
237 // Re-render via the same filter chain a live page uses. Counter classes
238 // start at 1 here; client splices via patchVariantClasses to align them.
239 $rendered = self::renderBlockHtml($targetBlock, $sourcePost);
240
241 return new \WP_REST_Response([
242 'ok' => true,
243 'blockId' => $blockId,
244 'blockType' => $blockType,
245 'rendered' => trim($rendered),
246 ]);
247 }
248
249 // Whether the save edits translatable text: the rawBlock text-editor path is
250 // always text, and the schema-patch path is text only for the content / text
251 // fields (align / image / url / service / level are shared, untranslated).
252 private static function writesText(string $rawBlock, array $patches): bool
253 {
254 if ($rawBlock !== '') {
255 return true;
256 }
257 foreach ($patches as $patch) {
258 if (is_array($patch) && in_array((string) ($patch['fieldKey'] ?? ''), ['content', 'text'], true)) {
259 return true;
260 }
261 }
262 return false;
263 }
264
265 // Keep a phone CTA dialing the number the user can see. When a saved
266 // paragraph's content is a single <a href="tel:…"> link, re-point the
267 // anchor's href + data-id at the normalized digits of its visible text and
268 // pin data-type="tel". Editing the digits in RichText keeps the link format
269 // but only swaps the text, leaving href on the number the link was first
270 // built with — without this, tap-to-call dials the stale number.
271 //
272 // Scoped narrowly: only a lone tel: anchor is touched. http/mailto links,
273 // multi-link paragraphs, no link, or text that yields no usable number are
274 // returned unchanged, so an ordinary paragraph's link is never rewritten.
275 // Runs after wp_kses_post; the value written (a tel: URI of digits and an
276 // optional leading +) needs no further sanitizing.
277 private static function syncTelLink(string $innerHtml): string
278 {
279 if (stripos($innerHtml, '<a') === false || stripos($innerHtml, 'tel:') === false) {
280 return $innerHtml;
281 }
282
283 $dom = new \DOMDocument();
284 $previous = libxml_use_internal_errors(true);
285 // The encoding hint stops DOMDocument mangling UTF-8; the flags keep it
286 // from wrapping the fragment in <html>/<body>.
287 $loaded = $dom->loadHTML(
288 '<?xml encoding="utf-8"?>' . $innerHtml,
289 LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
290 );
291 libxml_clear_errors();
292 libxml_use_internal_errors($previous);
293 if (!$loaded) {
294 return $innerHtml;
295 }
296
297 $anchors = $dom->getElementsByTagName('a');
298 if ($anchors->length !== 1) {
299 return $innerHtml;
300 }
301 $anchor = $anchors->item(0);
302 if (stripos((string) $anchor->getAttribute('href'), 'tel:') !== 0) {
303 return $innerHtml;
304 }
305
306 $normalized = self::normalizePhoneNumber($anchor->textContent);
307 if ($normalized === null) {
308 return $innerHtml;
309 }
310 $newHref = 'tel:' . $normalized;
311
312 // Rewrite the single anchor's opening tag only — the <p> wrapper and the
313 // link text stay byte-for-byte intact.
314 return preg_replace_callback(
315 '/<a\b[^>]*>/i',
316 static fn ($match) => self::setTagAttributes($match[0], [
317 'href' => $newHref,
318 'data-id' => $newHref,
319 'data-type' => 'tel',
320 ]),
321 $innerHtml,
322 1
323 );
324 }
325
326 // Reduce visible phone text to bare dialable digits: keep a single leading
327 // + (international prefix) and drop spaces / dashes / parens / other visual
328 // separators. Returns null when the result isn't a plausible phone number
329 // (E.164 caps at 15 digits) so the caller leaves the href untouched rather
330 // than writing a broken tel: link.
331 private static function normalizePhoneNumber(string $text)
332 {
333 $text = trim($text);
334 $plus = (strncmp($text, '+', 1) === 0) ? '+' : '';
335 $digits = (string) preg_replace('/\D+/', '', $text);
336 $length = strlen($digits);
337 if ($length < 7 || $length > 15) {
338 return null;
339 }
340 return $plus . $digits;
341 }
342
343 // Set attributes within a single opening-tag string: replace an existing
344 // attribute's value in place, otherwise inject it before the closing '>'.
345 private static function setTagAttributes(string $tag, array $attributes): string
346 {
347 foreach ($attributes as $name => $value) {
348 $rendered = ' ' . $name . '="' . esc_attr($value) . '"';
349 $pattern = '/\s' . preg_quote($name, '/') . '\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i';
350 $tag = preg_match($pattern, $tag)
351 ? preg_replace($pattern, $rendered, $tag, 1)
352 : preg_replace('/\s*\/?>$/', $rendered . '>', $tag, 1);
353 }
354 return $tag;
355 }
356
357 // The REST save request isn't language-scoped the way the page render is, so
358 // trust the translatedContext the client forwards (detected at enqueue); also
359 // re-check server-side in case this request is language-scoped on its own.
360 private static function isTranslatedRender(array $body): bool
361 {
362 $clientContext = $body['translatedContext'] ?? null;
363 if (is_array($clientContext) && !empty($clientContext['isTranslated'])) {
364 return true;
365 }
366 return !empty(TranslatedContext::detect()['isTranslated']);
367 }
368
369 // Render a single block through the same the_content chain a live page uses
370 // (expanding shortcodes, wptexturize, etc.). In a REST request the main
371 // query has no post, so wp_reset_postdata() can't restore $GLOBALS['post'] —
372 // snapshot and restore it so a template-part save can't leave global $post
373 // dangling for the rest of the request.
374 private static function renderBlockHtml(array $block, \WP_Post $sourcePost): string
375 {
376 $previousPost = $GLOBALS['post'] ?? null;
377 $GLOBALS['post'] = $sourcePost;
378 setup_postdata($sourcePost);
379 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core WP filter
380 $html = apply_filters('the_content', serialize_blocks([$block]));
381 wp_reset_postdata();
382 $GLOBALS['post'] = $previousPost;
383 return (string) $html;
384 }
385
386 // Every block of $blockType that carries the fingerprint, with its path.
387 // The caller only proceeds on a *unique* match — two blocks with the same
388 // content can't be told apart, so that refuses rather than guesses. Skips
389 // ignored dynamic loops and nested template-part scopes. Tries the cheap
390 // raw/fold match across candidates first and only renders (expanding
391 // shortcodes etc.) if nothing matched raw.
392 private static function findBlocksByFingerprint(
393 array $blocks,
394 string $blockType,
395 array $fingerprint,
396 \WP_Post $sourcePost
397 ): array {
398 $ignored = TagBlocks::$ignored;
399 $candidates = [];
400 $walk = function (array $list, array $pathSoFar) use (&$walk, &$candidates, $blockType, $ignored) {
401 foreach ($list as $i => $block) {
402 $name = $block['blockName'] ?? '';
403 if ($name === '') {
404 if (!empty($block['innerBlocks'])) {
405 $walk($block['innerBlocks'], array_merge($pathSoFar, [$i, 'innerBlocks']));
406 }
407 continue;
408 }
409 if ($name === 'core/template-part' || in_array($name, $ignored, true)) {
410 continue;
411 }
412 if ($name === $blockType) {
413 $candidates[] = ['block' => $block, 'path' => array_merge($pathSoFar, [$i])];
414 }
415 if (!empty($block['innerBlocks'])) {
416 $walk($block['innerBlocks'], array_merge($pathSoFar, [$i, 'innerBlocks']));
417 }
418 }
419 };
420 $walk($blocks, []);
421
422 $raw = array_values(array_filter(
423 $candidates,
424 static fn ($c) => BlockFingerprint::matches($c['block'], $fingerprint)
425 ));
426 if ($raw) {
427 return $raw;
428 }
429
430 $rendered = array_values(array_filter(
431 $candidates,
432 static fn ($c) => BlockFingerprint::matches(
433 $c['block'],
434 $fingerprint,
435 self::renderBlockHtml($c['block'], $sourcePost)
436 )
437 ));
438 if ($rendered) {
439 return $rendered;
440 }
441
442 // Last resort: a block-level shortcode render (e.g. [products]) splits
443 // the paragraph in the browser, so the live element's text — and thus
444 // the fingerprint — is truncated to a prefix of the stored block.
445 return array_values(array_filter(
446 $candidates,
447 static fn ($c) => BlockFingerprint::matches($c['block'], $fingerprint, '', true)
448 ));
449 }
450
451 private static function debugSnippet(array $block): string
452 {
453 return mb_substr(trim((string) wp_strip_all_tags((string) ($block['innerHTML'] ?? ''))), 0, 80);
454 }
455
456 // Devmode diagnostics: the raw text of every block of $blockType in the
457 // post, so a fingerprint mismatch can be compared against what's stored.
458 private static function collectTextsByType(array $blocks, string $blockType): array
459 {
460 $out = [];
461 $ignored = TagBlocks::$ignored;
462 $walk = function (array $list) use (&$walk, &$out, $blockType, $ignored) {
463 foreach ($list as $block) {
464 $name = $block['blockName'] ?? '';
465 if ($name === '') {
466 if (!empty($block['innerBlocks'])) {
467 $walk($block['innerBlocks']);
468 }
469 continue;
470 }
471 if ($name === 'core/template-part' || in_array($name, $ignored, true)) {
472 continue;
473 }
474 if ($name === $blockType) {
475 $out[] = self::debugSnippet($block);
476 }
477 if (!empty($block['innerBlocks'])) {
478 $walk($block['innerBlocks']);
479 }
480 }
481 };
482 $walk($blocks);
483 return $out;
484 }
485
486 /**
487 * @return \WP_Post|\WP_Error
488 */
489 private static function resolveSourcePost(array $source)
490 {
491 $kind = (string) ($source['kind'] ?? '');
492
493 if ($kind === 'post') {
494 $id = (int) ($source['id'] ?? 0);
495 $post = $id ? get_post($id) : null;
496 if (!$post) {
497 return new \WP_Error('not_found', 'post not found');
498 }
499 $disallowed = ['revision', 'wp_navigation', 'wp_template',
500 'wp_template_part', 'wp_block', 'attachment'];
501 if (
502 in_array($post->post_type, $disallowed, true)
503 || $post->post_status === 'auto-draft'
504 ) {
505 return new \WP_Error(
506 'post_type_not_supported',
507 'Edit this content via its dedicated endpoint'
508 );
509 }
510 return $post;
511 }
512
513 if ($kind === 'template-part') {
514 $slug = (string) ($source['partSlug'] ?? '');
515 if ($slug === '') {
516 return new \WP_Error('bad_source', 'template-part requires partSlug');
517 }
518 // Use WP's own resolver so the save lands on the post WP renders
519 // from. Raw get_posts by name returns rows from every wp_theme
520 // term — when an install has had multiple theme variants active
521 // at different times (e.g. `extendable` and `extendable-2` both
522 // owning a "header" post), the wrong row wins on post_date
523 // ordering and we patch a stale orphan instead of the live part.
524 $stylesheet = wp_get_theme()->get_stylesheet();
525 $template = get_block_template("{$stylesheet}//{$slug}", 'wp_template_part');
526 if (!$template || empty($template->wp_id)) {
527 return new \WP_Error('not_found', 'template-part not found');
528 }
529 $post = get_post($template->wp_id);
530 if (!$post) {
531 return new \WP_Error('not_found', 'template-part not found');
532 }
533 return $post;
534 }
535
536 return new \WP_Error('bad_source', 'unknown source kind');
537 }
538
539 private static function userCanEditSource(\WP_Post $post): bool
540 {
541 if ($post->post_type === 'wp_template_part') {
542 return current_user_can('edit_theme_options');
543 }
544 return current_user_can('edit_post', $post->ID);
545 }
546
547 // Walks the parsed-block tree the same way TagBlocks counts on the front-end
548 // so client blockIds line up with what the server resolves.
549 private static function findBlock(array $blocks, int $targetId)
550 {
551 $ignored = TagBlocks::$ignored;
552 $counter = 0;
553 $found = null;
554
555 $walk = function (array &$list, array $pathSoFar, int $skipDepth)
556 use (&$walk, &$counter, &$found, $targetId, $ignored) {
557 foreach ($list as $i => &$block) {
558 if (empty($block['blockName'])) {
559 if (!empty($block['innerBlocks'])) {
560 $walk($block['innerBlocks'], array_merge($pathSoFar, [$i, 'innerBlocks']), $skipDepth);
561 if ($found !== null) {
562 return;
563 }
564 }
565 continue;
566 }
567 $isIgnored = in_array($block['blockName'], $ignored, true);
568 if ($isIgnored || $skipDepth > 0) {
569 if (!empty($block['innerBlocks'])) {
570 $walk(
571 $block['innerBlocks'],
572 array_merge($pathSoFar, [$i, 'innerBlocks']),
573 $skipDepth + ($isIgnored ? 1 : 0)
574 );
575 if ($found !== null) {
576 return;
577 }
578 }
579 continue;
580 }
581 $counter++;
582 if ($counter === $targetId) {
583 $found = ['block' => $block, 'path' => array_merge($pathSoFar, [$i])];
584 return;
585 }
586 if (!empty($block['innerBlocks'])) {
587 $walk($block['innerBlocks'], array_merge($pathSoFar, [$i, 'innerBlocks']), 0);
588 if ($found !== null) {
589 return;
590 }
591 }
592 }
593 unset($block);
594 };
595 $walk($blocks, [], 0);
596
597 return $found;
598 }
599
600 // Path elements alternate index / 'innerBlocks' / index / 'innerBlocks' / ...
601 private static function replaceBlockAtPath(array $blocks, array $path, array $newBlock): array
602 {
603 if (empty($path)) {
604 return $blocks;
605 }
606 $head = $path[0];
607 $rest = array_slice($path, 1);
608 if (!is_int($head) || !isset($blocks[$head])) {
609 return $blocks;
610 }
611 if (empty($rest)) {
612 $blocks[$head] = $newBlock;
613 return $blocks;
614 }
615 if ($rest[0] === 'innerBlocks') {
616 $blocks[$head]['innerBlocks'] = self::replaceBlockAtPath(
617 $blocks[$head]['innerBlocks'] ?? [],
618 array_slice($rest, 1),
619 $newBlock
620 );
621 }
622 return $blocks;
623 }
624 }
625