From cb6025737cfc38607ca5a3d7190bf5703fd6e3cc Mon Sep 17 00:00:00 2001 From: alexandergull Date: Thu, 17 Sep 2026 15:06:56 +0500 Subject: [PATCH] Fix. Contacts Encoder. Simple precheck content before high-cost logic run. https://app.doboard.com/1/task/54678 --- .../Shortcodes/EmailEncoderShortCode.php | 27 +- .../Shortcodes/EncodeContentSC.php | 10 + .../Shortcodes/ExcludedEncodeContentSC.php | 14 +- .../EmailEncoderShortCodePrecheckTest.php | 260 ++++++++++++++++++ 4 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 tests/ApbctWP/ContactsEncoder/Shortcodes/EmailEncoderShortCodePrecheckTest.php diff --git a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/EmailEncoderShortCode.php b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/EmailEncoderShortCode.php index 224b525e9..5a5298f55 100644 --- a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/EmailEncoderShortCode.php +++ b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/EmailEncoderShortCode.php @@ -23,6 +23,25 @@ class EmailEncoderShortCode extends \Cleantalk\ApbctWP\ShortCode // Placeholder nonce for ensuring unique placeholders per render. protected $placeholder_nonce = ''; + /** + * Lightweight pre-check to avoid running heavy regexes on every hit. + * + * Any valid shortcode pair must contain an opening tag `[public_name` — a cheap + * strpos() lookup rejects the vast majority of content before preg_* is involved. + * + * @param string|null $content + * + * @return bool True if the content may contain this shortcode. + */ + protected function contentMayContainShortcode($content) + { + if ( ! is_string($content) || $content === '' || (string)$this->public_name === '' ) { + return false; + } + + return strpos($content, '[' . $this->public_name) !== false; + } + /** * Build a placeholder for the given counter using the child's $exclusion_wrapper as a template. * Lazy-initialises the per-render nonce so replacements survive isolated render passes. @@ -90,6 +109,10 @@ protected function doCallbackAction($content) return $content; } + if ( ! $this->contentMayContainShortcode($content) ) { + return $content; + } + if ( ! has_shortcode($content, $this->public_name) ) { return $content; } @@ -168,7 +191,9 @@ protected function changeContentAfterEncoderModify($content) */ protected function isShortcodeInsideHtmlTag($content) { - if ( ! is_string($content) ) { + // is_string() is repeated here for Psalm: it cannot infer the type narrowing + // that happens inside contentMayContainShortcode(). + if ( ! is_string($content) || ! $this->contentMayContainShortcode($content) ) { return false; } diff --git a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/EncodeContentSC.php b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/EncodeContentSC.php index b472b0c46..2b3a180c9 100644 --- a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/EncodeContentSC.php +++ b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/EncodeContentSC.php @@ -103,6 +103,11 @@ public function changeContentBeforeEncoderModify($content) return $content; } + // Cheap pre-check: bail out before any regex if the shortcode is not present at all. + if ( ! $this->contentMayContainShortcode($content) ) { + return $content; + } + if ( $this->exclusions->doReturnShortcodeContentBeforeModify($content) ) { return $content; } @@ -150,6 +155,11 @@ public function changeContentAfterEncoderModify($content) return $content; } + // Nothing was replaced and no shortcode is present - skip restoring and the callback pass. + if ( empty($this->shortcode_replacements) && ! $this->contentMayContainShortcode($content) ) { + return $content; + } + // Restore shortcodes foreach ($this->shortcode_replacements as $placeholder => $original) { $content = str_replace($placeholder, $original, $content); diff --git a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php index 526ebf9c0..511ebd99a 100644 --- a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php +++ b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php @@ -82,6 +82,11 @@ public function changeContentBeforeEncoderModify($content) return $content; } + // Cheap pre-check: bail out before any regex if the shortcode is not present at all. + if ( ! $this->contentMayContainShortcode($content) ) { + return $content; + } + if ($this->isShortcodeInsideHtmlAttribute($content)) { return $content; } @@ -170,6 +175,11 @@ protected function processSkipEncodingShortcodes($content) return ''; } + // Cheap pre-check: bail out before any regex if the shortcode is not present at all. + if ( ! $this->contentMayContainShortcode($content) ) { + return $content; + } + if ($this->isShortcodeInsideHtmlAttribute($content)) { return $content; } @@ -540,7 +550,9 @@ protected function resolvePostIdFromBlockContext($_parsed_block, $block) */ protected function isShortcodeInsideHtmlAttribute($content) { - if ( ! is_string($content) ) { + // is_string() is repeated here for Psalm: it cannot infer the type narrowing + // that happens inside contentMayContainShortcode(). + if ( ! is_string($content) || ! $this->contentMayContainShortcode($content) ) { return false; } diff --git a/tests/ApbctWP/ContactsEncoder/Shortcodes/EmailEncoderShortCodePrecheckTest.php b/tests/ApbctWP/ContactsEncoder/Shortcodes/EmailEncoderShortCodePrecheckTest.php new file mode 100644 index 000000000..c1f65851c --- /dev/null +++ b/tests/ApbctWP/ContactsEncoder/Shortcodes/EmailEncoderShortCodePrecheckTest.php @@ -0,0 +1,260 @@ +api_key = 'testapikey'; + + $this->encode_sc = new EncodeContentSC(new Params()); + $this->exclude_sc = new ExcludedEncodeContentSC(); + } + + /** + * Call a protected method on the given object. + * + * @param object $object + * @param string $method + * @param array $args + * + * @return mixed + */ + private function callProtected($object, $method, array $args) + { + $reflection = new \ReflectionMethod($object, $method); + $reflection->setAccessible(true); + + return $reflection->invokeArgs($object, $args); + } + + /** + * @return array + */ + public function encodeShortcodePrecheckProvider() + { + return array( + 'plain text' => array('Just a plain sentence with no shortcodes.', false), + 'email without shortcode' => array('Contact us at test@example.com please.', false), + 'html without shortcode' => array('

