PluginProbe
CryptX / 3.4
CryptX v3.4
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 +750 -2838 trunk3.4 View file →
@@ -1,2912 +1,824 @@
1 1 <?php
2 2
3 3 namespace CryptX;
4 4
5 -final class CryptX
6 -{
5 +class CryptX {
6 +
7 7 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 - */
8 + const MAIL_IDENTIFIER = 'mailto:';
17 9 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 - * The filters whose content a visitor wrote, not the site owner.
42 - *
43 - * A setting is the owner speaking about their own pages. Where the text
44 - * came in from outside, a blanket rule that switches protection off has to
45 - * be read narrowly -- see isAddressExempt().
46 - *
47 - * Only comments can be told apart with certainty. Forum and front-end
48 - * submission plugins -- bbPress, BuddyPress -- send visitor text through
49 - * 'the_content', which is also the owner's own filter, so no name can
50 - * separate the two. That is a limit of the approach and is documented in
51 - * the readme rather than papered over here.
52 - */
53 - private const VISITOR_WRITTEN_FILTERS = ['comment_text', 'comment_text_rss'];
54 -
55 - /**
56 - * Option keys a shortcode attribute must never reach.
57 - *
58 - * A shortcode may be written by anybody who may write a post. Key material
59 - * is not a presentation setting.
60 - */
61 - private const NOT_SETTABLE_BY_SHORTCODE = [
62 - 'encryption_password',
63 - 'image_token_secret',
64 - // The retired image secret is key material too -- it opens every token
65 - // made before the last rotation. Leaving it out was the exact mistake
66 - // this list exists to prevent, made one release after the list was
67 - // written.
68 - 'image_token_secret_previous',
69 - 'image_token_secret_previous_until',
70 - 'secrets_rotated_at',
71 - 'version',
72 - // Not key material, but not presentation either: shortcode_atts()
73 - // makes every key of the option array settable, so leaving this one in
74 - // would let anybody who may write a post move the date on which the
75 - // site owner is asked for a review.
76 - 'review_prompt_due',
77 - ];
78 -
79 - /**
80 - * The feed counterpart of each content filter.
81 - *
82 - * WordPress builds a feed from its own filters, not from the ones that
83 - * render a page: <description> comes from 'the_excerpt_rss',
84 - * <content:encoded> from 'the_content_feed'.
85 - */
86 - private const FEED_FILTERS = [
87 - 'the_content' => 'the_content_feed',
88 - 'the_excerpt' => 'the_excerpt_rss',
89 - 'comment_text' => 'comment_text_rss',
90 - ];
91 -
92 - /**
93 - * Filters after which WordPress expands shortcodes.
94 - *
95 - * Measured, not assumed: has_filter($name, 'do_shortcode') is 11 for these
96 - * four and false for the other five CryptX hangs on. Only here may an
97 - * unexpanded [cryptx] be set aside, because only here does something come
98 - * along afterwards to deal with it.
99 - */
100 - private const SHORTCODE_EXPANDED_AFTER = [
101 - 'the_content',
102 - 'render_block',
103 - 'widget_text_content',
104 - 'widget_block_content',
105 - ];
10 + const INDEX_TO_CHECK = 4;
11 + const PATTERN = '/(.*)(">)/i';
106 12 const ASCII_VALUES_BLACKLIST = ['32', '34', '39', '60', '62', '63', '92', '94', '96', '127'];
107 - /** Upper bound for the text rendered into a PNG, see cryptXtinyUrl(). */
108 - private const MAX_IMAGE_TEXT_LENGTH = 254;
109 13
110 - /**
111 - * Upper bound for the path segment the image endpoint reads.
112 - *
113 - * Wider than the text it may draw, because a token is longer than the
114 - * address inside it -- padded to a multiple of 32, plus IV and tag, plus
115 - * base64. Wide enough for the longest address the drawing limit allows,
116 - * and still nowhere near a size that could hurt.
117 - */
118 - private const MAX_IMAGE_REQUEST_LENGTH = 512;
119 - private static ?self $instance = null;
120 - private static array $cryptXOptions = [];
121 - private static int $imageCounter = 0;
14 + private static ?CryptX $_instance = null;
15 + private static mixed $cryptXOptions = null;
16 + private static array $defaults = array(
17 + 'version' => null,
18 + 'at' => ' [at] ',
19 + 'dot' => ' [dot] ',
20 + 'css_id' => '',
21 + 'css_class' => '',
22 + 'the_content' => 1,
23 + 'the_meta_key' => 1,
24 + 'the_excerpt' => 1,
25 + 'comment_text' => 1,
26 + 'widget_text' => 1,
27 + 'java' => 1,
28 + 'load_java' => 1,
29 + 'opt_linktext' => 0,
30 + 'autolink' => 1,
31 + 'alt_linktext' => '',
32 + 'alt_linkimage' => '',
33 + 'http_linkimage_title' => '',
34 + 'alt_linkimage_title' => '',
35 + 'excludedIDs' => '',
36 + 'metaBox' => 1,
37 + 'alt_uploadedimage' => '0',
38 + 'c2i_font' => null,
39 + 'c2i_fontSize' => 10,
40 + 'c2i_fontRGB' => '#000000',
41 + 'echo' => 1,
42 + 'filter' => array('the_content','the_meta_key','the_excerpt','comment_text','widget_text'),
43 + 'whiteList' => 'jpeg,jpg,png,gif',
44 + );
45 + private static int $imageCounter = 0;
46 + private function __construct()
47 + {
48 + self::$cryptXOptions = $this->loadCryptXOptionsWithDefaults();
49 + }
122 50
123 - /** CSS class of the links the click handler in cryptx.js listens for. */
124 - private const LINK_CLASS = 'cryptx-link';
51 + /**
52 + * Get the instance of the CryptX class.
53 + *
54 + * This method returns the instance of the CryptX class. If an instance does not exist,
55 + * it creates a new instance of the CryptX class and stores it in the static property.
56 + * Subsequent calls to this method will return the previously created instance.
57 + *
58 + * @return CryptX The instance of the CryptX class.
59 + */
60 + public static function getInstance(): CryptX
61 + {
62 + if ( ! ( self::$_instance instanceof self) ) {
63 + self::$_instance = new self();
64 + }
65 + return self::$_instance;
66 + }
125 67
126 - /** Marks a save request as coming from the post meta box. */
127 - private const METABOX_NONCE_ACTION = 'cryptx_metabox';
128 - private const METABOX_NONCE_FIELD = 'cryptx_metabox_nonce';
129 -
130 - /**
131 - * Set as soon as something on this page actually needs them. Version 3.2.7
132 - * once had this property ("the javascript will be loaded only if really
133 - * needed!"); the 4.0 rewrite lost it and loaded both files on every page,
134 - * including pages without a single address.
135 - */
136 - private static bool $scriptNeeded = false;
137 - private static bool $styleNeeded = false;
138 -
139 - /**
140 - * Parsed once per request instead of on every call. Both lists are read
141 - * from a comma separated option for every filter pass and, in the case of
142 - * the whitelist, for every single address found.
143 - */
144 - private static ?array $excludedIdCache = null;
145 - private static ?array $whiteListCache = null;
146 - private static ?array $exemptAddressCache = null;
147 -
148 - /**
149 - * True while the shortcode handler is processing its own content.
150 - *
151 - * Set in one place rather than threaded through the three stages: each of
152 - * them already carries a shortcode flag, but the decisions that need it sit
153 - * inside preg_replace_callback() handlers with fixed signatures, and
154 - * rewriting that machinery to pass an argument would risk more than it
155 - * buys.
156 - */
157 - private bool $inShortcode = false;
158 -
159 - private const FONT_EXTENSION = 'ttf';
160 - private const PAYPAL_DONATION_URL = 'https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=4026696';
161 - private Admin\SettingsPage $settingsPage;
162 - private Admin\SiteHealth $siteHealth;
163 - private Admin\ReviewNotice $reviewNotice;
164 - private Block $block;
165 - private Config $config;
166 -
167 - private function __construct()
168 - {
169 - $this->settingsPage = new Admin\SettingsPage();
170 - $this->siteHealth = new Admin\SiteHealth();
171 - $this->reviewNotice = new Admin\ReviewNotice();
172 - $this->block = new Block();
173 - $this->config = new Config(get_option('cryptX', []));
174 - self::$cryptXOptions = $this->loadCryptXOptionsWithDefaults();
68 + function startCryptX(): void {
69 + if( isset( self::$cryptXOptions['version'] ) && version_compare(CRYPTX_VERSION, self::$cryptXOptions['version']) > 0 ) {
70 + $this->updateCryptXSettings();
71 + }
72 + foreach(self::$cryptXOptions['filter'] as $filter) {
73 + if (@self::$cryptXOptions[$filter]) {
74 + $this->addPluginFilters($filter);
75 + }
76 + }
77 + add_action( 'activate_' . CRYPTX_BASENAME, [$this, 'installCryptX' ] );
78 + add_action( 'wp_enqueue_scripts', [$this, 'loadJavascriptFiles' ] );
79 + if (@self::$cryptXOptions['metaBox']) {
80 + add_action('admin_menu', [$this, 'metaBox' ]);
81 + add_action('wp_insert_post', [$this, 'addPostIdToExcludedList' ] );
82 + add_action('wp_update_post', [$this, 'addPostIdToExcludedList' ] );
83 + }
84 + add_filter( 'plugin_row_meta', 'rw_cryptx_init_row_meta', 10, 2 );
85 + add_filter( 'init', [$this, 'cryptXtinyUrl' ]);
86 + add_shortcode( 'cryptx', [$this, 'cryptXShortcode']);
175 87 }
176 88
177 - /**
178 - * Retrieves the singleton instance of the class.
179 - *
180 - * @return self The singleton instance of the class.
181 - */
182 - public static function get_instance(): self
183 - {
184 - $needs_initialization = !(self::$instance instanceof self);
89 + /**
90 + * Returns an array of default options for CryptX.
91 + *
92 + * This function retrieves an array of default options for CryptX. The default options include
93 + * the current version of CryptX and the first available TrueType font from the "fonts" directory.
94 + *
95 + * @return array The array of default options.
96 + */
97 + function getCryptXOptionsDefaults(): array
98 + {
99 + $firstFont = $this->getFilesInDirectory( CRYPTX_DIR_PATH . 'fonts', "ttf");
100 + return array_merge(self::$defaults, [ 'version' => CRYPTX_VERSION, 'c2i_font' => $firstFont[0] ]);
101 + }
185 102
186 - if ($needs_initialization) {
187 - self::$instance = new self();
188 - }
103 + /**
104 + * Loads the cryptX options with default values.
105 + *
106 + * @return array The cryptX options array with default values.
107 + */
108 + function loadCryptXOptionsWithDefaults(): array
109 + {
110 + $defaultValues = $this->getCryptXOptionsDefaults();
111 + $currentOptions = get_option('cryptX');
112 + return wp_parse_args($currentOptions, $defaultValues);
113 + }
189 114
190 - return self::$instance;
115 + /**
116 + * Saves the cryptX options by updating the 'cryptX' option with the saved options merged with the default options.
117 + *
118 + * @param array $saveOptions The options to be saved.
119 + *
120 + * @return void
121 + */
122 + function saveCryptXOptions( array $saveOptions): void {
123 + update_option( 'cryptX', wp_parse_args( $saveOptions, $this->loadCryptXOptionsWithDefaults() ) );
191 124 }
192 125
126 + /**
127 + * The shortcode function is used to process the content passed to it and apply the necessary encryption and linking.
128 + *
129 + * @param string|null $content The content to be processed. Default is null.
130 + *
131 + * @return string The processed content with encryption and linking applied.
132 + */
133 + function cryptXShortcode( string $content = null): string
134 + {
135 + if (@self::$cryptXOptions['autolink']) {
136 + $content = $this->addLinkToEmailAddresses($content, true);
137 + }
193 138
194 - /**
195 - * @return Config
196 - */
197 - public function getConfig(): Config
198 - {
199 - return $this->config;
200 - }
139 + return $this->encryptAndLinkContent($content);
140 + }
201 141
202 - /**
203 - * Initializes the CryptX plugin by setting up version checks, applying filters, registering core hooks, initializing meta boxes (if enabled), and adding additional hooks.
204 - *
205 - * @return void
206 - */
207 - public function startCryptX(): void
208 - {
209 - // The settings screen registers its own menu entry and REST routes.
210 - // Doing it here rather than in the constructor keeps the hooks out of
211 - // object construction, where they are easy to trigger by accident.
212 - $this->settingsPage->register();
213 - $this->siteHealth->register();
214 - $this->reviewNotice->register();
215 - $this->block->register();
142 + /**
143 + * Encrypts and links content.
144 + *
145 + * @param string $content The content to be encrypted and linked.
146 + *
147 + * @return string The encrypted and linked content.
148 + */
149 + function encryptAndLinkContent( string $content): string {
150 + $content = $this->findEmailAddressesInContent($content, true);
151 + $content = $this->replaceEmailInContent($content, true);
152 + return $content;
153 + }
154 + /**
155 + * create image from tinyurl
156 + *
157 + * @return image
158 + */
159 + function cryptXtinyUrl() : void
160 + {
161 + $url = $_SERVER['REQUEST_URI'];
162 + $params = explode( '/', $url );
163 + if ( count( $params ) > 1 ) {
164 + $tiny_url = $params[count( $params ) -2];
165 + if ( $tiny_url == md5( get_bloginfo('url') ) ) {
166 + $font = CRYPTX_DIR_PATH . 'fonts/' . self::$cryptXOptions['c2i_font'];
167 + $msg = $params[count( $params ) -1];
168 + $size = self::$cryptXOptions['c2i_fontSize'];
169 + $pad = 1;
170 + $transparent = 1;
171 + $rgb = str_replace("#", "", self::$cryptXOptions['c2i_fontRGB']);
172 + $red = hexdec(substr($rgb,0,2));
173 + $grn = hexdec(substr($rgb,2,2));
174 + $blu = hexdec(substr($rgb,4,2));
175 + $bg_red = 255 - $red;
176 + $bg_grn = 255 - $grn;
177 + $bg_blu = 255 - $blu;
178 + $width = 0;
179 + $height = 0;
180 + $offset_x = 0;
181 + $offset_y = 0;
182 + $bounds = array();
183 + $image = "";
184 + $bounds = ImageTTFBBox($size, 0, $font, "W");
185 + $font_height = abs($bounds[7]-$bounds[1]);
186 + $bounds = ImageTTFBBox($size, 0, $font, $msg);
187 + $width = abs($bounds[4]-$bounds[6]);
188 + $height = abs($bounds[7]-$bounds[1]);
189 + $offset_y = $font_height+abs(($height - $font_height)/2)-1;
190 + $offset_x = 0;
191 + $image = imagecreatetruecolor($width+($pad*2),$height+($pad*2));
192 + imagesavealpha($image, true);
193 + $foreground = ImageColorAllocate($image, $red, $grn, $blu);
194 + $background = imagecolorallocatealpha($image, 0, 0, 0, 127);
195 + imagefill($image, 0, 0, $background);
196 + ImageTTFText($image, $size, 0, $offset_x+$pad, $offset_y+$pad, $foreground, $font, $msg);
197 + Header("Content-type: image/png");
198 + imagePNG($image);
199 + die;
200 + }
201 + }
202 + }
216 203
217 - $this->checkAndUpdateVersion();
218 - $this->addUniversalWidgetFilters(); // Add this line
219 - $this->initializePluginFilters();
220 - $this->registerCoreHooks();
221 - $this->initializeMetaBoxIfEnabled();
222 - $this->registerAdditionalHooks();
223 - }
204 + /**
205 + * Add plugin filters.
206 + *
207 + * This function adds the specified plugin filter if the 'autolink' key is present and its value is true in the global $cryptXOptions variable.
208 + * It also adds the 'autolink' function as a filter to the $filterName if the global $shortcode_tags variable is not empty.
209 + * Additionally, this function calls the addCommonFilters() and addOtherFilters() functions at specific points.
210 + *
211 + * @param string $filterName The name of the filter to add.
212 + *
213 + * @return void
214 + */
215 + function addPluginFilters( string $filterName): void
216 + {
217 + global $shortcode_tags;
218 + if ( array_key_exists('autolink', self::$cryptXOptions) && self::$cryptXOptions['autolink']) {
219 + $this->addAutoLinkFilters($filterName);
220 + if (!empty($shortcode_tags)) {
221 + $this->addAutoLinkFilters($filterName, 11);
222 + //add_filter($filterName, [$this,'autolink'], 11);
223 + }
224 + }
225 + $this->addOtherFilters($filterName);
226 + }
224 227
225 - /**
226 - * Rebuilds the cached options and configuration for the site now in scope.
227 - *
228 - * Hooked to 'switch_blog', which WordPress fires for both switch_to_blog()
229 - * and restore_current_blog(), so the object follows the site rather than
230 - * the request.
231 - *
232 - * @return void
233 - */
234 - public function refreshForCurrentSite(): void
235 - {
236 - // wp_insert_site() switches into the new site BEFORE its tables exist,
237 - // and reading options there produces a database error in the log while
238 - // telling us nothing. wp_is_site_initialized() answers the question
239 - // without that -- it suppresses errors around its own query.
240 - //
241 - // The flag is not needed for the call below as the core stands today:
242 - // wp_is_site_initialized() only switches when the id differs from the
243 - // current one (wp-includes/ms-site.php), and we pass our own. It is
244 - // here for the two ways that changes -- a plugin filtering
245 - // 'pre_wp_is_site_initialized', or a later core version that switches
246 - // unconditionally -- either of which would call this method back into
247 - // itself.
248 - static $busy = false;
228 + /**
229 + * Adds common filters to a given filter name.
230 + *
231 + * This function adds the common filter 'autolink' to the provided $filterName.
232 + *
233 + * @param string $filterName The name of the filter to add common filters to.
234 + *
235 + * @return void
236 + */
237 + function addAutoLinkFilters( string $filterName, $prio = 5): void
238 + {
239 + add_filter($filterName, [$this, 'addLinkToEmailAddresses' ], $prio);
240 + }
249 241
250 - if ($busy) {
251 - return;
252 - }
242 + /**
243 + * Adds additional filters to a given filter name.
244 + *
245 + * This function adds two additional filters, 'encryptx' and 'replaceEmailInContent',
246 + * to the specified filter name. The 'encryptx' filter is added with a priority of 12,
247 + * and the 'replaceEmailInContent' filter is added with a priority of 13.
248 + *
249 + * @param string $filterName The name of the filter to add the additional filters to.
250 + *
251 + * @return void
252 + */
253 + function addOtherFilters( string $filterName): void
254 + {
255 + add_filter($filterName, [$this, 'findEmailAddressesInContent' ], 12);
256 + add_filter($filterName, [$this,'replaceEmailInContent'], 13);
257 + }
253 258
254 - $busy = true;
259 + /**
260 + * Checks if a given ID is excluded based on the 'excludedIDs' variable.
261 + *
262 + * @param int $ID The ID to check if excluded.
263 + *
264 + * @return bool Returns true if the ID is excluded, false otherwise.
265 + */
266 + function isIdExcluded( int $ID): bool
267 + {
268 + $excludedIds = explode(",", self::$cryptXOptions['excludedIDs']);
269 + return in_array($ID, $excludedIds);
270 + }
255 271
256 - try {
257 - if (is_multisite() && !wp_is_site_initialized(get_current_blog_id())) {
258 - return;
259 - }
272 + /**
273 + * Replaces email addresses with link text in the given content.
274 + *
275 + * This function replaces email addresses in the provided content with link text. If the current post
276 + * ID is not excluded or the `isShortcode` parameter is set to true, the email addresses will be replaced.
277 + *
278 + * @param string $content The
279 + */
280 + function replaceEmailInContent( string $content, bool $isShortcode=false): string {
281 + global $post;
282 + $postId = (is_object($post))? $post->ID : -1;
283 + if ( !$this->isIdExcluded($postId) || $isShortcode ) {
284 + $content = $this->replaceEmailWithLinkText($content);
285 + }
286 + return $content;
287 + }
260 288
261 - $this->config = new Config(get_option('cryptX', []));
262 - self::$cryptXOptions = $this->loadCryptXOptionsWithDefaults();
263 - self::resetOptionCaches();
264 - } finally {
265 - $busy = false;
266 - }
267 - }
289 + /**
290 + * Replace email addresses in a given content with link text.
291 + *
292 + * @param string $content The content to search for email addresses.
293 + *
294 + * @return string The content with email addresses replaced with link text.
295 + */
296 + function replaceEmailWithLinkText( string $content): string
297 + {
298 + $emailPattern = "/([_a-zA-Z0-9-+]+(\.[_a-zA-Z0-9-+]+)*@[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*(\.[a-zA-Z]{2,}))/i";
299 + return preg_replace_callback($emailPattern, [$this,'encodeEmailToLinkText'], $content );
300 + }
268 301
269 - /**
270 - * Checks the current version of the application against the stored version and updates settings if the application version is newer.
271 - *
272 - * @return void
273 - */
274 - private function checkAndUpdateVersion(): void
275 - {
276 - $currentVersion = self::$cryptXOptions['version'] ?? null;
302 + /**
303 + * Encode email address to link text.
304 + *
305 + * @param array $Match The matched email address.
306 + *
307 + * @return array|string The encoded link text.
308 + */
309 + function encodeEmailToLinkText( array $Match): array|string {
310 + if($this->in_white_list($Match)) return $Match[1];
311 + switch (self::$cryptXOptions['opt_linktext']) {
312 + case 1:
313 + $text = $this->getLinkText();
314 + break;
315 + case 2:
316 + $text = $this->getLinkImage();
317 + break;
318 + case 3:
319 + $img_url = wp_get_attachment_url( self::$cryptXOptions['alt_uploadedimage'] );
320 + $text = $this->getUploadedImage($img_url);
321 + self::$imageCounter++;
322 + break;
323 + case 4:
324 + $text = antispambot($Match[1]);
325 + break;
326 + case 5:
327 + $text = $this->getImageFromText($Match);
328 + self::$imageCounter++;
329 + break;
330 + default:
331 + $text = $this->getDefaultLinkText($Match);
332 + }
277 333
278 - if ($currentVersion && version_compare(CRYPTX_VERSION, $currentVersion) > 0) {
279 - $this->updateCryptXSettings();
334 + return $text;
335 + }
280 336
281 - return;
282 - }
337 + /**
338 + * Check if the given match is in the whitelist.
339 + *
340 + * @param array $Match The match to check against the whitelist.
341 + *
342 + * @return bool True if the match is in the whitelist, false otherwise.
343 + */
344 + function in_white_list( array $Match): bool
345 + {
346 + $whiteList = array_filter(array_map('trim', explode(",", self::$cryptXOptions['whiteList'])));
347 + $tmp = explode(".", $Match[0]);
348 + return in_array(end($tmp), $whiteList);
349 + }
283 350
284 - if ($currentVersion) {
285 - return;
286 - }
351 + /**
352 + * Get the link text from cryptXOptions
353 + *
354 + * @return string The link text
355 + */
356 + function getLinkText(): string {
357 + return self::$cryptXOptions['alt_linktext'];
358 + }
287 359
288 - // No stamp at all, and the option nevertheless exists. That is not the
289 - // fresh install it looks like: Config::save() writes the whole option
290 - // array whenever it has to mint a secret, and on a front-end request
291 - // that happens without installCryptX() ever running -- so the row is
292 - // created with 'version' => null, from Config::DEFAULT_OPTIONS.
293 - //
294 - // Left alone, that state is permanent. updateCryptXSettings() treats a
295 - // null version as "nothing to migrate" and returns, and nothing else
296 - // ever writes the stamp, so every future migration is skipped in
297 - // silence. Stamping it here costs one write, once.
298 - //
299 - // Stamping rather than migrating is the right half: a null version
300 - // means there is no earlier CryptX data to bring forward, which is
301 - // exactly the case the migrations already decline to handle.
302 - if (get_option('cryptX', null) === null) {
303 - return;
304 - }
360 + /**
361 + * Generate an HTML image tag with the link image URL as the source
362 + *
363 + * @return string The HTML image tag
364 + */
365 + function getLinkImage(): string
366 + {
367 + return "<img src=\"" . self::$cryptXOptions['alt_linkimage'] . "\" class=\"cryptxImage\" alt=\"" . self::$cryptXOptions['alt_linkimage_title'] . "\" title=\"" . antispambot(self::$cryptXOptions['alt_linkimage_title']) . "\" />";
368 + }
305 369
306 - self::$cryptXOptions['version'] = CRYPTX_VERSION;
307 - update_option('cryptX', self::$cryptXOptions);
308 - }
370 + /**
371 + * Get the HTML tag for an uploaded image.
372 + *
373 + * @param string $img_url The URL of the image.
374 + *
375 + * @return string The HTML tag for the image.
376 + */
377 + function getUploadedImage( string $img_url): string
378 + {
379 + return "<img src=\"" . $img_url . "\" class=\"cryptxImage cryptxImage_" . self::$imageCounter . "\" alt=\"" . self::$cryptXOptions['http_linkimage_title'] . " title=\"" . antispambot( self::$cryptXOptions['http_linkimage_title']) . "\" />";
380 + }
309 381
310 - /**
311 - * Initializes and registers plugin filters based on the configuration settings.
312 - *
313 - * This method retrieves the active filters from the configuration and applies
314 - * each filter by either adding widget-specific filters or other plugin-related filters.
315 - * If the theme is a block theme, it transforms certain filters to an appropriate block-based equivalent.
316 - * It also checks if autolink functionality is enabled and adds the respective filters when applicable.
317 - *
318 - * @return void
319 - */
320 - public function initializePluginFilters(): void
321 - {
322 - if (empty($this->config)) {
323 - return;
324 - }
382 + /**
383 + * Converts a matched image URL into an HTML image element with cryptX classes and attributes.
384 + *
385 + * @param array $Match The matched image URL and other related data.
386 + *
387 + * @return string Returns the HTML image element.
388 + */
389 + function getImageFromText( array $Match): string
390 + {
391 + 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]) . "\" />";
392 + }
325 393
326 - $activeFilters = $this->config->getActiveFilters();
394 + /**
395 + * Replaces specific characters with values from cryptX options in a given string.
396 + *
397 + * @param array $Match The array containing matches from a regular expression search.
398 + * Array format: `[0 => string, 1 => string, ...]`.
399 + * The first element is ignored, and the second element is used as input string.
400 + *
401 + * @return array|string The string with replaced characters or the original array if no matches were found.
402 + * If the input string is an array, the function returns an array with replaced characters
403 + * for each element.
404 + */
405 + function getDefaultLinkText( array $Match): array|string
406 + {
407 + $text = str_replace("@", self::$cryptXOptions['at'], $Match[1]);
408 + return str_replace(".", self::$cryptXOptions['dot'], $text);
409 + }
327 410
328 - if (function_exists('wp_is_block_theme') && wp_is_block_theme()) {
329 - $activeFilters = array_map(
330 - fn($value) => $value === 'the_content' ? 'render_block' : $value,
331 - $activeFilters
332 - );
333 - }
411 + /**
412 + * List all files in a directory that match the given filter.
413 + *
414 + * @param string $path The path of the directory to list files from.
415 + * @param array|string $filter The file extensions to filter by.
416 + * If it's a string, it will be converted to an array of a single element.
417 + *
418 + * @return array An array of file names that match the filter.
419 + */
420 + function getFilesInDirectory( string $path, array|string $filter): array
421 + {
422 + if(!is_array($filter)) {
423 + $filter = (array) $filter;
424 + }
425 + $directoryHandle = opendir($path);
426 + $directoryContent = array();
427 + while ($file = readdir($directoryHandle)) {
428 + $fileExtension = substr(strtolower($file), -3);
429 + if (in_array($fileExtension, $filter)) {
430 + $directoryContent[] = $file;
431 + }
432 + }
433 + return $directoryContent;
434 + }
334 435
335 - foreach ($activeFilters as $filter) {
336 - if ($filter === 'widget_text') {
337 - $this->addWidgetFilters();
338 - } else {
339 - // Add autolink filters for non-widget filters if autolink is enabled
340 - if ($this->config->isAutolinkEnabled()) {
341 - $this->addAutoLinkFilters($filter, 11);
342 - }
343 - $this->addOtherFilters($filter);
344 - }
345 - }
346 -
347 - $this->addFeedFilters();
348 - }
349 -
350 - /**
351 - * Registers the feed counterparts of the active content filters.
352 - *
353 - * Without these, "Leave RSS feeds unprotected = off" only half worked. A
354 - * feed's <description> comes from the_excerpt_rss(), and nothing CryptX
355 - * hangs on runs on the way there: on a block theme the plugin sits on
356 - * 'render_block', which fires only from do_blocks() -- and
357 - * wp_trim_excerpt() detaches do_blocks before building the excerpt. The
358 - * address went out in the feed while the setting said it would not.
359 - *
360 - * The guard inside the three stages stays as it is; it is what makes the
361 - * option work in the other direction, for filters that run in both feed
362 - * and page context.
363 - *
364 - * @return void
365 - */
366 - private function addFeedFilters(): void
436 + /**
437 + * Encrypts email addresses in the given content
438 + *
439 + * @param string $content The content to be encrypted
440 + * @param bool $shortcode Whether to encrypt email addresses even if post ID is excluded
441 + *
442 + * @return string The encrypted content
443 + */
444 + function findEmailAddressesInContent( string $content, bool $shortcode = false): string
367 445 {
368 - // The default: feeds are deliberately left alone, because a feed
369 - // reader runs no JavaScript and a protected link would be dead in it.
370 - //
371 - // Read from the store rather than from the static list. The two agree
372 - // when this runs during startup, but the static one is swapped for the
373 - // duration of a shortcode and of the settings preview -- and a method
374 - // that decides which hooks exist has no business depending on which of
375 - // those happened to be in flight.
376 - $options = $this->loadCryptXOptionsWithDefaults();
446 + global $post;
447 + $postId = (is_object($post))? $post->ID : -1;
377 448
378 - if (!empty($options['disable_rss'])) {
379 - return;
380 - }
449 + $isIdExcluded = $this->isIdExcluded($postId);
450 + $mailtoRegex = '/<a (.*?)(href=("|\')mailto:(.*?)("|\')(.*?)|)>\s*(.*?)\s*<\/a>/i';
381 451
382 - foreach ($this->config->getActiveFilters() as $filter) {
383 - if (!isset(self::FEED_FILTERS[$filter])) {
384 - continue;
385 - }
452 + if ((!$isIdExcluded || $shortcode !== null)) {
453 + $content = preg_replace_callback($mailtoRegex, [$this, 'encryptEmailAddress' ], $content);
454 + }
386 455
387 - $feedFilter = self::FEED_FILTERS[$filter];
456 + return $content;
457 + }
388 458
389 - if ($this->config->isAutolinkEnabled()) {
390 - $this->addAutoLinkFilters($feedFilter, 11);
391 - }
459 + /**
460 + * Encrypts email addresses in search results.
461 + *
462 + * @param array $searchResults The search results containing email addresses.
463 + *
464 + * @return string The search results with encrypted email addresses.
465 + */
466 + function encryptEmailAddress( array $searchResults): string
467 + {
468 + $originalValue = $searchResults[0];
392 469
393 - $this->addOtherFilters($feedFilter);
394 - }
395 - }
470 + if(strpos($searchResults[self::INDEX_TO_CHECK], '@') === self::NOT_FOUND) {
471 + return $originalValue;
472 + }
396 473
397 - /**
398 - * Registers core hooks for the plugin's functionality.
399 - *
400 - * @return void
401 - */
402 - private function registerCoreHooks(): void
403 - {
404 - add_action('activate_' . CRYPTX_BASENAME, [$this, 'installCryptX']);
474 + $mailReference = self::MAIL_IDENTIFIER . $searchResults[self::INDEX_TO_CHECK];
405 475
406 - // Multisite: this object is built once per request, from whichever site
407 - // was current at the time. switch_to_blog() changes what get_option()
408 - // returns but not what this instance already holds -- and
409 - // getCryptXOptionsDefaults() hands out $this->config, which is the
410 - // FIRST site's stored values, not a set of defaults. Everything read
411 - // after a switch therefore came from the wrong site, up to and
412 - // including its encryption secret.
413 - add_action('switch_blog', [$this, 'refreshForCurrentSite']);
414 - add_action('wp_enqueue_scripts', [$this, 'loadJavascriptFiles']);
415 - // Priority 1 so this still runs before wp_print_footer_scripts.
416 - add_action('wp_footer', [$this, 'enqueueAssetsIfNeeded'], 1);
417 - }
476 + if ( str_starts_with( $searchResults[ self::INDEX_TO_CHECK ], self::SUBJECT_IDENTIFIER ) ) {
477 + return $originalValue;
478 + }
418 479
419 - /**
420 - * Initializes the meta box functionality if enabled in the configuration.
421 - *
422 - * This method checks whether the meta box feature is enabled in the cryptX options.
423 - * If enabled, it adds the necessary actions for administering the meta box and managing the posts' exclusion list.
424 - *
425 - * @return void
426 - */
427 - private function initializeMetaBoxIfEnabled(): void
428 - {
429 - if (!isset(self::$cryptXOptions['metaBox']) || !self::$cryptXOptions['metaBox']) {
430 - return;
431 - }
480 + $return = $originalValue;
481 + if (!empty(self::$cryptXOptions['java'])) {
482 + $javaHandler="javascript:DeCryptX('" . $this->generateHashFromString($searchResults[self::INDEX_TO_CHECK]) . "')";
483 + $return = str_replace(self::MAIL_IDENTIFIER.$searchResults[self::INDEX_TO_CHECK], $javaHandler, $originalValue);
484 + }
432 485
433 - add_action('admin_menu', [$this, 'metaBox']);
486 + $return = str_replace($mailReference, antispambot($mailReference), $return);
434 487
435 - // Only 'wp_insert_post'. There was a second registration on
436 - // 'wp_update_post' -- a hook WordPress does not have: the core defines
437 - // a *function* of that name, and the only do_action() calls are
438 - // 'wp_insert_post' in wp-includes/post.php. Since wp_update_post()
439 - // routes through wp_insert_post(), an update was covered all along;
440 - // the line did nothing and suggested it did.
441 - add_action('wp_insert_post', [$this, 'addPostIdToExcludedList']);
442 - }
488 + if(!empty(self::$cryptXOptions['css_id'])) {
489 + $return = preg_replace(self::PATTERN, '$1" id="'.self::$cryptXOptions['css_id'] . '">', $return );
490 + }
443 491
444 - /**
445 - * Registers additional WordPress hooks and shortcodes.
446 - *
447 - * @return void
448 - */
449 - private function registerAdditionalHooks(): void
450 - {
451 - add_filter('plugin_row_meta', [$this, 'add_plugin_action_links'], 10, 2);
452 - // add_action, nicht add_filter: 'init' ist eine Action. Intern
453 - // dasselbe, aber der Aufruf soll sagen, was er tut.
454 - add_action('init', [$this, 'cryptXtinyUrl']);
455 - add_shortcode('cryptx', [$this, 'cryptXShortcode']);
456 - }
492 + if(!empty(self::$cryptXOptions['css_class'])) {
493 + $return = preg_replace(self::PATTERN, '$1" class="'.self::$cryptXOptions['css_class'] . '">', $return );
494 + }
457 495
458 - /**
459 - * Retrieves the default options for CryptX configuration.
460 - *
461 - * @return array The default CryptX options, including version and font settings.
462 - */
463 - public function getCryptXOptionsDefaults(): array
464 - {
465 - return array_merge(
466 - $this->config->getAll(),
467 - [
468 - 'version' => CRYPTX_VERSION,
469 - 'c2i_font' => $this->getDefaultFont()
470 - ]
471 - );
472 - }
496 + return $return;
497 + }
473 498
474 - /**
475 - * Retrieves the default font from the available fonts directory.
476 - *
477 - * @return string|null Returns the name of the default font found, or null if no fonts are available.
478 - */
479 - private function getDefaultFont(): ?string
480 - {
481 - $availableFonts = $this->getFilesInDirectory(
482 - CRYPTX_DIR_PATH . 'fonts',
483 - [self::FONT_EXTENSION]
484 - );
485 499
486 - return $availableFonts[0] ?? null;
487 - }
500 + /**
501 + * Generate a hash string for the given input string.
502 + *
503 + * @param string $inputString The input string to generate a hash for.
504 + *
505 + * @return string The generated hash string.
506 + */
507 + function generateHashFromString( string $inputString): string
508 + {
509 + $inputString = str_replace("&", "&", $inputString);
510 + $crypt = '';
488 511
489 - /**
490 - * Loads the cryptX options with default values.
491 - *
492 - * @return array The cryptX options array with default values.
493 - */
494 - public function loadCryptXOptionsWithDefaults(): array
495 - {
496 - $defaultValues = $this->getCryptXOptionsDefaults();
497 - $currentOptions = get_option('cryptX');
512 + for ($i = 0; $i < strlen($inputString); $i++) {
513 + do {
514 + $salt = mt_rand(0, 3);
515 + $asciiValue = ord(substr($inputString, $i)) + $salt;
516 + if (8364 <= $asciiValue) {
517 + $asciiValue = 128;
518 + }
519 + } while (in_array($asciiValue, self::ASCII_VALUES_BLACKLIST));
498 520
499 - return wp_parse_args($currentOptions, $defaultValues);
500 - }
521 + $crypt .= $salt . chr($asciiValue);
522 + }
523 + return $crypt;
524 + }
525 + /**
526 + * add link to email addresses
527 + */
528 + /**
529 + * Auto-link emails in the given content.
530 + *
531 + * @param string $content The content to process.
532 + * @param bool $shortcode Whether the function is called from a shortcode or not.
533 + *
534 + * @return string The content with emails auto-linked.
535 + */
536 + function addLinkToEmailAddresses( string $content, bool $shortcode = false ): string
537 + {
538 + global $post;
539 + $postID = is_object( $post ) ? $post->ID : - 1;
501 540
502 - /**
503 - * Saves the cryptX options by updating the 'cryptX' option with the saved options merged with the default options.
504 - *
505 - * @param array $saveOptions The options to be saved.
506 - *
507 - * @return void
508 - */
509 - public function saveCryptXOptions(array $saveOptions): void
510 - {
511 - update_option('cryptX', wp_parse_args($saveOptions, $this->loadCryptXOptionsWithDefaults()));
512 - }
541 + if ( $this->isIdExcluded( $postID ) && ! $shortcode ) {
542 + return $content;
543 + }
513 544
514 - /**
515 - * Decodes attributes from their encoded state and returns the decoded array.
516 - *
517 - * @param array $attributes The array of attributes, potentially encoded.
518 - * @return array The array of decoded attributes with the 'encoded' key removed if present.
519 - */
520 - /**
521 - * Runs a processing step with unexpanded [cryptx] shortcodes masked out.
522 - *
523 - * On a block theme the three filters hang on 'render_block', which fires
524 - * from do_blocks() at 'the_content' priority 9 -- while do_shortcode()
525 - * runs at priority 11. CryptX therefore sees the shortcode as raw text,
526 - * long before it becomes anything.
527 - *
528 - * Left alone, that ends badly in two ways. The address inside
529 - * "[cryptx]info@example.com[/cryptx]" is not linked, because it sits
530 - * behind a "]", yet the display stage replaces it anyway -- the same
531 - * silent failure the autolink patterns were widened for. And once the
532 - * replacement inserts "[at]" and "[dot]", the new square brackets tear the
533 - * shortcode apart, so the parser later prints the wreckage into the page.
534 - *
535 - * Masking hands the shortcode to do_shortcode() untouched. It does its own
536 - * encrypting, with its own attributes, exactly as on a classic theme.
537 - *
538 - * @param string $content The content.
539 - * @param callable $process Receives the masked content, returns the result.
540 - *
541 - * @return string The processed content, with the shortcodes back in place.
542 - */
543 - private function withShortcodesProtected(string $content, callable $process): string
544 - {
545 - // Masking is only safe where do_shortcode() runs after us. In
546 - // 'comment_text', 'the_excerpt', 'the_meta_key', 'widget_text' and
547 - // 'widget_custom_html_content' it does not -- WordPress never expands
548 - // shortcodes there. Masking unconditionally therefore handed the
549 - // address to nobody at all: it was skipped here and never picked up
550 - // later, and a "[cryptx]" written into a comment shipped the address in
551 - // the clear. 4.1.0 at least obfuscated it.
552 - //
553 - // Where the shortcode is not going to be expanded, the literal
554 - // "[cryptx]" stays visible in the output and the address inside it is
555 - // obfuscated like any other. Ugly, and the same as before -- but the
556 - // address is covered.
557 - if (stripos($content, '[cryptx') === false) {
558 - return $process($content);
559 - }
545 + $emailPattern = "[_a-zA-Z0-9-+]+(\.[_a-zA-Z0-9-+]+)*@[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*(\.[a-zA-Z]{2,})";
546 + $linkPattern = "<a href=\"mailto:\\2\">\\2</a>";
547 + $src = [
548 + "/([\s])($emailPattern)/si",
549 + "/(>)($emailPattern)(<)/si",
550 + "/(\()($emailPattern)(\))/si",
551 + "/(>)($emailPattern)([\s])/si",
552 + "/([\s])($emailPattern)(<)/si",
553 + "/^($emailPattern)/si",
554 + "/(<a[^>]*>)<a[^>]*>/",
555 + "/(<\/A>)<\/A>/i"
556 + ];
557 + $tar = [
558 + "\\1$linkPattern",
559 + "\\1$linkPattern\\6",
560 + "\\1$linkPattern\\6",
561 + "\\1$linkPattern\\6",
562 + "\\1$linkPattern\\6",
563 + "<a href=\"mailto:\\0\">\\0</a>",
564 + "\\1",
565 + "\\1"
566 + ];
560 567
561 - if (!in_array(current_filter(), self::SHORTCODE_EXPANDED_AFTER, true)) {
562 - // The shortcode never runs here, so cryptXShortcode() never sets
563 - // its flag -- and without it the exemption list would win over a
564 - // "[cryptx]" that was written precisely to overrule it. On a site
565 - // that exempts its own domain, an address wrapped in a shortcode
566 - // inside a hand-written excerpt or a custom field would have gone
567 - // out in the clear: not a shortcoming of the new list, but a step
568 - // back from 4.1.1, which obfuscated it.
569 - //
570 - // The flag covers the whole string rather than the shortcode's
571 - // body, because finding the body means splitting the content, and
572 - // splitting changes what the autolink patterns see either side of
573 - // the cut. The cost is that an exempt address elsewhere in the same
574 - // excerpt is obfuscated too. That is the harmless direction: too
575 - // much protection in a rare case, never too little.
576 - $wasInShortcode = $this->inShortcode;
577 - $this->inShortcode = true;
568 + return preg_replace( $src, $tar, $content );
569 + }
578 570
579 - try {
580 - return $process($content);
581 - } finally {
582 - $this->inShortcode = $wasInShortcode;
583 - }
584 - }
571 + /**
572 + * Installs the CryptX plugin by updating its options and loading default values.
573 + */
574 + function installCryptX(): void {
575 + global $wpdb;
576 + self::$cryptXOptions['admin_notices_deprecated'] =true;
577 + if ( self::$cryptXOptions['excludedIDs'] == "") {
578 + $tmp = array();
579 + $excludes = $wpdb->get_results("SELECT post_id FROM $wpdb->postmeta WHERE meta_key = 'cryptxoff' AND meta_value = 'true'");
580 + if(count($excludes) > 0) {
581 + foreach ($excludes as $exclude) {
582 + $tmp[] = $exclude->post_id;
583 + }
584 + sort($tmp);
585 + self::$cryptXOptions['excludedIDs'] = implode(",", $tmp);
586 + update_option( 'cryptX', self::$cryptXOptions);
587 + self::$cryptXOptions = $this->loadCryptXOptionsWithDefaults(); // reread Options
588 + $wpdb->query("DELETE FROM $wpdb->postmeta WHERE meta_key = 'cryptxoff'");
589 + }
590 + }
591 + if (empty(self::$cryptXOptions['c2i_font'])) {
592 + self::$cryptXOptions['c2i_font'] = CRYPTX_DIR_PATH . 'fonts/' . $firstFont[0];
593 + }
594 + if (empty(self::$cryptXOptions['c2i_fontSize'])) {
595 + self::$cryptXOptions['c2i_fontSize'] = 10;
596 + }
597 + if (empty(self::$cryptXOptions['c2i_fontRGB'])) {
598 + self::$cryptXOptions['c2i_fontRGB'] = '000000';
599 + }
600 + update_option( 'cryptX', self::$cryptXOptions);
601 + self::$cryptXOptions = $this->loadCryptXOptionsWithDefaults(); // reread Options
602 + }
585 603
586 - $store = [];
587 - $prefix = $this->maskingPrefix('sc');
604 + function addHooksHelper($function_name, $hook_name): void {
605 + if ( function_exists($function_name) ) {
606 + call_user_func($function_name, 'cryptx', 'CryptX', [$this, 'metaCheckbox' ], $hook_name);
607 + } else {
608 + add_action("dbx_{$hook_name}_sidebar", [$this, 'metaOptionFieldset' ]);
609 + }
610 + }
588 611
589 - // WordPress' own idea of what a shortcode looks like, rather than a
590 - // hand-rolled one: it knows the self-closing form, the enclosing form
591 - // and -- the reason this matters below -- the escaped form.
592 - $pattern = '/' . get_shortcode_regex(['cryptx']) . '/s';
612 + function metaBox(): void {
613 + $this->addHooksHelper('add_meta_box', 'post');
614 + $this->addHooksHelper('add_meta_box', 'page');
615 + }
593 616
594 - $masked = preg_replace_callback(
595 - $pattern,
596 - static function (array $match) use (&$store, $prefix): string {
597 - // "[[cryptx]...[/cryptx]]" is how a page shows a shortcode
598 - // instead of running it -- an instructions page explaining
599 - // CryptX, typically. do_shortcode() deliberately leaves it as
600 - // text, so masking it would carry the address straight through
601 - // to the visitor in the clear. Groups 1 and 6 are the extra
602 - // brackets; when both are there, this is not ours to protect
603 - // and has to go through the normal obfuscation.
604 - if (($match[1] ?? '') === '[' && ($match[6] ?? '') === ']') {
605 - return $match[0];
606 - }
617 + /**
618 + * Displays a checkbox to disable CryptX for the current post or page.
619 + *
620 + * This function outputs HTML code for a checkbox that allows the user to disable CryptX
621 + * functionality for the current post or page. If the current post or page ID is excluded
622 + **/
623 + function metaCheckbox(): void
624 + {
625 + global $post;
626 + ?>
627 + <label><input type="checkbox" name="disable_cryptx_pageid" <?php if ($this->isIdExcluded($post->ID)) { echo 'checked="checked"'; } ?>/>
628 + Disable CryptX for this post/page</label>
629 + <?php
630 + }
607 631
608 - $store[] = $match[0];
632 + /**
633 + * Renders the CryptX option fieldset for the current post/page if the user has permission to edit posts.
634 + * This fieldset allows the user to enable or disable CryptX for the current post/page.
635 + *
636 + * @return void
637 + */
638 + function metaOptionFieldset(): void
639 + {
640 + global $post;
641 + if ( current_user_can('edit_posts') ) { ?>
642 + <fieldset id="cryptxoption" class="dbx-box">
643 + <h3 class="dbx-handle">CryptX</h3>
644 + <div class="dbx-content">
645 + <label><input type="checkbox" name="disable_cryptx_pageid" <?php if ($this->isIdExcluded($post->ID)) { echo 'checked="checked"'; } ?>/> Disable CryptX for this post/page</label>
646 + </div>
647 + </fieldset>
648 + <?php
649 + }
650 + }
609 651
610 - return sprintf('<!--%s:%d-->', $prefix, count($store) - 1);
611 - },
612 - $content
613 - );
614 -
615 - // A PCRE failure must not cost the content; process it unmasked.
616 - if ($masked === null) {
617 - return $process($content);
618 - }
619 -
620 - $result = $process($masked);
621 -
622 - // Under 'render_block' the shortcode is expanded here rather than left
623 - // for later. The other three entries in SHORTCODE_EXPANDED_AFTER carry
624 - // do_shortcode() themselves; 'render_block' does not -- it relies on
625 - // the_content running afterwards, and there are core paths where that
626 - // never happens. A block pattern pulled in through core/pattern is
627 - // rendered by do_blocks() alone (wp-includes/blocks/pattern.php), so a
628 - // masked shortcode would have been handed to nobody and the address
629 - // would have reached the page in the clear.
630 - //
631 - // Expanding twice is harmless: whatever runs later finds an anchor, no
632 - // shortcode.
633 - if (current_filter() === 'render_block') {
634 - $store = array_map('do_shortcode', $store);
635 - }
636 -
637 - $tokens = array_map(
638 - static fn(int $index): string => sprintf('<!--%s:%d-->', $prefix, $index),
639 - array_keys($store)
640 - );
641 -
642 - return str_replace($tokens, $store, $result);
643 - }
644 -
645 - /**
646 - * Builds a mailto query from the shortcode's mail attributes.
647 - *
648 - * @param array<string, mixed> $attributes Lower-cased shortcode attributes.
649 - *
650 - * @return string The cleaned query, or an empty string.
651 - */
652 - private function buildMailtoQueryFromAttributes(array $attributes): string
653 - {
654 - $pairs = [];
655 -
656 - foreach (self::MAILTO_ATTRIBUTES as $name) {
657 - if (!isset($attributes[$name]) || is_array($attributes[$name])) {
658 - continue;
659 - }
660 -
661 - $value = (string) $attributes[$name];
662 -
663 - if (trim($value) === '') {
664 - continue;
665 - }
666 -
667 - $pairs[] = $name . '=' . rawurlencode($value);
668 - }
669 -
670 - // Straight through the same gate an address in the page goes through,
671 - // so the shortcode cannot express anything a link could not.
672 - return $this->sanitizeMailtoQuery(implode('&', $pairs));
673 - }
674 -
675 - /**
676 - * Appends a query to every mailto link that does not already carry one.
677 - *
678 - * A link written by hand with its own "?subject=" keeps it: the more
679 - * specific instruction wins over the shortcode's blanket one.
680 - *
681 - * @param string $content The content, after autolinking.
682 - * @param string $query The query to append, without the "?".
683 - *
684 - * @return string The content with the query in place.
685 - */
686 - private function addQueryToMailtoLinks(string $content, string $query): string
687 - {
688 - $result = preg_replace_callback(
689 - '/(href\s*=\s*(["\']))mailto:([^"\']+)(\2)/i',
690 - static function (array $match) use ($query): string {
691 - if (strpos($match[3], '?') !== false) {
692 - return $match[0];
693 - }
694 -
695 - return $match[1] . 'mailto:' . $match[3] . '?' . $query . $match[4];
696 - },
697 - $content
698 - );
699 -
700 - // Same reasoning as every other preg_* call site here: a PCRE failure
701 - // yields null, and handing that on would empty the content.
702 - return $result ?? $content;
703 - }
704 -
705 - private function decodeAttributes(array $attributes): array
706 - {
707 - if (($attributes['encoded'] ?? '') !== 'true') {
708 - return $attributes;
709 - }
710 -
711 - $decodedAttributes = array_map(
712 - fn($value) => $this->decodeString($value),
713 - $attributes
714 - );
715 - unset($decodedAttributes['encoded']);
716 -
717 - return $decodedAttributes;
718 - }
719 -
720 - /**
721 - * Processes the provided shortcode attributes and content, encrypts content, and optionally creates links for email addresses.
722 - *
723 - * @param array $atts Attributes passed to the shortcode. Defaults to an empty array.
724 - * @param string $content The content enclosed within the shortcode. Defaults to an empty string.
725 - * @param string $tag The name of the shortcode tag. Defaults to an empty string.
726 - * @return string The processed and encrypted content, optionally including links for email addresses.
727 - */
728 - public function cryptXShortcode(array $atts = [], string $content = '', string $tag = ''): string
729 - {
730 - // Decode attributes if needed
731 - $attributes = $this->decodeAttributes($atts);
732 - $attributes = array_change_key_case($attributes, CASE_LOWER);
733 -
734 - // The mail headers are pulled out first. They are not options -- there
735 - // is no "subject" in the option store and never was -- so leaving them
736 - // in would hand them to shortcode_atts(), which drops anything it does
737 - // not recognise. That is precisely what happened to "subject" for
738 - // years: accepted by the parser, silently discarded, and documented as
739 - // working.
740 - $mailQuery = $this->buildMailtoQueryFromAttributes($attributes);
741 - $attributes = array_diff_key($attributes, array_flip(self::MAILTO_ATTRIBUTES));
742 -
743 - // Update options if attributes provided
744 - if (!empty($attributes)) {
745 - // shortcode_atts() keeps whatever is in the defaults it is given,
746 - // so the option array decides which attribute names have an effect
747 - // -- and the option array holds the two secrets. Nothing reads them
748 - // from here today (both go through Config), so this changes no
749 - // behaviour; it is here so that the next person to reach for
750 - // self::$cryptXOptions cannot accidentally make key material
751 - // settable by anyone who may write a post.
752 - $overridable = array_diff_key(
753 - $this->loadCryptXOptionsWithDefaults(),
754 - array_flip(self::NOT_SETTABLE_BY_SHORTCODE)
755 - );
756 -
757 - self::$cryptXOptions = array_merge(
758 - array_intersect_key(
759 - $this->loadCryptXOptionsWithDefaults(),
760 - array_flip(self::NOT_SETTABLE_BY_SHORTCODE)
761 - ),
762 - shortcode_atts($overridable, $attributes, $tag)
763 - );
764 - self::resetOptionCaches();
765 - }
766 -
767 - // Saved and restored rather than set to false at the end: should this
768 - // ever run nested, the outer shortcode must keep its own state.
769 - $wasInShortcode = $this->inShortcode;
770 - $this->inShortcode = true;
771 -
772 - try {
773 - // Process content (inline the encryptAndLinkContent logic)
774 - if (self::$cryptXOptions['autolink'] ?? false) {
775 - $content = $this->addLinkToEmailAddresses($content, true);
776 - }
777 -
778 - // After autolinking, so a bare address in the shortcode body has a
779 - // link to carry the headers, and before encrypting, so they end up
780 - // inside the payload rather than in the page.
781 - if ($mailQuery !== '') {
782 - $content = $this->addQueryToMailtoLinks($content, $mailQuery);
783 - }
784 -
785 - $content = $this->findEmailAddressesInContent($content, true);
786 - $processedContent = $this->replaceEmailInContent($content, true);
787 - } finally {
788 - // Restored in a finally block: self::$cryptXOptions is static, so
789 - // an exception escaping from here would leave the shortcode's
790 - // values in place for the rest of the request.
791 - $this->inShortcode = $wasInShortcode;
792 - self::$cryptXOptions = $this->loadCryptXOptionsWithDefaults();
793 - self::resetOptionCaches();
794 - }
795 -
796 - return $processedContent;
797 - }
798 -
799 - /**
800 - * Retrieves the ID of the current post.
801 - *
802 - * @return int The current post ID if available, or -1 if no post object is present.
803 - */
804 - private function getCurrentPostId(): int
805 - {
806 - global $post;
807 - return (is_object($post)) ? $post->ID : -1;
808 - }
809 -
810 -
811 - /**
812 - * Generates and returns a tiny URL image.
813 - *
814 - * @return void
815 - */
816 - public function cryptXtinyUrl(): void
817 - {
818 - // sanitize_text_field(), not esc_url(): the latter is an output
819 - // escaper and turned "&" into "&#038;" on the way in.
820 - $url = (!empty($_SERVER['REQUEST_URI']))
821 - ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI']))
822 - : '';
823 - $params = explode('/', $url);
824 -
825 - if (count($params) < 2) {
826 - return;
827 - }
828 -
829 - if (!hash_equals(md5(get_bloginfo('url')), $params[count($params) - 2])) {
830 - return;
831 - }
832 -
833 - // Everything below writes an image to the output stream. Any PHP notice
834 - // that slips through would end up inside that stream, be served as
835 - // image/png and disclose the server path to the visitor. So every
836 - // prerequisite is checked first and the request is abandoned quietly
837 - // if one is missing.
838 - if (!function_exists('imagettfbbox')) {
839 - return;
840 - }
841 -
842 - $fontFile = self::$cryptXOptions['c2i_font'] ?? $this->getDefaultFont();
843 - if (!is_string($fontFile) || $fontFile === '') {
844 - return;
845 - }
846 -
847 - // basename() keeps the option from reaching outside the fonts folder,
848 - // even if it was tampered with in the database.
849 - $font = CRYPTX_DIR_PATH . 'fonts/' . basename(str_replace(' ', '_', $fontFile));
850 - // is_file(), not is_readable(): the latter is true for a directory as
851 - // well, and imagettfbbox() would then emit "Could not read font" with
852 - // the full server path -- exactly the disclosure this rewrite removes.
853 - if (!is_file($font) || !is_readable($font)) {
854 - return;
855 - }
856 -
857 - // Two different bounds, and they used to be one. What has to be limited
858 - // is the text that gets DRAWN -- without a bound a long request sizes
859 - // the canvas up accordingly and exhausts the memory limit, a cheap
860 - // denial of service. What arrives in the URL is now a token, and a
861 - // token is longer than the address inside it: cutting the request at
862 - // the drawing limit silently broke every address from about 160
863 - // characters upwards, because the token was truncated before it could
864 - // be read. So the request gets a bound of its own, wide enough for any
865 - // token and still far from anything that could hurt.
866 - $requested = substr(rawurldecode($params[count($params) - 1]), 0, self::MAX_IMAGE_REQUEST_LENGTH);
867 - if ($requested === '') {
868 - return;
869 - }
870 -
871 - $msg = ImageToken::read($requested);
872 -
873 - if ($msg === '') {
874 - // No token: either an URL from a page cached before the update, or
875 - // somebody asking for arbitrary text to be drawn. Pages cached
876 - // before the update carry the address entity-encoded, and dropping
877 - // them would leave a broken image where an address should be for as
878 - // long as the cache lives -- so they are still served.
879 - //
880 - // But only if what they ask for really is an address. That is the
881 - // difference to before: this endpoint used to draw whatever text a
882 - // request named, which made it a picture generator for anyone who
883 - // found it. The old form stays workable, the abuse does not.
884 - //
885 - // The entity decoding is belt and braces with no path to it, and
886 - // that is worth saying so nobody later mistakes it for a tested
887 - // guarantee: a browser resolves the entities before it makes the
888 - // request, so what arrives here is the plain address. The encoded
889 - // form cannot even reach this line -- sanitize_text_field() above
890 - // strips percent sequences, and an unencoded "#" is cut off as a
891 - // fragment. It stays because it costs nothing and would carry a
892 - // proxy that did deliver the encoded form.
893 - $decoded = html_entity_decode($requested, ENT_QUOTES | ENT_HTML5, 'UTF-8');
894 -
895 - if (!Exposure::isAddress($decoded)) {
896 - return;
897 - }
898 -
899 - $msg = $decoded;
900 - }
901 -
902 - // Whichever way it arrived, this is the bound that matters: it is what
903 - // gets drawn, and therefore what sizes the canvas. Exposure::isAddress()
904 - // has no length limit of its own.
905 - $msg = substr($msg, 0, self::MAX_IMAGE_TEXT_LENGTH);
906 -
907 - $size = (int) (self::$cryptXOptions['c2i_fontSize'] ?? 10);
908 - $size = max(1, min(96, $size));
909 -
910 - $rgb = ltrim((string) (self::$cryptXOptions['c2i_fontRGB'] ?? '#000000'), '#');
911 - if (!preg_match('/^[0-9a-f]{6}$/i', $rgb)) {
912 - $rgb = '000000';
913 - }
914 - $red = hexdec(substr($rgb, 0, 2));
915 - $grn = hexdec(substr($rgb, 2, 2));
916 - $blu = hexdec(substr($rgb, 4, 2));
917 -
918 - $pad = 1;
919 - $bounds = imagettfbbox($size, 0, $font, 'W');
920 - if ($bounds === false) {
921 - return;
922 - }
923 - $font_height = abs($bounds[7] - $bounds[1]);
924 -
925 - $bounds = imagettfbbox($size, 0, $font, $msg);
926 - if ($bounds === false) {
927 - return;
928 - }
929 - $width = abs($bounds[4] - $bounds[6]);
930 - $height = abs($bounds[7] - $bounds[1]);
931 - if ($width < 1 || $height < 1) {
932 - return;
933 - }
934 -
935 - $offset_y = $font_height + abs(($height - $font_height) / 2) - 1;
936 - $offset_x = 0;
937 -
938 - $image = imagecreatetruecolor($width + ($pad * 2), $height + ($pad * 2));
939 - if ($image === false) {
940 - return;
941 - }
942 - imagesavealpha($image, true);
943 - $foreground = imagecolorallocate($image, $red, $grn, $blu);
944 - $background = imagecolorallocatealpha($image, 0, 0, 0, 127);
945 -
946 - // Both return false when the palette is exhausted. Passing that on
947 - // would emit a warning into the image stream -- the very thing this
948 - // method is built to avoid.
949 - if ($foreground === false || $background === false) {
950 - imagedestroy($image);
951 - return;
952 - }
953 -
954 - imagefill($image, 0, 0, $background);
955 - imagettftext($image, $size, 0, (int) round($offset_x + $pad), (int) round($offset_y + $pad), $foreground, $font, $msg);
956 -
957 - header('Content-Type: image/png');
958 - header('X-Content-Type-Options: nosniff');
959 - imagepng($image);
960 - imagedestroy($image);
961 - die;
962 - }
963 -
964 - /**
965 - * Adds common filters to a given filter name.
966 - *
967 - * This function adds the common filter 'autolink' to the provided $filterName.
968 - *
969 - * @param string $filterName The name of the filter to add common filters to.
970 - *
971 - * @return void
972 - */
973 - private function addAutoLinkFilters(string $filterName, $prio = 5): void
974 - {
975 - add_filter($filterName, [$this, 'addLinkToEmailAddresses'], $prio);
976 - }
977 -
978 - /**
979 - * Adds additional filters to a given filter name.
980 - *
981 - * This function adds two additional filters, 'encryptx' and 'replaceEmailInContent',
982 - * to the specified filter name. The 'encryptx' filter is added with a priority of 12,
983 - * and the 'replaceEmailInContent' filter is added with a priority of 13.
984 - *
985 - * @param string $filterName The name of the filter to add the additional filters to.
986 - *
987 - * @return void
988 - */
989 - private function addOtherFilters(string $filterName): void
990 - {
991 - // Check if this is a widget filter
992 - $widgetFilters = $this->config->getWidgetFilters();
993 - $isWidgetFilter = in_array($filterName, $widgetFilters);
994 -
995 - if ($isWidgetFilter) {
996 - // Use higher priority for widget filters (after autolink at priority 10)
997 - add_filter($filterName, [$this, 'findEmailAddressesInContent'], 15);
998 - add_filter($filterName, [$this, 'replaceEmailInContent'], 16);
999 - } else {
1000 - // Standard priorities for other filters
1001 - add_filter($filterName, [$this, 'findEmailAddressesInContent'], 12);
1002 - add_filter($filterName, [$this, 'replaceEmailInContent'], 13);
1003 - }
1004 - }
1005 -
1006 -
1007 - /**
1008 - * Adds and applies widget filters from the configuration.
1009 - *
1010 - * @return void
1011 - */
1012 - private function addWidgetFilters(): void
1013 - {
1014 - $widgetFilters = $this->config->getWidgetFilters();
1015 -
1016 - foreach ($widgetFilters as $widgetFilter) {
1017 - // No isAutolinkEnabled() check here, unlike the branch above that
1018 - // handles every other filter -- so switching autolink off leaves it
1019 - // on in widgets. Measured, not assumed: with autolink=0,
1020 - // has_filter() is false for the_content, the_excerpt and
1021 - // comment_text and true for all three widget filters.
1022 - //
1023 - // Left as it is on purpose. The difference errs towards protection:
1024 - // a plain address in a sidebar is linked and encrypted rather than
1025 - // left readable. Honouring the setting here would mean an update
1026 - // that makes addresses readable on sites that never asked for that,
1027 - // which is the one direction this plugin must not move in silently.
1028 - // The setting's help text says so instead.
1029 - $this->addAutoLinkFilters($widgetFilter, 11);
1030 - $this->addOtherFilters($widgetFilter);
1031 - }
1032 - }
1033 -
1034 - /**
1035 - * Checks if a given ID is excluded based on the 'excludedIDs' variable.
1036 - *
1037 - * @param int $ID The ID to check if excluded.
1038 - *
1039 - * @return bool Returns true if the ID is excluded, false otherwise.
1040 - */
1041 - private function isIdExcluded(int $ID): bool
1042 - {
1043 - if (self::$excludedIdCache === null) {
1044 - $raw = (string) (self::$cryptXOptions['excludedIDs'] ?? '');
1045 - self::$excludedIdCache = array_map(
1046 - 'intval',
1047 - array_filter(array_map('trim', explode(',', $raw)), 'strlen')
1048 - );
1049 - }
1050 -
1051 - return in_array($ID, self::$excludedIdCache, true);
1052 - }
1053 -
1054 - /**
1055 - * Drops the parsed option lists.
1056 - *
1057 - * Both caches mirror values from self::$cryptXOptions. Whenever those are
1058 - * replaced -- by the shortcode or after saving -- the caches have to go
1059 - * with them, otherwise a stale exclusion list survives the change.
1060 - *
1061 - * @return void
1062 - */
1063 - private static function resetOptionCaches(): void
1064 - {
1065 - self::$excludedIdCache = null;
1066 - self::$whiteListCache = null;
1067 - self::$exemptAddressCache = null;
1068 - }
1069 -
1070 - /**
1071 - * Replaces email addresses in content with link texts.
1072 - *
1073 - * @param string|null $content The content to replace the email addresses in.
1074 - * @param bool $isShortcode Flag indicating whether the method is called from a shortcode.
1075 - *
1076 - * @return string|null The content with replaced email addresses.
1077 - */
1078 - public function replaceEmailInContent(?string $content, bool $isShortcode = false): ?string
1079 - {
1080 - global $post;
1081 -
1082 - if (self::$cryptXOptions['disable_rss'] && $this->isRssFeed()) return $content;
1083 -
1084 - // Nothing to find without an at sign. Bailing out here skips the whole
1085 - // regular expression machinery for the vast majority of content -- and
1086 - // on a block theme this filter runs once per block, not once per post.
1087 - if ($content === null || strpos($content, '@') === false) {
1088 - return $content;
1089 - }
1090 -
1091 - // Check if current filter is a widget filter
1092 - $widgetFilters = $this->config->getWidgetFilters();
1093 - $isWidgetContext = in_array(current_filter(), $widgetFilters);
1094 -
1095 - $postId = (is_object($post)) ? $post->ID : -1;
1096 -
1097 - // For widgets, always process; for other content, check exclusion rules
1098 - if (($isWidgetContext || !$this->isIdExcluded($postId) || $isShortcode) && !empty($content)) {
1099 - $content = $this->withShortcodesProtected(
1100 - $content,
1101 - fn(string $masked): string => $this->replaceEmailWithLinkText($masked)
1102 - );
1103 - }
1104 -
1105 - return $content;
1106 - }
1107 -
1108 -
1109 - /**
1110 - * Replace email addresses in a given content with link text.
1111 - *
1112 - * @param string $content The content to search for email addresses.
1113 - *
1114 - * @return string The content with email addresses replaced with link text.
1115 - */
1116 - private function replaceEmailWithLinkText(string $content): string
1117 - {
1118 - $emailPattern = "/([_a-zA-Z0-9-+]+(\.[_a-zA-Z0-9-+]+)*@[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*(\.[a-zA-Z]{2,}))/i";
1119 -
1120 - $result = preg_replace_callback($emailPattern, [$this, 'encodeEmailToLinkText'], $content);
1121 -
1122 - // On a PCRE error -- a backtrack or recursion limit on unusually large
1123 - // or awkward content -- preg_* returns null. Handing that back would
1124 - // make the whole post body disappear, so the untouched content wins.
1125 - return $result ?? $content;
1126 - }
1127 -
1128 - /**
1129 - * Encode email address to link text.
1130 - *
1131 - * @param array $Match The matched email address.
1132 - *
1133 - * @return string The encoded link text.
1134 - */
1135 - private function encodeEmailToLinkText(array $Match): string
1136 - {
1137 - if ($this->inWhiteList($Match) || $this->isAddressExempt($Match[1])) {
1138 - return $Match[1];
1139 - }
1140 - switch (self::$cryptXOptions['opt_linktext']) {
1141 - case 1:
1142 - $text = $this->getLinkText();
1143 - break;
1144 - case 2:
1145 - $text = $this->getLinkImage();
1146 - break;
1147 - case 3:
1148 - $img_url = wp_get_attachment_url(self::$cryptXOptions['alt_uploadedimage']);
1149 - // false when the attachment was deleted; would have produced
1150 - // <img src=""> and a TypeError on the string parameter.
1151 - $text = $img_url === false ? $this->getDefaultLinkText($Match) : $this->getUploadedImage($img_url);
1152 - self::$imageCounter++;
1153 - break;
1154 - case 4:
1155 - $text = antispambot($Match[1]);
1156 - break;
1157 - case 5:
1158 - $text = $this->getImageFromText($Match);
1159 - self::$imageCounter++;
1160 - break;
1161 - default:
1162 - $text = $this->getDefaultLinkText($Match);
1163 - }
1164 -
1165 - return $text;
1166 - }
1167 -
1168 - /**
1169 - * A placeholder that content cannot forge.
1170 - *
1171 - * The masking steps set a piece of content aside, run something over the
1172 - * rest, and put it back by searching for the placeholder they left. With a
1173 - * fixed placeholder that search cannot tell its own marker from one an
1174 - * author typed: a post explaining CryptX, a code example, or a comment
1175 - * written by a stranger. Whoever wrote it got the stored value substituted
1176 - * into their text -- an address they never wrote, appearing in their post.
1177 - *
1178 - * Nothing could be injected that way, because the store only ever holds
1179 - * matches of the address pattern and those cannot contain a markup
1180 - * character. But it altered content, and content nobody typed is a bug
1181 - * whatever its contents. The random part per call closes it: the author
1182 - * cannot write a placeholder that this call will look for.
1183 - *
1184 - * @param string $kind Distinguishes the two masking steps.
1185 - *
1186 - * @return string The prefix, unique to this call.
1187 - */
1188 - private function maskingPrefix(string $kind): string
1189 - {
1190 - try {
1191 - $nonce = bin2hex(random_bytes(8));
1192 - } catch (\Exception $e) {
1193 - // Only reachable when the platform has no source of randomness at
1194 - // all. Falling back keeps the page rendering; wp_rand() is seeded
1195 - // well enough for a marker that lives for one request.
1196 - $nonce = dechex(wp_rand(0, PHP_INT_MAX)) . dechex(wp_rand(0, PHP_INT_MAX));
1197 - }
1198 -
1199 - return 'cryptx-' . $kind . '-' . $nonce;
1200 - }
1201 -
1202 - /**
1203 - * Runs a step with the exempt addresses masked out of the content.
1204 - *
1205 - * @param string $content The content.
1206 - * @param callable $process Receives the masked content, returns the result.
1207 - *
1208 - * @return string The processed content, addresses back in place.
1209 - */
1210 - private function withExemptAddressesProtected(string $content, callable $process): string
1211 - {
1212 - if (strpos($content, '@') === false) {
1213 - return $process($content);
1214 - }
1215 -
1216 - $store = [];
1217 - $prefix = $this->maskingPrefix('keep');
1218 -
1219 - $masked = preg_replace_callback(
1220 - '/[_a-zA-Z0-9-+]+(\.[_a-zA-Z0-9-+]+)*@[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*(\.[a-zA-Z]{2,})/',
1221 - function (array $match) use (&$store, $prefix): string {
1222 - if (!$this->isAddressExempt($match[0])) {
1223 - return $match[0];
1224 - }
1225 -
1226 - $store[] = $match[0];
1227 -
1228 - // No "@" in the token, so none of the address patterns can see
1229 - // it, and no square brackets, so a shortcode cannot be torn
1230 - // apart by it either.
1231 - return sprintf('<!--%s:%d-->', $prefix, count($store) - 1);
1232 - },
1233 - $content
1234 - );
1235 -
1236 - if ($masked === null) {
1237 - return $process($content);
1238 - }
1239 -
1240 - $result = $process($masked);
1241 -
1242 - $tokens = array_map(
1243 - static fn(int $index): string => sprintf('<!--%s:%d-->', $prefix, $index),
1244 - array_keys($store)
1245 - );
1246 -
1247 - return str_replace($tokens, $store, $result);
1248 - }
1249 -
1250 - /**
1251 - * Whether an address is one the site owner asked CryptX to leave alone.
1252 - *
1253 - * The endings list next door answers a different question -- it keeps
1254 - * "logo@2x.png" from being mistaken for an address at all. This one is
1255 - * about real addresses that are meant to stay readable: a support address
1256 - * a helpdesk parses out of the page, an address in a code example, an
1257 - * address a partner site scrapes on purpose. Until now the answer was "not
1258 - * possible", and the FAQ said so.
1259 - *
1260 - * An entry is either a whole address, or "@example.com" for every address
1261 - * at that domain. The domain form is the common case: a site tends to want
1262 - * its own addresses treated alike.
1263 - *
1264 - * Two places deliberately do not honour the list, both for the same
1265 - * reason -- a blanket setting must not overrule a narrower instruction:
1266 - *
1267 - * Inside "[cryptx]...[/cryptx]" nothing is exempt. The shortcode is
1268 - * somebody writing "protect this one, here"; a list entry set months ago on
1269 - * another screen is not an answer to that. The reverse order let a site
1270 - * that had exempted its own domain publish, in the clear, exactly the
1271 - * address it had wrapped in a shortcode to protect.
1272 - *
1273 - * In comments only the whole-address form counts. Comments are written by
1274 - * strangers, and "@example.com" is a statement about the site's own
1275 - * addresses, not about every address at that domain a visitor might leave
1276 - * behind. Honouring the domain form there turned the comment section into a
1277 - * harvest for anyone who could guess the domain. A whole address is
1278 - * different: the site owner named that one address exactly, and a visitor
1279 - * quoting it is quoting the site's own.
1280 - *
1281 - * @param string $address The address, as written in the content.
1282 - *
1283 - * @return bool True when CryptX must not touch it.
1284 - */
1285 - private function isAddressExempt(string $address): bool
1286 - {
1287 - if ($this->inShortcode) {
1288 - return false;
1289 - }
1290 -
1291 - if (self::$exemptAddressCache === null) {
1292 - $raw = (string) (self::$cryptXOptions['exemptAddresses'] ?? '');
1293 - self::$exemptAddressCache = array_filter(
1294 - array_map(
1295 - static fn(string $entry): string => strtolower(trim($entry)),
1296 - explode(',', $raw)
1297 - ),
1298 - 'strlen'
1299 - );
1300 - }
1301 -
1302 - if (self::$exemptAddressCache === []) {
1303 - return false;
1304 - }
1305 -
1306 - $address = strtolower(trim($address));
1307 -
1308 - if (in_array($address, self::$exemptAddressCache, true)) {
1309 - return true;
1310 - }
1311 -
1312 - if (in_array(current_filter(), self::VISITOR_WRITTEN_FILTERS, true)) {
1313 - return false;
1314 - }
1315 -
1316 - $at = strrpos($address, '@');
1317 -
1318 - if ($at === false) {
1319 - return false;
1320 - }
1321 -
1322 - return in_array(substr($address, $at), self::$exemptAddressCache, true);
1323 - }
1324 -
1325 - /**
1326 - * Check if the given match is in the whitelist.
1327 - *
1328 - * @param array $Match The match to check against the whitelist.
1329 - *
1330 - * @return bool True if the match is in the whitelist, false otherwise.
1331 - */
1332 - private function inWhiteList(array $Match): bool
1333 - {
1334 - if (self::$whiteListCache === null) {
1335 - $raw = (string) (self::$cryptXOptions['whiteList'] ?? '');
1336 - self::$whiteListCache = array_filter(array_map('trim', explode(',', $raw)), 'strlen');
1337 - }
1338 -
1339 - if (self::$whiteListCache === []) {
1340 - return false;
1341 - }
1342 -
1343 - $tmp = explode(".", $Match[0]);
1344 -
1345 - return in_array(end($tmp), self::$whiteListCache, true);
1346 - }
1347 -
1348 - /**
1349 - * Get the link text from cryptXOptions
1350 - *
1351 - * @return string The link text
1352 - */
1353 - private function getLinkText(): string
1354 - {
1355 - // Escaped here rather than at the source: a shortcode attribute of the
1356 - // same name reaches self::$cryptXOptions without passing through the
1357 - // settings validation at all.
1358 - //
1359 - // esc_html and not wp_kses_post, although the settings screen stores
1360 - // the value with wp_kses_post: the link text sits inside an anchor that
1361 - // CryptX builds itself, and markup there could close that anchor early.
1362 - // The two stages therefore mean different things on purpose -- storage
1363 - // keeps what a post may contain, output shows it as text. See
1364 - // SettingsSchema::sanitizeValue().
1365 - return esc_html((string) self::$cryptXOptions['alt_linktext']);
1366 - }
1367 -
1368 - /**
1369 - * Generate an HTML image tag with the link image URL as the source
1370 - *
1371 - * @return string The HTML image tag
1372 - */
1373 - private function getLinkImage(): string
1374 - {
1375 - self::$styleNeeded = true;
1376 - $title = (string) self::$cryptXOptions['alt_linkimage_title'];
1377 -
1378 - return sprintf(
1379 - '<img src="%s" class="cryptxImage" alt="%s" title="%s" />',
1380 - esc_url(self::$cryptXOptions['alt_linkimage']),
1381 - esc_attr($title),
1382 - esc_attr(antispambot($title))
1383 - );
1384 - }
1385 -
1386 - /**
1387 - * Get the HTML tag for an uploaded image.
1388 - *
1389 - * @param string $img_url The URL of the image.
1390 - *
1391 - * @return string The HTML tag for the image.
1392 - */
1393 - private function getUploadedImage(string $img_url): string
1394 - {
1395 - self::$styleNeeded = true;
1396 - $title = (string) self::$cryptXOptions['http_linkimage_title'];
1397 -
1398 - // The alt attribute used to be missing its closing quote, which ran the
1399 - // title straight into it and produced broken markup.
1400 - return sprintf(
1401 - '<img src="%s" class="cryptxImage cryptxImage_%d" alt="%s" title="%s" />',
1402 - esc_url($img_url),
1403 - self::$imageCounter,
1404 - esc_attr($title),
1405 - esc_attr(antispambot($title))
1406 - );
1407 - }
1408 -
1409 - /**
1410 - * Converts a matched image URL into an HTML image element with cryptX classes and attributes.
1411 - *
1412 - * @param array $Match The matched image URL and other related data.
1413 - *
1414 - * @return string Returns the HTML image element.
1415 - */
1416 - private function getImageFromText(array $Match): string
1417 - {
1418 - self::$styleNeeded = true;
1419 -
1420 - $address = (string) $Match[1];
1421 -
1422 - // Until 4.2.0 this put antispambot($address) into the URL, the alt and
1423 - // the title. Entity-encoding stops nothing that decodes entities -- and
1424 - // the browser decodes them before it makes the request, so the address
1425 - // travelled in the request line of every image load: into the access
1426 - // log, and through every proxy and CDN on the way. A visitor's browser
1427 - // handed the address to more machines than a plainly written one would
1428 - // have.
1429 -
1430 - // Built before the token, because the fallback below needs it too.
1431 - //
1432 - // _x() rather than __(): on its own the phrase could be a field label,
1433 - // a heading or a column name, and a translator seeing it in a list has
1434 - // no way to tell.
1435 - $label = _x(
1436 - 'Email address',
1437 - 'alt text of the picture that shows an email address',
1438 - 'cryptx'
1439 - );
1440 -
1441 - $token = ImageToken::mint($address);
1442 -
1443 - if ($token === '') {
1444 - // No token, no picture. Three answers were possible and two are
1445 - // wrong. Falling back to the old URL would put the address straight
1446 - // back where this took it out. Returning an empty string leaves
1447 - // "<a href=\"#\" data-cx=\"...\"></a>" -- a link with nothing in it,
1448 - // invisible on the page and nameless to a screen reader.
1449 - //
1450 - // The third, and the tempting one, is the ordinary obfuscated text.
1451 - // It writes the address as " [at] " and " [dot] ", which any
1452 - // harvester undoes with a single regular expression -- and escaping
1453 - // exactly that is why somebody chose this variant. Worse, those two
1454 - // separators are free-text settings: a site that put them back to
1455 - // "@" and "." would have the address written out in full.
1456 - //
1457 - // So the label the picture would have carried, and no address
1458 - // anywhere.
1459 - return esc_html($label);
1460 - }
1461 -
1462 - // Not the address in the alt attribute either. An alt is read out by
1463 - // screen readers and indexed by crawlers alike; putting the address
1464 - // there would hand it to both, and the link works for either of them
1465 - // without it. "Email address" says what the picture is, which is what
1466 - // an alt attribute is for.
1467 -
1468 - // No title attribute. It used to repeat the alt text, which some
1469 - // assistive software then reads out twice and which adds nothing for
1470 - // anybody else. While both carried the address that was merely
1471 - // pointless; now it would be noise.
1472 - return sprintf(
1473 - '<img src="%s" class="cryptxImage cryptxImage_%d" alt="%s" />',
1474 - esc_url(get_bloginfo('url') . '/' . md5(get_bloginfo('url')) . '/' . $token),
1475 - self::$imageCounter,
1476 - esc_attr($label)
1477 - );
1478 - }
1479 -
1480 - /**
1481 - * Replaces specific characters with values from cryptX options in a given string.
1482 - *
1483 - * @param array $Match The array containing matches from a regular expression search.
1484 - * Array format: `[0 => string, 1 => string, ...]`.
1485 - * The first element is ignored, and the second element is used as input string.
1486 - *
1487 - * @return string The string with replaced characters or the original array if no matches were found.
1488 - * If the input string is an array, the function returns an array with replaced characters
1489 - * for each element.
1490 - */
1491 - private function getDefaultLinkText(array $Match): string
1492 - {
1493 - // Escaped here for the same reason as in getLinkText(): the settings
1494 - // page runs both values through wp_kses_post(), but a shortcode
1495 - // attribute of the same name reaches self::$cryptXOptions unfiltered.
1496 - // Today only KSES stops an author from putting markup here -- that is
1497 - // WordPress protecting the plugin, not the plugin protecting itself.
1498 - $at = esc_html((string) self::$cryptXOptions['at']);
1499 - $dot = esc_html((string) self::$cryptXOptions['dot']);
1500 -
1501 - $text = str_replace("@", $at, $Match[1]);
1502 -
1503 - return str_replace(".", $dot, $text);
1504 - }
1505 -
1506 - /**
1507 - * List all files in a directory that match the given filter.
1508 - *
1509 - * @param string $path The path of the directory to list files from.
1510 - * @param array $filter The file extensions to filter by.
1511 - * If it's a string, it will be converted to an array of a single element.
1512 - *
1513 - * @return array An array of file names that match the filter.
1514 - */
1515 - public function getFilesInDirectory(string $path, array $filter): array
1516 - {
1517 - if (!is_dir($path)) {
1518 - return [];
1519 - }
1520 -
1521 - $directoryContent = [];
1522 - foreach (new \DirectoryIterator($path) as $file) {
1523 - if (!$file->isFile()) {
1524 - continue;
1525 - }
1526 - if (in_array(strtolower($file->getExtension()), $filter, true)) {
1527 - $directoryContent[] = $file->getFilename();
1528 - }
1529 - }
1530 -
1531 - // readdir() order depends on the file system, which made the default
1532 - // font differ between servers. Sorting keeps it reproducible.
1533 - sort($directoryContent);
1534 -
1535 - return $directoryContent;
1536 - }
1537 -
1538 - /**
1539 - * Finds and processes email addresses within the given content.
1540 - *
1541 - * This method scans the provided content for email addresses and encrypts them based on the configuration.
1542 - * It checks for RSS feed settings and excluded post IDs to determine whether encryption should be applied.
1543 - *
1544 - * @param string|null $content The content to search for email addresses. If null, the method returns null.
1545 - * @param bool $shortcode Specifies whether the method is invoked via a shortcode.
1546 - * @return string|null The processed content with email addresses encrypted, or null if the input content is null.
1547 - */
1548 - public function findEmailAddressesInContent(?string $content, bool $shortcode = false): ?string
1549 - {
1550 - global $post;
1551 -
1552 - if (self::$cryptXOptions['disable_rss'] && $this->isRssFeed()) return $content;
1553 -
1554 - if ($content === null) {
1555 - return null;
1556 - }
1557 -
1558 - // A mailto link without an at sign cannot carry an address. Cheapest
1559 - // possible way out before the regular expression runs.
1560 - if (strpos($content, '@') === false) {
1561 - return $content;
1562 - }
1563 -
1564 - // Check if current filter is a widget filter
1565 - $widgetFilters = $this->config->getWidgetFilters();
1566 - $isWidgetContext = in_array(current_filter(), $widgetFilters);
1567 -
1568 - $postId = (is_object($post)) ? $post->ID : -1;
1569 - $isIdExcluded = $this->isIdExcluded($postId);
1570 -
1571 - // Quoted attribute values may contain ">", so the tag must not simply
1572 - // end at the first one -- title="a > b" used to cut the match in half
1573 - // and produce mangled markup. Same construction as in
1574 - // rewriteOpeningAnchorTag(); the two have to agree on what a tag is.
1575 - $mailtoRegex = '/<a\b(?:[^>"\']|"[^"]*"|\'[^\']*\')*?href\s*=\s*(["\'])mailto:([^"\']+)\1(?:[^>"\']|"[^"]*"|\'[^\']*\')*>(.*?)<\/a>/is';
1576 - $that = $this;
1577 -
1578 - // For widgets, always process since there's no specific post context
1579 - // For other content, check exclusion rules
1580 - if ($isWidgetContext || !$isIdExcluded || $shortcode) {
1581 - $content = $this->withShortcodesProtected($content, static function (string $masked) use ($mailtoRegex, $that): string {
1582 - $result = preg_replace_callback($mailtoRegex, [$that, 'encryptEmailAddressSecure'], $masked);
1583 -
1584 - // null means PCRE gave up (backtrack limit). Keeping the
1585 - // original content is far better than returning null and
1586 - // wiping the page.
1587 - return $result ?? $masked;
1588 - });
1589 - }
1590 -
1591 - return $content;
1592 - }
1593 -
1594 - /**
1595 - * Generate a hash string for the given input string.
1596 - *
1597 - * @param string $inputString The input string to generate a hash for.
1598 - *
1599 - * @return string The generated hash string.
1600 - */
1601 - private function generateHashFromString(string $inputString): string
1602 - {
1603 - $inputString = str_replace("&", "&", $inputString);
1604 - $crypt = '';
1605 -
1606 - for ($i = 0; $i < strlen($inputString); $i++) {
1607 - do {
1608 - $salt = wp_rand(0, 3);
1609 - $asciiValue = ord(substr($inputString, $i)) + $salt;
1610 - if (8364 <= $asciiValue) {
1611 - $asciiValue = 128;
1612 - }
1613 - } while (in_array($asciiValue, self::ASCII_VALUES_BLACKLIST));
1614 -
1615 - $crypt .= $salt . chr($asciiValue);
1616 - }
1617 -
1618 - return $crypt;
1619 - }
1620 -
1621 - /**
1622 - * add link to email addresses
1623 - */
1624 - /**
1625 - * Auto-link emails in the given content.
1626 - *
1627 - * @param string $content The content to process.
1628 - * @param bool $shortcode Whether the function is called from a shortcode or not.
1629 - *
1630 - * @return string The content with emails auto-linked.
1631 - */
1632 - public function addLinkToEmailAddresses(string $content, bool $shortcode = false): string
1633 - {
1634 - global $post;
1635 -
1636 - // The same gate the other two stages carry, and missing here until
1637 - // 4.1.1. "Leave RSS feeds unprotected" is meant as "do not touch
1638 - // feeds"; without this, the autolink stage still turned a bare address
1639 - // into a mailto link in the feed, while the two stages that protect it
1640 - // stepped aside. The result was not a leak -- with the option on, the
1641 - // address is in the feed either way -- but it was CryptX changing
1642 - // content it had just been told to leave alone.
1643 - //
1644 - // The $shortcode exception is made here and not in the other two
1645 - // stages: those bail out of a feed unconditionally. Keeping it means
1646 - // the shortcode path behaves exactly as it did before this guard
1647 - // existed, which is the point -- the shortcode is an explicit
1648 - // instruction and outranks a blanket setting.
1649 - if (!$shortcode && self::$cryptXOptions['disable_rss'] && $this->isRssFeed()) {
1650 - return $content;
1651 - }
1652 -
1653 - // Eight regular expressions follow, each carrying the full address
1654 - // pattern. Without an at sign not one of them can match, so this test
1655 - // saves the entire pass.
1656 - if (strpos($content, '@') === false) {
1657 - return $content;
1658 - }
1659 -
1660 - // Check if current filter is a widget filter
1661 - $widgetFilters = $this->config->getWidgetFilters();
1662 - $isWidgetContext = in_array(current_filter(), $widgetFilters);
1663 -
1664 - $postID = is_object($post) ? $post->ID : -1;
1665 -
1666 - // For widgets, always process; for other content, check exclusion rules
1667 - if (!$isWidgetContext && $this->isIdExcluded($postID) && !$shortcode) {
1668 - return $content;
1669 - }
1670 -
1671 - $emailPattern = "[_a-zA-Z0-9-+]+(\\.[_a-zA-Z0-9-+]+)*@[a-zA-Z0-9-]+(\\.[a-zA-Z0-9-]+)*(\\.[a-zA-Z]{2,})";
1672 - $linkPattern = "<a href=\"mailto:\\2\">\\2</a>";
1673 - // Two widenings, both from the same report. The patterns after ">"
1674 - // required a "<" or whitespace to follow, so an address that ended the
1675 - // string right after a tag -- "Kontakt:<br>info@example.com" -- was
1676 - // never linked; hence the "$" variant. And they accepted only ">",
1677 - // while wp_kses_post() turns a bare ">" into "&gt;", leaving a ";"
1678 - // in front of the address; hence "[>;]", which covers the end of any
1679 - // HTML entity.
1680 - //
1681 - // In post content neither showed much, because a closing tag almost
1682 - // always follows an address. Through cryptx_encrypt() both showed every
1683 - // time. Worse than the missing link was what came next: the display
1684 - // stage still swapped the address for the configured link text, so the
1685 - // address vanished from the page without anything working taking its
1686 - // place.
1687 - $src = [
1688 - "/([\\s])($emailPattern)/si",
1689 - "/([>;])($emailPattern)(<)/si",
1690 - "/(\\()($emailPattern)(\\))/si",
1691 - "/([>;])($emailPattern)([\\s])/si",
1692 - "/([\\s])($emailPattern)(<)/si",
1693 - "/([>;])($emailPattern)$/si",
1694 - "/^($emailPattern)/si",
1695 - "/(<a[^>]*>)<a[^>]*>/",
1696 - "/(<\\/A>)<\\/A>/i"
1697 - ];
1698 - $tar = [
1699 - "\\1$linkPattern",
1700 - "\\1$linkPattern\\6",
1701 - "\\1$linkPattern\\6",
1702 - "\\1$linkPattern\\6",
1703 - "\\1$linkPattern\\6",
1704 - "\\1$linkPattern",
1705 - "<a href=\"mailto:\\0\">\\0</a>",
1706 - "\\1",
1707 - "\\1"
1708 - ];
1709 -
1710 - return $this->withShortcodesProtected($content, function (string $masked) use ($src, $tar): string {
1711 - // Exempt addresses are set aside for the duration. The eight
1712 - // patterns below are a preg_replace, not a callback, so there is no
1713 - // per-match decision to hook into -- and rewriting that machinery
1714 - // to get one would risk far more than it buys.
1715 - return $this->withExemptAddressesProtected(
1716 - $masked,
1717 - static function (string $inner) use ($src, $tar): string {
1718 - $result = preg_replace($src, $tar, $inner);
1719 -
1720 - // Same reasoning as elsewhere: a PCRE failure yields null,
1721 - // and handing that on would silently empty the page.
1722 - return $result ?? $inner;
1723 - }
1724 - );
1725 - });
1726 - }
1727 -
1728 - /**
1729 - * Installs the CryptX plugin by updating its options and loading default values.
1730 - */
1731 - public function installCryptX(): void
1732 - {
1733 - global $wpdb;
1734 -
1735 - // Load-bearing, not a duplicate of the 'switch_blog' hook -- do not
1736 - // remove it as one. When a plugin is activated, WordPress includes its
1737 - // file from activate_plugin(), long after plugins_loaded has fired, so
1738 - // startCryptX() never runs in that request and the hook is not
1739 - // registered. Measured: activating an inactive plugin, has_action(
1740 - // 'switch_blog') is false throughout. Without this line the network
1741 - // activation loop writes site 1's values into every other site --
1742 - // secret, link text and exclusion list -- which is how the bug was
1743 - // found in the first place.
1744 - $this->refreshForCurrentSite();
1745 -
1746 - // Nothing is written into a site whose tables do not exist yet.
1747 - // refreshForCurrentSite() returns early in that case WITHOUT touching
1748 - // the static option list -- and that list is static, so it survives
1749 - // switch_to_blog(). The update_option() at the end of this method would
1750 - // then write the PREVIOUS site's values into the new one, exclusion
1751 - // list included: the 4.1.1 bug, reached through a different door.
1752 - //
1753 - // No caller does that today (wp_initialize_site runs at priority 20,
1754 - // after the tables exist), which is exactly why this is here: the
1755 - // guarantee should not depend on the priority of somebody else's hook.
1756 - if (is_multisite() && !wp_is_site_initialized(get_current_blog_id())) {
1757 - return;
1758 - }
1759 -
1760 - // A site that has never stored anything starts from the network's
1761 - // defaults rather than the plugin's. Only then: a site with a stored
1762 - // option has an administrator who chose something, and a network
1763 - // default is a starting point, not an instruction. Getting that
1764 - // backwards is how 4.1.1 came to publish addresses on sites whose
1765 - // owners had excluded them -- the two settings that caused it are not
1766 - // shareable at all, see Admin\NetworkDefaults.
1767 - if (is_multisite() && get_option('cryptX', null) === null) {
1768 - self::$cryptXOptions = array_merge(
1769 - self::$cryptXOptions,
1770 - Admin\NetworkDefaults::forNewSite()
1771 - );
1772 - }
1773 -
1774 - self::$cryptXOptions['admin_notices_deprecated'] = true;
1775 - if (self::$cryptXOptions['excludedIDs'] == "") {
1776 - $tmp = array();
1777 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1778 - $excludes = $wpdb->get_results($wpdb->prepare(
1779 - "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = %s",
1780 - 'cryptxoff',
1781 - 'true'
1782 - ));
1783 - if (count($excludes) > 0) {
1784 - foreach ($excludes as $exclude) {
1785 - $tmp[] = $exclude->post_id;
1786 - }
1787 - sort($tmp);
1788 - self::$cryptXOptions['excludedIDs'] = implode(",", $tmp);
1789 - update_option('cryptX', self::$cryptXOptions);
1790 - self::$cryptXOptions = $this->loadCryptXOptionsWithDefaults(); // reread Options
1791 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1792 - $wpdb->query($wpdb->prepare(
1793 - "DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s",
1794 - 'cryptxoff'
1795 - ));
1796 - }
1797 - }
1798 - if (empty(self::$cryptXOptions['c2i_font'])) {
1799 - // Only the file name is stored here. cryptXtinyUrl() prepends
1800 - // CRYPTX_DIR_PATH . 'fonts/' itself, so an absolute path would
1801 - // produce an unusable font path.
1802 - self::$cryptXOptions['c2i_font'] = $this->getDefaultFont();
1803 - }
1804 - if (empty(self::$cryptXOptions['c2i_fontSize'])) {
1805 - self::$cryptXOptions['c2i_fontSize'] = 10;
1806 - }
1807 - if (empty(self::$cryptXOptions['c2i_fontRGB'])) {
1808 - self::$cryptXOptions['c2i_fontRGB'] = '000000';
1809 - }
652 + /**
653 + * Adds a post ID to the excluded list in the cryptX options.
654 + *
655 + * @param int $postId The post ID to be added to the excluded list.
656 + *
657 + * @return void
658 + */
659 + function addPostIdToExcludedList( int $postId): void {
660 + $postId = wp_is_post_revision($postId) ?: $postId;
661 + $excludedIds = $this->updateExcludedIdsList(self::$cryptXOptions['excludedIDs'], $postId);
662 + self::$cryptXOptions['excludedIDs'] = implode(",", array_filter($excludedIds));
1810 663 update_option('cryptX', self::$cryptXOptions);
1811 - self::$cryptXOptions = $this->loadCryptXOptionsWithDefaults(); // reread Options
1812 - }
664 + }
1813 665
1814 - private function addHooksHelper($function_name, $hook_name): void
1815 - {
1816 - if (function_exists($function_name)) {
1817 - call_user_func($function_name, 'cryptx', 'CryptX', [$this, 'metaCheckbox'], $hook_name);
1818 - } else {
1819 - add_action("dbx_{$hook_name}_sidebar", [$this, 'metaOptionFieldset']);
1820 - }
1821 - }
666 + /**
667 + * Updates the excluded IDs list based on a given ID and the current list.
668 + *
669 + * @param string $excludedIds The current excluded IDs list, separated by commas.
670 + * @param int $postId The ID to be updated in the excluded IDs list.
671 + *
672 + * @return array The updated excluded IDs list as an array, with the ID removed if it existed and added if necessary.
673 + */
674 + function updateExcludedIdsList(string $excludedIds, int $postId): array {
675 + $excludedIdsArray = explode(",", $excludedIds);
676 + $excludedIdsArray = $this->removePostIdFromExcludedIds($excludedIdsArray, $postId);
677 + $excludedIdsArray = $this->addPostIdToExcludedIdsIfNecessary($excludedIdsArray, $postId);
678 + return $this->makeExcludedIdsUniqueAndSorted($excludedIdsArray);
679 + }
1822 680
1823 - public function metaBox(): void
1824 - {
1825 - $this->addHooksHelper('add_meta_box', 'post');
1826 - $this->addHooksHelper('add_meta_box', 'page');
1827 - }
681 + /**
682 + * Removes a specific post ID from the array of excluded IDs.
683 + *
684 + * @param array $excludedIds The array of excluded IDs.
685 + * @param int $postId The ID of the post to be removed from the excluded IDs.
686 + *
687 + * @return array The updated array of excluded IDs without the specified post ID.
688 + */
689 + function removePostIdFromExcludedIds(array $excludedIds, int $postId): array {
690 + foreach($excludedIds as $key => $id) {
691 + if($id == $postId) {
692 + unset($excludedIds[$key]);
693 + break;
694 + }
695 + }
696 + return $excludedIds;
697 + }
1828 698
1829 - /**
1830 - * Displays a checkbox to disable CryptX for the current post or page.
1831 - *
1832 - * This function outputs HTML code for a checkbox that allows the user to disable CryptX
1833 - * functionality for the current post or page. If the current post or page ID is excluded
1834 - **/
1835 - public function metaCheckbox(): void
1836 - {
1837 - global $post;
699 + /**
700 + * Adds the post ID to the list of excluded IDs if necessary.
701 + *
702 + * @param array $excludedIds The array of excluded IDs.
703 + * @param int $postId The post ID to be added to the excluded IDs.
704 + *
705 + * @return array The updated array of excluded IDs.
706 + */
707 + function addPostIdToExcludedIdsIfNecessary(array $excludedIds, int $postId): array {
708 + if (isset($_POST['disable_cryptx_pageid'])) {
709 + $excludedIds[] = $postId;
710 + }
711 + return $excludedIds;
712 + }
1838 713
1839 - if (!is_object($post)) {
1840 - return;
1841 - }
714 + /**
715 + * Makes the excluded IDs unique and sorted.
716 + *
717 + * @param array $excludedIds The array of excluded IDs.
718 + *
719 + * @return array The array of excluded IDs with duplicate values removed and sorted in ascending order.
720 + */
721 + function makeExcludedIdsUniqueAndSorted(array $excludedIds): array {
722 + $excludedIds = array_unique($excludedIds);
723 + sort($excludedIds);
724 + return $excludedIds;
725 + }
1842 726
1843 - wp_nonce_field(self::METABOX_NONCE_ACTION, self::METABOX_NONCE_FIELD);
1844 - ?>
1845 - <label><input type="checkbox" name="disable_cryptx_pageid" <?php if ($this->isIdExcluded($post->ID)) {
1846 - echo 'checked="checked"';
1847 - } ?>/>
1848 - <?php esc_html_e('Disable CryptX for this post/page', 'cryptx'); ?></label>
1849 - <?php
1850 - }
727 + /**
728 + * Displays a message in a styled div.
729 + *
730 + * @param string $message The message to be displayed.
731 + * @param bool $errormsg Optional. Indicates whether the message is an error message. Default is false.
732 + *
733 + * @return void
734 + */
735 + function showMessage($message, $errormsg = false): void {
736 + if ($errormsg) {
737 + echo '<div id="message" class="error">';
738 + }
739 + else {
740 + echo '<div id="message" class="updated fade">';
741 + }
1851 742
1852 - /**
1853 - * Renders the CryptX option fieldset for the current post/page if the user has permission to edit posts.
1854 - * This fieldset allows the user to enable or disable CryptX for the current post/page.
1855 - *
1856 - * @return void
1857 - */
1858 - public function metaOptionFieldset(): void
1859 - {
1860 - global $post;
743 + echo "$message</div>";
744 + }
1861 745
1862 - if (!is_object($post) || !current_user_can('edit_post', $post->ID)) {
1863 - return;
1864 - }
1865 - ?>
1866 - <fieldset id="cryptxoption" class="dbx-box">
1867 - <h3 class="dbx-handle">CryptX</h3>
1868 - <div class="dbx-content">
1869 - <?php wp_nonce_field(self::METABOX_NONCE_ACTION, self::METABOX_NONCE_FIELD); ?>
1870 - <label><input type="checkbox"
1871 - name="disable_cryptx_pageid" <?php if ($this->isIdExcluded($post->ID)) {
1872 - echo 'checked="checked"';
1873 - } ?>/> <?php esc_html_e('Disable CryptX for this post/page', 'cryptx'); ?></label>
1874 - </div>
1875 - </fieldset>
1876 - <?php
1877 - }
746 + /**
747 + * Retrieves the domain from the current site URL.
748 + *
749 + * @return string The domain of the current site URL.
750 + */
751 + function getDomain(): string {
752 + return $this->trimSlashFromDomain($this->removeProtocolFromUrl($this->getSiteUrl()));
753 + }
1878 754
1879 - /**
1880 - * Adds a post ID to the excluded list in the cryptX options.
1881 - *
1882 - * @param int $postId The post ID to be added to the excluded list.
1883 - *
1884 - * @return void
1885 - */
1886 - public function addPostIdToExcludedList(int $postId): void
1887 - {
1888 - // The meta box has to have taken part in this request. Without this
1889 - // gate every save that carries no $_POST at all -- REST, WP-CLI,
1890 - // autosave, the block editor's first pass -- removed the post from the
1891 - // exclusion list and silently switched CryptX back on for it.
1892 - //
1893 - // The gate hangs on the nonce, deliberately not on the checkbox: an
1894 - // unchecked box is not submitted at all, so "checkbox missing" would
1895 - // mean both "meta box was not involved" and "user cleared the tick".
1896 - // Guarding on that would make an excluded post impossible to include
1897 - // again.
1898 - if (!isset($_POST[self::METABOX_NONCE_FIELD])) {
1899 - return;
1900 - }
755 + /**
756 + * Retrieves the site URL.
757 + *
758 + * @return string The site URL.
759 + */
760 + function getSiteUrl(): string {
761 + return get_option( 'siteurl' );
762 + }
1901 763
1902 - $nonce = sanitize_text_field(wp_unslash($_POST[self::METABOX_NONCE_FIELD]));
1903 - if (!wp_verify_nonce($nonce, self::METABOX_NONCE_ACTION)) {
1904 - return;
1905 - }
764 + /**
765 + * Removes the protocol from a URL.
766 + *
767 + * @param string $url The URL string to remove the protocol from.
768 + *
769 + * @return string The URL string without the protocol.
770 + */
771 + function removeProtocolFromUrl(string $url): string
772 + {
773 + return preg_replace( '|https?://|', '', $url );
774 + }
1906 775
1907 - $postId = wp_is_post_revision($postId) ?: $postId;
776 + /**
777 + * Trims the trailing slash from a domain.
778 + *
779 + * @param string $domain The domain to trim the slash from.
780 + *
781 + * @return string The domain with the trailing slash removed.
782 + */
783 + function trimSlashFromDomain(string $domain): string
784 + {
785 + if ( $slashPosition = strpos( $domain, '/' ) ) {
786 + $domain = substr( $domain, 0, $slashPosition );
787 + }
788 + return $domain;
789 + }
1908 790
1909 - if (!current_user_can('edit_post', $postId)) {
1910 - return;
1911 - }
791 + /**
792 + * Loads Javascript files required for CryptX functionality.
793 + *
794 + * @return void
795 + */
796 + function loadJavascriptFiles(): void {
797 + wp_enqueue_script( 'cryptx-js', CRYPTX_DIR_URL . 'js/cryptx.min.js', false, false, self::$cryptXOptions['load_java'] );
798 + wp_enqueue_style( 'cryptx-styles', CRYPTX_DIR_URL . 'css/cryptx.css');
799 + }
1912 800
1913 - // Read the option fresh instead of writing back self::$cryptXOptions.
1914 - // That property is static and the shortcode overwrites it while it
1915 - // runs; storing it wholesale could persist a shortcode's temporary
1916 - // values. Only the one key we are responsible for is touched.
1917 - $options = get_option('cryptX', []);
1918 - if (!is_array($options)) {
1919 - $options = [];
1920 - }
1921 -
1922 - $excludedIds = $this->updateExcludedIdsList((string) ($options['excludedIDs'] ?? ''), $postId);
1923 - $options['excludedIDs'] = implode(',', array_filter($excludedIds));
1924 -
1925 - update_option('cryptX', $options);
1926 -
1927 - self::$cryptXOptions['excludedIDs'] = $options['excludedIDs'];
1928 - self::resetOptionCaches();
1929 - }
1930 -
1931 - /**
1932 - * Updates the excluded IDs list based on a given ID and the current list.
1933 - *
1934 - * @param string $excludedIds The current excluded IDs list, separated by commas.
1935 - * @param int $postId The ID to be updated in the excluded IDs list.
1936 - *
1937 - * @return array The updated excluded IDs list as an array, with the ID removed if it existed and added if necessary.
1938 - */
1939 - private function updateExcludedIdsList(string $excludedIds, int $postId): array
1940 - {
1941 - $excludedIdsArray = explode(",", $excludedIds);
1942 - $excludedIdsArray = $this->removePostIdFromExcludedIds($excludedIdsArray, $postId);
1943 - $excludedIdsArray = $this->addPostIdToExcludedIdsIfNecessary($excludedIdsArray, $postId);
1944 -
1945 - return $this->makeExcludedIdsUniqueAndSorted($excludedIdsArray);
1946 - }
1947 -
1948 - /**
1949 - * Removes a specific post ID from the array of excluded IDs.
1950 - *
1951 - * @param array $excludedIds The array of excluded IDs.
1952 - * @param int $postId The ID of the post to be removed from the excluded IDs.
1953 - *
1954 - * @return array The updated array of excluded IDs without the specified post ID.
1955 - */
1956 - private function removePostIdFromExcludedIds(array $excludedIds, int $postId): array
1957 - {
1958 - foreach ($excludedIds as $key => $id) {
1959 - if ($id == $postId) {
1960 - unset($excludedIds[$key]);
1961 - break;
1962 - }
1963 - }
1964 -
1965 - return $excludedIds;
1966 - }
1967 -
1968 - /**
1969 - * Adds the post ID to the list of excluded IDs if necessary.
1970 - *
1971 - * @param array $excludedIds The array of excluded IDs.
1972 - * @param int $postId The post ID to be added to the excluded IDs.
1973 - *
1974 - * @return array The updated array of excluded IDs.
1975 - */
1976 - private function addPostIdToExcludedIdsIfNecessary(array $excludedIds, int $postId): array
1977 - {
1978 - // The only caller, addPostIdToExcludedList(), checks the nonce field,
1979 - // then wp_verify_nonce(), then current_user_can('edit_post', $postId)
1980 - // before reaching this method. The scanner cannot follow three call
1981 - // levels and sees only the superglobal. Read as presence or absence of
1982 - // a checkbox; the value is never used.
1983 - //
1984 - // The annotation has to sit on the line directly above the statement --
1985 - // with the explanation above it, it silenced the next comment line and
1986 - // the warning stayed.
1987 - // phpcs:ignore WordPress.Security.NonceVerification.Missing
1988 - if (isset($_POST['disable_cryptx_pageid'])) {
1989 - $excludedIds[] = $postId;
1990 - }
1991 -
1992 - return $excludedIds;
1993 - }
1994 -
1995 - /**
1996 - * Makes the excluded IDs unique and sorted.
1997 - *
1998 - * @param array $excludedIds The array of excluded IDs.
1999 - *
2000 - * @return array The array of excluded IDs with duplicate values removed and sorted in ascending order.
2001 - */
2002 - private function makeExcludedIdsUniqueAndSorted(array $excludedIds): array
2003 - {
2004 - $excludedIds = array_unique($excludedIds);
2005 - sort($excludedIds);
2006 -
2007 - return $excludedIds;
2008 - }
2009 -
2010 - /**
2011 - * Retrieves the domain from the current site URL.
2012 - *
2013 - * @return string The domain of the current site URL.
2014 - */
2015 - public function getDomain(): string
2016 - {
2017 - return $this->trimSlashFromDomain($this->removeProtocolFromUrl($this->getSiteUrl()));
2018 - }
2019 -
2020 - /**
2021 - * Retrieves the site URL.
2022 - *
2023 - * @return string The site URL.
2024 - */
2025 - private function getSiteUrl(): string
2026 - {
2027 - return get_option('siteurl');
2028 - }
2029 -
2030 - /**
2031 - * Removes the protocol from a URL.
2032 - *
2033 - * @param string $url The URL string to remove the protocol from.
2034 - *
2035 - * @return string The URL string without the protocol.
2036 - */
2037 - private function removeProtocolFromUrl(string $url): string
2038 - {
2039 - return preg_replace('|https?://|', '', $url);
2040 - }
2041 -
2042 - /**
2043 - * Trims the trailing slash from a domain.
2044 - *
2045 - * @param string $domain The domain to trim the slash from.
2046 - *
2047 - * @return string The domain with the trailing slash removed.
2048 - */
2049 - private function trimSlashFromDomain(string $domain): string
2050 - {
2051 - if ($slashPosition = strpos($domain, '/')) {
2052 - $domain = substr($domain, 0, $slashPosition);
2053 - }
2054 -
2055 - return $domain;
2056 - }
2057 -
2058 - /**
2059 - * Registers the frontend assets.
2060 - *
2061 - * Registering is not loading. Whether the files end up on the page is
2062 - * decided in enqueueAssetsIfNeeded() once the content has been processed
2063 - * and it is known whether anything was encrypted at all.
2064 - *
2065 - * One exception: with the script placed in the head (load_java = 0) that
2066 - * decision cannot be deferred -- the head is sent before the content runs.
2067 - * In that configuration the script is enqueued unconditionally, as before.
2068 - *
2069 - * That head branch deliberately does not look at "java" at all, and never
2070 - * did -- this method leaves it untouched. It follows that a
2071 - * "[cryptx java=...]" shortcode override has nothing to add there: the
2072 - * script is already on every page regardless of the global setting, so
2073 - * only the footer branch below needs to read "java" or care about a
2074 - * shortcode overriding it.
2075 - *
2076 - * @return void
2077 - */
2078 - public function loadJavascriptFiles(): void
2079 - {
2080 - $inFooter = !empty(self::$cryptXOptions['load_java']);
2081 -
2082 - wp_register_script('cryptx-js', CRYPTX_DIR_URL . 'js/cryptx.min.js', [], CRYPTX_VERSION, $inFooter);
2083 - wp_localize_script('cryptx-js', 'cryptxConfig', SecureEncryption::getJavaScriptConfig());
2084 - wp_register_style('cryptx-styles', CRYPTX_DIR_URL . 'css/cryptx.css', [], CRYPTX_VERSION);
2085 -
2086 - if (!$inFooter) {
2087 - wp_enqueue_script('cryptx-js');
2088 - wp_enqueue_style('cryptx-styles');
2089 - } elseif (!empty(self::$cryptXOptions['java'])) {
2090 - // Footer placement, JavaScript handler enabled: enqueue cryptx-js
2091 - // unconditionally, here, before it is known whether THIS request's
2092 - // content carries an address. wp_register_script() above already
2093 - // registered it with $inFooter = true, so this does not move the
2094 - // print location -- it still prints in wp_footer, exactly as
2095 - // before. Deferring to enqueueAssetsIfNeeded() (scriptNeeded) only
2096 - // covers a classic page load, where the address a visitor clicks
2097 - // is guaranteed to be in the same document that carried the
2098 - // script. A client-side navigation (swup.js, PJAX, Barba, Turbo)
2099 - // can land a visitor on a page with no address at all and then
2100 - // drop .cryptx-link elements in later, without ever loading a
2101 - // second script -- the delegated handler in cryptx.js was simply
2102 - // never attached. See
2103 - // docs/entscheidungen/2026-09-11-assets-bei-clientseitiger-navigation.md
2104 - // for the analysis.
2105 - //
2106 - // Known remaining gap, not closable from here: this branch only
2107 - // reads the global "java" setting. A page whose first load carries
2108 - // no [cryptx java="1"] shortcode, under a global java = 0, still
2109 - // enqueues nothing here -- so a later client-side navigation to a
2110 - // page that DOES carry that shortcode still finds no click handler
2111 - // attached. See the decision doc above for why this is left open.
2112 - wp_enqueue_script('cryptx-js');
2113 - }
2114 - }
2115 -
2116 - /**
2117 - * Loads the assets that this page turned out to need.
2118 - *
2119 - * Runs late, in the footer, when every filter has done its work.
2120 - *
2121 - * @return void
2122 - */
2123 - public function enqueueAssetsIfNeeded(): void
2124 - {
2125 - if (self::$scriptNeeded) {
2126 - // Still needed as a net: a shortcode can set java=1 for its own
2127 - // instance while the global setting says java=0, since "java" is
2128 - // not in NOT_SETTABLE_BY_SHORTCODE. loadJavascriptFiles() only
2129 - // sees the global setting, so this is what catches that case. A
2130 - // second wp_enqueue_script() on an already-enqueued handle is a
2131 - // no-op.
2132 - wp_enqueue_script('cryptx-js');
2133 - }
2134 -
2135 - // wp_register_style() has no footer flag to lean on the way the
2136 - // script does, and enqueuing the stylesheet at wp_enqueue_scripts
2137 - // would move it from the footer to <head> -- a behaviour change for
2138 - // classic navigation, which is exactly what must not happen. So the
2139 - // same client-side-navigation gap for a configured picture variant
2140 - // (opt_linktext 2/3/5, the only settings that need img.cryptxImage
2141 - // { height: 1em }) is closed here instead, gated on the setting
2142 - // alone rather than on whether THIS request's content produced one.
2143 - if (self::$styleNeeded || self::isPictureLinktext((int) (self::$cryptXOptions['opt_linktext'] ?? 0))) {
2144 - wp_enqueue_style('cryptx-styles');
2145 - }
2146 - }
2147 -
2148 - /**
2149 - * Whether an opt_linktext setting renders links as pictures.
2150 - *
2151 - * Same known gap as the script branch in loadJavascriptFiles(), and purely
2152 - * cosmetic here: a page whose first load carries no picture-producing
2153 - * shortcode override, under a global opt_linktext that is not 2/3/5, still
2154 - * skips the stylesheet -- a later client-side navigation to a page that
2155 - * DOES render a picture link can arrive without img.cryptxImage. See
2156 - * docs/entscheidungen/2026-09-11-assets-bei-clientseitiger-navigation.md.
2157 - *
2158 - * @param int $optLinktext The opt_linktext setting value.
2159 - *
2160 - * @return bool
2161 - */
2162 - private static function isPictureLinktext(int $optLinktext): bool
2163 - {
2164 - return in_array($optLinktext, [2, 3, 5], true);
2165 - }
2166 -
2167 - /**
2168 - * Updates the CryptX settings.
2169 - *
2170 - * This method retrieves the current CryptX options from the database and checks if the version of CryptX
2171 - * stored in the options is less than the current version of CryptX. If the version is outdated, the method
2172 - * updates the necessary settings and saves the updated options back to the database.
2173 - *
2174 - * @return void
2175 - */
2176 - private function updateCryptXSettings(): void
2177 - {
2178 - self::$cryptXOptions = get_option('cryptX');
2179 -
2180 - $storedVersion = self::$cryptXOptions['version'] ?? null;
2181 -
2182 - if ($storedVersion === null || version_compare(CRYPTX_VERSION, $storedVersion) <= 0) {
2183 - return;
2184 - }
2185 -
2186 - // Every step below used to run on EVERY version bump, although each was
2187 - // written for one particular upgrade. Measured on an installation
2188 - // carrying 4.1.0: the chosen font fell back to the first available one,
2189 - // the colour "#3366ff" became "##3366ff" -- gaining another "#" with
2190 - // every future update -- and the encryption secret was thrown away, so
2191 - // every link on an already cached page stopped resolving. None of that
2192 - // was intended, and none of it was visible to the site owner.
2193 - //
2194 - // Each migration is now tied to the version it belongs to, or written
2195 - // so that repeating it changes nothing.
2196 -
2197 - // Up to 4.0.11 the password was derived from AUTH_KEY and
2198 - // SECURE_AUTH_KEY, and that value is published in the markup of every
2199 - // page. Since site_url is public, an attacker could test candidate keys
2200 - // offline -- above all the placeholders from wp-config-sample.php that
2201 - // unattended installations still carry. Dropping it lets
2202 - // Config::getEncryptionPassword() mint a random one. The price is that
2203 - // links on pages already sitting in a cache stop resolving until that
2204 - // cache turns over, which is why it must happen exactly once.
2205 - if (version_compare($storedVersion, '4.0.12', '<')) {
2206 - unset(self::$cryptXOptions['encryption_password']);
2207 -
2208 - // 4.0.12 replaced the bundled Arial, Times New Roman and Verdana
2209 - // with freely licensed faces. A stored name from the old set no
2210 - // longer exists on disk, so the choice has to be made again.
2211 - unset(self::$cryptXOptions['c2i_font']);
2212 - }
2213 -
2214 - // Value-based rather than version-based, and therefore harmless to
2215 - // repeat: colours were stored without the leading "#" before 4.0.
2216 - if (!empty(self::$cryptXOptions['c2i_fontRGB'])
2217 - && strpos((string) self::$cryptXOptions['c2i_fontRGB'], '#') !== 0) {
2218 - self::$cryptXOptions['c2i_fontRGB'] = '#' . self::$cryptXOptions['c2i_fontRGB'];
2219 - }
2220 -
2221 - // Also value-based: an attachment id that is not an id is unusable, no
2222 - // matter which version wrote it.
2223 - if (isset(self::$cryptXOptions['alt_uploadedimage'])
2224 - && !is_int(self::$cryptXOptions['alt_uploadedimage'])
2225 - && !ctype_digit((string) self::$cryptXOptions['alt_uploadedimage'])) {
2226 - unset(self::$cryptXOptions['alt_uploadedimage']);
2227 -
2228 - if ((int) (self::$cryptXOptions['opt_linktext'] ?? 0) === 3) {
2229 - unset(self::$cryptXOptions['opt_linktext']);
2230 - }
2231 - }
2232 -
2233 - // Only a feature update earns a review prompt, and only in a fortnight
2234 - // -- Admin\ReviewNotice decides both, because that is where the rule
2235 - // can be read next to the reason for it. This is the only place that
2236 - // still knows which version was installed before.
2237 - self::$cryptXOptions = Admin\ReviewNotice::scheduleAfterUpdate(
2238 - self::$cryptXOptions,
2239 - (string) $storedVersion
2240 - );
2241 -
2242 - self::$cryptXOptions['version'] = CRYPTX_VERSION;
2243 - self::$cryptXOptions = wp_parse_args(self::$cryptXOptions, $this->getCryptXOptionsDefaults());
2244 - update_option('cryptX', self::$cryptXOptions);
2245 - }
2246 -
2247 - /**
2248 - * Encodes a string by replacing special characters with their corresponding HTML entities.
2249 - *
2250 - * @param string|null $str The string to be encoded.
2251 - *
2252 - * @return string The encoded string, or an array of encoded strings if an array was passed.
2253 - */
2254 - private function encodeString(?string $str): string
2255 - {
2256 - $str = htmlentities($str, ENT_QUOTES, 'UTF-8');
2257 - $special = array(
2258 - '[' => '&#91;',
2259 - ']' => '&#93;',
2260 - );
2261 -
2262 - return str_replace(array_keys($special), array_values($special), $str);
2263 - }
2264 -
2265 - /**
2266 - * Decodes a string that has been HTML entity encoded.
2267 - *
2268 - * @param string|null $str The string to decode. If null, an empty string is returned.
2269 - *
2270 - * @return string The decoded string.
2271 - */
2272 - private function decodeString(?string $str): string
2273 - {
2274 - return html_entity_decode($str, ENT_QUOTES, 'UTF-8');
2275 - }
2276 -
2277 - /**
2278 - * Converts an associative array into an argument string.
2279 - *
2280 - * @param array $args An optional associative array where keys represent argument names and values represent argument values.
2281 - * @return string A formatted string of arguments where each key-value pair is encoded and concatenated.
2282 - */
2283 - public function convertArrayToArgumentString(array $args = []): string
2284 - {
2285 - $string = "";
2286 - if (!empty($args)) {
2287 - foreach ($args as $key => $value) {
2288 - $string .= sprintf(" %s=\"%s\"", $key, esc_attr($value));
2289 - }
2290 - $string .= " encoded=\"true\"";
2291 - }
2292 -
2293 - return $string;
2294 - }
2295 -
2296 - /**
2297 - * Check if current request is for an RSS feed
2298 - *
2299 - * @return bool True if current request is for an RSS feed, false otherwise
2300 - */
2301 - private function isRssFeed(): bool
2302 - {
2303 - return is_feed();
2304 - }
2305 -
2306 - /**
2307 - * Adds plugin action links to the WordPress plugin row
2308 - *
2309 - * @param array $links Existing plugin row links
2310 - * @param string $file Plugin file path
2311 - * @return array Modified plugin row links
2312 - */
2313 - public function add_plugin_action_links(array $links, string $file): array
2314 - {
2315 - if ($file !== CRYPTX_BASENAME) {
2316 - return $links;
2317 - }
2318 -
2319 - $additional_links = [
2320 - $this->create_settings_link(),
2321 - $this->create_donation_link()
2322 - ];
2323 -
2324 - return array_merge($links, $additional_links);
2325 - }
2326 -
2327 - /**
2328 - * Creates and returns a settings link for the options page.
2329 - *
2330 - * @return string The HTML link to the settings page.
2331 - */
2332 - private function create_settings_link(): string
2333 - {
2334 - // Admin\SettingsPage::MENU_SLUG und nicht CRYPTX_BASEFOLDER: die
2335 - // Seite haengt am Slug, nicht am Verzeichnisnamen. Auf wordpress.org
2336 - // sind beide 'cryptx', nach einem Umbenennen des Ordners zeigte der
2337 - // Link ins Leere.
2338 - return sprintf(
2339 - '<a href="%s">%s</a>',
2340 - esc_url(admin_url('options-general.php?page=' . Admin\SettingsPage::MENU_SLUG)),
2341 - esc_html__('Settings', 'cryptx')
2342 - );
2343 - }
2344 -
2345 - /**
2346 - * Creates and returns a donation link in HTML format.
2347 - *
2348 - * @return string The HTML string for the donation link.
2349 - */
2350 - private function create_donation_link(): string
2351 - {
2352 - return sprintf(
2353 - '<a href="%s">%s</a>',
2354 - esc_url(self::PAYPAL_DONATION_URL),
2355 - esc_html__('Donate', 'cryptx')
2356 - );
2357 - }
2358 -
2359 - /**
2360 - * Adds a universal filter for all widget types by hooking into the widget display process.
2361 - *
2362 - * @return void
2363 - */
2364 - private function addUniversalWidgetFilters(): void
2365 - {
2366 - // Hook into the widget display process to catch all widget types
2367 - add_filter('widget_display_callback', [$this, 'processWidgetContent'], 10, 3);
2368 - }
2369 -
2370 - /**
2371 - * Processes widget content to handle email addresses by adding links, identifying occurrences,
2372 - * and replacing them based on predefined rules.
2373 - *
2374 - * @param array|false $instance An array containing widget instance data, or false if no instance was provided.
2375 - * @param object $widget The widget object whose content is being processed.
2376 - * @param array $args Additional arguments provided to the widget.
2377 - *
2378 - * @return array|false Modified widget instance data as an array, or false if processing was not applicable.
2379 - */
2380 - public function processWidgetContent(array|false $instance, $widget, $args): array|false
2381 - {
2382 - if ($instance === false) {
2383 - return false;
2384 - }
2385 -
2386 - // Only process if widget_text option is enabled
2387 - if (!(self::$cryptXOptions['widget_text'] ?? false)) {
2388 - return $instance;
2389 - }
2390 -
2391 - // Check if instance has text content (traditional text widgets)
2392 - if (isset($instance['text']) && stripos($instance['text'], '@') !== false) {
2393 - $instance['text'] = $this->addLinkToEmailAddresses($instance['text']);
2394 - $instance['text'] = $this->findEmailAddressesInContent($instance['text']);
2395 - $instance['text'] = $this->replaceEmailInContent($instance['text']);
2396 - }
2397 -
2398 - // Check if instance has content field (block widgets)
2399 - if (isset($instance['content']) && stripos($instance['content'], '@') !== false) {
2400 - $instance['content'] = $this->addLinkToEmailAddresses($instance['content']);
2401 - $instance['content'] = $this->findEmailAddressesInContent($instance['content']);
2402 - $instance['content'] = $this->replaceEmailInContent($instance['content']);
2403 - }
2404 -
2405 - return $instance;
2406 - }
2407 -
2408 - /**
2409 - * Enhanced email encryption with security validation
2410 - *
2411 - * @param array $searchResults
2412 - * @return string
2413 - */
2414 - /**
2415 - * Cleans the query of a mailto link -- the "?subject=..." part.
2416 - *
2417 - * A positive list, not an exclusion list, because this value ends up
2418 - * decrypted in the browser and handed to window.location. RFC 6068 defines
2419 - * exactly these four headers as safe to accept from a link; everything else
2420 - * is dropped rather than escaped, because there is no legitimate reason for
2421 - * it to be there and no way to be sure what a mail client would do with it.
2422 - *
2423 - * Values are decoded and re-encoded rather than passed through: an incoming
2424 - * "Hallo%20Welt" must not become "Hallo%2520Welt", and a raw space must not
2425 - * stay a raw space.
2426 - *
2427 - * @param string $rawQuery The query as written in the href, without the "?".
2428 - * @param int $budget How many characters the finished query may occupy.
2429 - *
2430 - * @return string The cleaned query, or an empty string if nothing survives.
2431 - */
2432 - private function sanitizeMailtoQuery(string $rawQuery, int $budget = PHP_INT_MAX): string
2433 - {
2434 - if ($rawQuery === '' || $budget <= 0) {
2435 - return '';
2436 - }
2437 -
2438 - // "&amp;" is how a second parameter is spelled in valid HTML, and that
2439 - // is what the regular expression handed us.
2440 - $rawQuery = html_entity_decode($rawQuery, ENT_QUOTES, 'UTF-8');
2441 -
2442 - $allowed = ['subject', 'body', 'cc', 'bcc'];
2443 - $parts = [];
2444 -
2445 - foreach (explode('&', $rawQuery) as $pair) {
2446 - if ($pair === '' || strpos($pair, '=') === false) {
2447 - continue;
2448 - }
2449 -
2450 - [$key, $value] = explode('=', $pair, 2);
2451 - $key = strtolower(trim($key));
2452 -
2453 - if (!in_array($key, $allowed, true) || isset($parts[$key])) {
2454 - continue;
2455 - }
2456 -
2457 - $value = rawurldecode($value);
2458 -
2459 - // A recipient list is still a list of addresses, and an invalid one
2460 - // has no business being carried into a mail client.
2461 - if ($key === 'cc' || $key === 'bcc') {
2462 - $addresses = array_filter(array_map(
2463 - static fn($address) => sanitize_email(trim($address)),
2464 - explode(',', $value)
2465 - ));
2466 -
2467 - if ($addresses === []) {
2468 - continue;
2469 - }
2470 -
2471 - $value = implode(',', $addresses);
2472 - } else {
2473 - // Control characters would let a payload break out of the
2474 - // header it is written into.
2475 - $value = preg_replace('/[\x00-\x1F\x7F]/u', '', $value) ?? '';
2476 -
2477 - if (trim($value) === '') {
2478 - continue;
2479 - }
2480 -
2481 - $value = mb_substr($value, 0, self::MAX_MAILTO_VALUE_LENGTH);
2482 - }
2483 -
2484 - $pair = $this->fitPairToBudget(
2485 - $key,
2486 - $value,
2487 - // What is left once the pairs already collected, and the "&"
2488 - // that would join this one, are accounted for.
2489 - $budget - strlen(implode('&', $parts)) - ($parts === [] ? 0 : 1),
2490 - ($key === 'cc' || $key === 'bcc') ? ',' : ''
2491 - );
2492 -
2493 - if ($pair === '') {
2494 - continue;
2495 - }
2496 -
2497 - $parts[$key] = $pair;
2498 - }
2499 -
2500 - return implode('&', $parts);
2501 - }
2502 -
2503 - /**
2504 - * Encodes one header and shortens it until it fits the space left.
2505 - *
2506 - * The value is cut before encoding, never after: percent encoding turns one
2507 - * character into up to twelve, and a cut through "%C3%A4" leaves a sequence
2508 - * no client can read.
2509 - *
2510 - * Why there is a budget at all: cryptx.js refuses to navigate to a URL
2511 - * longer than 2048 characters, and so does SecureEncryption::validateUrl().
2512 - * Counting the value in characters before encoding is not the same measure
2513 - * -- 512 characters of Japanese become over 4000 once encoded. The link
2514 - * then did nothing at all, with nothing on the page to say why.
2515 - *
2516 - * @param string $key The header name.
2517 - * @param string $value The decoded value.
2518 - * @param int $available Characters left for the encoded pair.
2519 - * @param string $separator Set for list values: whole entries are dropped
2520 - * instead of characters.
2521 - *
2522 - * @return string The encoded pair, or an empty string if it cannot fit.
2523 - */
2524 - private function fitPairToBudget(
2525 - string $key,
2526 - string $value,
2527 - int $available,
2528 - string $separator = ''
2529 - ): string {
2530 - $encodedKey = rawurlencode($key);
2531 -
2532 - // The shortest useful pair is "key=" plus one character.
2533 - if ($available < strlen($encodedKey) + 2) {
2534 - return '';
2535 - }
2536 -
2537 - $pair = $encodedKey . '=' . rawurlencode($value);
2538 -
2539 - // A recipient list is not free text. Cutting it by characters leaves a
2540 - // fragment like "chef@examp" in a header a mail client will act on --
2541 - // either bouncing or, worse, delivering somewhere unintended. Whole
2542 - // addresses go, or the header goes.
2543 - if ($separator !== '') {
2544 - $items = explode($separator, $value);
2545 -
2546 - while (strlen($pair) > $available && count($items) > 1) {
2547 - array_pop($items);
2548 - $pair = $encodedKey . '=' . rawurlencode(implode($separator, $items));
2549 - }
2550 -
2551 - return strlen($pair) > $available ? '' : $pair;
2552 - }
2553 -
2554 - while (strlen($pair) > $available && $value !== '') {
2555 - $value = mb_substr($value, 0, mb_strlen($value) - 1);
2556 - $pair = $encodedKey . '=' . rawurlencode($value);
2557 - }
2558 -
2559 - return $value === '' ? '' : $pair;
2560 - }
2561 -
2562 - private function encryptEmailAddressSecure(array $searchResults): string
2563 - {
2564 - $originalValue = $searchResults[0]; // Full match
2565 - $rawTarget = $searchResults[2]; // Everything after "mailto:", verbatim
2566 -
2567 - // Address and query are separated BEFORE sanitising. sanitize_email()
2568 - // used to run over the whole target, and it strips "?" and "=" -- so
2569 - // "sales@example.com?subject=Hello" became
2570 - // "sales@example.comsubjectHello". Two things followed from that, both
2571 - // reported in the support forum and neither obvious: the payload
2572 - // carried a broken address, and the str_replace() below could no longer
2573 - // find its needle, so the untouched "mailto:" href stayed in the page.
2574 - $queryPosition = strpos($rawTarget, '?');
2575 - $rawAddress = $queryPosition === false ? $rawTarget : substr($rawTarget, 0, $queryPosition);
2576 - $rawQuery = $queryPosition === false ? '' : substr($rawTarget, $queryPosition + 1);
2577 -
2578 - $emailAddress = sanitize_email($rawAddress);
2579 -
2580 - if (strpos($emailAddress, '@') === self::NOT_FOUND) {
2581 - return $originalValue;
2582 - }
2583 -
2584 - // Left exactly as written, link and all. "Leave this address alone"
2585 - // has to mean all three stages, not just the visible text -- an
2586 - // address that keeps its readable form but loses its working mailto is
2587 - // neither protected nor usable.
2588 - if ($this->isAddressExempt($emailAddress)) {
2589 - return $originalValue;
2590 - }
2591 -
2592 - // The budget is what the browser will still accept once "mailto:",
2593 - // the address and the "?" are in place.
2594 - $query = $this->sanitizeMailtoQuery(
2595 - $rawQuery,
2596 - self::MAX_MAILTO_URL_LENGTH - strlen('mailto:' . $emailAddress . '?')
2597 - );
2598 - $mailtoTarget = $emailAddress . ($query === '' ? '' : '?' . $query);
2599 -
2600 - $return = $originalValue;
2601 -
2602 - // Apply JavaScript handler if enabled
2603 - if (!empty(self::$cryptXOptions['java'])) {
2604 - $encryptionMode = $this->config->getEncryptionMode();
2605 - $payloadMode = 'legacy';
2606 - $password = '';
2607 -
2608 - // Determine which encryption method to use
2609 - if ($encryptionMode === 'secure' &&
2610 - $this->config->isSecureEncryptionEnabled() &&
2611 - class_exists('CryptX\SecureEncryption')) {
2612 -
2613 - // Use modern AES-256-GCM encryption
2614 - try {
2615 - $password = $this->config->getEncryptionPassword();
2616 - $mailtoUrl = 'mailto:' . $mailtoTarget;
2617 - $encryptedEmail = SecureEncryption::encrypt($mailtoUrl, $password);
2618 - $payloadMode = 'secure';
2619 - } catch (\Exception $e) {
2620 - // Fallback to legacy if secure encryption fails
2621 - $encryptedEmail = $this->generateHashFromString($mailtoTarget);
2622 - $password = '';
2623 - }
2624 - } else {
2625 - // Use legacy encryption (original algorithm). cryptx.js puts
2626 - // "mailto:" in front of whatever comes out, so the query rides
2627 - // along here as well.
2628 - $encryptedEmail = $this->generateHashFromString($mailtoTarget);
2629 - }
2630 -
2631 - self::$scriptNeeded = true;
2632 -
2633 - if ($this->getLinkMode() === 'data') {
2634 - // Preferred form: the payload travels in data attributes and a
2635 - // delegated click handler in cryptx.js does the work. A
2636 - // "javascript:" URI would be blocked outright by any halfway
2637 - // strict Content-Security-Policy, taking every CryptX link on
2638 - // the page with it -- silently.
2639 - $attributes = sprintf(
2640 - ' data-cx="%s" data-cxm="%s"',
2641 - esc_attr($encryptedEmail),
2642 - esc_attr($payloadMode)
2643 - );
2644 - if ($payloadMode === 'secure') {
2645 - // The key travels with the link, so changing the secret
2646 - // never breaks one that is already out there. The iteration
2647 - // count did not, and that was the real dead-link problem:
2648 - // it was read from the global cryptxConfig at click time,
2649 - // so raising it in the settings silently killed every link
2650 - // in every cached page and in every browser tab still open.
2651 - // Now each link says how it was made.
2652 - $attributes .= sprintf(
2653 - ' data-cxk="%s" data-cxi="%d"',
2654 - esc_attr($password),
2655 - SecureEncryption::getIterations()
2656 - );
2657 - }
2658 -
2659 - // The raw target, not the sanitised address: they differ as
2660 - // soon as a query is present, and a needle that is not in the
2661 - // haystack leaves the plain "mailto:" href untouched.
2662 - //
2663 - // str_ireplace, because the pattern above matches case
2664 - // insensitively: an href written "MAILTO:" was found, but a
2665 - // lower-case needle then missed it -- same failure, reached
2666 - // through the spelling of the scheme instead of the query.
2667 - $return = str_ireplace('mailto:' . $rawTarget, '#', $originalValue);
2668 - $return = $this->addAttributesToAnchor($return, $attributes);
2669 - $return = $this->addClassToAnchor($return, self::LINK_CLASS);
2670 - } else {
2671 - // Legacy form, kept for installations that depend on it.
2672 - // The iteration count is passed here too, as a third argument.
2673 - // Older pages call the function with two, which still works --
2674 - // it then falls back to the configured value, exactly as before.
2675 - $javaHandler = $payloadMode === 'secure'
2676 - ? "javascript:secureDecryptAndNavigate('" . esc_js($encryptedEmail) . "', '"
2677 - . esc_js($password) . "', " . SecureEncryption::getIterations() . ")"
2678 - : "javascript:DeCryptX('" . esc_js($encryptedEmail) . "')";
2679 -
2680 - $return = str_ireplace('mailto:' . $rawTarget, $javaHandler, $originalValue);
2681 - }
2682 - } else {
2683 - // Fallback to antispambot if JavaScript is not enabled
2684 - $return = str_ireplace('mailto:' . $rawTarget,
2685 - antispambot('mailto:' . $mailtoTarget), $return);
2686 - }
2687 -
2688 - // Add CSS attributes if specified
2689 - if (!empty(self::$cryptXOptions['css_id'])) {
2690 - // Guarded like every other preg_* call site in this class: a PCRE
2691 - // error yields null, and $return is declared string.
2692 - $return = $this->addIdToAnchor($return, self::$cryptXOptions['css_id']);
2693 - }
2694 -
2695 - if (!empty(self::$cryptXOptions['css_class'])) {
2696 - $return = $this->addClassToAnchor($return, self::$cryptXOptions['css_class']);
2697 - }
2698 -
2699 - return $return;
2700 - }
2701 -
2702 - /**
2703 - * Runs a sample through the real processing chain for the settings preview.
2704 - *
2705 - * Deliberately not a reimplementation: the preview calls the same three
2706 - * filters the front end calls, with the same encryption. A separate
2707 - * "preview renderer" would drift away from the truth sooner or later, and
2708 - * a preview that lies is worse than none.
2709 - *
2710 - * Nothing is written. Both the static option list and the Config instance
2711 - * are swapped for the duration and restored in a finally block -- Config
2712 - * matters because the encryption path reads its mode and password from
2713 - * there, not from the static list.
2714 - *
2715 - * @param array $overrides Option values as they stand in the unsaved form.
2716 - * @param string $content The sample content.
2717 - *
2718 - * @return string The processed markup.
2719 - */
2720 - public function renderPreviewMarkup(array $overrides, string $content): string
2721 - {
2722 - $previousOptions = self::$cryptXOptions;
2723 - $previousConfig = $this->config;
2724 -
2725 - // Make sure a secret exists before the swap, and mint it through the
2726 - // REAL Config if it does not.
2727 - //
2728 - // Config::getEncryptionPassword() writes when it has to mint, and
2729 - // Config::save() stores the whole option array -- which, on the
2730 - // throwaway Config below, is the administrator's unsaved form state.
2731 - // A preview would then silently persist settings that were only being
2732 - // tried out. The window is real: updateCryptXSettings() drops the
2733 - // secret on every version bump, and the settings screen is the first
2734 - // place an administrator goes after an update.
2735 - $stored = $this->loadCryptXOptionsWithDefaults();
2736 -
2737 - if (empty($stored['encryption_password'])) {
2738 - // Mint through a Config built from the STORED options, and carry the
2739 - // result into $merged by hand.
2740 - //
2741 - // Doing it through the live Config instead was not enough: that one
2742 - // holds an in-memory copy taken at startup, so it can believe it has
2743 - // a password while the row no longer does. It then writes nothing,
2744 - // $merged is still without a secret, and the throwaway Config below
2745 - // mints -- persisting the unsaved form along with it. A test that
2746 - // watches pre_update_option_cryptX found exactly that.
2747 - $stored['encryption_password'] = (new Config($stored))->getEncryptionPassword();
2748 - }
2749 -
2750 - // Same reasoning, same trap, second secret. The image variant mints one
2751 - // of its own the first time an address is drawn as a picture -- and the
2752 - // first time that happens is usually in this very preview, the moment
2753 - // an administrator picks "image" from the list. Minting it through the
2754 - // throwaway Config below would save the unsaved form along with it.
2755 - if (empty($stored['image_token_secret'])) {
2756 - $stored['image_token_secret'] = (new Config($stored))->getImageTokenSecret();
2757 - }
2758 -
2759 - $merged = wp_parse_args($overrides, $stored);
2760 -
2761 - self::$cryptXOptions = $merged;
2762 - $this->config = new Config($merged);
2763 - self::resetOptionCaches();
2764 -
2765 - try {
2766 - if (!empty(self::$cryptXOptions['autolink'])) {
2767 - $content = $this->addLinkToEmailAddresses($content, true);
2768 - }
2769 -
2770 - $content = $this->findEmailAddressesInContent($content, true);
2771 -
2772 - return (string) $this->replaceEmailInContent($content, true);
2773 - } finally {
2774 - self::$cryptXOptions = $previousOptions;
2775 - $this->config = $previousConfig;
2776 - self::resetOptionCaches();
2777 - }
2778 - }
2779 -
2780 - /**
2781 - * Which link form the encrypted address is delivered in.
2782 - *
2783 - * 'data' puts the payload into data attributes and lets a delegated click
2784 - * handler take over -- the only form that survives a Content-Security-Policy.
2785 - * 'js' is the historical "javascript:" URI, offered under Advanced for
2786 - * installations that depend on the old behaviour.
2787 - *
2788 - * @return string Either 'data' or 'js'.
2789 - */
2790 - private function getLinkMode(): string
2791 - {
2792 - $mode = (string) (self::$cryptXOptions['link_mode'] ?? 'data');
2793 -
2794 - return $mode === 'js' ? 'js' : 'data';
2795 - }
2796 -
2797 - /**
2798 - * Inserts additional attributes into the opening tag of an anchor.
2799 - *
2800 - * @param string $html The anchor markup.
2801 - * @param string $attributes Attribute string, starting with a space.
2802 - *
2803 - * @return string The markup with the attributes added.
2804 - */
2805 - private function addAttributesToAnchor(string $html, string $attributes): string
2806 - {
2807 - return $this->rewriteOpeningAnchorTag(
2808 - $html,
2809 - static fn(string $tag): string => preg_replace('/(\s*\/?>)$/', $attributes . '$1', $tag, 1) ?? $tag
2810 - );
2811 - }
2812 -
2813 - /**
2814 - * Adds a class to an anchor, keeping any class that is already there.
2815 - *
2816 - * @param string $html The anchor markup.
2817 - * @param string $class The class to add.
2818 - *
2819 - * @return string The markup with the class added.
2820 - */
2821 - private function addClassToAnchor(string $html, string $class): string
2822 - {
2823 - $class = esc_attr($class);
2824 -
2825 - return $this->rewriteOpeningAnchorTag(
2826 - $html,
2827 - function (string $tag) use ($class): string {
2828 - // (?:^|\s) rather than \b: a word boundary also sits
2829 - // between the quote and the "c" of an attribute value such
2830 - // as data-x="class='y'", so \bclass would bind to the text
2831 - // inside that value. Requiring whitespace before the name
2832 - // makes this an attribute rather than any occurrence of the
2833 - // word -- and it holds no matter which attribute comes
2834 - // first, which the greedy and the lazy variant each got
2835 - // wrong in one of the two orders.
2836 - if (preg_match('/(?:^|\s)class\s*=\s*(["\'])(.*?)\1/i', $tag)) {
2837 - return preg_replace(
2838 - '/((?:^|\s)class\s*=\s*(["\']))(.*?)\2/i',
2839 - '$1$3 ' . $class . '$2',
2840 - $tag,
2841 - 1
2842 - ) ?? $tag;
2843 - }
2844 -
2845 - return preg_replace('/(\s*\/?>)$/', ' class="' . $class . '"$1', $tag, 1) ?? $tag;
2846 - }
2847 - );
2848 - }
2849 -
2850 - /**
2851 - * Applies a rewrite to the opening tag of the first anchor only.
2852 - *
2853 - * Regular expressions on HTML are a poor tool, and this is the narrow case
2854 - * where it is still defensible: the markup comes from CryptX's own mailto
2855 - * pattern, so there is exactly one anchor and the payload is escaped before
2856 - * it gets here. Isolating the opening tag keeps the rewrite from reaching
2857 - * into attribute values or into the link text.
2858 - *
2859 - * @param string $html The anchor markup.
2860 - * @param callable $rewrite Receives the opening tag, returns the new one.
2861 - *
2862 - * @return string The markup with the rewritten opening tag.
2863 - */
2864 - private function rewriteOpeningAnchorTag(string $html, callable $rewrite): string
2865 - {
2866 - // Quoted attribute values may legitimately contain ">", so a plain
2867 - // [^>]* would end the tag too early and splice the new attribute into
2868 - // the middle of somebody else's title.
2869 - $openingTag = '/<a\b(?:[^>"\']|"[^"]*"|\'[^\']*\')*>/i';
2870 -
2871 - if (!preg_match($openingTag, $html, $matches, PREG_OFFSET_CAPTURE)) {
2872 - return $html;
2873 - }
2874 -
2875 - $tag = $matches[0][0];
2876 - $offset = $matches[0][1];
2877 - $rewritten = $rewrite($tag);
2878 -
2879 - return substr($html, 0, $offset) . $rewritten . substr($html, $offset + strlen($tag));
2880 - }
2881 -
2882 - /**
2883 - * Adds an id to an anchor, keeping any id that is already there.
2884 - *
2885 - * @param string $html The anchor markup.
2886 - * @param string $id The id to add.
2887 - *
2888 - * @return string The markup with the id added.
2889 - */
2890 - private function addIdToAnchor(string $html, string $id): string
2891 - {
2892 - $id = esc_attr($id);
2893 -
2894 - return $this->rewriteOpeningAnchorTag(
2895 - $html,
2896 - function (string $tag) use ($id): string {
2897 - // Same reasoning as in addClassToAnchor().
2898 - if (preg_match('/(?:^|\s)id\s*=\s*(["\'])(.*?)\1/i', $tag)) {
2899 - return preg_replace(
2900 - '/((?:^|\s)id\s*=\s*(["\']))(.*?)\2/i',
2901 - '$1$3 ' . $id . '$2',
2902 - $tag,
2903 - 1
2904 - ) ?? $tag;
2905 - }
2906 -
2907 - return preg_replace('/(\s*\/?>)$/', ' id="' . $id . '"$1', $tag, 1) ?? $tag;
2908 - }
2909 - );
2910 - }
2911 -
801 + /**
802 + * Updates the CryptX settings.
803 + *
804 + * This method retrieves the current CryptX options from the database and checks if the version of CryptX
805 + * stored in the options is less than the current version of CryptX. If the version is outdated, the method
806 + * updates the necessary settings and saves the updated options back to the database.
807 + *
808 + * @return void
809 + */
810 + function updateCryptXSettings(): void {
811 + self::$cryptXOptions = get_option('cryptX');
812 + if( isset( self::$cryptXOptions['version'] ) && version_compare(CRYPTX_VERSION, self::$cryptXOptions['version']) > 0 ) {
813 + if( isset(self::$cryptXOptions['version']) ) unset(self::$cryptXOptions['version']);
814 + if( isset(self::$cryptXOptions['c2i_font']) ) unset(self::$cryptXOptions['c2i_font']);
815 + if( isset(self::$cryptXOptions['c2i_fontRGB']) ) self::$cryptXOptions['c2i_fontRGB'] = "#" . self::$cryptXOptions['c2i_fontRGB'];
816 + if( isset(self::$cryptXOptions['alt_uploadedimage']) && !is_int(self::$cryptXOptions['alt_uploadedimage'])) {
817 + unset(self::$cryptXOptions['alt_uploadedimage']);
818 + if( self::$cryptXOptions['opt_linktext'] == 3 ) unset(self::$cryptXOptions['opt_linktext']);
819 + }
820 + self::$cryptXOptions = wp_parse_args( self::$cryptXOptions, $this->getCryptXOptionsDefaults() );
821 + update_option( 'cryptX', self::$cryptXOptions);
822 + }
823 + }
2912 824 }