PluginProbe
CryptX / 3.5.2
CryptX v3.5.2
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
← All changes | classes/CryptX.php +354 -1526 4.1.13.5.2 View file →
@@ -3,108 +3,29 @@
3 3 namespace CryptX;
4 4
5 5 final class CryptX
6 6 {
7 +
7 8 const NOT_FOUND = false;
8 -
9 - /**
10 - * Kept for compatibility: it is public, so a theme may reference it.
11 - *
12 - * @deprecated 4.1.1 The guard that used it compared against an already
13 - * sanitised address and could therefore never match --
14 - * sanitize_email('?subject=x') returns an empty string. Query
15 - * handling now lives in sanitizeMailtoQuery().
16 - */
9 + const MAIL_IDENTIFIER = 'mailto:';
17 10 const SUBJECT_IDENTIFIER = "?subject=";
18 -
19 - /** Upper bound for a single mailto header value, in characters. */
20 - private const MAX_MAILTO_VALUE_LENGTH = 512;
21 -
22 - /**
23 - * Upper bound for the whole "mailto:..." target, in characters.
24 - *
25 - * Matches CONFIG.MAX_URL_LENGTH in js/cryptx.js and the limit in
26 - * SecureEncryption::validateUrl(). Above it the click handler refuses to
27 - * navigate, and the link silently does nothing.
28 - */
29 - private const MAX_MAILTO_URL_LENGTH = 2048;
30 -
31 - /**
32 - * Shortcode attributes that describe the mail, not the plugin's settings.
33 - *
34 - * The names are those of the mailto headers in RFC 6068, so
35 - * [cryptx subject="..."] and href="mailto:...?subject=..." mean the same
36 - * thing and are cleaned by the same code.
37 - */
38 - private const MAILTO_ATTRIBUTES = ['subject', 'body', 'cc', 'bcc'];
39 -
40 - /**
41 - * Filters after which WordPress expands shortcodes.
42 - *
43 - * Measured, not assumed: has_filter($name, 'do_shortcode') is 11 for these
44 - * four and false for the other five CryptX hangs on. Only here may an
45 - * unexpanded [cryptx] be set aside, because only here does something come
46 - * along afterwards to deal with it.
47 - */
48 - /**
49 - * The feed counterpart of each content filter.
50 - *
51 - * WordPress builds a feed from its own filters, not from the ones that
52 - * render a page: <description> comes from 'the_excerpt_rss',
53 - * <content:encoded> from 'the_content_feed'.
54 - */
55 - private const FEED_FILTERS = [
56 - 'the_content' => 'the_content_feed',
57 - 'the_excerpt' => 'the_excerpt_rss',
58 - 'comment_text' => 'comment_text_rss',
59 - ];
60 -
61 - private const SHORTCODE_EXPANDED_AFTER = [
62 - 'the_content',
63 - 'render_block',
64 - 'widget_text_content',
65 - 'widget_block_content',
66 - ];
11 + const INDEX_TO_CHECK = 4;
12 + const PATTERN = '/(.*)(">)/i';
67 13 const ASCII_VALUES_BLACKLIST = ['32', '34', '39', '60', '62', '63', '92', '94', '96', '127'];
68 - /** Upper bound for the text rendered into a PNG, see cryptXtinyUrl(). */
69 - private const MAX_IMAGE_TEXT_LENGTH = 254;
70 14 private static ?self $instance = null;
71 15 private static array $cryptXOptions = [];
72 16 private static int $imageCounter = 0;
73 -
74 - /** CSS class of the links the click handler in cryptx.js listens for. */
75 - private const LINK_CLASS = 'cryptx-link';
76 -
77 - /** Marks a save request as coming from the post meta box. */
78 - private const METABOX_NONCE_ACTION = 'cryptx_metabox';
79 - private const METABOX_NONCE_FIELD = 'cryptx_metabox_nonce';
80 -
81 - /**
82 - * Set as soon as something on this page actually needs them. Version 3.2.7
83 - * once had this property ("the javascript will be loaded only if really
84 - * needed!"); the 4.0 rewrite lost it and loaded both files on every page,
85 - * including pages without a single address.
86 - */
87 - private static bool $scriptNeeded = false;
88 - private static bool $styleNeeded = false;
89 -
90 - /**
91 - * Parsed once per request instead of on every call. Both lists are read
92 - * from a comma separated option for every filter pass and, in the case of
93 - * the whitelist, for every single address found.
94 - */
95 - private static ?array $excludedIdCache = null;
96 - private static ?array $whiteListCache = null;
97 -
98 17 private const FONT_EXTENSION = 'ttf';
99 18 private const PAYPAL_DONATION_URL = 'https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=4026696';
100 - private Admin\SettingsPage $settingsPage;
19 + private const MAILTO_PATTERN = '/<a (.*?)(href=("|\')mailto:(.*?)("|\')(.*?)|)>\s*(.*?)\s*<\/a>/i';
20 + private const EMAIL_PATTERN = "/([_a-zA-Z0-9-+]+(\.[_a-zA-Z0-9-+]+)*@[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*(\.[a-zA-Z]{2,}))/i";
21 + private CryptXSettingsTabs $settingsTabs;
101 22 private Config $config;
102 23
103 24 private function __construct()
104 25 {
105 - $this->settingsPage = new Admin\SettingsPage();
106 - $this->config = new Config(get_option('cryptX', []));
26 + $this->settingsTabs = new CryptXSettingsTabs($this);
27 + $this->config = new Config( get_option('cryptX', []) );
107 28 self::$cryptXOptions = $this->loadCryptXOptionsWithDefaults();
108 29 }
109 30
110 31 /**
@@ -138,15 +59,9 @@
138 59 * @return void
139 60 */
140 61 public function startCryptX(): void
141 62 {
142 - // The settings screen registers its own menu entry and REST routes.
143 - // Doing it here rather than in the constructor keeps the hooks out of
144 - // object construction, where they are easy to trigger by accident.
145 - $this->settingsPage->register();
146 -
147 63 $this->checkAndUpdateVersion();
148 - $this->addUniversalWidgetFilters(); // Add this line
149 64 $this->initializePluginFilters();
150 65 $this->registerCoreHooks();
151 66 $this->initializeMetaBoxIfEnabled();
152 67 $this->registerAdditionalHooks();
@@ -152,52 +67,8 @@
152 67 $this->registerAdditionalHooks();
153 68 }
154 69
155 70 /**
156 - * Rebuilds the cached options and configuration for the site now in scope.
157 - *
158 - * Hooked to 'switch_blog', which WordPress fires for both switch_to_blog()
159 - * and restore_current_blog(), so the object follows the site rather than
160 - * the request.
161 - *
162 - * @return void
163 - */
164 - public function refreshForCurrentSite(): void
165 - {
166 - // wp_insert_site() switches into the new site BEFORE its tables exist,
167 - // and reading options there produces a database error in the log while
168 - // telling us nothing. wp_is_site_initialized() answers the question
169 - // without that -- it suppresses errors around its own query.
170 - //
171 - // The flag is not needed for the call below as the core stands today:
172 - // wp_is_site_initialized() only switches when the id differs from the
173 - // current one (wp-includes/ms-site.php), and we pass our own. It is
174 - // here for the two ways that changes -- a plugin filtering
175 - // 'pre_wp_is_site_initialized', or a later core version that switches
176 - // unconditionally -- either of which would call this method back into
177 - // itself.
178 - static $busy = false;
179 -
180 - if ($busy) {
181 - return;
182 - }
183 -
184 - $busy = true;
185 -
186 - try {
187 - if (is_multisite() && !wp_is_site_initialized(get_current_blog_id())) {
188 - return;
189 - }
190 -
191 - $this->config = new Config(get_option('cryptX', []));
192 - self::$cryptXOptions = $this->loadCryptXOptionsWithDefaults();
193 - self::resetOptionCaches();
194 - } finally {
195 - $busy = false;
196 - }
197 - }
198 -
199 - /**
200 71 * Checks the current version of the application against the stored version and updates settings if the application version is newer.
201 72 *
202 73 * @return void
203 74 */
@@ -209,95 +80,22 @@
209 80 }
210 81 }
211 82
212 83 /**
213 - * Initializes and registers plugin filters based on the configuration settings.
84 + * Initializes and applies plugin filters based on the defined configuration options.
214 85 *
215 - * This method retrieves the active filters from the configuration and applies
216 - * each filter by either adding widget-specific filters or other plugin-related filters.
217 - * If the theme is a block theme, it transforms certain filters to an appropriate block-based equivalent.
218 - * It also checks if autolink functionality is enabled and adds the respective filters when applicable.
219 - *
220 86 * @return void
221 87 */
222 - public function initializePluginFilters(): void
88 + private function initializePluginFilters(): void
223 89 {
224 - if (empty($this->config)) {
225 - return;
226 - }
227 -
228 - $activeFilters = $this->config->getActiveFilters();
229 -
230 - if (function_exists('wp_is_block_theme') && wp_is_block_theme()) {
231 - $activeFilters = array_map(
232 - fn($value) => $value === 'the_content' ? 'render_block' : $value,
233 - $activeFilters
234 - );
235 - }
236 -
237 - foreach ($activeFilters as $filter) {
238 - if ($filter === 'widget_text') {
239 - $this->addWidgetFilters();
240 - } else {
241 - // Add autolink filters for non-widget filters if autolink is enabled
242 - if ($this->config->isAutolinkEnabled()) {
243 - $this->addAutoLinkFilters($filter, 11);
244 - }
245 - $this->addOtherFilters($filter);
90 + foreach (self::$cryptXOptions['filter'] as $filter) {
91 + if (isset(self::$cryptXOptions[$filter]) && self::$cryptXOptions[$filter]) {
92 + $this->addPluginFilters($filter);
246 93 }
247 94 }
248 -
249 - $this->addFeedFilters();
250 95 }
251 96
252 97 /**
253 - * Registers the feed counterparts of the active content filters.
254 - *
255 - * Without these, "Leave RSS feeds unprotected = off" only half worked. A
256 - * feed's <description> comes from the_excerpt_rss(), and nothing CryptX
257 - * hangs on runs on the way there: on a block theme the plugin sits on
258 - * 'render_block', which fires only from do_blocks() -- and
259 - * wp_trim_excerpt() detaches do_blocks before building the excerpt. The
260 - * address went out in the feed while the setting said it would not.
261 - *
262 - * The guard inside the three stages stays as it is; it is what makes the
263 - * option work in the other direction, for filters that run in both feed
264 - * and page context.
265 - *
266 - * @return void
267 - */
268 - private function addFeedFilters(): void
269 - {
270 - // The default: feeds are deliberately left alone, because a feed
271 - // reader runs no JavaScript and a protected link would be dead in it.
272 - //
273 - // Read from the store rather than from the static list. The two agree
274 - // when this runs during startup, but the static one is swapped for the
275 - // duration of a shortcode and of the settings preview -- and a method
276 - // that decides which hooks exist has no business depending on which of
277 - // those happened to be in flight.
278 - $options = $this->loadCryptXOptionsWithDefaults();
279 -
280 - if (!empty($options['disable_rss'])) {
281 - return;
282 - }
283 -
284 - foreach ($this->config->getActiveFilters() as $filter) {
285 - if (!isset(self::FEED_FILTERS[$filter])) {
286 - continue;
287 - }
288 -
289 - $feedFilter = self::FEED_FILTERS[$filter];
290 -
291 - if ($this->config->isAutolinkEnabled()) {
292 - $this->addAutoLinkFilters($feedFilter, 11);
293 - }
294 -
295 - $this->addOtherFilters($feedFilter);
296 - }
297 - }
298 -
299 - /**
300 98 * Registers core hooks for the plugin's functionality.
301 99 *
302 100 * @return void
303 101 */
@@ -303,20 +101,9 @@
303 101 */
304 102 private function registerCoreHooks(): void
305 103 {
306 104 add_action('activate_' . CRYPTX_BASENAME, [$this, 'installCryptX']);
307 -
308 - // Multisite: this object is built once per request, from whichever site
309 - // was current at the time. switch_to_blog() changes what get_option()
310 - // returns but not what this instance already holds -- and
311 - // getCryptXOptionsDefaults() hands out $this->config, which is the
312 - // FIRST site's stored values, not a set of defaults. Everything read
313 - // after a switch therefore came from the wrong site, up to and
314 - // including its encryption secret.
315 - add_action('switch_blog', [$this, 'refreshForCurrentSite']);
316 105 add_action('wp_enqueue_scripts', [$this, 'loadJavascriptFiles']);
317 - // Priority 1 so this still runs before wp_print_footer_scripts.
318 - add_action('wp_footer', [$this, 'enqueueAssetsIfNeeded'], 1);
319 106 }
320 107
321 108 /**
322 109 * Initializes the meta box functionality if enabled in the configuration.
@@ -332,16 +119,10 @@
332 119 return;
333 120 }
334 121
335 122 add_action('admin_menu', [$this, 'metaBox']);
336 -
337 - // Only 'wp_insert_post'. There was a second registration on
338 - // 'wp_update_post' -- a hook WordPress does not have: the core defines
339 - // a *function* of that name, and the only do_action() calls are
340 - // 'wp_insert_post' in wp-includes/post.php. Since wp_update_post()
341 - // routes through wp_insert_post(), an update was covered all along;
342 - // the line did nothing and suggested it did.
343 123 add_action('wp_insert_post', [$this, 'addPostIdToExcludedList']);
124 + add_action('wp_update_post', [$this, 'addPostIdToExcludedList']);
344 125 }
345 126
346 127 /**
347 128 * Registers additional WordPress hooks and shortcodes.
@@ -350,11 +131,9 @@
350 131 */
351 132 private function registerAdditionalHooks(): void
352 133 {
353 134 add_filter('plugin_row_meta', [$this, 'add_plugin_action_links'], 10, 2);
354 - // add_action, nicht add_filter: 'init' ist eine Action. Intern
355 - // dasselbe, aber der Aufruf soll sagen, was er tut.
356 - add_action('init', [$this, 'cryptXtinyUrl']);
135 + add_filter('init', [$this, 'cryptXtinyUrl']);
357 136 add_shortcode('cryptx', [$this, 'cryptXShortcode']);
358 137 }
359 138
360 139 /**
@@ -364,13 +143,13 @@
364 143 */
365 144 public function getCryptXOptionsDefaults(): array
366 145 {
367 146 return array_merge(
368 - $this->config->getAll(),
369 - [
370 - 'version' => CRYPTX_VERSION,
371 - 'c2i_font' => $this->getDefaultFont()
372 - ]
147 + $this->config->getAll(),
148 + [
149 + 'version' => CRYPTX_VERSION,
150 + 'c2i_font' => $this->getDefaultFont()
151 + ]
373 152 );
374 153 }
375 154
376 155 /**
@@ -380,10 +159,10 @@
380 159 */
381 160 private function getDefaultFont(): ?string
382 161 {
383 162 $availableFonts = $this->getFilesInDirectory(
384 - CRYPTX_DIR_PATH . 'fonts',
385 - [self::FONT_EXTENSION]
163 + CRYPTX_DIR_PATH . 'fonts',
164 + [self::FONT_EXTENSION]
386 165 );
387 166
388 167 return $availableFonts[0] ?? null;
389 168 }
@@ -418,168 +197,8 @@
418 197 *
419 198 * @param array $attributes The array of attributes, potentially encoded.
420 199 * @return array The array of decoded attributes with the 'encoded' key removed if present.
421 200 */
422 - /**
423 - * Runs a processing step with unexpanded [cryptx] shortcodes masked out.
424 - *
425 - * On a block theme the three filters hang on 'render_block', which fires
426 - * from do_blocks() at 'the_content' priority 9 -- while do_shortcode()
427 - * runs at priority 11. CryptX therefore sees the shortcode as raw text,
428 - * long before it becomes anything.
429 - *
430 - * Left alone, that ends badly in two ways. The address inside
431 - * "[cryptx]info@example.com[/cryptx]" is not linked, because it sits
432 - * behind a "]", yet the display stage replaces it anyway -- the same
433 - * silent failure the autolink patterns were widened for. And once the
434 - * replacement inserts "[at]" and "[dot]", the new square brackets tear the
435 - * shortcode apart, so the parser later prints the wreckage into the page.
436 - *
437 - * Masking hands the shortcode to do_shortcode() untouched. It does its own
438 - * encrypting, with its own attributes, exactly as on a classic theme.
439 - *
440 - * @param string $content The content.
441 - * @param callable $process Receives the masked content, returns the result.
442 - *
443 - * @return string The processed content, with the shortcodes back in place.
444 - */
445 - private function withShortcodesProtected(string $content, callable $process): string
446 - {
447 - // Masking is only safe where do_shortcode() runs after us. In
448 - // 'comment_text', 'the_excerpt', 'the_meta_key', 'widget_text' and
449 - // 'widget_custom_html_content' it does not -- WordPress never expands
450 - // shortcodes there. Masking unconditionally therefore handed the
451 - // address to nobody at all: it was skipped here and never picked up
452 - // later, and a "[cryptx]" written into a comment shipped the address in
453 - // the clear. 4.1.0 at least obfuscated it.
454 - //
455 - // Where the shortcode is not going to be expanded, the literal
456 - // "[cryptx]" stays visible in the output and the address inside it is
457 - // obfuscated like any other. Ugly, and the same as before -- but the
458 - // address is covered.
459 - if (stripos($content, '[cryptx') === false
460 - || !in_array(current_filter(), self::SHORTCODE_EXPANDED_AFTER, true)) {
461 - return $process($content);
462 - }
463 -
464 - $store = [];
465 -
466 - // WordPress' own idea of what a shortcode looks like, rather than a
467 - // hand-rolled one: it knows the self-closing form, the enclosing form
468 - // and -- the reason this matters below -- the escaped form.
469 - $pattern = '/' . get_shortcode_regex(['cryptx']) . '/s';
470 -
471 - $masked = preg_replace_callback(
472 - $pattern,
473 - static function (array $match) use (&$store): string {
474 - // "[[cryptx]...[/cryptx]]" is how a page shows a shortcode
475 - // instead of running it -- an instructions page explaining
476 - // CryptX, typically. do_shortcode() deliberately leaves it as
477 - // text, so masking it would carry the address straight through
478 - // to the visitor in the clear. Groups 1 and 6 are the extra
479 - // brackets; when both are there, this is not ours to protect
480 - // and has to go through the normal obfuscation.
481 - if (($match[1] ?? '') === '[' && ($match[6] ?? '') === ']') {
482 - return $match[0];
483 - }
484 -
485 - $store[] = $match[0];
486 -
487 - return sprintf('<!--cryptx:%d-->', count($store) - 1);
488 - },
489 - $content
490 - );
491 -
492 - // A PCRE failure must not cost the content; process it unmasked.
493 - if ($masked === null) {
494 - return $process($content);
495 - }
496 -
497 - $result = $process($masked);
498 -
499 - // Under 'render_block' the shortcode is expanded here rather than left
500 - // for later. The other three entries in SHORTCODE_EXPANDED_AFTER carry
501 - // do_shortcode() themselves; 'render_block' does not -- it relies on
502 - // the_content running afterwards, and there are core paths where that
503 - // never happens. A block pattern pulled in through core/pattern is
504 - // rendered by do_blocks() alone (wp-includes/blocks/pattern.php), so a
505 - // masked shortcode would have been handed to nobody and the address
506 - // would have reached the page in the clear.
507 - //
508 - // Expanding twice is harmless: whatever runs later finds an anchor, no
509 - // shortcode.
510 - if (current_filter() === 'render_block') {
511 - $store = array_map('do_shortcode', $store);
512 - }
513 -
514 - $tokens = array_map(
515 - static fn(int $index): string => sprintf('<!--cryptx:%d-->', $index),
516 - array_keys($store)
517 - );
518 -
519 - return str_replace($tokens, $store, $result);
520 - }
521 -
522 - /**
523 - * Builds a mailto query from the shortcode's mail attributes.
524 - *
525 - * @param array<string, mixed> $attributes Lower-cased shortcode attributes.
526 - *
527 - * @return string The cleaned query, or an empty string.
528 - */
529 - private function buildMailtoQueryFromAttributes(array $attributes): string
530 - {
531 - $pairs = [];
532 -
533 - foreach (self::MAILTO_ATTRIBUTES as $name) {
534 - if (!isset($attributes[$name]) || is_array($attributes[$name])) {
535 - continue;
536 - }
537 -
538 - $value = (string) $attributes[$name];
539 -
540 - if (trim($value) === '') {
541 - continue;
542 - }
543 -
544 - $pairs[] = $name . '=' . rawurlencode($value);
545 - }
546 -
547 - // Straight through the same gate an address in the page goes through,
548 - // so the shortcode cannot express anything a link could not.
549 - return $this->sanitizeMailtoQuery(implode('&', $pairs));
550 - }
551 -
552 - /**
553 - * Appends a query to every mailto link that does not already carry one.
554 - *
555 - * A link written by hand with its own "?subject=" keeps it: the more
556 - * specific instruction wins over the shortcode's blanket one.
557 - *
558 - * @param string $content The content, after autolinking.
559 - * @param string $query The query to append, without the "?".
560 - *
561 - * @return string The content with the query in place.
562 - */
563 - private function addQueryToMailtoLinks(string $content, string $query): string
564 - {
565 - $result = preg_replace_callback(
566 - '/(href\s*=\s*(["\']))mailto:([^"\']+)(\2)/i',
567 - static function (array $match) use ($query): string {
568 - if (strpos($match[3], '?') !== false) {
569 - return $match[0];
570 - }
571 -
572 - return $match[1] . 'mailto:' . $match[3] . '?' . $query . $match[4];
573 - },
574 - $content
575 - );
576 -
577 - // Same reasoning as every other preg_* call site here: a PCRE failure
578 - // yields null, and handing that on would empty the content.
579 - return $result ?? $content;
580 - }
581 -
582 201 private function decodeAttributes(array $attributes): array
583 202 {
584 203 if (($attributes['encoded'] ?? '') !== 'true') {
585 204 return $attributes;
@@ -585,10 +204,10 @@
585 204 return $attributes;
586 205 }
587 206
588 207 $decodedAttributes = array_map(
589 - fn($value) => $this->decodeString($value),
590 - $attributes
208 + fn($value) => $this->decodeString($value),
209 + $attributes
591 210 );
592 211 unset($decodedAttributes['encoded']);
593 212
594 213 return $decodedAttributes;
@@ -605,176 +224,165 @@
605 224 public function cryptXShortcode(array $atts = [], string $content = '', string $tag = ''): string
606 225 {
607 226 // Decode attributes if needed
608 227 $attributes = $this->decodeAttributes($atts);
609 - $attributes = array_change_key_case($attributes, CASE_LOWER);
610 228
611 - // The mail headers are pulled out first. They are not options -- there
612 - // is no "subject" in the option store and never was -- so leaving them
613 - // in would hand them to shortcode_atts(), which drops anything it does
614 - // not recognise. That is precisely what happened to "subject" for
615 - // years: accepted by the parser, silently discarded, and documented as
616 - // working.
617 - $mailQuery = $this->buildMailtoQueryFromAttributes($attributes);
618 - $attributes = array_diff_key($attributes, array_flip(self::MAILTO_ATTRIBUTES));
619 -
620 229 // Update options if attributes provided
621 230 if (!empty($attributes)) {
622 231 self::$cryptXOptions = shortcode_atts(
623 - $this->loadCryptXOptionsWithDefaults(),
624 - $attributes,
625 - $tag
232 + $this->loadCryptXOptionsWithDefaults(),
233 + array_change_key_case($attributes, CASE_LOWER),
234 + $tag
626 235 );
627 - self::resetOptionCaches();
628 236 }
629 237
630 - try {
631 - // Process content (inline the encryptAndLinkContent logic)
632 - if (self::$cryptXOptions['autolink'] ?? false) {
633 - $content = $this->addLinkToEmailAddresses($content, true);
634 - }
238 + // Process content
239 + if (self::$cryptXOptions['autolink'] ?? false) {
240 + $content = $this->addLinkToEmailAddresses($content, true);
241 + }
635 242
636 - // After autolinking, so a bare address in the shortcode body has a
637 - // link to carry the headers, and before encrypting, so they end up
638 - // inside the payload rather than in the page.
639 - if ($mailQuery !== '') {
640 - $content = $this->addQueryToMailtoLinks($content, $mailQuery);
641 - }
243 + $processedContent = $this->encryptAndLinkContent($content, true);
642 244
643 - $content = $this->findEmailAddressesInContent($content, true);
644 - $processedContent = $this->replaceEmailInContent($content, true);
645 - } finally {
646 - // Restored in a finally block: self::$cryptXOptions is static, so
647 - // an exception escaping from here would leave the shortcode's
648 - // values in place for the rest of the request.
649 - self::$cryptXOptions = $this->loadCryptXOptionsWithDefaults();
650 - self::resetOptionCaches();
651 - }
245 + // Reset options to defaults
246 + self::$cryptXOptions = $this->loadCryptXOptionsWithDefaults();
652 247
653 248 return $processedContent;
654 249 }
655 250
656 251 /**
657 - * Retrieves the ID of the current post.
252 + * Encrypts and links content.
658 253 *
659 - * @return int The current post ID if available, or -1 if no post object is present.
254 + * @param string $content The content to be encrypted and linked.
255 + *
256 + * @return string The encrypted and linked content.
660 257 */
661 - private function getCurrentPostId(): int
258 + private function encryptAndLinkContent(string $content, bool $shortcode = false): string
662 259 {
663 - global $post;
664 - return (is_object($post)) ? $post->ID : -1;
260 + $content = $this->findEmailAddressesInContent($content, $shortcode);
261 +
262 + return $this->replaceEmailInContent($content, $shortcode);
665 263 }
666 264
265 + private function processAndEncryptEmails(EmailProcessingConfig $config): string
266 + {
267 + $content = $this->encryptMailtoLinks($config);
268 + return $this->encryptPlainEmails($content, $config);
269 + }
667 270
668 - /**
669 - * Generates and returns a tiny URL image.
670 - *
671 - * @return void
672 - */
673 - public function cryptXtinyUrl(): void
271 + private function encryptMailtoLinks(EmailProcessingConfig $config): ?string
674 272 {
675 - // sanitize_text_field(), not esc_url(): the latter is an output
676 - // escaper and turned "&" into "&#038;" on the way in.
677 - $url = (!empty($_SERVER['REQUEST_URI']))
678 - ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI']))
679 - : '';
680 - $params = explode('/', $url);
681 -
682 - if (count($params) < 2) {
683 - return;
273 + $content = $config->getContent();
274 + if ($content === null) {
275 + return null;
684 276 }
685 277
686 - if (!hash_equals(md5(get_bloginfo('url')), $params[count($params) - 2])) {
687 - return;
688 - }
278 + $postId = $config->getPostId() ?? $this->getCurrentPostId();
689 279
690 - // Everything below writes an image to the output stream. Any PHP notice
691 - // that slips through would end up inside that stream, be served as
692 - // image/png and disclose the server path to the visitor. So every
693 - // prerequisite is checked first and the request is abandoned quietly
694 - // if one is missing.
695 - if (!function_exists('imagettfbbox')) {
696 - return;
280 + if (!$this->isIdExcluded($postId) || $config->isShortcode()) {
281 + return preg_replace_callback(
282 + self::MAILTO_PATTERN,
283 + [$this, 'encryptEmailAddress'],
284 + $content
285 + );
697 286 }
698 287
699 - $fontFile = self::$cryptXOptions['c2i_font'] ?? $this->getDefaultFont();
700 - if (!is_string($fontFile) || $fontFile === '') {
701 - return;
702 - }
288 + return $content;
289 + }
703 290
704 - // basename() keeps the option from reaching outside the fonts folder,
705 - // even if it was tampered with in the database.
706 - $font = CRYPTX_DIR_PATH . 'fonts/' . basename(str_replace(' ', '_', $fontFile));
707 - // is_file(), not is_readable(): the latter is true for a directory as
708 - // well, and imagettfbbox() would then emit "Could not read font" with
709 - // the full server path -- exactly the disclosure this rewrite removes.
710 - if (!is_file($font) || !is_readable($font)) {
711 - return;
712 - }
291 + private function encryptPlainEmails(string $content, EmailProcessingConfig $config): string
292 + {
293 + $postId = $config->getPostId() ?? $this->getCurrentPostId();
713 294
714 - // The text comes straight from the URL. Without a bound, a long request
715 - // would size the canvas up accordingly and exhaust the memory limit --
716 - // a cheap denial of service. No address is anywhere near this long.
717 - $msg = substr(rawurldecode($params[count($params) - 1]), 0, self::MAX_IMAGE_TEXT_LENGTH);
718 - if ($msg === '') {
719 - return;
295 + if ((!$this->isIdExcluded($postId) || $config->isShortcode()) && !empty($content)) {
296 + return preg_replace_callback(
297 + self::EMAIL_PATTERN,
298 + [$this, 'encodeEmailToLinkText'],
299 + $content
300 + );
720 301 }
721 302
722 - $size = (int) (self::$cryptXOptions['c2i_fontSize'] ?? 10);
723 - $size = max(1, min(96, $size));
303 + return $content;
304 + }
724 305
725 - $rgb = ltrim((string) (self::$cryptXOptions['c2i_fontRGB'] ?? '#000000'), '#');
726 - if (!preg_match('/^[0-9a-f]{6}$/i', $rgb)) {
727 - $rgb = '000000';
728 - }
729 - $red = hexdec(substr($rgb, 0, 2));
730 - $grn = hexdec(substr($rgb, 2, 2));
731 - $blu = hexdec(substr($rgb, 4, 2));
306 + private function getCurrentPostId(): int
307 + {
308 + global $post;
309 + return (is_object($post)) ? $post->ID : -1;
310 + }
732 311
733 - $pad = 1;
734 - $bounds = imagettfbbox($size, 0, $font, 'W');
735 - if ($bounds === false) {
736 - return;
737 - }
738 - $font_height = abs($bounds[7] - $bounds[1]);
739 312
740 - $bounds = imagettfbbox($size, 0, $font, $msg);
741 - if ($bounds === false) {
742 - return;
313 + /**
314 + * Generates and returns a tiny URL image.
315 + *
316 + * @return void
317 + */
318 + public function cryptXtinyUrl(): void
319 + {
320 + $url = $_SERVER['REQUEST_URI'];
321 + $params = explode('/', $url);
322 + if (count($params) > 1) {
323 + $tiny_url = $params[count($params) - 2];
324 + if ($tiny_url == md5(get_bloginfo('url'))) {
325 + $font = CRYPTX_DIR_PATH . 'fonts/' . self::$cryptXOptions['c2i_font'];
326 + $msg = $params[count($params) - 1];
327 + $size = self::$cryptXOptions['c2i_fontSize'];
328 + $pad = 1;
329 + $transparent = 1;
330 + $rgb = str_replace("#", "", self::$cryptXOptions['c2i_fontRGB']);
331 + $red = hexdec(substr($rgb, 0, 2));
332 + $grn = hexdec(substr($rgb, 2, 2));
333 + $blu = hexdec(substr($rgb, 4, 2));
334 + $bg_red = 255 - $red;
335 + $bg_grn = 255 - $grn;
336 + $bg_blu = 255 - $blu;
337 + $width = 0;
338 + $height = 0;
339 + $offset_x = 0;
340 + $offset_y = 0;
341 + $bounds = array();
342 + $image = "";
343 + $bounds = ImageTTFBBox($size, 0, $font, "W");
344 + $font_height = abs($bounds[7] - $bounds[1]);
345 + $bounds = ImageTTFBBox($size, 0, $font, $msg);
346 + $width = abs($bounds[4] - $bounds[6]);
347 + $height = abs($bounds[7] - $bounds[1]);
348 + $offset_y = $font_height + abs(($height - $font_height) / 2) - 1;
349 + $offset_x = 0;
350 + $image = imagecreatetruecolor($width + ($pad * 2), $height + ($pad * 2));
351 + imagesavealpha($image, true);
352 + $foreground = ImageColorAllocate($image, $red, $grn, $blu);
353 + $background = imagecolorallocatealpha($image, 0, 0, 0, 127);
354 + imagefill($image, 0, 0, $background);
355 + ImageTTFText($image, $size, 0, round($offset_x + $pad, 0), round($offset_y + $pad, 0), $foreground, $font, $msg);
356 + Header("Content-type: image/png");
357 + imagePNG($image);
358 + die;
359 + }
743 360 }
744 - $width = abs($bounds[4] - $bounds[6]);
745 - $height = abs($bounds[7] - $bounds[1]);
746 - if ($width < 1 || $height < 1) {
747 - return;
748 - }
361 + }
749 362
750 - $offset_y = $font_height + abs(($height - $font_height) / 2) - 1;
751 - $offset_x = 0;
363 + /**
364 + * Add plugin filters.
365 + *
366 + * This function adds the specified plugin filter if the 'autolink' key is present and its value is true in the global $cryptXOptions variable.
367 + * It also adds the 'autolink' function as a filter to the $filterName if the global $shortcode_tags variable is not empty.
368 + * Additionally, this function calls the addCommonFilters() and addOtherFilters() functions at specific points.
369 + *
370 + * @param string $filterName The name of the filter to add.
371 + *
372 + * @return void
373 + */
374 + private function addPluginFilters(string $filterName): void
375 + {
376 + global $shortcode_tags;
752 377
753 - $image = imagecreatetruecolor($width + ($pad * 2), $height + ($pad * 2));
754 - if ($image === false) {
755 - return;
378 + if (array_key_exists('autolink', self::$cryptXOptions) && self::$cryptXOptions['autolink']) {
379 + $this->addAutoLinkFilters($filterName);
380 + if (!empty($shortcode_tags)) {
381 + $this->addAutoLinkFilters($filterName, 11);
382 + }
756 383 }
757 - imagesavealpha($image, true);
758 - $foreground = imagecolorallocate($image, $red, $grn, $blu);
759 - $background = imagecolorallocatealpha($image, 0, 0, 0, 127);
760 -
761 - // Both return false when the palette is exhausted. Passing that on
762 - // would emit a warning into the image stream -- the very thing this
763 - // method is built to avoid.
764 - if ($foreground === false || $background === false) {
765 - imagedestroy($image);
766 - return;
767 - }
768 -
769 - imagefill($image, 0, 0, $background);
770 - imagettftext($image, $size, 0, (int) round($offset_x + $pad), (int) round($offset_y + $pad), $foreground, $font, $msg);
771 -
772 - header('Content-Type: image/png');
773 - header('X-Content-Type-Options: nosniff');
774 - imagepng($image);
775 - imagedestroy($image);
776 - die;
384 + $this->addOtherFilters($filterName);
777 385 }
778 386
779 387 /**
780 388 * Adds common filters to a given filter name.
@@ -802,40 +410,13 @@
802 410 * @return void
803 411 */
804 412 private function addOtherFilters(string $filterName): void
805 413 {
806 - // Check if this is a widget filter
807 - $widgetFilters = $this->config->getWidgetFilters();
808 - $isWidgetFilter = in_array($filterName, $widgetFilters);
809 -
810 - if ($isWidgetFilter) {
811 - // Use higher priority for widget filters (after autolink at priority 10)
812 - add_filter($filterName, [$this, 'findEmailAddressesInContent'], 15);
813 - add_filter($filterName, [$this, 'replaceEmailInContent'], 16);
814 - } else {
815 - // Standard priorities for other filters
816 - add_filter($filterName, [$this, 'findEmailAddressesInContent'], 12);
817 - add_filter($filterName, [$this, 'replaceEmailInContent'], 13);
818 - }
414 + add_filter($filterName, [$this, 'findEmailAddressesInContent'], 12);
415 + add_filter($filterName, [$this, 'replaceEmailInContent'], 13);
819 416 }
820 417
821 -
822 418 /**
823 - * Adds and applies widget filters from the configuration.
824 - *
825 - * @return void
826 - */
827 - private function addWidgetFilters(): void
828 - {
829 - $widgetFilters = $this->config->getWidgetFilters();
830 -
831 - foreach ($widgetFilters as $widgetFilter) {
832 - $this->addAutoLinkFilters($widgetFilter, 11);
833 - $this->addOtherFilters($widgetFilter);
834 - }
835 - }
836 -
837 - /**
838 419 * Checks if a given ID is excluded based on the 'excludedIDs' variable.
839 420 *
840 421 * @param int $ID The ID to check if excluded.
841 422 *
@@ -842,35 +423,14 @@
842 423 * @return bool Returns true if the ID is excluded, false otherwise.
843 424 */
844 425 private function isIdExcluded(int $ID): bool
845 426 {
846 - if (self::$excludedIdCache === null) {
847 - $raw = (string) (self::$cryptXOptions['excludedIDs'] ?? '');
848 - self::$excludedIdCache = array_map(
849 - 'intval',
850 - array_filter(array_map('trim', explode(',', $raw)), 'strlen')
851 - );
852 - }
427 + $excludedIds = explode(",", self::$cryptXOptions['excludedIDs']);
853 428
854 - return in_array($ID, self::$excludedIdCache, true);
429 + return in_array($ID, $excludedIds);
855 430 }
856 431
857 432 /**
858 - * Drops the parsed option lists.
859 - *
860 - * Both caches mirror values from self::$cryptXOptions. Whenever those are
861 - * replaced -- by the shortcode or after saving -- the caches have to go
862 - * with them, otherwise a stale exclusion list survives the change.
863 - *
864 - * @return void
865 - */
866 - private static function resetOptionCaches(): void
867 - {
868 - self::$excludedIdCache = null;
869 - self::$whiteListCache = null;
870 - }
871 -
872 - /**
873 433 * Replaces email addresses in content with link texts.
874 434 *
875 435 * @param string|null $content The content to replace the email addresses in.
876 436 * @param bool $isShortcode Flag indicating whether the method is called from a shortcode.
@@ -880,35 +440,18 @@
880 440 public function replaceEmailInContent(?string $content, bool $isShortcode = false): ?string
881 441 {
882 442 global $post;
883 443
884 - if (self::$cryptXOptions['disable_rss'] && $this->isRssFeed()) return $content;
444 + if (self::$cryptXOptions['disable_rss'] && $this->isRssFeed()) return $content;
885 445
886 - // Nothing to find without an at sign. Bailing out here skips the whole
887 - // regular expression machinery for the vast majority of content -- and
888 - // on a block theme this filter runs once per block, not once per post.
889 - if ($content === null || strpos($content, '@') === false) {
890 - return $content;
891 - }
892 -
893 - // Check if current filter is a widget filter
894 - $widgetFilters = $this->config->getWidgetFilters();
895 - $isWidgetContext = in_array(current_filter(), $widgetFilters);
896 -
897 446 $postId = (is_object($post)) ? $post->ID : -1;
898 -
899 - // For widgets, always process; for other content, check exclusion rules
900 - if (($isWidgetContext || !$this->isIdExcluded($postId) || $isShortcode) && !empty($content)) {
901 - $content = $this->withShortcodesProtected(
902 - $content,
903 - fn(string $masked): string => $this->replaceEmailWithLinkText($masked)
904 - );
447 + if ((!$this->isIdExcluded($postId) || $isShortcode) && !empty($content)) {
448 + $content = $this->replaceEmailWithLinkText($content);
905 449 }
906 450
907 451 return $content;
908 452 }
909 453
910 -
911 454 /**
912 455 * Replace email addresses in a given content with link text.
913 456 *
914 457 * @param string $content The content to search for email addresses.
@@ -918,14 +461,9 @@
918 461 private function replaceEmailWithLinkText(string $content): string
919 462 {
920 463 $emailPattern = "/([_a-zA-Z0-9-+]+(\.[_a-zA-Z0-9-+]+)*@[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*(\.[a-zA-Z]{2,}))/i";
921 464
922 - $result = preg_replace_callback($emailPattern, [$this, 'encodeEmailToLinkText'], $content);
923 -
924 - // On a PCRE error -- a backtrack or recursion limit on unusually large
925 - // or awkward content -- preg_* returns null. Handing that back would
926 - // make the whole post body disappear, so the untouched content wins.
927 - return $result ?? $content;
465 + return preg_replace_callback($emailPattern, [$this, 'encodeEmailToLinkText'], $content);
928 466 }
929 467
930 468 /**
931 469 * Encode email address to link text.
@@ -947,11 +485,9 @@
947 485 $text = $this->getLinkImage();
948 486 break;
949 487 case 3:
950 488 $img_url = wp_get_attachment_url(self::$cryptXOptions['alt_uploadedimage']);
951 - // false when the attachment was deleted; would have produced
952 - // <img src=""> and a TypeError on the string parameter.
953 - $text = $img_url === false ? $this->getDefaultLinkText($Match) : $this->getUploadedImage($img_url);
489 + $text = $this->getUploadedImage($img_url);
954 490 self::$imageCounter++;
955 491 break;
956 492 case 4:
957 493 $text = antispambot($Match[1]);
@@ -975,20 +511,12 @@
975 511 * @return bool True if the match is in the whitelist, false otherwise.
976 512 */
977 513 private function inWhiteList(array $Match): bool
978 514 {
979 - if (self::$whiteListCache === null) {
980 - $raw = (string) (self::$cryptXOptions['whiteList'] ?? '');
981 - self::$whiteListCache = array_filter(array_map('trim', explode(',', $raw)), 'strlen');
982 - }
983 -
984 - if (self::$whiteListCache === []) {
985 - return false;
986 - }
987 -
515 + $whiteList = array_filter(array_map('trim', explode(",", self::$cryptXOptions['whiteList'])));
988 516 $tmp = explode(".", $Match[0]);
989 517
990 - return in_array(end($tmp), self::$whiteListCache, true);
518 + return in_array(end($tmp), $whiteList);
991 519 }
992 520
993 521 /**
994 522 * Get the link text from cryptXOptions
@@ -996,19 +524,9 @@
996 524 * @return string The link text
997 525 */
998 526 private function getLinkText(): string
999 527 {
1000 - // Escaped here rather than at the source: a shortcode attribute of the
1001 - // same name reaches self::$cryptXOptions without passing through the
1002 - // settings validation at all.
1003 - //
1004 - // esc_html and not wp_kses_post, although the settings screen stores
1005 - // the value with wp_kses_post: the link text sits inside an anchor that
1006 - // CryptX builds itself, and markup there could close that anchor early.
1007 - // The two stages therefore mean different things on purpose -- storage
1008 - // keeps what a post may contain, output shows it as text. See
1009 - // SettingsSchema::sanitizeValue().
1010 - return esc_html((string) self::$cryptXOptions['alt_linktext']);
528 + return self::$cryptXOptions['alt_linktext'];
1011 529 }
1012 530
1013 531 /**
1014 532 * Generate an HTML image tag with the link image URL as the source
@@ -1016,17 +534,9 @@
1016 534 * @return string The HTML image tag
1017 535 */
1018 536 private function getLinkImage(): string
1019 537 {
1020 - self::$styleNeeded = true;
1021 - $title = (string) self::$cryptXOptions['alt_linkimage_title'];
1022 -
1023 - return sprintf(
1024 - '<img src="%s" class="cryptxImage" alt="%s" title="%s" />',
1025 - esc_url(self::$cryptXOptions['alt_linkimage']),
1026 - esc_attr($title),
1027 - esc_attr(antispambot($title))
1028 - );
538 + return "<img src=\"" . self::$cryptXOptions['alt_linkimage'] . "\" class=\"cryptxImage\" alt=\"" . self::$cryptXOptions['alt_linkimage_title'] . "\" title=\"" . antispambot(self::$cryptXOptions['alt_linkimage_title']) . "\" />";
1029 539 }
1030 540
1031 541 /**
1032 542 * Get the HTML tag for an uploaded image.
@@ -1036,20 +546,9 @@
1036 546 * @return string The HTML tag for the image.
1037 547 */
1038 548 private function getUploadedImage(string $img_url): string
1039 549 {
1040 - self::$styleNeeded = true;
1041 - $title = (string) self::$cryptXOptions['http_linkimage_title'];
1042 -
1043 - // The alt attribute used to be missing its closing quote, which ran the
1044 - // title straight into it and produced broken markup.
1045 - return sprintf(
1046 - '<img src="%s" class="cryptxImage cryptxImage_%d" alt="%s" title="%s" />',
1047 - esc_url($img_url),
1048 - self::$imageCounter,
1049 - esc_attr($title),
1050 - esc_attr(antispambot($title))
1051 - );
550 + return "<img src=\"" . $img_url . "\" class=\"cryptxImage cryptxImage_" . self::$imageCounter . "\" alt=\"" . self::$cryptXOptions['http_linkimage_title'] . " title=\"" . antispambot(self::$cryptXOptions['http_linkimage_title']) . "\" />";
1052 551 }
1053 552
1054 553 /**
1055 554 * Converts a matched image URL into an HTML image element with cryptX classes and attributes.
@@ -1059,18 +558,9 @@
1059 558 * @return string Returns the HTML image element.
1060 559 */
1061 560 private function getImageFromText(array $Match): string
1062 561 {
1063 - self::$styleNeeded = true;
1064 - $scrambled = antispambot($Match[1]);
1065 -
1066 - return sprintf(
1067 - '<img src="%s" class="cryptxImage cryptxImage_%d" alt="%s" title="%s" />',
1068 - esc_url(get_bloginfo('url') . '/' . md5(get_bloginfo('url')) . '/' . $scrambled),
1069 - self::$imageCounter,
1070 - esc_attr($scrambled),
1071 - esc_attr($scrambled)
1072 - );
562 + return "<img src=\"" . get_bloginfo('url') . "/" . md5(get_bloginfo('url')) . "/" . antispambot($Match[1]) . "\" class=\"cryptxImage cryptxImage_" . self::$imageCounter . "\" alt=\"" . antispambot($Match[1]) . "\" title=\"" . antispambot($Match[1]) . "\" />";
1073 563 }
1074 564
1075 565 /**
1076 566 * Replaces specific characters with values from cryptX options in a given string.
@@ -1084,19 +574,11 @@
1084 574 * for each element.
1085 575 */
1086 576 private function getDefaultLinkText(array $Match): string
1087 577 {
1088 - // Escaped here for the same reason as in getLinkText(): the settings
1089 - // page runs both values through wp_kses_post(), but a shortcode
1090 - // attribute of the same name reaches self::$cryptXOptions unfiltered.
1091 - // Today only KSES stops an author from putting markup here -- that is
1092 - // WordPress protecting the plugin, not the plugin protecting itself.
1093 - $at = esc_html((string) self::$cryptXOptions['at']);
1094 - $dot = esc_html((string) self::$cryptXOptions['dot']);
578 + $text = str_replace("@", self::$cryptXOptions['at'], $Match[1]);
1095 579
1096 - $text = str_replace("@", $at, $Match[1]);
1097 -
1098 - return str_replace(".", $dot, $text);
580 + return str_replace(".", self::$cryptXOptions['dot'], $text);
1099 581 }
1100 582
1101 583 /**
1102 584 * List all files in a directory that match the given filter.
@@ -1108,26 +590,17 @@
1108 590 * @return array An array of file names that match the filter.
1109 591 */
1110 592 public function getFilesInDirectory(string $path, array $filter): array
1111 593 {
1112 - if (!is_dir($path)) {
1113 - return [];
1114 - }
1115 -
1116 - $directoryContent = [];
1117 - foreach (new \DirectoryIterator($path) as $file) {
1118 - if (!$file->isFile()) {
1119 - continue;
594 + $directoryHandle = opendir($path);
595 + $directoryContent = array();
596 + while ($file = readdir($directoryHandle)) {
597 + $fileExtension = substr(strtolower($file), -3);
598 + if (in_array($fileExtension, $filter)) {
599 + $directoryContent[] = $file;
1120 600 }
1121 - if (in_array(strtolower($file->getExtension()), $filter, true)) {
1122 - $directoryContent[] = $file->getFilename();
1123 - }
1124 601 }
1125 602
1126 - // readdir() order depends on the file system, which made the default
1127 - // font differ between servers. Sorting keeps it reproducible.
1128 - sort($directoryContent);
1129 -
1130 603 return $directoryContent;
1131 604 }
1132 605
1133 606 /**
@@ -1143,51 +616,118 @@
1143 616 public function findEmailAddressesInContent(?string $content, bool $shortcode = false): ?string
1144 617 {
1145 618 global $post;
1146 619
1147 - if (self::$cryptXOptions['disable_rss'] && $this->isRssFeed()) return $content;
620 + if (self::$cryptXOptions['disable_rss'] && $this->isRssFeed()) return $content;
1148 621
1149 622 if ($content === null) {
1150 623 return null;
1151 624 }
1152 625
1153 - // A mailto link without an at sign cannot carry an address. Cheapest
1154 - // possible way out before the regular expression runs.
1155 - if (strpos($content, '@') === false) {
1156 - return $content;
626 + $postId = (is_object($post)) ? $post->ID : -1;
627 + $isIdExcluded = $this->isIdExcluded($postId);
628 +
629 + // FIXED: Added 's' modifier to handle multiline HTML (like Elementor buttons)
630 + $mailtoRegex = '/<a\s+[^>]*href=(["\'])mailto:([^"\']+)\1[^>]*>(.*?)<\/a>/is';
631 +
632 + if ((!$isIdExcluded || $shortcode !== null)) {
633 + $content = preg_replace_callback($mailtoRegex, [$this, 'encryptEmailAddressNew'], $content);
1157 634 }
1158 635
1159 - // Check if current filter is a widget filter
1160 - $widgetFilters = $this->config->getWidgetFilters();
1161 - $isWidgetContext = in_array(current_filter(), $widgetFilters);
636 + return $content;
637 + }
1162 638
1163 - $postId = (is_object($post)) ? $post->ID : -1;
1164 - $isIdExcluded = $this->isIdExcluded($postId);
639 + /**
640 + * Encrypts email addresses in search results.
641 + *
642 + * @param array $searchResults The search results containing email addresses.
643 + *
644 + * @return string The search results with encrypted email addresses.
645 + */
646 + private function encryptEmailAddress(array $searchResults): string
647 + {
648 + $originalValue = $searchResults[0];
1165 649
1166 - // Quoted attribute values may contain ">", so the tag must not simply
1167 - // end at the first one -- title="a > b" used to cut the match in half
1168 - // and produce mangled markup. Same construction as in
1169 - // rewriteOpeningAnchorTag(); the two have to agree on what a tag is.
1170 - $mailtoRegex = '/<a\b(?:[^>"\']|"[^"]*"|\'[^\']*\')*?href\s*=\s*(["\'])mailto:([^"\']+)\1(?:[^>"\']|"[^"]*"|\'[^\']*\')*>(.*?)<\/a>/is';
1171 - $that = $this;
650 + if (strpos($searchResults[self::INDEX_TO_CHECK], '@') === self::NOT_FOUND) {
651 + return $originalValue;
652 + }
1172 653
1173 - // For widgets, always process since there's no specific post context
1174 - // For other content, check exclusion rules
1175 - if ($isWidgetContext || !$isIdExcluded || $shortcode) {
1176 - $content = $this->withShortcodesProtected($content, static function (string $masked) use ($mailtoRegex, $that): string {
1177 - $result = preg_replace_callback($mailtoRegex, [$that, 'encryptEmailAddressSecure'], $masked);
654 + $mailReference = self::MAIL_IDENTIFIER . $searchResults[self::INDEX_TO_CHECK];
1178 655
1179 - // null means PCRE gave up (backtrack limit). Keeping the
1180 - // original content is far better than returning null and
1181 - // wiping the page.
1182 - return $result ?? $masked;
1183 - });
656 + if (str_starts_with($searchResults[self::INDEX_TO_CHECK], self::SUBJECT_IDENTIFIER)) {
657 + return $originalValue;
1184 658 }
1185 659
1186 - return $content;
660 + $return = $originalValue;
661 +
662 + // Apply JavaScript handler if enabled
663 + if (!empty(self::$cryptXOptions['java'])) {
664 + $javaHandler = "javascript:DeCryptX('" . $this->generateHashFromString($searchResults[self::INDEX_TO_CHECK]) . "')";
665 + $return = str_replace(self::MAIL_IDENTIFIER . $searchResults[self::INDEX_TO_CHECK], $javaHandler, $originalValue);
666 + } else {
667 + // Only apply antispambot if JavaScript is not enabled
668 + $return = str_replace($mailReference, antispambot($mailReference), $return);
669 + }
670 +
671 + // Add CSS attributes if specified
672 + if (!empty(self::$cryptXOptions['css_id'])) {
673 + $return = preg_replace(self::PATTERN, '$1" id="' . self::$cryptXOptions['css_id'] . '">', $return);
674 + }
675 +
676 + if (!empty(self::$cryptXOptions['css_class'])) {
677 + $return = preg_replace(self::PATTERN, '$1" class="' . self::$cryptXOptions['css_class'] . '">', $return);
678 + }
679 +
680 + return $return;
1187 681 }
1188 682
1189 683 /**
684 + * Encrypts an email address found in the search results and modifies it to safeguard against email harvesting.
685 + *
686 + * @param array $searchResults Array containing search result data, where:
687 + * - Index 0 contains the full match.
688 + * - Index 2 contains the email address.
689 + * - Index 3 contains the link text for the email.
690 + * @return string The encrypted or modified email link.
691 + */
692 + private function encryptEmailAddressNew(array $searchResults): string
693 + {
694 + $originalValue = $searchResults[0]; // Full match
695 + $emailAddress = $searchResults[2]; // Email address (now at index 2)
696 + $linkText = $searchResults[3]; // Link text (now at index 3)
697 +
698 + if (strpos($emailAddress, '@') === self::NOT_FOUND) {
699 + return $originalValue;
700 + }
701 +
702 + if (str_starts_with($emailAddress, self::SUBJECT_IDENTIFIER)) {
703 + return $originalValue;
704 + }
705 +
706 + $return = $originalValue;
707 +
708 + // Apply JavaScript handler if enabled
709 + if (!empty(self::$cryptXOptions['java'])) {
710 + $javaHandler = "javascript:DeCryptX('" . $this->generateHashFromString($emailAddress) . "')";
711 + $return = str_replace('mailto:' . $emailAddress, $javaHandler, $originalValue);
712 + } else {
713 + // Only apply antispambot if JavaScript is not enabled
714 + $return = str_replace('mailto:' . $emailAddress, antispambot('mailto:' . $emailAddress), $return);
715 + }
716 +
717 + // Add CSS attributes if specified
718 + if (!empty(self::$cryptXOptions['css_id'])) {
719 + $return = preg_replace('/(<a\s+[^>]*)(>)/i', '$1 id="' . self::$cryptXOptions['css_id'] . '"$2', $return);
720 + }
721 +
722 + if (!empty(self::$cryptXOptions['css_class'])) {
723 + $return = preg_replace('/(<a\s+[^>]*)(>)/i', '$1 class="' . self::$cryptXOptions['css_class'] . '"$2', $return);
724 + }
725 +
726 + return $return;
727 + }
728 +
729 + /**
1190 730 * Generate a hash string for the given input string.
1191 731 *
1192 732 * @param string $inputString The input string to generate a hash for.
1193 733 *
@@ -1199,9 +739,9 @@
1199 739 $crypt = '';
1200 740
1201 741 for ($i = 0; $i < strlen($inputString); $i++) {
1202 742 do {
1203 - $salt = wp_rand(0, 3);
743 + $salt = mt_rand(0, 3);
1204 744 $asciiValue = ord(substr($inputString, $i)) + $salt;
1205 745 if (8364 <= $asciiValue) {
1206 746 $asciiValue = 128;
1207 747 }
@@ -1226,90 +766,38 @@
1226 766 */
1227 767 public function addLinkToEmailAddresses(string $content, bool $shortcode = false): string
1228 768 {
1229 769 global $post;
1230 -
1231 - // The same gate the other two stages carry, and missing here until
1232 - // 4.1.1. "Leave RSS feeds unprotected" is meant as "do not touch
1233 - // feeds"; without this, the autolink stage still turned a bare address
1234 - // into a mailto link in the feed, while the two stages that protect it
1235 - // stepped aside. The result was not a leak -- with the option on, the
1236 - // address is in the feed either way -- but it was CryptX changing
1237 - // content it had just been told to leave alone.
1238 - //
1239 - // The $shortcode exception is made here and not in the other two
1240 - // stages: those bail out of a feed unconditionally. Keeping it means
1241 - // the shortcode path behaves exactly as it did before this guard
1242 - // existed, which is the point -- the shortcode is an explicit
1243 - // instruction and outranks a blanket setting.
1244 - if (!$shortcode && self::$cryptXOptions['disable_rss'] && $this->isRssFeed()) {
1245 - return $content;
1246 - }
1247 -
1248 - // Eight regular expressions follow, each carrying the full address
1249 - // pattern. Without an at sign not one of them can match, so this test
1250 - // saves the entire pass.
1251 - if (strpos($content, '@') === false) {
1252 - return $content;
1253 - }
1254 -
1255 - // Check if current filter is a widget filter
1256 - $widgetFilters = $this->config->getWidgetFilters();
1257 - $isWidgetContext = in_array(current_filter(), $widgetFilters);
1258 -
1259 770 $postID = is_object($post) ? $post->ID : -1;
1260 771
1261 - // For widgets, always process; for other content, check exclusion rules
1262 - if (!$isWidgetContext && $this->isIdExcluded($postID) && !$shortcode) {
772 + if ($this->isIdExcluded($postID) && !$shortcode) {
1263 773 return $content;
1264 774 }
1265 775
1266 - $emailPattern = "[_a-zA-Z0-9-+]+(\\.[_a-zA-Z0-9-+]+)*@[a-zA-Z0-9-]+(\\.[a-zA-Z0-9-]+)*(\\.[a-zA-Z]{2,})";
776 + $emailPattern = "[_a-zA-Z0-9-+]+(\.[_a-zA-Z0-9-+]+)*@[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*(\.[a-zA-Z]{2,})";
1267 777 $linkPattern = "<a href=\"mailto:\\2\">\\2</a>";
1268 - // Two widenings, both from the same report. The patterns after ">"
1269 - // required a "<" or whitespace to follow, so an address that ended the
1270 - // string right after a tag -- "Kontakt:<br>info@example.com" -- was
1271 - // never linked; hence the "$" variant. And they accepted only ">",
1272 - // while wp_kses_post() turns a bare ">" into "&gt;", leaving a ";"
1273 - // in front of the address; hence "[>;]", which covers the end of any
1274 - // HTML entity.
1275 - //
1276 - // In post content neither showed much, because a closing tag almost
1277 - // always follows an address. Through cryptx_encrypt() both showed every
1278 - // time. Worse than the missing link was what came next: the display
1279 - // stage still swapped the address for the configured link text, so the
1280 - // address vanished from the page without anything working taking its
1281 - // place.
1282 778 $src = [
1283 - "/([\\s])($emailPattern)/si",
1284 - "/([>;])($emailPattern)(<)/si",
1285 - "/(\\()($emailPattern)(\\))/si",
1286 - "/([>;])($emailPattern)([\\s])/si",
1287 - "/([\\s])($emailPattern)(<)/si",
1288 - "/([>;])($emailPattern)$/si",
1289 - "/^($emailPattern)/si",
1290 - "/(<a[^>]*>)<a[^>]*>/",
1291 - "/(<\\/A>)<\\/A>/i"
779 + "/([\s])($emailPattern)/si",
780 + "/(>)($emailPattern)(<)/si",
781 + "/(\()($emailPattern)(\))/si",
782 + "/(>)($emailPattern)([\s])/si",
783 + "/([\s])($emailPattern)(<)/si",
784 + "/^($emailPattern)/si",
785 + "/(<a[^>]*>)<a[^>]*>/",
786 + "/(<\/A>)<\/A>/i"
1292 787 ];
1293 788 $tar = [
1294 - "\\1$linkPattern",
1295 - "\\1$linkPattern\\6",
1296 - "\\1$linkPattern\\6",
1297 - "\\1$linkPattern\\6",
1298 - "\\1$linkPattern\\6",
1299 - "\\1$linkPattern",
1300 - "<a href=\"mailto:\\0\">\\0</a>",
1301 - "\\1",
1302 - "\\1"
789 + "\\1$linkPattern",
790 + "\\1$linkPattern\\6",
791 + "\\1$linkPattern\\6",
792 + "\\1$linkPattern\\6",
793 + "\\1$linkPattern\\6",
794 + "<a href=\"mailto:\\0\">\\0</a>",
795 + "\\1",
796 + "\\1"
1303 797 ];
1304 798
1305 - return $this->withShortcodesProtected($content, static function (string $masked) use ($src, $tar): string {
1306 - $result = preg_replace($src, $tar, $masked);
1307 -
1308 - // Same reasoning as elsewhere: a PCRE failure yields null, and
1309 - // handing that on would silently empty the page.
1310 - return $result ?? $masked;
1311 - });
799 + return preg_replace($src, $tar, $content);
1312 800 }
1313 801
1314 802 /**
1315 803 * Installs the CryptX plugin by updating its options and loading default values.
@@ -1316,29 +804,12 @@
1316 804 */
1317 805 public function installCryptX(): void
1318 806 {
1319 807 global $wpdb;
1320 -
1321 - // Load-bearing, not a duplicate of the 'switch_blog' hook -- do not
1322 - // remove it as one. When a plugin is activated, WordPress includes its
1323 - // file from activate_plugin(), long after plugins_loaded has fired, so
1324 - // startCryptX() never runs in that request and the hook is not
1325 - // registered. Measured: activating an inactive plugin, has_action(
1326 - // 'switch_blog') is false throughout. Without this line the network
1327 - // activation loop writes site 1's values into every other site --
1328 - // secret, link text and exclusion list -- which is how the bug was
1329 - // found in the first place.
1330 - $this->refreshForCurrentSite();
1331 -
1332 808 self::$cryptXOptions['admin_notices_deprecated'] = true;
1333 809 if (self::$cryptXOptions['excludedIDs'] == "") {
1334 810 $tmp = array();
1335 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1336 - $excludes = $wpdb->get_results($wpdb->prepare(
1337 - "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = %s",
1338 - 'cryptxoff',
1339 - 'true'
1340 - ));
811 + $excludes = $wpdb->get_results("SELECT post_id FROM $wpdb->postmeta WHERE meta_key = 'cryptxoff' AND meta_value = 'true'");
1341 812 if (count($excludes) > 0) {
1342 813 foreach ($excludes as $exclude) {
1343 814 $tmp[] = $exclude->post_id;
1344 815 }
@@ -1345,20 +816,13 @@
1345 816 sort($tmp);
1346 817 self::$cryptXOptions['excludedIDs'] = implode(",", $tmp);
1347 818 update_option('cryptX', self::$cryptXOptions);
1348 819 self::$cryptXOptions = $this->loadCryptXOptionsWithDefaults(); // reread Options
1349 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1350 - $wpdb->query($wpdb->prepare(
1351 - "DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s",
1352 - 'cryptxoff'
1353 - ));
820 + $wpdb->query("DELETE FROM $wpdb->postmeta WHERE meta_key = 'cryptxoff'");
1354 821 }
1355 822 }
1356 823 if (empty(self::$cryptXOptions['c2i_font'])) {
1357 - // Only the file name is stored here. cryptXtinyUrl() prepends
1358 - // CRYPTX_DIR_PATH . 'fonts/' itself, so an absolute path would
1359 - // produce an unusable font path.
1360 - self::$cryptXOptions['c2i_font'] = $this->getDefaultFont();
824 + self::$cryptXOptions['c2i_font'] = CRYPTX_DIR_PATH . 'fonts/' . $firstFont[0];
1361 825 }
1362 826 if (empty(self::$cryptXOptions['c2i_fontSize'])) {
1363 827 self::$cryptXOptions['c2i_fontSize'] = 10;
1364 828 }
@@ -1392,19 +856,13 @@
1392 856 **/
1393 857 public function metaCheckbox(): void
1394 858 {
1395 859 global $post;
1396 -
1397 - if (!is_object($post)) {
1398 - return;
1399 - }
1400 -
1401 - wp_nonce_field(self::METABOX_NONCE_ACTION, self::METABOX_NONCE_FIELD);
1402 860 ?>
1403 861 <label><input type="checkbox" name="disable_cryptx_pageid" <?php if ($this->isIdExcluded($post->ID)) {
1404 862 echo 'checked="checked"';
1405 863 } ?>/>
1406 - <?php esc_html_e('Disable CryptX for this post/page', 'cryptx'); ?></label>
864 + Disable CryptX for this post/page</label>
1407 865 <?php
1408 866 }
1409 867
1410 868 /**
@@ -1415,24 +873,20 @@
1415 873 */
1416 874 public function metaOptionFieldset(): void
1417 875 {
1418 876 global $post;
1419 -
1420 - if (!is_object($post) || !current_user_can('edit_post', $post->ID)) {
1421 - return;
877 + if (current_user_can('edit_posts')) { ?>
878 + <fieldset id="cryptxoption" class="dbx-box">
879 + <h3 class="dbx-handle">CryptX</h3>
880 + <div class="dbx-content">
881 + <label><input type="checkbox"
882 + name="disable_cryptx_pageid" <?php if ($this->isIdExcluded($post->ID)) {
883 + echo 'checked="checked"';
884 + } ?>/> Disable CryptX for this post/page</label>
885 + </div>
886 + </fieldset>
887 + <?php
1422 888 }
1423 - ?>
1424 - <fieldset id="cryptxoption" class="dbx-box">
1425 - <h3 class="dbx-handle">CryptX</h3>
1426 - <div class="dbx-content">
1427 - <?php wp_nonce_field(self::METABOX_NONCE_ACTION, self::METABOX_NONCE_FIELD); ?>
1428 - <label><input type="checkbox"
1429 - name="disable_cryptx_pageid" <?php if ($this->isIdExcluded($post->ID)) {
1430 - echo 'checked="checked"';
1431 - } ?>/> <?php esc_html_e('Disable CryptX for this post/page', 'cryptx'); ?></label>
1432 - </div>
1433 - </fieldset>
1434 - <?php
1435 889 }
1436 890
1437 891 /**
1438 892 * Adds a post ID to the excluded list in the cryptX options.
@@ -1442,49 +896,12 @@
1442 896 * @return void
1443 897 */
1444 898 public function addPostIdToExcludedList(int $postId): void
1445 899 {
1446 - // The meta box has to have taken part in this request. Without this
1447 - // gate every save that carries no $_POST at all -- REST, WP-CLI,
1448 - // autosave, the block editor's first pass -- removed the post from the
1449 - // exclusion list and silently switched CryptX back on for it.
1450 - //
1451 - // The gate hangs on the nonce, deliberately not on the checkbox: an
1452 - // unchecked box is not submitted at all, so "checkbox missing" would
1453 - // mean both "meta box was not involved" and "user cleared the tick".
1454 - // Guarding on that would make an excluded post impossible to include
1455 - // again.
1456 - if (!isset($_POST[self::METABOX_NONCE_FIELD])) {
1457 - return;
1458 - }
1459 -
1460 - $nonce = sanitize_text_field(wp_unslash($_POST[self::METABOX_NONCE_FIELD]));
1461 - if (!wp_verify_nonce($nonce, self::METABOX_NONCE_ACTION)) {
1462 - return;
1463 - }
1464 -
1465 900 $postId = wp_is_post_revision($postId) ?: $postId;
1466 -
1467 - if (!current_user_can('edit_post', $postId)) {
1468 - return;
1469 - }
1470 -
1471 - // Read the option fresh instead of writing back self::$cryptXOptions.
1472 - // That property is static and the shortcode overwrites it while it
1473 - // runs; storing it wholesale could persist a shortcode's temporary
1474 - // values. Only the one key we are responsible for is touched.
1475 - $options = get_option('cryptX', []);
1476 - if (!is_array($options)) {
1477 - $options = [];
1478 - }
1479 -
1480 - $excludedIds = $this->updateExcludedIdsList((string) ($options['excludedIDs'] ?? ''), $postId);
1481 - $options['excludedIDs'] = implode(',', array_filter($excludedIds));
1482 -
1483 - update_option('cryptX', $options);
1484 -
1485 - self::$cryptXOptions['excludedIDs'] = $options['excludedIDs'];
1486 - self::resetOptionCaches();
901 + $excludedIds = $this->updateExcludedIdsList(self::$cryptXOptions['excludedIDs'], $postId);
902 + self::$cryptXOptions['excludedIDs'] = implode(",", array_filter($excludedIds));
903 + update_option('cryptX', self::$cryptXOptions);
1487 904 }
1488 905
1489 906 /**
1490 907 * Updates the excluded IDs list based on a given ID and the current list.
@@ -1555,8 +972,27 @@
1555 972 return $excludedIds;
1556 973 }
1557 974
1558 975 /**
976 + * Displays a message in a styled div.
977 + *
978 + * @param string $message The message to be displayed.
979 + * @param bool $errormsg Optional. Indicates whether the message is an error message. Default is false.
980 + *
981 + * @return void
982 + */
983 + private function showMessage(string $message, bool $errormsg = false): void
984 + {
985 + if ($errormsg) {
986 + echo '<div id="message" class="error">';
987 + } else {
988 + echo '<div id="message" class="updated fade">';
989 + }
990 +
991 + echo "$message</div>";
992 + }
993 +
994 + /**
1559 995 * Retrieves the domain from the current site URL.
1560 996 *
1561 997 * @return string The domain of the current site URL.
1562 998 */
@@ -1603,53 +1039,19 @@
1603 1039 return $domain;
1604 1040 }
1605 1041
1606 1042 /**
1607 - * Registers the frontend assets.
1043 + * Loads Javascript files required for CryptX functionality.
1608 1044 *
1609 - * Registering is not loading. Whether the files end up on the page is
1610 - * decided in enqueueAssetsIfNeeded() once the content has been processed
1611 - * and it is known whether anything was encrypted at all.
1612 - *
1613 - * One exception: with the script placed in the head (load_java = 0) that
1614 - * decision cannot be deferred -- the head is sent before the content runs.
1615 - * In that configuration the script is enqueued unconditionally, as before.
1616 - *
1617 1045 * @return void
1618 1046 */
1619 1047 public function loadJavascriptFiles(): void
1620 1048 {
1621 - $inFooter = !empty(self::$cryptXOptions['load_java']);
1622 -
1623 - wp_register_script('cryptx-js', CRYPTX_DIR_URL . 'js/cryptx.min.js', [], CRYPTX_VERSION, $inFooter);
1624 - wp_localize_script('cryptx-js', 'cryptxConfig', SecureEncryption::getJavaScriptConfig());
1625 - wp_register_style('cryptx-styles', CRYPTX_DIR_URL . 'css/cryptx.css', [], CRYPTX_VERSION);
1626 -
1627 - if (!$inFooter) {
1628 - wp_enqueue_script('cryptx-js');
1629 - wp_enqueue_style('cryptx-styles');
1630 - }
1049 + wp_enqueue_script('cryptx-js', CRYPTX_DIR_URL . 'js/cryptx.min.js', false, false, self::$cryptXOptions['load_java']);
1050 + wp_enqueue_style('cryptx-styles', CRYPTX_DIR_URL . 'css/cryptx.css');
1631 1051 }
1632 1052
1633 1053 /**
1634 - * Loads the assets that this page turned out to need.
1635 - *
1636 - * Runs late, in the footer, when every filter has done its work.
1637 - *
1638 - * @return void
1639 - */
1640 - public function enqueueAssetsIfNeeded(): void
1641 - {
1642 - if (self::$scriptNeeded) {
1643 - wp_enqueue_script('cryptx-js');
1644 - }
1645 -
1646 - if (self::$styleNeeded) {
1647 - wp_enqueue_style('cryptx-styles');
1648 - }
1649 - }
1650 -
1651 - /**
1652 1054 * Updates the CryptX settings.
1653 1055 *
1654 1056 * This method retrieves the current CryptX options from the database and checks if the version of CryptX
1655 1057 * stored in the options is less than the current version of CryptX. If the version is outdated, the method
@@ -1659,65 +1061,27 @@
1659 1061 */
1660 1062 private function updateCryptXSettings(): void
1661 1063 {
1662 1064 self::$cryptXOptions = get_option('cryptX');
1663 -
1664 - $storedVersion = self::$cryptXOptions['version'] ?? null;
1665 -
1666 - if ($storedVersion === null || version_compare(CRYPTX_VERSION, $storedVersion) <= 0) {
1667 - return;
1668 - }
1669 -
1670 - // Every step below used to run on EVERY version bump, although each was
1671 - // written for one particular upgrade. Measured on an installation
1672 - // carrying 4.1.0: the chosen font fell back to the first available one,
1673 - // the colour "#3366ff" became "##3366ff" -- gaining another "#" with
1674 - // every future update -- and the encryption secret was thrown away, so
1675 - // every link on an already cached page stopped resolving. None of that
1676 - // was intended, and none of it was visible to the site owner.
1677 - //
1678 - // Each migration is now tied to the version it belongs to, or written
1679 - // so that repeating it changes nothing.
1680 -
1681 - // Up to 4.0.11 the password was derived from AUTH_KEY and
1682 - // SECURE_AUTH_KEY, and that value is published in the markup of every
1683 - // page. Since site_url is public, an attacker could test candidate keys
1684 - // offline -- above all the placeholders from wp-config-sample.php that
1685 - // unattended installations still carry. Dropping it lets
1686 - // Config::getEncryptionPassword() mint a random one. The price is that
1687 - // links on pages already sitting in a cache stop resolving until that
1688 - // cache turns over, which is why it must happen exactly once.
1689 - if (version_compare($storedVersion, '4.0.12', '<')) {
1690 - unset(self::$cryptXOptions['encryption_password']);
1691 -
1692 - // 4.0.12 replaced the bundled Arial, Times New Roman and Verdana
1693 - // with freely licensed faces. A stored name from the old set no
1694 - // longer exists on disk, so the choice has to be made again.
1695 - unset(self::$cryptXOptions['c2i_font']);
1696 - }
1697 -
1698 - // Value-based rather than version-based, and therefore harmless to
1699 - // repeat: colours were stored without the leading "#" before 4.0.
1700 - if (!empty(self::$cryptXOptions['c2i_fontRGB'])
1701 - && strpos((string) self::$cryptXOptions['c2i_fontRGB'], '#') !== 0) {
1702 - self::$cryptXOptions['c2i_fontRGB'] = '#' . self::$cryptXOptions['c2i_fontRGB'];
1703 - }
1704 -
1705 - // Also value-based: an attachment id that is not an id is unusable, no
1706 - // matter which version wrote it.
1707 - if (isset(self::$cryptXOptions['alt_uploadedimage'])
1708 - && !is_int(self::$cryptXOptions['alt_uploadedimage'])
1709 - && !ctype_digit((string) self::$cryptXOptions['alt_uploadedimage'])) {
1710 - unset(self::$cryptXOptions['alt_uploadedimage']);
1711 -
1712 - if ((int) (self::$cryptXOptions['opt_linktext'] ?? 0) === 3) {
1713 - unset(self::$cryptXOptions['opt_linktext']);
1065 + if (isset(self::$cryptXOptions['version']) && version_compare(CRYPTX_VERSION, self::$cryptXOptions['version']) > 0) {
1066 + if (isset(self::$cryptXOptions['version'])) {
1067 + unset(self::$cryptXOptions['version']);
1714 1068 }
1069 + if (isset(self::$cryptXOptions['c2i_font'])) {
1070 + unset(self::$cryptXOptions['c2i_font']);
1071 + }
1072 + if (isset(self::$cryptXOptions['c2i_fontRGB'])) {
1073 + self::$cryptXOptions['c2i_fontRGB'] = "#" . self::$cryptXOptions['c2i_fontRGB'];
1074 + }
1075 + if (isset(self::$cryptXOptions['alt_uploadedimage']) && !is_int(self::$cryptXOptions['alt_uploadedimage'])) {
1076 + unset(self::$cryptXOptions['alt_uploadedimage']);
1077 + if (self::$cryptXOptions['opt_linktext'] == 3) {
1078 + unset(self::$cryptXOptions['opt_linktext']);
1079 + }
1080 + }
1081 + self::$cryptXOptions = wp_parse_args(self::$cryptXOptions, $this->getCryptXOptionsDefaults());
1082 + update_option('cryptX', self::$cryptXOptions);
1715 1083 }
1716 -
1717 - self::$cryptXOptions['version'] = CRYPTX_VERSION;
1718 - self::$cryptXOptions = wp_parse_args(self::$cryptXOptions, $this->getCryptXOptionsDefaults());
1719 - update_option('cryptX', self::$cryptXOptions);
1720 1084 }
1721 1085
1722 1086 /**
1723 1087 * Encodes a string by replacing special characters with their corresponding HTML entities.
@@ -1729,10 +1093,10 @@
1729 1093 private function encodeString(?string $str): string
1730 1094 {
1731 1095 $str = htmlentities($str, ENT_QUOTES, 'UTF-8');
1732 1096 $special = array(
1733 - '[' => '&#91;',
1734 - ']' => '&#93;',
1097 + '[' => '&#91;',
1098 + ']' => '&#93;',
1735 1099 );
1736 1100
1737 1101 return str_replace(array_keys($special), array_values($special), $str);
1738 1102 }
@@ -1748,20 +1112,14 @@
1748 1112 {
1749 1113 return html_entity_decode($str, ENT_QUOTES, 'UTF-8');
1750 1114 }
1751 1115
1752 - /**
1753 - * Converts an associative array into an argument string.
1754 - *
1755 - * @param array $args An optional associative array where keys represent argument names and values represent argument values.
1756 - * @return string A formatted string of arguments where each key-value pair is encoded and concatenated.
1757 - */
1758 1116 public function convertArrayToArgumentString(array $args = []): string
1759 1117 {
1760 1118 $string = "";
1761 1119 if (!empty($args)) {
1762 1120 foreach ($args as $key => $value) {
1763 - $string .= sprintf(" %s=\"%s\"", $key, esc_attr($value));
1121 + $string .= sprintf(" %s=\"%s\"", $key, $this->encodeString($value));
1764 1122 }
1765 1123 $string .= " encoded=\"true\"";
1766 1124 }
1767 1125
@@ -1780,10 +1138,10 @@
1780 1138
1781 1139 /**
1782 1140 * Adds plugin action links to the WordPress plugin row
1783 1141 *
1784 - * @param array $links Existing plugin row links
1785 - * @param string $file Plugin file path
1142 + * @param array $links Existing plugin row links
1143 + * @param string $file Plugin file path
1786 1144 * @return array Modified plugin row links
1787 1145 */
1788 1146 public function add_plugin_action_links(array $links, string $file): array
1789 1147 {
@@ -1791,10 +1149,10 @@
1791 1149 return $links;
1792 1150 }
1793 1151
1794 1152 $additional_links = [
1795 - $this->create_settings_link(),
1796 - $this->create_donation_link()
1153 + $this->create_settings_link(),
1154 + $this->create_donation_link()
1797 1155 ];
1798 1156
1799 1157 return array_merge($links, $additional_links);
1800 1158 }
@@ -1799,557 +1157,27 @@
1799 1157 return array_merge($links, $additional_links);
1800 1158 }
1801 1159
1802 1160 /**
1803 - * Creates and returns a settings link for the options page.
1804 - *
1805 - * @return string The HTML link to the settings page.
1161 + * Creates the settings link for the plugin
1806 1162 */
1807 1163 private function create_settings_link(): string
1808 1164 {
1809 - // Admin\SettingsPage::MENU_SLUG und nicht CRYPTX_BASEFOLDER: die
1810 - // Seite haengt am Slug, nicht am Verzeichnisnamen. Auf wordpress.org
1811 - // sind beide 'cryptx', nach einem Umbenennen des Ordners zeigte der
1812 - // Link ins Leere.
1813 1165 return sprintf(
1814 - '<a href="%s">%s</a>',
1815 - esc_url(admin_url('options-general.php?page=' . Admin\SettingsPage::MENU_SLUG)),
1816 - esc_html__('Settings', 'cryptx')
1166 + '<a href="options-general.php?page=%s">%s</a>',
1167 + CRYPTX_BASEFOLDER,
1168 + __('Settings')
1817 1169 );
1818 1170 }
1819 1171
1820 1172 /**
1821 - * Creates and returns a donation link in HTML format.
1822 - *
1823 - * @return string The HTML string for the donation link.
1173 + * Creates the donation link for the plugin
1824 1174 */
1825 1175 private function create_donation_link(): string
1826 1176 {
1827 1177 return sprintf(
1828 - '<a href="%s">%s</a>',
1829 - esc_url(self::PAYPAL_DONATION_URL),
1830 - esc_html__('Donate', 'cryptx')
1178 + '<a href="%s">%s</a>',
1179 + self::PAYPAL_DONATION_URL,
1180 + __('Donate', 'cryptx')
1831 1181 );
1832 1182 }
1833 -
1834 - /**
1835 - * Adds a universal filter for all widget types by hooking into the widget display process.
1836 - *
1837 - * @return void
1838 - */
1839 - private function addUniversalWidgetFilters(): void
1840 - {
1841 - // Hook into the widget display process to catch all widget types
1842 - add_filter('widget_display_callback', [$this, 'processWidgetContent'], 10, 3);
1843 - }
1844 -
1845 - /**
1846 - * Processes widget content to handle email addresses by adding links, identifying occurrences,
1847 - * and replacing them based on predefined rules.
1848 - *
1849 - * @param array|false $instance An array containing widget instance data, or false if no instance was provided.
1850 - * @param object $widget The widget object whose content is being processed.
1851 - * @param array $args Additional arguments provided to the widget.
1852 - *
1853 - * @return array|false Modified widget instance data as an array, or false if processing was not applicable.
1854 - */
1855 - public function processWidgetContent(array|false $instance, $widget, $args): array|false
1856 - {
1857 - if ($instance === false) {
1858 - return false;
1859 - }
1860 -
1861 - // Only process if widget_text option is enabled
1862 - if (!(self::$cryptXOptions['widget_text'] ?? false)) {
1863 - return $instance;
1864 - }
1865 -
1866 - // Check if instance has text content (traditional text widgets)
1867 - if (isset($instance['text']) && stripos($instance['text'], '@') !== false) {
1868 - $instance['text'] = $this->addLinkToEmailAddresses($instance['text']);
1869 - $instance['text'] = $this->findEmailAddressesInContent($instance['text']);
1870 - $instance['text'] = $this->replaceEmailInContent($instance['text']);
1871 - }
1872 -
1873 - // Check if instance has content field (block widgets)
1874 - if (isset($instance['content']) && stripos($instance['content'], '@') !== false) {
1875 - $instance['content'] = $this->addLinkToEmailAddresses($instance['content']);
1876 - $instance['content'] = $this->findEmailAddressesInContent($instance['content']);
1877 - $instance['content'] = $this->replaceEmailInContent($instance['content']);
1878 - }
1879 -
1880 - return $instance;
1881 - }
1882 -
1883 - /**
1884 - * Enhanced email encryption with security validation
1885 - *
1886 - * @param array $searchResults
1887 - * @return string
1888 - */
1889 - /**
1890 - * Cleans the query of a mailto link -- the "?subject=..." part.
1891 - *
1892 - * A positive list, not an exclusion list, because this value ends up
1893 - * decrypted in the browser and handed to window.location. RFC 6068 defines
1894 - * exactly these four headers as safe to accept from a link; everything else
1895 - * is dropped rather than escaped, because there is no legitimate reason for
1896 - * it to be there and no way to be sure what a mail client would do with it.
1897 - *
1898 - * Values are decoded and re-encoded rather than passed through: an incoming
1899 - * "Hallo%20Welt" must not become "Hallo%2520Welt", and a raw space must not
1900 - * stay a raw space.
1901 - *
1902 - * @param string $rawQuery The query as written in the href, without the "?".
1903 - * @param int $budget How many characters the finished query may occupy.
1904 - *
1905 - * @return string The cleaned query, or an empty string if nothing survives.
1906 - */
1907 - private function sanitizeMailtoQuery(string $rawQuery, int $budget = PHP_INT_MAX): string
1908 - {
1909 - if ($rawQuery === '' || $budget <= 0) {
1910 - return '';
1911 - }
1912 -
1913 - // "&amp;" is how a second parameter is spelled in valid HTML, and that
1914 - // is what the regular expression handed us.
1915 - $rawQuery = html_entity_decode($rawQuery, ENT_QUOTES, 'UTF-8');
1916 -
1917 - $allowed = ['subject', 'body', 'cc', 'bcc'];
1918 - $parts = [];
1919 -
1920 - foreach (explode('&', $rawQuery) as $pair) {
1921 - if ($pair === '' || strpos($pair, '=') === false) {
1922 - continue;
1923 - }
1924 -
1925 - [$key, $value] = explode('=', $pair, 2);
1926 - $key = strtolower(trim($key));
1927 -
1928 - if (!in_array($key, $allowed, true) || isset($parts[$key])) {
1929 - continue;
1930 - }
1931 -
1932 - $value = rawurldecode($value);
1933 -
1934 - // A recipient list is still a list of addresses, and an invalid one
1935 - // has no business being carried into a mail client.
1936 - if ($key === 'cc' || $key === 'bcc') {
1937 - $addresses = array_filter(array_map(
1938 - static fn($address) => sanitize_email(trim($address)),
1939 - explode(',', $value)
1940 - ));
1941 -
1942 - if ($addresses === []) {
1943 - continue;
1944 - }
1945 -
1946 - $value = implode(',', $addresses);
1947 - } else {
1948 - // Control characters would let a payload break out of the
1949 - // header it is written into.
1950 - $value = preg_replace('/[\x00-\x1F\x7F]/u', '', $value) ?? '';
1951 -
1952 - if (trim($value) === '') {
1953 - continue;
1954 - }
1955 -
1956 - $value = mb_substr($value, 0, self::MAX_MAILTO_VALUE_LENGTH);
1957 - }
1958 -
1959 - $pair = $this->fitPairToBudget(
1960 - $key,
1961 - $value,
1962 - // What is left once the pairs already collected, and the "&"
1963 - // that would join this one, are accounted for.
1964 - $budget - strlen(implode('&', $parts)) - ($parts === [] ? 0 : 1),
1965 - ($key === 'cc' || $key === 'bcc') ? ',' : ''
1966 - );
1967 -
1968 - if ($pair === '') {
1969 - continue;
1970 - }
1971 -
1972 - $parts[$key] = $pair;
1973 - }
1974 -
1975 - return implode('&', $parts);
1976 - }
1977 -
1978 - /**
1979 - * Encodes one header and shortens it until it fits the space left.
1980 - *
1981 - * The value is cut before encoding, never after: percent encoding turns one
1982 - * character into up to twelve, and a cut through "%C3%A4" leaves a sequence
1983 - * no client can read.
1984 - *
1985 - * Why there is a budget at all: cryptx.js refuses to navigate to a URL
1986 - * longer than 2048 characters, and so does SecureEncryption::validateUrl().
1987 - * Counting the value in characters before encoding is not the same measure
1988 - * -- 512 characters of Japanese become over 4000 once encoded. The link
1989 - * then did nothing at all, with nothing on the page to say why.
1990 - *
1991 - * @param string $key The header name.
1992 - * @param string $value The decoded value.
1993 - * @param int $available Characters left for the encoded pair.
1994 - * @param string $separator Set for list values: whole entries are dropped
1995 - * instead of characters.
1996 - *
1997 - * @return string The encoded pair, or an empty string if it cannot fit.
1998 - */
1999 - private function fitPairToBudget(
2000 - string $key,
2001 - string $value,
2002 - int $available,
2003 - string $separator = ''
2004 - ): string {
2005 - $encodedKey = rawurlencode($key);
2006 -
2007 - // The shortest useful pair is "key=" plus one character.
2008 - if ($available < strlen($encodedKey) + 2) {
2009 - return '';
2010 - }
2011 -
2012 - $pair = $encodedKey . '=' . rawurlencode($value);
2013 -
2014 - // A recipient list is not free text. Cutting it by characters leaves a
2015 - // fragment like "chef@examp" in a header a mail client will act on --
2016 - // either bouncing or, worse, delivering somewhere unintended. Whole
2017 - // addresses go, or the header goes.
2018 - if ($separator !== '') {
2019 - $items = explode($separator, $value);
2020 -
2021 - while (strlen($pair) > $available && count($items) > 1) {
2022 - array_pop($items);
2023 - $pair = $encodedKey . '=' . rawurlencode(implode($separator, $items));
2024 - }
2025 -
2026 - return strlen($pair) > $available ? '' : $pair;
2027 - }
2028 -
2029 - while (strlen($pair) > $available && $value !== '') {
2030 - $value = mb_substr($value, 0, mb_strlen($value) - 1);
2031 - $pair = $encodedKey . '=' . rawurlencode($value);
2032 - }
2033 -
2034 - return $value === '' ? '' : $pair;
2035 - }
2036 -
2037 - private function encryptEmailAddressSecure(array $searchResults): string
2038 - {
2039 - $originalValue = $searchResults[0]; // Full match
2040 - $rawTarget = $searchResults[2]; // Everything after "mailto:", verbatim
2041 -
2042 - // Address and query are separated BEFORE sanitising. sanitize_email()
2043 - // used to run over the whole target, and it strips "?" and "=" -- so
2044 - // "sales@example.com?subject=Hello" became
2045 - // "sales@example.comsubjectHello". Two things followed from that, both
2046 - // reported in the support forum and neither obvious: the payload
2047 - // carried a broken address, and the str_replace() below could no longer
2048 - // find its needle, so the untouched "mailto:" href stayed in the page.
2049 - $queryPosition = strpos($rawTarget, '?');
2050 - $rawAddress = $queryPosition === false ? $rawTarget : substr($rawTarget, 0, $queryPosition);
2051 - $rawQuery = $queryPosition === false ? '' : substr($rawTarget, $queryPosition + 1);
2052 -
2053 - $emailAddress = sanitize_email($rawAddress);
2054 -
2055 - if (strpos($emailAddress, '@') === self::NOT_FOUND) {
2056 - return $originalValue;
2057 - }
2058 -
2059 - // The budget is what the browser will still accept once "mailto:",
2060 - // the address and the "?" are in place.
2061 - $query = $this->sanitizeMailtoQuery(
2062 - $rawQuery,
2063 - self::MAX_MAILTO_URL_LENGTH - strlen('mailto:' . $emailAddress . '?')
2064 - );
2065 - $mailtoTarget = $emailAddress . ($query === '' ? '' : '?' . $query);
2066 -
2067 - $return = $originalValue;
2068 -
2069 - // Apply JavaScript handler if enabled
2070 - if (!empty(self::$cryptXOptions['java'])) {
2071 - $encryptionMode = $this->config->getEncryptionMode();
2072 - $payloadMode = 'legacy';
2073 - $password = '';
2074 -
2075 - // Determine which encryption method to use
2076 - if ($encryptionMode === 'secure' &&
2077 - $this->config->isSecureEncryptionEnabled() &&
2078 - class_exists('CryptX\SecureEncryption')) {
2079 -
2080 - // Use modern AES-256-GCM encryption
2081 - try {
2082 - $password = $this->config->getEncryptionPassword();
2083 - $mailtoUrl = 'mailto:' . $mailtoTarget;
2084 - $encryptedEmail = SecureEncryption::encrypt($mailtoUrl, $password);
2085 - $payloadMode = 'secure';
2086 - } catch (\Exception $e) {
2087 - // Fallback to legacy if secure encryption fails
2088 - $encryptedEmail = $this->generateHashFromString($mailtoTarget);
2089 - $password = '';
2090 - }
2091 - } else {
2092 - // Use legacy encryption (original algorithm). cryptx.js puts
2093 - // "mailto:" in front of whatever comes out, so the query rides
2094 - // along here as well.
2095 - $encryptedEmail = $this->generateHashFromString($mailtoTarget);
2096 - }
2097 -
2098 - self::$scriptNeeded = true;
2099 -
2100 - if ($this->getLinkMode() === 'data') {
2101 - // Preferred form: the payload travels in data attributes and a
2102 - // delegated click handler in cryptx.js does the work. A
2103 - // "javascript:" URI would be blocked outright by any halfway
2104 - // strict Content-Security-Policy, taking every CryptX link on
2105 - // the page with it -- silently.
2106 - $attributes = sprintf(
2107 - ' data-cx="%s" data-cxm="%s"',
2108 - esc_attr($encryptedEmail),
2109 - esc_attr($payloadMode)
2110 - );
2111 - if ($payloadMode === 'secure') {
2112 - $attributes .= sprintf(' data-cxk="%s"', esc_attr($password));
2113 - }
2114 -
2115 - // The raw target, not the sanitised address: they differ as
2116 - // soon as a query is present, and a needle that is not in the
2117 - // haystack leaves the plain "mailto:" href untouched.
2118 - //
2119 - // str_ireplace, because the pattern above matches case
2120 - // insensitively: an href written "MAILTO:" was found, but a
2121 - // lower-case needle then missed it -- same failure, reached
2122 - // through the spelling of the scheme instead of the query.
2123 - $return = str_ireplace('mailto:' . $rawTarget, '#', $originalValue);
2124 - $return = $this->addAttributesToAnchor($return, $attributes);
2125 - $return = $this->addClassToAnchor($return, self::LINK_CLASS);
2126 - } else {
2127 - // Legacy form, kept for installations that depend on it.
2128 - $javaHandler = $payloadMode === 'secure'
2129 - ? "javascript:secureDecryptAndNavigate('" . esc_js($encryptedEmail) . "', '" . esc_js($password) . "')"
2130 - : "javascript:DeCryptX('" . esc_js($encryptedEmail) . "')";
2131 -
2132 - $return = str_ireplace('mailto:' . $rawTarget, $javaHandler, $originalValue);
2133 - }
2134 - } else {
2135 - // Fallback to antispambot if JavaScript is not enabled
2136 - $return = str_ireplace('mailto:' . $rawTarget,
2137 - antispambot('mailto:' . $mailtoTarget), $return);
2138 - }
2139 -
2140 - // Add CSS attributes if specified
2141 - if (!empty(self::$cryptXOptions['css_id'])) {
2142 - // Guarded like every other preg_* call site in this class: a PCRE
2143 - // error yields null, and $return is declared string.
2144 - $return = $this->addIdToAnchor($return, self::$cryptXOptions['css_id']);
2145 - }
2146 -
2147 - if (!empty(self::$cryptXOptions['css_class'])) {
2148 - $return = $this->addClassToAnchor($return, self::$cryptXOptions['css_class']);
2149 - }
2150 -
2151 - return $return;
2152 - }
2153 -
2154 - /**
2155 - * Runs a sample through the real processing chain for the settings preview.
2156 - *
2157 - * Deliberately not a reimplementation: the preview calls the same three
2158 - * filters the front end calls, with the same encryption. A separate
2159 - * "preview renderer" would drift away from the truth sooner or later, and
2160 - * a preview that lies is worse than none.
2161 - *
2162 - * Nothing is written. Both the static option list and the Config instance
2163 - * are swapped for the duration and restored in a finally block -- Config
2164 - * matters because the encryption path reads its mode and password from
2165 - * there, not from the static list.
2166 - *
2167 - * @param array $overrides Option values as they stand in the unsaved form.
2168 - * @param string $content The sample content.
2169 - *
2170 - * @return string The processed markup.
2171 - */
2172 - public function renderPreviewMarkup(array $overrides, string $content): string
2173 - {
2174 - $previousOptions = self::$cryptXOptions;
2175 - $previousConfig = $this->config;
2176 -
2177 - // Make sure a secret exists before the swap, and mint it through the
2178 - // REAL Config if it does not.
2179 - //
2180 - // Config::getEncryptionPassword() writes when it has to mint, and
2181 - // Config::save() stores the whole option array -- which, on the
2182 - // throwaway Config below, is the administrator's unsaved form state.
2183 - // A preview would then silently persist settings that were only being
2184 - // tried out. The window is real: updateCryptXSettings() drops the
2185 - // secret on every version bump, and the settings screen is the first
2186 - // place an administrator goes after an update.
2187 - $stored = $this->loadCryptXOptionsWithDefaults();
2188 -
2189 - if (empty($stored['encryption_password'])) {
2190 - // Mint through a Config built from the STORED options, and carry the
2191 - // result into $merged by hand.
2192 - //
2193 - // Doing it through the live Config instead was not enough: that one
2194 - // holds an in-memory copy taken at startup, so it can believe it has
2195 - // a password while the row no longer does. It then writes nothing,
2196 - // $merged is still without a secret, and the throwaway Config below
2197 - // mints -- persisting the unsaved form along with it. A test that
2198 - // watches pre_update_option_cryptX found exactly that.
2199 - $stored['encryption_password'] = (new Config($stored))->getEncryptionPassword();
2200 - }
2201 -
2202 - $merged = wp_parse_args($overrides, $stored);
2203 -
2204 - self::$cryptXOptions = $merged;
2205 - $this->config = new Config($merged);
2206 - self::resetOptionCaches();
2207 -
2208 - try {
2209 - if (!empty(self::$cryptXOptions['autolink'])) {
2210 - $content = $this->addLinkToEmailAddresses($content, true);
2211 - }
2212 -
2213 - $content = $this->findEmailAddressesInContent($content, true);
2214 -
2215 - return (string) $this->replaceEmailInContent($content, true);
2216 - } finally {
2217 - self::$cryptXOptions = $previousOptions;
2218 - $this->config = $previousConfig;
2219 - self::resetOptionCaches();
2220 - }
2221 - }
2222 -
2223 - /**
2224 - * Which link form the encrypted address is delivered in.
2225 - *
2226 - * 'data' puts the payload into data attributes and lets a delegated click
2227 - * handler take over -- the only form that survives a Content-Security-Policy.
2228 - * 'js' is the historical "javascript:" URI, offered under Advanced for
2229 - * installations that depend on the old behaviour.
2230 - *
2231 - * @return string Either 'data' or 'js'.
2232 - */
2233 - private function getLinkMode(): string
2234 - {
2235 - $mode = (string) (self::$cryptXOptions['link_mode'] ?? 'data');
2236 -
2237 - return $mode === 'js' ? 'js' : 'data';
2238 - }
2239 -
2240 - /**
2241 - * Inserts additional attributes into the opening tag of an anchor.
2242 - *
2243 - * @param string $html The anchor markup.
2244 - * @param string $attributes Attribute string, starting with a space.
2245 - *
2246 - * @return string The markup with the attributes added.
2247 - */
2248 - private function addAttributesToAnchor(string $html, string $attributes): string
2249 - {
2250 - return $this->rewriteOpeningAnchorTag(
2251 - $html,
2252 - static fn(string $tag): string => preg_replace('/(\s*\/?>)$/', $attributes . '$1', $tag, 1) ?? $tag
2253 - );
2254 - }
2255 -
2256 - /**
2257 - * Adds a class to an anchor, keeping any class that is already there.
2258 - *
2259 - * @param string $html The anchor markup.
2260 - * @param string $class The class to add.
2261 - *
2262 - * @return string The markup with the class added.
2263 - */
2264 - private function addClassToAnchor(string $html, string $class): string
2265 - {
2266 - $class = esc_attr($class);
2267 -
2268 - return $this->rewriteOpeningAnchorTag(
2269 - $html,
2270 - function (string $tag) use ($class): string {
2271 - // (?:^|\s) rather than \b: a word boundary also sits
2272 - // between the quote and the "c" of an attribute value such
2273 - // as data-x="class='y'", so \bclass would bind to the text
2274 - // inside that value. Requiring whitespace before the name
2275 - // makes this an attribute rather than any occurrence of the
2276 - // word -- and it holds no matter which attribute comes
2277 - // first, which the greedy and the lazy variant each got
2278 - // wrong in one of the two orders.
2279 - if (preg_match('/(?:^|\s)class\s*=\s*(["\'])(.*?)\1/i', $tag)) {
2280 - return preg_replace(
2281 - '/((?:^|\s)class\s*=\s*(["\']))(.*?)\2/i',
2282 - '$1$3 ' . $class . '$2',
2283 - $tag,
2284 - 1
2285 - ) ?? $tag;
2286 - }
2287 -
2288 - return preg_replace('/(\s*\/?>)$/', ' class="' . $class . '"$1', $tag, 1) ?? $tag;
2289 - }
2290 - );
2291 - }
2292 -
2293 - /**
2294 - * Applies a rewrite to the opening tag of the first anchor only.
2295 - *
2296 - * Regular expressions on HTML are a poor tool, and this is the narrow case
2297 - * where it is still defensible: the markup comes from CryptX's own mailto
2298 - * pattern, so there is exactly one anchor and the payload is escaped before
2299 - * it gets here. Isolating the opening tag keeps the rewrite from reaching
2300 - * into attribute values or into the link text.
2301 - *
2302 - * @param string $html The anchor markup.
2303 - * @param callable $rewrite Receives the opening tag, returns the new one.
2304 - *
2305 - * @return string The markup with the rewritten opening tag.
2306 - */
2307 - private function rewriteOpeningAnchorTag(string $html, callable $rewrite): string
2308 - {
2309 - // Quoted attribute values may legitimately contain ">", so a plain
2310 - // [^>]* would end the tag too early and splice the new attribute into
2311 - // the middle of somebody else's title.
2312 - $openingTag = '/<a\b(?:[^>"\']|"[^"]*"|\'[^\']*\')*>/i';
2313 -
2314 - if (!preg_match($openingTag, $html, $matches, PREG_OFFSET_CAPTURE)) {
2315 - return $html;
2316 - }
2317 -
2318 - $tag = $matches[0][0];
2319 - $offset = $matches[0][1];
2320 - $rewritten = $rewrite($tag);
2321 -
2322 - return substr($html, 0, $offset) . $rewritten . substr($html, $offset + strlen($tag));
2323 - }
2324 -
2325 - /**
2326 - * Adds an id to an anchor, keeping any id that is already there.
2327 - *
2328 - * @param string $html The anchor markup.
2329 - * @param string $id The id to add.
2330 - *
2331 - * @return string The markup with the id added.
2332 - */
2333 - private function addIdToAnchor(string $html, string $id): string
2334 - {
2335 - $id = esc_attr($id);
2336 -
2337 - return $this->rewriteOpeningAnchorTag(
2338 - $html,
2339 - function (string $tag) use ($id): string {
2340 - // Same reasoning as in addClassToAnchor().
2341 - if (preg_match('/(?:^|\s)id\s*=\s*(["\'])(.*?)\1/i', $tag)) {
2342 - return preg_replace(
2343 - '/((?:^|\s)id\s*=\s*(["\']))(.*?)\2/i',
2344 - '$1$3 ' . $id . '$2',
2345 - $tag,
2346 - 1
2347 - ) ?? $tag;
2348 - }
2349 -
2350 - return preg_replace('/(\s*\/?>)$/', ' id="' . $id . '"$1', $tag, 1) ?? $tag;
2351 - }
2352 - );
2353 - }
2354 -
2355 1183 }