| 1 |
<?php |
| 2 |
|
| 3 |
namespace Extendify\QuickEdit\Services; |
| 4 |
|
| 5 |
defined('ABSPATH') || die('No direct access.'); |
| 6 |
|
| 7 |
// Render-time identity check for a clicked block. The client reads a block's |
| 8 |
// integer id from a render-time data attribute on the live DOM node it |
| 9 |
// clicked; the server re-derives that id by counting the parsed post. The two |
| 10 |
// can disagree (synced patterns, nested navs, dynamic expansion) and land on a |
| 11 |
// different block of the same type — past the type guard. The client also |
| 12 |
// sends a fingerprint of the block it actually clicked, and this refuses the |
| 13 |
// save when the resolved block doesn't carry it. |
| 14 |
// |
| 15 |
// The fingerprint is read from the LIVE element on the client, never the |
| 16 |
// cached block source — that source is resolved by the same count as the save, |
| 17 |
// so it would echo a misresolve and the check would pass on the wrong block. |
| 18 |
class BlockFingerprint |
| 19 |
{ |
| 20 |
// True when every field the client provided matches the resolved block. |
| 21 |
// Fields are independent and additive (text, service); an absent field |
| 22 |
// doesn't constrain the match, so a client that sends nothing fails open. |
| 23 |
// |
| 24 |
// The client reads text from the rendered DOM, so when a shortcode or other |
| 25 |
// the_content transform expands it differently than the stored markup, the |
| 26 |
// caller can pass the block's rendered HTML as an extra text candidate. |
| 27 |
// |
| 28 |
// $prefix matches when the fingerprint is only a *prefix* of the block's |
| 29 |
// text. A block-level shortcode render (e.g. [products] -> a <div>) can't |
| 30 |
// nest in a <p>, so the browser splits the paragraph and the live element's |
| 31 |
// text is truncated at that point — the fingerprint becomes a prefix of the |
| 32 |
// stored block. Used as a last-resort recovery, unique-match only. |
| 33 |
public static function matches( |
| 34 |
array $block, |
| 35 |
array $fingerprint, |
| 36 |
string $renderedText = '', |
| 37 |
bool $prefix = false |
| 38 |
): bool { |
| 39 |
if (isset($fingerprint['service'])) { |
| 40 |
$service = (string) ($block['attrs']['service'] ?? ''); |
| 41 |
if ($service !== (string) $fingerprint['service']) { |
| 42 |
return false; |
| 43 |
} |
| 44 |
} |
| 45 |
|
| 46 |
if (isset($fingerprint['text'])) { |
| 47 |
$want = self::normalize((string) $fingerprint['text']); |
| 48 |
// Visible text lives in attrs.label for dynamic items (nav |
| 49 |
// link/submenu) and in innerHTML for static text blocks (paragraph, |
| 50 |
// heading, button) — accept either, plus the rendered text if given. |
| 51 |
$candidates = [ |
| 52 |
self::normalize((string) ($block['attrs']['label'] ?? '')), |
| 53 |
self::normalize(self::stripText((string) ($block['innerHTML'] ?? ''))), |
| 54 |
]; |
| 55 |
if ($renderedText !== '') { |
| 56 |
$candidates[] = self::normalize(self::stripText($renderedText)); |
| 57 |
} |
| 58 |
$textOk = false; |
| 59 |
foreach ($candidates as $candidate) { |
| 60 |
$hit = $prefix |
| 61 |
? ($want !== '' && strncmp($candidate, $want, strlen($want)) === 0) |
| 62 |
: ($candidate === $want); |
| 63 |
if ($hit) { |
| 64 |
$textOk = true; |
| 65 |
break; |
| 66 |
} |
| 67 |
} |
| 68 |
if (!$textOk) { |
| 69 |
return false; |
| 70 |
} |
| 71 |
} |
| 72 |
|
| 73 |
return true; |
| 74 |
} |
| 75 |
|
| 76 |
private static function stripText(string $html): string |
| 77 |
{ |
| 78 |
return html_entity_decode(wp_strip_all_tags($html), ENT_QUOTES); |
| 79 |
} |
| 80 |
|
| 81 |
// Fold wptexturize's typographic substitutions back to ASCII: the client |
| 82 |
// reads the block's text from the rendered (texturized) DOM while this |
| 83 |
// fingerprints the raw stored markup, so without folding an apostrophe |
| 84 |
// alone ("Woody's" vs "Woody’s") falses a 409. Must stay in lockstep with |
| 85 |
// normalizeText in src/QuickEdit/lib/fingerprint.js. |
| 86 |
private static function normalize(string $value): string |
| 87 |
{ |
| 88 |
$value = strtr($value, [ |
| 89 |
"\u{2018}" => "'", "\u{2019}" => "'", "\u{201A}" => "'", "\u{201B}" => "'", |
| 90 |
"\u{201C}" => '"', "\u{201D}" => '"', "\u{201E}" => '"', "\u{201F}" => '"', |
| 91 |
"\u{2012}" => '-', "\u{2013}" => '-', "\u{2014}" => '-', "\u{2015}" => '-', |
| 92 |
"\u{2026}" => '...', |
| 93 |
"\u{00A0}" => ' ', "\u{2009}" => ' ', "\u{202F}" => ' ', |
| 94 |
]); |
| 95 |
$value = (string) preg_replace('/-{2,}/', '-', $value); |
| 96 |
return trim((string) preg_replace('/\s+/', ' ', $value)); |
| 97 |
} |
| 98 |
} |
| 99 |
|