Some markup here.

', false), + 'other plugin shortcode' => array('[gallery ids="1,2,3"]', false), + 'similar but different name' => array('[apbct_encode_data_other]x[/apbct_encode_data_other]', true), + 'bare opening tag' => array('[apbct_encode_data]', true), + 'full shortcode' => array('[apbct_encode_data]a@b.com[/apbct_encode_data]', true), + 'shortcode with attributes' => array('[apbct_encode_data mode="blur"]a@b.com[/apbct_encode_data]', true), + ); + } + + /** + * @dataProvider encodeShortcodePrecheckProvider + * + * @param string $content + * @param bool $expected + */ + public function testContentMayContainShortcodeForEncodeSC($content, $expected): void + { + $this->assertSame( + $expected, + $this->callProtected($this->encode_sc, 'contentMayContainShortcode', array($content)) + ); + } + + /** + * The pre-check must reject every non-string and empty value without touching a regex. + * + * @return array + */ + public function nonStringProvider() + { + return array( + 'null' => array(null), + 'empty string' => array(''), + 'false' => array(false), + 'integer' => array(10), + 'array' => array(array('[apbct_encode_data]')), + 'object' => array(new \stdClass()), + ); + } + + /** + * @dataProvider nonStringProvider + * + * @param mixed $content + */ + public function testContentMayContainShortcodeRejectsNonStrings($content): void + { + $this->assertFalse( + $this->callProtected($this->encode_sc, 'contentMayContainShortcode', array($content)) + ); + $this->assertFalse( + $this->callProtected($this->exclude_sc, 'contentMayContainShortcode', array($content)) + ); + } + + /** + * Each subclass must match only its own tag. + */ + public function testPrecheckIsScopedToOwnShortcodeName(): void + { + $encode_tag = '[apbct_encode_data]a@b.com[/apbct_encode_data]'; + $exclude_tag = '[apbct_skip_encoding]a@b.com[/apbct_skip_encoding]'; + + $this->assertTrue( + $this->callProtected($this->encode_sc, 'contentMayContainShortcode', array($encode_tag)) + ); + $this->assertFalse( + $this->callProtected($this->encode_sc, 'contentMayContainShortcode', array($exclude_tag)) + ); + + $this->assertTrue( + $this->callProtected($this->exclude_sc, 'contentMayContainShortcode', array($exclude_tag)) + ); + $this->assertFalse( + $this->callProtected($this->exclude_sc, 'contentMayContainShortcode', array($encode_tag)) + ); + } + + /** + * A closing tag alone is not a valid pair, so the pre-check must skip it. + */ + public function testPrecheckIgnoresOrphanClosingTag(): void + { + $this->assertFalse( + $this->callProtected($this->encode_sc, 'contentMayContainShortcode', array('text [/apbct_encode_data]')) + ); + } + + /** + * Fast path: content without the shortcode must come back byte-identical. + */ + public function testChangeContentBeforeEncoderModifyReturnsContentWithoutShortcodeUntouched(): void + { + $content = '

