PluginProbe
CryptX / 4.2.1
CryptX v4.2.1
4.2.1 4.2.0 4.1.1 trunk 1.0 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.9 2.0 2.1 2.2 2.3 2.3.1 2.3.2 2.3.3 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 All 93 releases
cryptx / classes / Block.php

Block.php in CryptX 4.2.1, at classes/Block.php

185 lines 7.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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