| 1 |
<?php |
| 2 |
|
| 3 |
namespace CryptX; |
| 4 |
|
| 5 |
/** |
| 6 |
* The block editor's way of asking for a protected address. |
| 7 |
* |
| 8 |
* Until now the only way to protect one address deliberately was to type |
| 9 |
* "[cryptx]info@example.com[/cryptx]" into a paragraph. That works, and it goes |
| 10 |
* on working, but nobody discovers it: a shortcode is invisible in the inserter, |
| 11 |
* and the site owner who most needs it is the one least likely to know the |
| 12 |
* syntax. |
| 13 |
* |
| 14 |
* Two properties decide how this is built, and both point the same way: |
| 15 |
* |
| 16 |
* It renders on the server, every time. A block that saved its own markup would |
| 17 |
* freeze one ciphertext into the post content -- and the ciphertext is bound to |
| 18 |
* the site's secret. Change the secret, or restore a site from a backup taken |
| 19 |
* before it was minted, and every saved block becomes a link to nowhere with no |
| 20 |
* indication of what went wrong. Nothing is stored but the address and what the |
| 21 |
* author typed alongside it. |
| 22 |
* |
| 23 |
* And it renders through cryptXShortcode(), rather than reimplementing the three |
| 24 |
* stages. That is not only about avoiding duplication: the shortcode path is |
| 25 |
* where "this is an explicit instruction" lives. A block is the same kind of |
| 26 |
* statement as a shortcode -- somebody pointing at one address and asking for it |
| 27 |
* to be protected -- so it has to outrank the exemption list in exactly the same |
| 28 |
* way, and it does, because it is the same code. |
| 29 |
* |
| 30 |
* @package CryptX |
| 31 |
* @since 4.2.0 |
| 32 |
*/ |
| 33 |
final class Block |
| 34 |
{ |
| 35 |
/** |
| 36 |
* Where the built block lives, relative to the plugin directory. |
| 37 |
* |
| 38 |
* The block.json next to it is what register_block_type() reads; the |
| 39 |
* attributes are declared there once and used by both sides. |
| 40 |
*/ |
| 41 |
private const BUILD_PATH = 'build/block'; |
| 42 |
|
| 43 |
/** |
| 44 |
* The attributes that become mailto headers rather than settings. |
| 45 |
* |
| 46 |
* The same four the shortcode takes, under the same names, so the two |
| 47 |
* cannot drift apart. |
| 48 |
*/ |
| 49 |
private const MAILTO_ATTRIBUTES = ['subject', 'body', 'cc', 'bcc']; |
| 50 |
|
| 51 |
/** |
| 52 |
* Hooks the block in. |
| 53 |
* |
| 54 |
* @return void |
| 55 |
*/ |
| 56 |
public function register(): void |
| 57 |
{ |
| 58 |
add_action('init', [$this, 'registerBlockType']); |
| 59 |
} |
| 60 |
|
| 61 |
/** |
| 62 |
* Declares the block against its own metadata. |
| 63 |
* |
| 64 |
* @return void |
| 65 |
*/ |
| 66 |
public function registerBlockType(): void |
| 67 |
{ |
| 68 |
$path = CRYPTX_DIR_PATH . self::BUILD_PATH; |
| 69 |
|
| 70 |
// A missing build directory is a packaging fault, not a site fault. |
| 71 |
// register_block_type() would emit a _doing_it_wrong() notice on every |
| 72 |
// request; saying nothing is the better failure here, because the |
| 73 |
// editor simply does not offer a block it never heard of. |
| 74 |
if (!file_exists($path . '/block.json')) { |
| 75 |
return; |
| 76 |
} |
| 77 |
|
| 78 |
register_block_type($path, ['render_callback' => [$this, 'render']]); |
| 79 |
} |
| 80 |
|
| 81 |
/** |
| 82 |
* One attribute, as a trimmed string, or nothing. |
| 83 |
* |
| 84 |
* Casting straight to string looks equivalent and is not: an array reaches |
| 85 |
* (string) as "Array" and a PHP warning, which lands in the page or the |
| 86 |
* log. Through the block parser that cannot happen -- WordPress checks the |
| 87 |
* declared type and substitutes the default -- but render() is public, and |
| 88 |
* a method that only behaves when called the expected way is a method that |
| 89 |
* will one day be called another way. |
| 90 |
* |
| 91 |
* @param array<string, mixed> $attributes The attributes. |
| 92 |
* @param string $name Which one. |
| 93 |
* |
| 94 |
* @return string The value, or an empty string if it is not usable. |
| 95 |
*/ |
| 96 |
private static function text(array $attributes, string $name): string |
| 97 |
{ |
| 98 |
$value = $attributes[$name] ?? ''; |
| 99 |
|
| 100 |
return is_string($value) ? trim($value) : ''; |
| 101 |
} |
| 102 |
|
| 103 |
/** |
| 104 |
* Renders one block through the same path the shortcode uses. |
| 105 |
* |
| 106 |
* @param array<string, mixed> $attributes The block's stored attributes. |
| 107 |
* |
| 108 |
* @return string The markup for the front end. |
| 109 |
*/ |
| 110 |
public function render(array $attributes): string |
| 111 |
{ |
| 112 |
// Cleaned before it is judged, and the cleaned form is what gets |
| 113 |
// rendered -- so what the editor accepted and what the page shows are |
| 114 |
// the same string. Judging one and using the other is how a validator |
| 115 |
// ends up guarding nothing. |
| 116 |
// |
| 117 |
// Deliberately not through text(), which trims: PHP's trim() also |
| 118 |
// removes a NUL byte, and NUL is the one character in its set that |
| 119 |
// neither cleaner touches. Trimming here would therefore have made the |
| 120 |
// server accept a value the editor was warning about -- the same |
| 121 |
// disagreement, one character wide. |
| 122 |
$raw = $attributes['address'] ?? ''; |
| 123 |
$address = is_string($raw) ? Exposure::cleanAddress($raw) : ''; |
| 124 |
|
| 125 |
// Validated, and nothing rendered if it fails. The attribute reaches |
| 126 |
// this method exactly as it was stored in the post, and the stages |
| 127 |
// below hand back anything they do not recognise as an address -- |
| 128 |
// unchanged. So "<img src=x onerror=...>@example.com" would have been |
| 129 |
// written into the page verbatim: stored cross-site scripting, needing |
| 130 |
// no more than the right to edit a post. |
| 131 |
// |
| 132 |
// Exposure::isAddress() rather than is_email(), and neither is |
| 133 |
// sanitize_email(). sanitize_email() strips what it dislikes and |
| 134 |
// returns the rest, so it would render a repaired address the author |
| 135 |
// never typed. is_email() is the other way round -- too generous: it |
| 136 |
// accepts what RFC 5321 allows in a local part, and "x'/y'@example.com" |
| 137 |
// passes it while matching none of the plugin's own patterns, so it |
| 138 |
// travelled through every stage untouched and landed in the page in |
| 139 |
// plain text, under a block whose whole promise is the opposite. |
| 140 |
// |
| 141 |
// The gate is therefore the plugin's own idea of an address: if it |
| 142 |
// renders, it is protected, and if it cannot be protected, it does not |
| 143 |
// render. The editor says so before it gets that far. |
| 144 |
if ($address === '' || !Exposure::isAddress($address)) { |
| 145 |
return ''; |
| 146 |
} |
| 147 |
|
| 148 |
$atts = []; |
| 149 |
|
| 150 |
foreach (self::MAILTO_ATTRIBUTES as $name) { |
| 151 |
$value = self::text($attributes, $name); |
| 152 |
|
| 153 |
if ($value !== '') { |
| 154 |
$atts[$name] = $value; |
| 155 |
} |
| 156 |
} |
| 157 |
|
| 158 |
$linkText = self::text($attributes, 'linkText'); |
| 159 |
|
| 160 |
if ($linkText !== '') { |
| 161 |
// opt_linktext=1 is "use the text below"; without it the alternative |
| 162 |
// text is stored and ignored, which is the trap the shortcode's |
| 163 |
// "subject" attribute sat in for years. |
| 164 |
$atts['opt_linktext'] = '1'; |
| 165 |
$atts['alt_linktext'] = $linkText; |
| 166 |
} |
| 167 |
|
| 168 |
$markup = CryptX::get_instance()->cryptXShortcode($atts, $address, 'cryptx'); |
| 169 |
|
| 170 |
// get_block_wrapper_attributes() carries the class names the editor |
| 171 |
// promised -- alignment, custom class, the block's own. Without it a |
| 172 |
// block that looks styled in the editor arrives unstyled on the page. |
| 173 |
// |
| 174 |
// Trimmed and conditional, so an empty result gives "<div>" and not |
| 175 |
// "<div >". |
| 176 |
$wrapper = trim(get_block_wrapper_attributes()); |
| 177 |
|
| 178 |
return sprintf( |
| 179 |
'<div%s>%s</div>', |
| 180 |
$wrapper === '' ? '' : ' ' . $wrapper, |
| 181 |
$markup |
| 182 |
); |
| 183 |
} |
| 184 |
} |
| 185 |
|