diff --git a/lib/Cleantalk/ApbctWP/ContactsEncoder/ContactsEncoder.php b/lib/Cleantalk/ApbctWP/ContactsEncoder/ContactsEncoder.php
index f3591133c..801a392ff 100644
--- a/lib/Cleantalk/ApbctWP/ContactsEncoder/ContactsEncoder.php
+++ b/lib/Cleantalk/ApbctWP/ContactsEncoder/ContactsEncoder.php
@@ -99,11 +99,11 @@ public function runEncoding($content = '')
}
if ( $hook === 'the_content' ) {
// Priority 9 runs after do_blocks (9) when registered from init — placeholders keep
wrappers.
- $this->shortcodes->addActionsBeforeModify($hook, 9);
+ $this->shortcodes->addActionsBeforeModifyEncodeOnly($hook, 9);
$this->shortcodes->addActionsAfterModifyEncodeOnly($hook, 999);
continue;
}
- $this->shortcodes->addActionsBeforeModify($hook, 9);
+ $this->shortcodes->addActionsBeforeModifyEncodeOnly($hook, 9);
$this->shortcodes->addActionsAfterModifyEncodeOnly($hook, 999);
}
} else {
diff --git a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php
index 11178353d..92744182f 100644
--- a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php
+++ b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php
@@ -6,6 +6,31 @@ class ExcludedEncodeContentSC extends EmailEncoderShortCode
{
protected $public_name = 'apbct_skip_encoding';
+ /**
+ * @var array Placeholder => original shortcode inner content.
+ */
+ public $shortcode_replacements = array();
+
+ /**
+ * @var string Wrapper used to mark content excluded from encoding.
+ */
+ public $exclusion_wrapper = '%%APBCT_SHORT_CODE_SKIP_EE_0%%';
+
+ /**
+ * @var int Counter for shortcode placeholders.
+ */
+ private $shortcode_counter = 0;
+
+ /**
+ * @var string[] Active render_block stack.
+ */
+ private $render_block_stack = array();
+
+ /**
+ * @var array Raw post titles captured before encoder mutates post objects.
+ */
+ private static $raw_titles_cache = array();
+
/**
* Callback for the shortcode
*
@@ -21,6 +46,12 @@ public function callback($_atts, $content, $_tag)
return $content;
}
+ global $apbct;
+
+ if ( ! $apbct->settings['data__email_decoder_buffer'] ) {
+ return $this->createPlaceholder($content);
+ }
+
// Pattern to get data-original-string attribute
$pattern = '/data-original-string=(["\'])(.*?)\1/';
preg_match($pattern, $content, $matches);
@@ -36,6 +67,30 @@ public function callback($_atts, $content, $_tag)
return wp_kses_post($content);
}
+ /**
+ * Replace skip-encoding shortcodes with placeholders before ContactsEncoder::modifyContent().
+ *
+ * @param string $content
+ *
+ * @return string
+ */
+ public function changeContentBeforeEncoderModify($content)
+ {
+ if ($this->isShortcodeInsideHtmlAttribute($content)) {
+ return $content;
+ }
+
+ $pattern = '/\[apbct_skip_encoding\](.*?)\[\/apbct_skip_encoding\]/s';
+
+ return preg_replace_callback($pattern, function ($matches) {
+ if (isset($matches[1])) {
+ return $this->createPlaceholder($matches[1]);
+ }
+
+ return isset($matches[0]) ? $matches[0] : '';
+ }, $content);
+ }
+
/**
* This method runs at the end of Contacts Encoder and tries to process unprocessed shortcodes
* The unprocessed shortcodes may be only in `the_title` hook
@@ -48,12 +103,16 @@ public function changeContentAfterEncoderModify($content)
{
global $apbct;
- if ( ! is_string($content) ) {
- return $content;
- }
+ if ( ! $apbct->settings['data__email_decoder_buffer'] ) {
+ if ( ! $this->shouldDeferPlaceholderRestore() ) {
+ $content = $this->restorePlaceholders($content);
+ }
- if ( ! $apbct->settings['data__email_decoder_buffer'] && $this->getCurrentAction() !== 'the_title' ) {
- return $content;
+ $content = $this->processSkipEncodingShortcodes($content);
+
+ // widget_block_content (and similar hooks) re-run modifyContent() on already
+ // rendered page-list HTML and encode skip titles again — restore after that.
+ return $this->restorePageListLinkTitlesInHtml($content);
}
if ( $apbct->settings['data__email_decoder_buffer'] ) {
@@ -65,33 +124,953 @@ public function changeContentAfterEncoderModify($content)
}
}
- // Skip processing if shortcode is inside an HTML tag to prevent attribute injection
- if ($this->isShortcodeInsideHtmlTag($content)) {
+ // Skip processing if shortcode is inside an HTML attribute to prevent attribute injection
+ if ($this->isShortcodeInsideHtmlAttribute($content)) {
if ( $apbct->settings['data__email_decoder_buffer'] ) {
$apbct->buffer = $content;
}
return $content;
}
+ $content = $this->restorePlaceholders($content);
+
+ $result = $this->processSkipEncodingShortcodes($content);
+
+ if ( $apbct->settings['data__email_decoder_buffer'] ) {
+ $apbct->buffer = $result;
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param string $content
+ *
+ * @return string
+ */
+ protected function processSkipEncodingShortcodes($content)
+ {
+ if ($this->isShortcodeInsideHtmlAttribute($content)) {
+ return $content;
+ }
+
$pattern = '/\[apbct_skip_encoding\](.*?)\[\/apbct_skip_encoding\]/s';
- $result = preg_replace_callback($pattern, function ($matches) {
- // $matches[0] - all full match
+
+ return preg_replace_callback($pattern, function ($matches) {
if ( isset($matches[1]) ) {
- // $matches[1] - only between tags group
- $modifiedContent = $this->callback([], $matches[1], '');
- return $modifiedContent; // Return modified (decoded) content without tags
+ return $this->callback([], $matches[1], '');
}
/** @psalm-suppress PossiblyUndefinedIntArrayOffset */
- return $matches[0]; // By default, return not modified match
+ return $matches[0];
}, $content);
+ }
- if ( $apbct->settings['data__email_decoder_buffer'] ) {
- $apbct->buffer = $result;
+ /**
+ * @param string $content
+ *
+ * @return string
+ */
+ protected function restorePlaceholders($content)
+ {
+ $content = $content === null ? '' : $content;
+
+ foreach ($this->shortcode_replacements as $placeholder => $original) {
+ $content = str_replace($placeholder, wp_kses_post($original), $content);
+ }
+
+ $this->resetShortcodeReplacements();
+
+ return $content;
+ }
+
+ /**
+ * Track currently rendered blocks to restore placeholders after the last encoding pass.
+ *
+ * @param mixed $pre_render
+ * @param array $block
+ *
+ * @return mixed
+ * @psalm-suppress PossiblyUnusedMethod
+ */
+ public function pushRenderBlockStack($pre_render, $block)
+ {
+ if ( $pre_render !== null ) {
+ return $pre_render;
+ }
+
+ $this->render_block_stack[] = isset($block['blockName']) ? (string)$block['blockName'] : '';
+
+ return $pre_render;
+ }
+
+ /**
+ * Restore placeholders once the outermost render_block finished ContactsEncoder::modifyContent().
+ *
+ * @param string $content
+ *
+ * @return string
+ * @psalm-suppress PossiblyUnusedMethod
+ */
+ public function finalizePlaceholderRestoreAfterRenderBlock($content)
+ {
+ array_pop($this->render_block_stack);
+
+ if ( ! empty($this->render_block_stack) ) {
+ return $content;
+ }
+
+ if ( function_exists('doing_filter')
+ && ( doing_filter('the_content') || doing_filter('widget_block_content') )
+ ) {
+ return $content;
+ }
+
+ if ( strpos($content, 'APBCT_SHORT_CODE_SKIP') !== false ) {
+ $content = $this->restorePlaceholders($content);
+ }
+
+ return $content;
+ }
+
+ /**
+ * Keep placeholders until the outermost render_block finishes encoding.
+ *
+ * @return bool
+ */
+ protected function shouldDeferPlaceholderRestore()
+ {
+ if ( $this->getCurrentAction() === 'render_block' ) {
+ return true;
+ }
+
+ return ! empty($this->render_block_stack);
+ }
+
+ /**
+ * @param string $content
+ *
+ * @return string
+ */
+ protected function createPlaceholder($content)
+ {
+ $placeholder = preg_replace('/EE\_\d+/', 'EE_' . (string)$this->shortcode_counter++, $this->exclusion_wrapper);
+ if (is_null($placeholder)) {
+ $placeholder = $this->exclusion_wrapper;
+ }
+ $this->shortcode_replacements[$placeholder] = $content;
+
+ return $placeholder;
+ }
+
+ /**
+ * @return void
+ */
+ public function resetShortcodeReplacements()
+ {
+ $this->shortcode_replacements = array();
+ $this->shortcode_counter = 0;
+ }
+
+ /**
+ * @param string $content
+ * @param array $block
+ *
+ * @return string
+ * @psalm-suppress PossiblyUnusedMethod
+ */
+ public function restorePageListLinkTitles($content, $block)
+ {
+ $block_name = isset($block['blockName']) ? $block['blockName'] : '';
+ if ( ! in_array($block_name, array('core/page-list', 'core/navigation'), true) ) {
+ return $content;
+ }
+
+ return $this->restorePageListLinkTitlesInHtml($content);
+ }
+
+ /**
+ * @param string $content
+ * @param array $_parsed_block
+ * @param object|null $block
+ *
+ * @return string
+ */
+ public function restoreEncodedBlockTitlesFilter($content, $_parsed_block, $block = null)
+ {
+ if ( $this->isBufferMode() ) {
+ return $content;
+ }
+
+ $content = $this->restorePageListLinkTitlesInHtml($content);
+
+ return $this->restorePostTitleInHtml($content, $_parsed_block, $block);
+ }
+
+ /**
+ * @param string $content
+ * @param array $_parsed_block
+ *
+ * @return string
+ * @deprecated Use restoreEncodedBlockTitlesFilter().
+ * @psalm-suppress PossiblyUnusedMethod
+ */
+ public function restorePageListLinkTitlesFilter($content, $_parsed_block)
+ {
+ return $this->restoreEncodedBlockTitlesFilter($content, $_parsed_block);
+ }
+
+ /**
+ * @param string $content
+ *
+ * @return string
+ */
+ protected function restorePageListLinkTitlesInHtml($content)
+ {
+ if ( strpos($content, ']*href=["\']([^"\']+)["\'][^>]*>)(.*?)(<\/a>)/is',
+ function ($matches) {
+ if ( ! isset($matches[0], $matches[1], $matches[2], $matches[3], $matches[4]) ) {
+ return isset($matches[0]) ? $matches[0] : '';
+ }
+
+ if (
+ strpos($matches[3], 'apbct-email-encoder') === false
+ && strpos($matches[3], '[apbct_skip_encoding]') === false
+ ) {
+ return $matches[0];
+ }
+
+ $post_id = $this->resolvePostIdFromHref($matches[2]);
+ if ( ! $post_id ) {
+ return $matches[0];
+ }
+
+ $title = $this->getPrimedRawPostTitle($post_id);
+ if ( $title === '' ) {
+ $title = $this->getRawPostTitle($post_id);
+ }
+
+ if ( ! is_string($title) || $title === '' || strpos($title, '[apbct_skip_encoding]') === false ) {
+ return $matches[0];
+ }
+
+ return $matches[1] . esc_html($this->processTitleString($title)) . $matches[4];
+ },
+ $content
+ );
+ }
+
+ /**
+ * Resolve post ID from an href used in page-list / navigation links.
+ *
+ * @param string $href
+ *
+ * @return int
+ */
+ protected function resolvePostIdFromHref($href)
+ {
+ if ( ! is_string($href) || $href === '' ) {
+ return 0;
+ }
+
+ $post_id = url_to_postid($href);
+ if ( $post_id ) {
+ return (int)$post_id;
+ }
+
+ $path = wp_parse_url($href, PHP_URL_PATH);
+ if ( ! is_string($path) || $path === '' ) {
+ return 0;
+ }
+
+ $path = trim($path, '/');
+ if ( $path === '' ) {
+ return 0;
+ }
+
+ $page = get_page_by_path($path);
+ if ( $page instanceof \WP_Post ) {
+ return (int)$page->ID;
+ }
+
+ foreach ( self::$raw_titles_cache as $cached_id => $_title ) {
+ $permalink = get_permalink((int)$cached_id);
+ if ( ! is_string($permalink) || $permalink === '' ) {
+ continue;
+ }
+
+ if ( untrailingslashit($permalink) === untrailingslashit($href) ) {
+ return (int)$cached_id;
+ }
+
+ $cached_path = wp_parse_url($permalink, PHP_URL_PATH);
+ if ( is_string($cached_path) && trim($cached_path, '/') === $path ) {
+ return (int)$cached_id;
+ }
+ }
+
+ return 0;
+ }
+
+ /**
+ * Restore post-title block output after nested render_block encoding passes.
+ *
+ * @param string $content
+ * @param array $_parsed_block
+ * @param object|null $block
+ *
+ * @return string
+ */
+ protected function restorePostTitleInHtml($content, $_parsed_block = array(), $block = null)
+ {
+ if ( strpos($content, 'wp-block-post-title') === false ) {
+ return $content;
+ }
+
+ $post_id = $this->resolvePostIdFromBlockContext($_parsed_block, $block);
+ if ( ! $post_id ) {
+ return $content;
+ }
+
+ if (
+ strpos($content, '[apbct_skip_encoding]') === false
+ && strpos($content, 'apbct-email-encoder') === false
+ && $this->getPrimedRawPostTitle($post_id) === ''
+ ) {
+ return $content;
+ }
+
+ $title = $this->getPrimedRawPostTitle($post_id);
+ if ( $title === '' ) {
+ $title = $this->getRawPostTitle($post_id);
+ }
+
+ if ( $title === '' || strpos($title, '[apbct_skip_encoding]') === false ) {
+ return $content;
+ }
+
+ $processed = $this->processTitleString($title, false);
+
+ return preg_replace_callback(
+ '/(]*\bwp-block-post-title\b[^>]*>)(.*?)(<\/h[1-6]>)/is',
+ function ($matches) use ($processed) {
+ if ( ! isset($matches[0], $matches[1], $matches[2], $matches[3]) ) {
+ return isset($matches[0]) ? $matches[0] : '';
+ }
+
+ if (
+ strpos($matches[2], '[apbct_skip_encoding]') === false
+ && strpos($matches[2], 'apbct-email-encoder') === false
+ && strpos($matches[2], '%%APBCT_SHORT_CODE_SKIP') === false
+ ) {
+ return $matches[0];
+ }
+
+ return $matches[1] . $processed . $matches[3];
+ },
+ $content
+ );
+ }
+
+ /**
+ * @param array $_parsed_block
+ * @param object|null $block
+ *
+ * @return int
+ */
+ protected function resolvePostIdFromBlockContext($_parsed_block, $block)
+ {
+ if ( is_object($block) && isset($block->context['postId']) ) {
+ return (int)$block->context['postId'];
+ }
+
+ if ( is_singular() ) {
+ return get_queried_object_id();
+ }
+
+ return 0;
+ }
+
+ /**
+ * Check whether shortcode is inside an HTML attribute rather than element text.
+ *
+ * @param string $content
+ *
+ * @return bool
+ */
+ protected function isShortcodeInsideHtmlAttribute($content)
+ {
+ preg_match_all(
+ '/\[\/?apbct_skip_encoding(?:\s[^\]]*)?\]/',
+ $content,
+ $matches,
+ PREG_OFFSET_CAPTURE
+ );
+
+ if ( ! isset($matches[0]) ) {
+ return false;
+ }
+
+ foreach ($matches[0] as $match) {
+ $offset = $match[1] ?? null;
+
+ if ( $offset === null ) {
+ continue;
+ }
+
+ if ( $this->isOffsetInsideHtmlTag($content, $offset) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Remove contacts that appear both inside skip-encoding shortcodes and as plain text outside them.
+ * Preserves original text/shortcode order (does not move shortcodes to the end).
+ *
+ * @param string $title
+ *
+ * @return string
+ */
+ protected function removeDuplicateContactsOutsideShortcodes($title)
+ {
+ if ( strpos($title, '[apbct_skip_encoding]') === false ) {
+ return $title;
+ }
+
+ preg_match_all(
+ '/\[apbct_skip_encoding\](.*?)\[\/apbct_skip_encoding\]/s',
+ $title,
+ $matches,
+ PREG_OFFSET_CAPTURE
+ );
+
+ if ( empty($matches[0]) || ! isset($matches[1]) ) {
+ return $title;
+ }
+
+ $protected_contacts = array();
+ foreach ( $matches[1] as $inner_match ) {
+ $inner = trim($inner_match[0]);
+ if ( $inner !== '' ) {
+ $protected_contacts[$inner] = true;
+ }
+ }
+
+ if ( empty($protected_contacts) ) {
+ return $title;
+ }
+
+ $result = '';
+ $offset = 0;
+
+ foreach ( $matches[0] as $shortcode_match ) {
+ $shortcode = $shortcode_match[0];
+ $position = $shortcode_match[1];
+ $text_before = substr($title, $offset, $position - $offset);
+ $result .= $this->stripProtectedContactsFromPlainText($text_before, array_keys($protected_contacts));
+ $result .= $shortcode;
+ $offset = $position + strlen($shortcode);
+ }
+
+ $result .= $this->stripProtectedContactsFromPlainText(substr($title, $offset), array_keys($protected_contacts));
+
+ return trim(preg_replace('/\s+/', ' ', $result));
+ }
+
+ /**
+ * @param string $text
+ * @param string[] $contacts
+ *
+ * @return string
+ */
+ protected function stripProtectedContactsFromPlainText($text, array $contacts)
+ {
+ if ( $text === '' || $text === false ) {
+ return '';
+ }
+
+ foreach ( $contacts as $contact ) {
+ $quoted = preg_quote($contact, '/');
+ $text = preg_replace('/' . $quoted . '/', '', $text);
+ }
+
+ return $text;
+ }
+
+ /**
+ * Cache titles with skip-encoding shortcodes before frontend encoding mutates post objects.
+ *
+ * @return void
+ * @psalm-suppress PossiblyUnusedMethod
+ */
+ public function primeRawTitleCache()
+ {
+ if ( is_admin() ) {
+ return;
+ }
+
+ global $wpdb;
+
+ $rows = $wpdb->get_results(
+ "SELECT ID, post_title FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_title LIKE '%[apbct_skip_encoding]%'",
+ OBJECT_K
+ );
+
+ if ( ! is_array($rows) ) {
+ return;
+ }
+
+ foreach ( $rows as $post_id => $row ) {
+ if (
+ isset($row->post_title)
+ && is_string($row->post_title)
+ && strpos($row->post_title, 'apbct-email-encoder') === false
+ ) {
+ self::$raw_titles_cache[(int)$post_id] = $row->post_title;
+ }
+ }
+ }
+
+ /**
+ * Return raw title from init-time prime cache when this post is known to use skip-encoding.
+ *
+ * @param int $post_id
+ *
+ * @return string
+ */
+ protected function getPrimedRawPostTitle($post_id)
+ {
+ $post_id = (int) $post_id;
+
+ if (
+ $post_id
+ && isset(self::$raw_titles_cache[$post_id])
+ && strpos(self::$raw_titles_cache[$post_id], 'apbct-email-encoder') === false
+ ) {
+ return self::$raw_titles_cache[$post_id];
+ }
+
+ return '';
+ }
+
+ /**
+ * Read post title from DB bypassing in-memory mutations during encoding.
+ *
+ * @param int $post_id
+ *
+ * @return string
+ */
+ protected function getRawPostTitle($post_id)
+ {
+ $post_id = (int) $post_id;
+
+ if ( ! $post_id ) {
+ return '';
+ }
+
+ $primed_title = $this->getPrimedRawPostTitle($post_id);
+ if ( $primed_title !== '' ) {
+ return $primed_title;
+ }
+
+ global $wpdb;
+
+ $title = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT post_title FROM {$wpdb->posts} WHERE ID = %d LIMIT 1",
+ $post_id
+ )
+ );
+
+ if ( ! is_string($title) ) {
+ return '';
+ }
+
+ if ( strpos($title, 'apbct-email-encoder') === false ) {
+ self::$raw_titles_cache[$post_id] = $title;
+ }
+
+ return $title;
+ }
+
+ /**
+ * @return bool
+ */
+ protected function isBufferMode()
+ {
+ global $apbct;
+
+ return ! empty($apbct->settings['data__email_decoder_buffer']);
+ }
+
+ /**
+ * Keep shortcode tags in title during render; buffer mode encodes on shutdown.
+ *
+ * @param string $title
+ *
+ * @return string
+ */
+ protected function prepareTitleForBufferMode($title)
+ {
+ if ( strpos($title, '[apbct_skip_encoding]') === false ) {
+ return $title;
+ }
+
+ return $this->removeDuplicateContactsOutsideShortcodes($title);
+ }
+
+ /**
+ * Rebuild encoded titles in the full output buffer after buffer-mode encoding.
+ *
+ * @param string $buffer
+ *
+ * @return string
+ */
+ public function restoreEncodedTitlesInBuffer($buffer)
+ {
+ if ( ! is_string($buffer) || $buffer === '' ) {
+ return $buffer;
+ }
+
+ $buffer = $this->restoreDocumentTitleInHtml($buffer);
+ $buffer = $this->restorePageListLinkTitlesInHtml($buffer);
+
+ return $this->restorePostTitleInHtml($buffer);
+ }
+
+ /**
+ * Finalize output buffer after ContactsEncoder::modifyContent() in buffer mode.
+ *
+ * @param string $buffer
+ *
+ * @return string
+ * @psalm-suppress PossiblyUnusedMethod
+ */
+ public function finalizeBufferAfterEncoding($buffer)
+ {
+ if ( ! is_string($buffer) || $buffer === '' ) {
+ return $buffer;
+ }
+
+ if ( ! $this->isShortcodeInsideHtmlAttribute($buffer) ) {
+ $buffer = $this->restorePlaceholders($buffer);
+ $buffer = $this->processSkipEncodingShortcodes($buffer);
+ }
+
+ return $this->restoreEncodedTitlesInBuffer($buffer);
+ }
+
+ /**
+ * Restore document after buffer-mode encoding.
+ *
+ * @param string $content
+ *
+ * @return string
+ */
+ protected function restoreDocumentTitleInHtml($content)
+ {
+ if ( strpos($content, 'getPrimedRawPostTitle($post_id) === ''
+ ) {
+ return $content;
+ }
+
+ $raw_title = $this->getPrimedRawPostTitle($post_id);
+ if ( $raw_title === '' ) {
+ $raw_title = $this->getRawPostTitle($post_id);
+ }
+
+ if ( $raw_title === '' || strpos($raw_title, '[apbct_skip_encoding]') === false ) {
+ return $content;
+ }
+
+ $processed = esc_html($this->processTitleString($raw_title));
+
+ return preg_replace_callback(
+ '/(]*>)(.*?)(<\/title>)/is',
+ function ($matches) use ($processed) {
+ if ( ! isset($matches[0], $matches[1], $matches[2], $matches[3]) ) {
+ return isset($matches[0]) ? $matches[0] : '';
+ }
+
+ if (
+ strpos($matches[2], '[apbct_skip_encoding]') === false
+ && strpos($matches[2], 'apbct-email-encoder') === false
+ && strpos($matches[2], '%%APBCT_SHORT_CODE_SKIP') === false
+ ) {
+ return $matches[0];
+ }
+
+ $suffix = '';
+ if ( preg_match('/^(.+?)(\s(?:–|–|–|-)\s.+)$/u', $matches[2], $parts) && isset($parts[2]) ) {
+ $suffix = $parts[2];
+ }
+
+ return $matches[1] . $processed . $suffix . $matches[3];
+ },
+ $content,
+ 1
+ );
+ }
+
+ /**
+ * Run callback without mutating shared placeholder state used by content hooks.
+ *
+ * @param callable $callback
+ *
+ * @return mixed
+ */
+ protected function withIsolatedShortcodeState($callback)
+ {
+ $saved_replacements = $this->shortcode_replacements;
+ $saved_counter = $this->shortcode_counter;
+
+ $this->resetShortcodeReplacements();
+
+ try {
+ return $callback();
+ } finally {
+ $this->shortcode_replacements = $saved_replacements;
+ $this->shortcode_counter = $saved_counter;
+ }
+ }
+
+ /**
+ * Process title strings where WordPress does not run the the_title filter.
+ *
+ * @param string $title
+ * @param bool $strip_html
+ *
+ * @return string
+ */
+ public function processTitleString($title, $strip_html = true)
+ {
+ if ( $title === '' || $title === null ) {
+ return (string)$title;
+ }
+
+ $result = $this->withIsolatedShortcodeState(function () use ($title) {
+ $title = $this->removeDuplicateContactsOutsideShortcodes($title);
+ $processed = $this->changeContentBeforeEncoderModify($title);
+ $processed = apbctGetContactsEncoder()->modifyContent($processed);
+ $processed = $this->restorePlaceholders($processed);
+
+ return $this->processSkipEncodingShortcodes($processed);
+ });
+
+ if ( $strip_html ) {
+ $result = wp_strip_all_tags($result);
+ $result = trim(preg_replace('/\s+/', ' ', $result));
}
return $result;
}
+ /**
+ * Remove skip-encoding shortcodes before slug generation.
+ *
+ * @param string $title
+ *
+ * @return string
+ */
+ public static function stripShortcodesForSlug($title)
+ {
+ $title = preg_replace('/\[apbct_skip_encoding\](.*?)\[\/apbct_skip_encoding\]/s', '$1', $title);
+ $title = preg_replace('/\[\/?apbct_skip_encoding\]/', '', $title);
+
+ return trim(preg_replace('/\s+/', ' ', $title));
+ }
+
+ /**
+ * @param string $title
+ * @param int $post_id
+ *
+ * @return string
+ * @psalm-suppress PossiblyUnusedMethod
+ */
+ public function filterTheTitle($title, $post_id)
+ {
+ if ( is_admin() && ! wp_doing_ajax() ) {
+ return $title;
+ }
+
+ $raw_title = $this->getPrimedRawPostTitle($post_id);
+ if ( $raw_title === '' || strpos($raw_title, '[apbct_skip_encoding]') === false ) {
+ return $title;
+ }
+
+ if ( $this->isBufferMode() ) {
+ return $this->prepareTitleForBufferMode($raw_title);
+ }
+
+ return $this->processTitleString($raw_title, false);
+ }
+
+ /**
+ * @param string $title
+ * @param \WP_Post $_post
+ *
+ * @return string
+ * @psalm-suppress PossiblyUnusedMethod
+ */
+ public function filterSinglePostTitle($title, $_post)
+ {
+ $post_id = is_object($_post) && isset($_post->ID) ? (int)$_post->ID : 0;
+ $raw_title = $post_id ? $this->getPrimedRawPostTitle($post_id) : $title;
+
+ if ( $raw_title === '' || strpos($raw_title, '[apbct_skip_encoding]') === false ) {
+ return $title;
+ }
+
+ if ( $this->isBufferMode() ) {
+ return $this->prepareTitleForBufferMode($raw_title);
+ }
+
+ return $this->processTitleString($raw_title);
+ }
+
+ /**
+ * @param array $parts
+ *
+ * @return array
+ * @psalm-suppress PossiblyUnusedMethod
+ */
+ public function filterDocumentTitleParts($parts)
+ {
+ if ( empty($parts['title']) ) {
+ return $parts;
+ }
+
+ $post_id = is_singular() ? get_queried_object_id() : 0;
+ $title = $parts['title'];
+
+ if ( $post_id ) {
+ $raw_title = $this->getPrimedRawPostTitle($post_id);
+ if ( $raw_title !== '' && strpos($raw_title, '[apbct_skip_encoding]') !== false ) {
+ $title = $raw_title;
+ }
+ }
+
+ if ( strpos($title, '[apbct_skip_encoding]') === false ) {
+ return $parts;
+ }
+
+ if ( $this->isBufferMode() ) {
+ $parts['title'] = $this->prepareTitleForBufferMode($title);
+
+ return $parts;
+ }
+
+ $parts['title'] = $this->processTitleString($title);
+
+ return $parts;
+ }
+
+ /**
+ * @param string $title
+ * @param \WP_Post $menu_item
+ *
+ * @return string
+ * @psalm-suppress PossiblyUnusedMethod
+ */
+ public function filterNavMenuItemTitle($title, $menu_item)
+ {
+ $post_id = 0;
+ if ( is_object($menu_item) && isset($menu_item->object, $menu_item->object_id) && $menu_item->object === 'page' ) {
+ $post_id = (int)$menu_item->object_id;
+ }
+
+ if ( $post_id ) {
+ $raw_title = $this->getPrimedRawPostTitle($post_id);
+ if ( $raw_title !== '' && strpos($raw_title, '[apbct_skip_encoding]') !== false ) {
+ if ( $this->isBufferMode() ) {
+ return $this->prepareTitleForBufferMode($raw_title);
+ }
+
+ return $this->processTitleString($raw_title);
+ }
+ }
+
+ if ( strpos($title, '[apbct_skip_encoding]') === false ) {
+ return $title;
+ }
+
+ if ( $this->isBufferMode() ) {
+ return $this->prepareTitleForBufferMode($title);
+ }
+
+ return $this->processTitleString($title);
+ }
+
+ /**
+ * @param array $data
+ * @param array $postarr
+ *
+ * @return array
+ * @psalm-suppress PossiblyUnusedMethod
+ */
+ public function filterPostDataForSlug($data, $postarr)
+ {
+ $post_id = isset($postarr['ID']) ? (int)$postarr['ID'] : 0;
+
+ if (
+ ! empty($data['post_title'])
+ && strpos($data['post_title'], 'apbct-email-encoder') !== false
+ && $post_id
+ && isset(self::$raw_titles_cache[$post_id])
+ ) {
+ $data['post_title'] = self::$raw_titles_cache[$post_id];
+ }
+
+ if ( empty($data['post_title']) || strpos($data['post_title'], '[apbct_skip_encoding]') === false ) {
+ return $data;
+ }
+
+ $status = isset($data['post_status']) ? $data['post_status'] : '';
+ if ( in_array($status, array('draft', 'pending', 'auto-draft'), true) ) {
+ return $data;
+ }
+
+ $post_name = isset($data['post_name']) ? $data['post_name'] : '';
+ $explicit_slug = isset($postarr['post_name']) && $postarr['post_name'] !== '';
+ $clean_slug = sanitize_title(self::stripShortcodesForSlug($data['post_title']));
+
+ if ( $post_name === '' && ! $explicit_slug ) {
+ $data['post_name'] = $clean_slug;
+ } elseif ( strpos($post_name, 'apbct_skip_encoding') !== false ) {
+ $data['post_name'] = $clean_slug;
+ }
+
+ return $data;
+ }
+
protected function getCurrentAction()
{
return function_exists('current_action') ? current_action() : null;
diff --git a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ShortCodesService.php b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ShortCodesService.php
index ebacb45a8..7dbdd82c5 100644
--- a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ShortCodesService.php
+++ b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ShortCodesService.php
@@ -24,15 +24,43 @@ public function registerAll()
if (!$this->shortcodes_registered) {
$this->encode->register();
+ $this->registerTitleFilters();
if ( ! $apbct->settings['data__email_decoder_buffer'] ) {
// If buffer is active, do not run WordPress shortcode replacement -
// encoder processes apbct_skip_encoding in modifyBufferAfter().
$this->shortcode_to_exclude->register();
+ $this->registerRenderBlockNestingHooks();
}
$this->shortcodes_registered = true;
}
}
+ /**
+ * @return void
+ */
+ private function registerRenderBlockNestingHooks()
+ {
+ add_filter('pre_render_block', array($this->shortcode_to_exclude, 'pushRenderBlockStack'), 10, 2);
+ add_filter('render_block', array($this->shortcode_to_exclude, 'finalizePlaceholderRestoreAfterRenderBlock'), 11);
+ }
+
+ /**
+ * @return void
+ */
+ public function registerTitleFilters()
+ {
+ add_filter('the_title', array($this->shortcode_to_exclude, 'filterTheTitle'), 1000, 2);
+ add_filter('single_post_title', array($this->shortcode_to_exclude, 'filterSinglePostTitle'), 20, 2);
+ add_filter('document_title_parts', array($this->shortcode_to_exclude, 'filterDocumentTitleParts'), 20);
+ add_filter('nav_menu_item_title', array($this->shortcode_to_exclude, 'filterNavMenuItemTitle'), 20, 2);
+ add_filter('wp_insert_post_data', array($this->shortcode_to_exclude, 'filterPostDataForSlug'), 10, 2);
+ add_action('init', array($this->shortcode_to_exclude, 'primeRawTitleCache'), 1);
+ add_filter('render_block', array($this->shortcode_to_exclude, 'restoreEncodedBlockTitlesFilter'), 1000, 3);
+ add_filter('render_block_core/page-list', array($this->shortcode_to_exclude, 'restoreEncodedBlockTitlesFilter'), 1000, 3);
+ add_filter('render_block_core/navigation', array($this->shortcode_to_exclude, 'restoreEncodedBlockTitlesFilter'), 1000, 3);
+ add_filter('render_block_core/post-title', array($this->shortcode_to_exclude, 'restoreEncodedBlockTitlesFilter'), 1000, 3);
+ }
+
public function __construct(Params $params)
{
$this->encode = new EncodeContentSC($params);
@@ -40,6 +68,12 @@ public function __construct(Params $params)
}
public function addActionsBeforeModify($hook, $priority = 1)
+ {
+ add_filter($hook, array($this->encode, 'changeContentBeforeEncoderModify'), $priority);
+ add_filter($hook, array($this->shortcode_to_exclude, 'changeContentBeforeEncoderModify'), $priority);
+ }
+
+ public function addActionsBeforeModifyEncodeOnly($hook, $priority = 1)
{
add_filter($hook, array($this->encode, 'changeContentBeforeEncoderModify'), $priority);
}
@@ -65,6 +99,8 @@ public function addActionsAfterModifyEncodeOnly($hook, $priority = 999)
public function modifyBufferBefore($buffer)
{
$this->encode->resetShortcodeReplacements();
+ $this->shortcode_to_exclude->resetShortcodeReplacements();
+ $buffer = $this->shortcode_to_exclude->changeContentBeforeEncoderModify($buffer);
return $this->encode->changeContentBeforeEncoderModify($buffer);
}
@@ -83,15 +119,12 @@ public function modifyBufferAfter($buffer)
$buffer = $this->encode->changeContentAfterEncoderModify($buffer);
if ( $apbct->settings['data__email_decoder_buffer'] ) {
+ $buffer = $this->shortcode_to_exclude->finalizeBufferAfterEncoding($buffer);
$apbct->buffer = $buffer;
- }
- $buffer = $this->shortcode_to_exclude->changeContentAfterEncoderModify($buffer);
-
- if ( $apbct->settings['data__email_decoder_buffer'] ) {
- return $apbct->buffer;
+ return $buffer;
}
- return $buffer;
+ return $this->shortcode_to_exclude->changeContentAfterEncoderModify($buffer);
}
}
diff --git a/lib/Cleantalk/ApbctWP/RemoteCalls.php b/lib/Cleantalk/ApbctWP/RemoteCalls.php
index bead9ee7b..a4ef843ef 100644
--- a/lib/Cleantalk/ApbctWP/RemoteCalls.php
+++ b/lib/Cleantalk/ApbctWP/RemoteCalls.php
@@ -691,11 +691,17 @@ private static function isSelfRemoteCall(): bool
$remote = Helper::ipGet('remote_addr', true);
$server = Server::getString('SERVER_ADDR');
- if ($remote !== '' && $server !== '' && $remote === $server) {
+ if ( $remote === '' || $server === '' ) {
+ return false;
+ }
+
+ if ( $remote === $server ) {
return true;
}
- return in_array($remote, array('127.0.0.1', '::1'), true);
+ $loopback = array('127.0.0.1', '::1');
+
+ return in_array($remote, $loopback, true) && in_array($server, $loopback, true);
}
/**
@@ -710,7 +716,7 @@ private static function rateLimitCheck()
$action = strtolower(Request::getString('spbc_remote_call_action'));
- // Self-RC for SFW queue — not abuse, skip rate limit
+ // Self-RC for SFW queue — raise rate limit (not skip)
if (self::isSelfRemoteCall() && $action === 'sfw_update__worker') {
$limit = 100;
}
diff --git a/readme.txt b/readme.txt
index 518355009..037951a40 100644
--- a/readme.txt
+++ b/readme.txt
@@ -442,7 +442,6 @@ Upd. Settings. Implement honey test.
New. Settings. Getting apikey wizard.
New. RateLimit. Enabled the RateLimit shared library
New. Footer. New link on Website Feedback Plugin
-New. RateLimit. Enabled the RateLimit shared library
Upd. Contacts Encoder. Flow improvements.
Upd. ContactEncoder. Improve shortcode flow.
Mod. BannerReview. Changing the design of the review banner
diff --git a/tests/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSCTest.php b/tests/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSCTest.php
index 38479a03e..28e205003 100644
--- a/tests/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSCTest.php
+++ b/tests/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSCTest.php
@@ -47,11 +47,14 @@ public function testCallbackWithEmptyContent(): void
}
/**
- * Test callback with valid content containing data-original-string
+ * Test callback with valid content containing data-original-string (buffer mode).
*/
public function testCallbackWithValidContent(): void
{
- // Prepare test data
+ global $apbct;
+
+ $apbct->settings['data__email_decoder_buffer'] = true;
+
$originalString = 'test@example.com';
$encodedString = $this->contacts_encoder->modifyContent($originalString);
@@ -60,11 +63,37 @@ public function testCallbackWithValidContent(): void
$this->assertEquals($originalString, $result);
}
+ /**
+ * Test callback stores placeholder when buffer mode is off.
+ */
+ public function testCallbackWithValidContentBufferOffReturnsPlaceholder(): void
+ {
+ global $apbct;
+
+ $previous_buffer = $apbct->settings['data__email_decoder_buffer'];
+ $apbct->settings['data__email_decoder_buffer'] = false;
+
+ try {
+ $originalString = 'test@example.com';
+ $encodedString = $this->contacts_encoder->modifyContent($originalString);
+
+ $result = $this->exclude_content_sc->callback([], $encodedString, '');
+
+ $this->assertStringContainsString('APBCT_SHORT_CODE_SKIP', $result);
+ } finally {
+ $apbct->settings['data__email_decoder_buffer'] = $previous_buffer;
+ }
+ }
+
/**
* Test callback with content without data-original-string
*/
public function testCallbackWithContentWithoutDataAttribute(): void
{
+ global $apbct;
+
+ $apbct->settings['data__email_decoder_buffer'] = true;
+
$content = 'Some text without data attribute';
$result = $this->exclude_content_sc->callback([], $content, '');
@@ -118,32 +147,64 @@ public function testChangeContentAfterEncoderModifyWithBufferOnUsesBufferDuringS
}
/**
- * Test changeContentAfterEncoderModify when buffer is off but in the_title hook
+ * Test changeContentAfterEncoderModify when buffer is off and not in the_title
*/
- public function testChangeContentAfterEncoderModifyInTheTitleHook(): void
+ public function testChangeContentAfterEncoderModifyWithBufferOffOnTheContent(): void
{
global $apbct;
- $content = 'title with [apbct_skip_encoding]test@example.com[/apbct_skip_encoding]';
+ $content = '[apbct_skip_encoding]asfg@srth.rew[/apbct_skip_encoding]
';
$apbct->settings['data__email_decoder_buffer'] = false;
$apbct->saveSettings();
- $this->contacts_encoder->dropInstance(); // Need to rebuild the object after the settings changed
+ $this->contacts_encoder->dropInstance();
$this->contacts_encoder = apbctGetContactsEncoder();
- // Create a partial mock that overrides getCurrentAction
- $shortcodeMock = $this->getMockBuilder(ExcludedEncodeContentSC::class)
- ->setMethods(['getCurrentAction'])
- ->getMock();
+ $protected_content = $this->exclude_content_sc->changeContentBeforeEncoderModify($content);
+ $encoded_content = $this->contacts_encoder->modifyContent($protected_content);
+ $result = $this->exclude_content_sc->changeContentAfterEncoderModify($encoded_content);
- // Mock getCurrentAction to return 'the_title'
- $shortcodeMock->expects($this->once())
- ->method('getCurrentAction')
- ->willReturn('the_title');
+ $this->assertEquals('asfg@srth.rew
', $result);
+ }
+
+ /**
+ * Test changeContentAfterEncoderModify when buffer is off and not in the_title
+ */
+ public function testChangeContentAfterEncoderModifyWithBufferOffSkipsPhoneAndEmail(): void
+ {
+ global $apbct;
+
+ $content = '[apbct_skip_encoding]asfg@srth.rew[/apbct_skip_encoding] [apbct_skip_encoding]+79991112233[/apbct_skip_encoding]';
+
+ $apbct->settings['data__email_decoder_buffer'] = false;
+ $apbct->saveSettings();
+ $this->contacts_encoder->dropInstance();
+ $this->contacts_encoder = apbctGetContactsEncoder();
+
+ $protected_content = $this->exclude_content_sc->changeContentBeforeEncoderModify($content);
+ $encoded_content = $this->contacts_encoder->modifyContent($protected_content);
+ $result = $this->exclude_content_sc->changeContentAfterEncoderModify($encoded_content);
+
+ $this->assertEquals('asfg@srth.rew +79991112233', $result);
+ }
+
+ /**
+ * Test changeContentAfterEncoderModify when buffer is off for title-like content
+ */
+ public function testChangeContentAfterEncoderModifyInTheTitleHook(): void
+ {
+ global $apbct;
+
+ $content = 'title with [apbct_skip_encoding]test@example.com[/apbct_skip_encoding]';
- $encoded_content = $this->contacts_encoder->modifyContent($content);
+ $apbct->settings['data__email_decoder_buffer'] = false;
+ $apbct->saveSettings();
+ $this->contacts_encoder->dropInstance();
+ $this->contacts_encoder = apbctGetContactsEncoder();
- $result = $shortcodeMock->changeContentAfterEncoderModify($encoded_content);
+ $protected_content = $this->exclude_content_sc->changeContentBeforeEncoderModify($content);
+ $encoded_content = $this->contacts_encoder->modifyContent($protected_content);
+ $result = $this->exclude_content_sc->changeContentAfterEncoderModify($encoded_content);
$this->assertEquals('title with test@example.com', $result);
}
@@ -224,6 +285,10 @@ public function testChangeContentAfterEncoderModifyWhenCallbackReturnsUnmodified
*/
public function testCallbackSanitizesDirectScriptInjection(): void
{
+ global $apbct;
+
+ $apbct->settings['data__email_decoder_buffer'] = true;
+
$content = '';
$result = $this->exclude_content_sc->callback([], $content, '');
$this->assertStringNotContainsString('';
$result = $this->exclude_content_sc->callback([], $content, '');
$this->assertStringNotContainsString('