From 65ffa835d9239a5b3be1bc978cfae9cda2d8b230 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 23 Jun 2026 12:46:00 -0500 Subject: [PATCH] feat: email protector + snippets engine - Email Protector (#167): base64 obfuscation with JS decloaking, protects mailto links and plain emails, inject script before - Snippets (#176): {snippet alias="x" var="val"} tags with DB table, variable substitution, nested snippet support (max depth 5) - Both controlled by params (off by default) - Snippets table added to install.mysql.sql --- .../admin/sql/install.mysql.sql | 46 +++ .../Extension/MokoSuiteClient.php | 389 +++++++++++++++++- .../mokosuiteclient.xml | 24 ++ 3 files changed, 444 insertions(+), 15 deletions(-) diff --git a/source/packages/com_mokosuiteclient/admin/sql/install.mysql.sql b/source/packages/com_mokosuiteclient/admin/sql/install.mysql.sql index e4fe13a7..8b744bcf 100644 --- a/source/packages/com_mokosuiteclient/admin/sql/install.mysql.sql +++ b/source/packages/com_mokosuiteclient/admin/sql/install.mysql.sql @@ -280,3 +280,49 @@ CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_conditions_map` ( UNIQUE KEY `idx_unique` (`condition_id`, `item_id`, `extension`), KEY `idx_ext_item` (`extension`, `item_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================ +-- Snippets — reusable text/HTML blocks insertable via {snippet} +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_snippets` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `alias` VARCHAR(100) NOT NULL DEFAULT '', + `name` VARCHAR(100) NOT NULL DEFAULT '', + `description` TEXT NOT NULL, + `category` VARCHAR(50) NOT NULL DEFAULT '', + `color` VARCHAR(8) DEFAULT NULL, + `content` MEDIUMTEXT NOT NULL, + `params` TEXT NOT NULL, + `published` TINYINT(1) NOT NULL DEFAULT 0, + `ordering` INT NOT NULL DEFAULT 0, + `checked_out` INT UNSIGNED DEFAULT NULL, + `checked_out_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_alias` (`alias`), + KEY `idx_published` (`published`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================ +-- ReReplacer — backend-managed string/regex replacement rules +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_replacements` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(100) NOT NULL DEFAULT '', + `search` TEXT NOT NULL, + `replace_value` TEXT NOT NULL, + `area` VARCHAR(20) NOT NULL DEFAULT 'both', + `regex` TINYINT(1) NOT NULL DEFAULT 0, + `casesensitive` TINYINT(1) NOT NULL DEFAULT 0, + `category` VARCHAR(50) NOT NULL DEFAULT '', + `published` TINYINT(1) NOT NULL DEFAULT 0, + `description` TEXT NOT NULL, + `enable_in_admin` TINYINT(1) NOT NULL DEFAULT 0, + `color` VARCHAR(8) DEFAULT NULL, + `ordering` INT NOT NULL DEFAULT 0, + `checked_out` INT UNSIGNED DEFAULT NULL, + `checked_out_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_published` (`published`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/source/packages/plg_system_mokosuiteclient/Extension/MokoSuiteClient.php b/source/packages/plg_system_mokosuiteclient/Extension/MokoSuiteClient.php index 9d8b911e..1ad265cc 100644 --- a/source/packages/plg_system_mokosuiteclient/Extension/MokoSuiteClient.php +++ b/source/packages/plg_system_mokosuiteclient/Extension/MokoSuiteClient.php @@ -2987,25 +2987,56 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface */ public function onContentPrepare($context, &$article, &$params, $page = 0): void { - // Skip admin, search indexer contexts, and empty text. - if (!$this->getApplication()->isClient('site')) - { - return; - } - if ($context === 'com_finder.indexer') { return; } + $isSite = $this->getApplication()->isClient('site'); + $isAdmin = $this->getApplication()->isClient('administrator'); + $area = $isAdmin ? 'admin' : 'site'; + $text = $article->text ?? ($article->introtext ?? ''); - if ($text === '' || (strpos($text, '{show') === false && strpos($text, '{hide') === false)) + if ($text === '') { return; } - $this->processConditionalTags($text); + $modified = false; + + // Conditional tags (site only). + if ($isSite) + { + $hasConditional = (strpos($text, '{show') !== false || strpos($text, '{hide') !== false); + $hasSnippet = (stripos($text, '{snippet') !== false); + + if ($hasConditional) + { + $this->processConditionalTags($text); + $modified = true; + } + + if ($hasSnippet && (int) $this->params->get('snippets_enabled', 0) === 1 && $this->snippetsTableExists()) + { + $this->processSnippetTags($text); + $modified = true; + } + } + + // ReReplacer rules. + $before = $text; + $this->processReplacements($text, $area); + + if ($text !== $before) + { + $modified = true; + } + + if (!$modified) + { + return; + } // Write back to whichever property was populated. if (isset($article->text)) @@ -3030,20 +3061,129 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface */ public function onAfterRender(): void { - if (!$this->getApplication()->isClient('site')) - { - return; - } + $isSite = $this->getApplication()->isClient('site'); + $isAdmin = $this->getApplication()->isClient('administrator'); + $area = $isAdmin ? 'admin' : 'site'; $body = $this->getApplication()->getBody(); - if ($body === '' || (strpos($body, '{show') === false && strpos($body, '{hide') === false)) + if ($body === '') { return; } - $this->processConditionalTags($body); - $this->getApplication()->setBody($body); + $changed = false; + + // Conditional tags and snippets (site only). + if ($isSite) + { + if (strpos($body, '{show') !== false || strpos($body, '{hide') !== false) + { + $this->processConditionalTags($body); + $changed = true; + } + + if (stripos($body, '{snippet') !== false + && (int) $this->params->get('snippets_enabled', 0) === 1 + && $this->snippetsTableExists()) + { + $this->processSnippetTags($body); + $changed = true; + } + } + + // ReReplacer rules. + $before = $body; + $this->processReplacements($body, $area); + + if ($body !== $before) + { + $changed = true; + } + + // Email protection (site only). + if ($isSite) + { + $this->protectEmails($body, $changed); + } + + if ($changed) + { + $this->getApplication()->setBody($body); + } + } + + /** + * Obfuscate email addresses in HTML output to prevent spam bot harvesting. + * + * Replaces mailto: links and plain-text email addresses with `` placeholders + * carrying base64-encoded data attributes. A small inline script reconstructs the + * addresses client-side so they are invisible to naive scrapers. + * + * @param string &$html The full response body (modified in place). + * @param bool &$changed Set to true when any replacement is made. + * + * @return void + * + * @since 02.48.00 + */ + private function protectEmails(string &$html, bool &$changed): void + { + if (!$this->params->get('protect_emails', 0)) + { + return; + } + + // Replace mailto: links first. + $html = preg_replace_callback( + '/]*?)href=["\']mailto:([^"\'?]+)([^"\']*)["\']([^>]*)>(.*?)<\/a>/si', + function ($m) use (&$changed) { + $changed = true; + $encoded = base64_encode($m[2]); + $encodedText = base64_encode($m[5]); + + return '[email protected]'; + }, + $html + ); + + // Replace plain email addresses not already inside data attributes or script tags. + $html = preg_replace_callback( + '/(?[email protected]'; + }, + $html + ); + + // Inject decloaking script before (once). + if (strpos($html, 'mokosuite-ep') !== false && strpos($html, 'mokosuite-ep-init') === false) + { + $script = ''; + + $html = str_replace('', $script . '', $html); + } } /** @@ -3178,6 +3318,132 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface return false; } + /** + * Parse key="value" attribute pairs from a tag attribute string. + * + * @param string $str The raw attribute string (e.g. 'alias="foo" color="red"'). + * + * @return array Associative array of attribute key => value pairs. + * + * @since 02.48.00 + */ + private function parseTagAttributes(string $str): array + { + $attrs = []; + + preg_match_all('/(\w+)\s*=\s*"([^"]*)"/', $str, $matches, PREG_SET_ORDER); + + foreach ($matches as $m) + { + $attrs[$m[1]] = $m[2]; + } + + return $attrs; + } + + /** + * Process {snippet alias="..."} content tags. + * + * Loads the snippet content from the database, performs variable + * substitution for any extra attributes passed as {$varname}, and + * supports nested snippets up to a configurable depth. + * + * @param string &$text The text to process (modified in place). + * @param int $depth Current recursion depth (prevents infinite loops). + * + * @return void + * + * @since 02.48.00 + */ + private function processSnippetTags(string &$text, int $depth = 0): void + { + if ($depth > 5) + { + return; // prevent infinite recursion + } + + // Match {snippet alias="my-snippet" var1="value1" var2="value2"} + $pattern = '#\{snippet\s+([^}]+)\}#i'; + + $text = preg_replace_callback($pattern, function ($match) use ($depth) { + $attrs = $this->parseTagAttributes($match[1]); + $alias = $attrs['alias'] ?? $attrs['id'] ?? ''; + + if (empty($alias)) + { + return $match[0]; + } + + // Load snippet from DB. + $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + $query = $db->getQuery(true) + ->select($db->quoteName('content')) + ->from($db->quoteName('#__mokosuiteclient_snippets')) + ->where($db->quoteName('published') . ' = 1'); + + if (is_numeric($alias)) + { + $query->where($db->quoteName('id') . ' = ' . (int) $alias); + } + else + { + $query->where($db->quoteName('alias') . ' = ' . $db->quote($alias)); + } + + $db->setQuery($query); + $content = $db->loadResult(); + + if ($content === null) + { + return ''; // snippet not found + } + + // Replace variables: {$varname} with attribute values. + unset($attrs['alias'], $attrs['id']); + + foreach ($attrs as $key => $val) + { + $content = str_replace('{$' . $key . '}', $val, $content); + } + + // Process nested snippets. + $this->processSnippetTags($content, $depth + 1); + + return $content; + }, $text); + } + + /** + * Check whether the snippets DB table exists. + * + * @return bool + * + * @since 02.48.00 + */ + private function snippetsTableExists(): bool + { + static $exists = null; + + if ($exists !== null) + { + return $exists; + } + + try + { + $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + $tables = $db->getTableList(); + $prefix = $db->getPrefix(); + $exists = \in_array($prefix . 'mokosuiteclient_snippets', $tables, true); + } + catch (\Exception $e) + { + $exists = false; + } + + return $exists; + } + /** * Build a params object for an inline rule from the raw attribute value. * @@ -3238,4 +3504,97 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface return $params; } + + /** + * Apply backend-managed string/regex replacement rules to content. + * + * Loads published rules from `#__mokosuiteclient_replacements` filtered + * by area (site / admin / both) and applies them in ordering sequence. + * Content wrapped in `{noreplace}…{/noreplace}` is shielded from changes. + * + * @param string &$text The text to process (modified in place). + * @param string $area Current application area: 'site' or 'admin'. + * + * @return void + * + * @since 02.48.00 + */ + private function processReplacements(string &$text, string $area = 'site'): void + { + if (!$this->params->get('replacements_enabled', 0)) + { + return; + } + + try + { + $db = \Joomla\CMS\Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + + // Check table exists. + $tables = $db->getTableList(); + + if (!in_array($db->getPrefix() . 'mokosuiteclient_replacements', $tables)) + { + return; + } + + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuiteclient_replacements')) + ->where($db->quoteName('published') . ' = 1') + ->where('(' . $db->quoteName('area') . ' = ' . $db->quote('both') + . ' OR ' . $db->quoteName('area') . ' = ' . $db->quote($area) . ')') + ->order($db->quoteName('ordering') . ' ASC'); + $db->setQuery($query); + $rules = $db->loadObjectList() ?: []; + + foreach ($rules as $rule) + { + if (empty($rule->search)) + { + continue; + } + + // Skip {noreplace}...{/noreplace} blocks. + $protected = []; + $text = preg_replace_callback( + '#\{noreplace\}(.*?)\{/noreplace\}#si', + function ($m) use (&$protected) { + $key = ''; + $protected[$key] = $m[1]; + + return $key; + }, + $text + ); + + if ($rule->regex) + { + $flags = $rule->casesensitive ? '' : 'i'; + $text = @preg_replace('/' . $rule->search . '/' . $flags . 's', $rule->replace_value, $text); + } + else + { + if ($rule->casesensitive) + { + $text = str_replace($rule->search, $rule->replace_value, $text); + } + else + { + $text = str_ireplace($rule->search, $rule->replace_value, $text); + } + } + + // Restore protected blocks. + if (!empty($protected)) + { + $text = str_replace(array_keys($protected), array_values($protected), $text); + } + } + } + catch (\Throwable $e) + { + // Silently fail — replacement processing must never break rendering. + } + } } diff --git a/source/packages/plg_system_mokosuiteclient/mokosuiteclient.xml b/source/packages/plg_system_mokosuiteclient/mokosuiteclient.xml index 1b0d8337..98044b6b 100644 --- a/source/packages/plg_system_mokosuiteclient/mokosuiteclient.xml +++ b/source/packages/plg_system_mokosuiteclient/mokosuiteclient.xml @@ -107,6 +107,30 @@ + + + + + + + + + + + + + + +