Hello, reach us at test@example.com or [gallery id="2"].

'; + + $this->assertSame($content, $this->encode_sc->changeContentBeforeEncoderModify($content)); + $this->assertSame($content, $this->exclude_sc->changeContentBeforeEncoderModify($content)); + } + + /** + * Slow path must still work: the shortcode is replaced by a placeholder. + */ + public function testChangeContentBeforeEncoderModifyStillProcessesShortcode(): void + { + $encode_result = $this->encode_sc->changeContentBeforeEncoderModify( + 'Mail: [apbct_encode_data]a@b.com[/apbct_encode_data]' + ); + $this->assertStringContainsString('APBCT_SHORT_CODE_INCLUDE', $encode_result); + $this->assertStringNotContainsString('[apbct_encode_data]', $encode_result); + + $exclude_result = $this->exclude_sc->changeContentBeforeEncoderModify( + 'Mail: [apbct_skip_encoding]a@b.com[/apbct_skip_encoding]' + ); + $this->assertStringContainsString('APBCT_SHORT_CODE_SKIP', $exclude_result); + $this->assertStringNotContainsString('[apbct_skip_encoding]', $exclude_result); + } + + /** + * WP passes null to get_header/get_footer hooks - the guard must survive it. + */ + public function testChangeContentBeforeEncoderModifyHandlesNull(): void + { + $this->assertNull($this->encode_sc->changeContentBeforeEncoderModify(null)); + $this->assertNull($this->exclude_sc->changeContentBeforeEncoderModify(null)); + } + + /** + * The "after" pass must short-circuit when there is nothing to restore and no shortcode left. + */ + public function testChangeContentAfterEncoderModifyShortCircuitsWithoutReplacements(): void + { + $this->encode_sc->resetShortcodeReplacements(); + + $content = '

Nothing to do here, test@example.com.

'; + + $this->assertSame($content, $this->encode_sc->changeContentAfterEncoderModify($content)); + } + + /** + * The short-circuit must not drop pending placeholders. + */ + public function testChangeContentAfterEncoderModifyRestoresPendingPlaceholders(): void + { + $this->encode_sc->resetShortcodeReplacements(); + + $before = $this->encode_sc->changeContentBeforeEncoderModify( + 'Mail: [apbct_encode_data]a@b.com[/apbct_encode_data]' + ); + + $this->assertNotEmpty($this->encode_sc->shortcode_replacements); + + $after = $this->encode_sc->changeContentAfterEncoderModify($before); + + $this->assertStringNotContainsString('APBCT_SHORT_CODE_INCLUDE', $after); + } + + /** + * Round-trip on shortcode-free content must be a no-op through both passes. + */ + public function testFullRoundTripIsNoOpForContentWithoutShortcodes(): void + { + $this->encode_sc->resetShortcodeReplacements(); + + $content = '
Plain content, no shortcodes at all.
'; + + $processed = $this->encode_sc->changeContentBeforeEncoderModify($content); + $processed = $this->encode_sc->changeContentAfterEncoderModify($processed); + + $this->assertSame($content, $processed); + } + + /** + * The pre-check must not change the "shortcode inside an HTML tag" detection result. + */ + public function testIsShortcodeInsideHtmlTagStillDetectsAttributeContext(): void + { + $inside = 'x'; + $outside = '

[apbct_encode_data]a@b.com[/apbct_encode_data]

'; + + $this->assertTrue($this->callProtected($this->encode_sc, 'isShortcodeInsideHtmlTag', array($inside))); + $this->assertFalse($this->callProtected($this->encode_sc, 'isShortcodeInsideHtmlTag', array($outside))); + } + + /** + * Guarded early return: no shortcode at all means "not inside a tag", and no regex is run. + */ + public function testIsShortcodeInsideHtmlTagReturnsFalseWithoutShortcode(): void + { + $this->assertFalse( + $this->callProtected($this->encode_sc, 'isShortcodeInsideHtmlTag', array('x')) + ); + $this->assertFalse( + $this->callProtected($this->encode_sc, 'isShortcodeInsideHtmlTag', array(null)) + ); + } +}