[$this, 'render']]); } /** * One attribute, as a trimmed string, or nothing. * * Casting straight to string looks equivalent and is not: an array reaches * (string) as "Array" and a PHP warning, which lands in the page or the * log. Through the block parser that cannot happen -- WordPress checks the * declared type and substitutes the default -- but render() is public, and * a method that only behaves when called the expected way is a method that * will one day be called another way. * * @param array $attributes The attributes. * @param string $name Which one. * * @return string The value, or an empty string if it is not usable. */ private static function text(array $attributes, string $name): string { $value = $attributes[$name] ?? ''; return is_string($value) ? trim($value) : ''; } /** * Renders one block through the same path the shortcode uses. * * @param array $attributes The block's stored attributes. * * @return string The markup for the front end. */ public function render(array $attributes): string { // Cleaned before it is judged, and the cleaned form is what gets // rendered -- so what the editor accepted and what the page shows are // the same string. Judging one and using the other is how a validator // ends up guarding nothing. // // Deliberately not through text(), which trims: PHP's trim() also // removes a NUL byte, and NUL is the one character in its set that // neither cleaner touches. Trimming here would therefore have made the // server accept a value the editor was warning about -- the same // disagreement, one character wide. $raw = $attributes['address'] ?? ''; $address = is_string($raw) ? Exposure::cleanAddress($raw) : ''; // Validated, and nothing rendered if it fails. The attribute reaches // this method exactly as it was stored in the post, and the stages // below hand back anything they do not recognise as an address -- // unchanged. So "@example.com" would have been // written into the page verbatim: stored cross-site scripting, needing // no more than the right to edit a post. // // Exposure::isAddress() rather than is_email(), and neither is // sanitize_email(). sanitize_email() strips what it dislikes and // returns the rest, so it would render a repaired address the author // never typed. is_email() is the other way round -- too generous: it // accepts what RFC 5321 allows in a local part, and "x'/y'@example.com" // passes it while matching none of the plugin's own patterns, so it // travelled through every stage untouched and landed in the page in // plain text, under a block whose whole promise is the opposite. // // The gate is therefore the plugin's own idea of an address: if it // renders, it is protected, and if it cannot be protected, it does not // render. The editor says so before it gets that far. if ($address === '' || !Exposure::isAddress($address)) { return ''; } $atts = []; foreach (self::MAILTO_ATTRIBUTES as $name) { $value = self::text($attributes, $name); if ($value !== '') { $atts[$name] = $value; } } $linkText = self::text($attributes, 'linkText'); if ($linkText !== '') { // opt_linktext=1 is "use the text below"; without it the alternative // text is stored and ignored, which is the trap the shortcode's // "subject" attribute sat in for years. $atts['opt_linktext'] = '1'; $atts['alt_linktext'] = $linkText; } $markup = CryptX::get_instance()->cryptXShortcode($atts, $address, 'cryptx'); // get_block_wrapper_attributes() carries the class names the editor // promised -- alignment, custom class, the block's own. Without it a // block that looks styled in the editor arrives unstyled on the page. // // Trimmed and conditional, so an empty result gives "
" and not // "
". $wrapper = trim(get_block_wrapper_attributes()); return sprintf( '%s
', $wrapper === '' ? '' : ' ' . $wrapper, $markup ); } }