feat: email protector + snippets engine
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Generic: Project CI / Tests (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 10s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Successful in 17s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 18s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 38s
Generic: Project CI / Lint & Validate (pull_request) Successful in 39s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 42s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 49s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 43s

- Email Protector (#167): base64 obfuscation with JS decloaking,
  protects mailto links and plain emails, inject script before </body>
- 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
This commit is contained in:
Jonathan Miller
2026-06-23 12:46:00 -05:00
parent a91d78beff
commit 65ffa835d9
3 changed files with 444 additions and 15 deletions
@@ -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;
@@ -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 `<span>` 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(
'/<a\s([^>]*?)href=["\']mailto:([^"\'?]+)([^"\']*)["\']([^>]*)>(.*?)<\/a>/si',
function ($m) use (&$changed) {
$changed = true;
$encoded = base64_encode($m[2]);
$encodedText = base64_encode($m[5]);
return '<span class="mokosuite-ep" data-e="' . $encoded
. '" data-t="' . $encodedText
. '" data-q="' . base64_encode($m[3])
. '">[email protected]</span>';
},
$html
);
// Replace plain email addresses not already inside data attributes or script tags.
$html = preg_replace_callback(
'/(?<!="|data-[a-z]+=")([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})/',
function ($m) use (&$changed) {
$changed = true;
return '<span class="mokosuite-ep" data-e="' . base64_encode($m[1])
. '">[email protected]</span>';
},
$html
);
// Inject decloaking script before </body> (once).
if (strpos($html, 'mokosuite-ep') !== false && strpos($html, 'mokosuite-ep-init') === false)
{
$script = '<script id="mokosuite-ep-init">'
. 'document.addEventListener("DOMContentLoaded",function(){'
. 'document.querySelectorAll(".mokosuite-ep").forEach(function(el){'
. 'var e=atob(el.dataset.e);'
. 'if(el.dataset.t){'
. 'var t=atob(el.dataset.t);'
. 'var q=el.dataset.q?atob(el.dataset.q):"";'
. 'var a=document.createElement("a");'
. 'a.href="mailto:"+e+q;'
. 'a.innerHTML=t;'
. 'el.replaceWith(a)'
. '}else{'
. 'el.textContent=e'
. '}'
. '})'
. '});</script>';
$html = str_replace('</body>', $script . '</body>', $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 = '<!--MOKO_NR_' . count($protected) . '-->';
$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.
}
}
}
@@ -107,6 +107,30 @@
<option value="0">JNO</option>
</field>
<field name="protect_emails" type="radio" default="0"
label="Email Protection"
description="Obfuscate email addresses in HTML output to prevent spam bot harvesting. Uses JavaScript decloaking."
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="snippets_enabled" type="radio" default="0"
label="Snippets"
description="Enable {snippet alias=&quot;name&quot;} content tags. Reusable text/HTML blocks stored in the database with variable substitution support."
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="replacements_enabled" type="radio" default="0"
label="ReReplacer"
description="Enable backend-managed string and regex replacement rules. Published rules from the replacements table are applied to site and/or admin content."
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="monitor_signing_key" type="hidden"
default="LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2QUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktZd2dnU2lBZ0VBQW9JQkFRQ2xZNnNzOTZpeTZOOGMKTHRxbndhbnU4eEozdDcrdDhXT3hoY0Yyclc2QmlmOVhNaEpnYkw0c055N0wwV1dTT2tkMmZxalBNcDFtOFNyNAo1VnNycjE3cFc5b0FNMmtmdFdsaTZ1NkhTVEYyN2pVVUJrT3o4MHZMRklMMGNGNkJCUkpYN2JVWkRpamdUMjc1ClREb3dXZy82Zk9GeWFEelBHUkJuYXFacTljU2lEYWoyNlpSTVZIbktQUERWTG92VzRPTDQzL2gwZ3BtN25nUGIKdWJlLzFFTDRUMHFRbm1Xc2FEOFZ6VStoRXFGSDRTVUtMaDVNeklGbUxFZzRlZ0xCbTBXcWdxbzZRQVBnZDVPYgoybXhmQndta3RLVm5hcWR6eG9KSytzaTVuZkYreGpxbWRMZThUdmEyTHNuTUxlZmsrODVoQ3hxS2x1eWRta1lXCjlvUk5qcDhiQWdNQkFBRUNnZ0VBQkZOUS9NSVZaV2gxdlZUMFh3TFBvUEkyZjI4TTBrM0gzN0t4MXBxK2t5QzYKenRyK1pBczBCaEFEWjAwNHJOUmRYaG45N0QxVXBJYVdLeUJFZkNZQUEzWmxneS9WQmdGR21sR3VuMWNvdGdXUQoyYzg0SWhLdzNzVFFqL2dJWUxOelFWMTBLUTJYd0JZVHZ1MWhjRFpLeUxCUGJTQ1F4cEhQUGdVcUNRNFljR3lFClErVmc1dHJUYk8wQ2xCZ1U5bkVnYU1RakRJZ0F3WVZPV203dUxJTW84UC9nT3FuT2tmaFhzdzl3VTJVYWxFeTEKRmRZbGhMbGJ0ZS9MZ3lkYlJ2RStjNEtqZVp0Z3ptc1RneEh2dzM5YVVmZUZTclFRT0FjcXc0alNzUjdMck9UZAp5bDhpelRrZVBrTVFMamFqR0pabWdPbitkRzhtUlpMa3FKcWdGaVpqRVFLQmdRRFV0L0xlU0h5SmhvY3VFL240CkZreEpaclJoWUVsWnc2WlZJUnQzWDlPQ1Nmaklab3I1ZkZlczhvUzZySFhKdGZYeWx4QUxOSjJjTUhKTTViVnUKbUFSUFU4cThBeVc0OE03cHAyNmtVVTMxNXc2OU1SUkhzbWgyekRabEtDeG5GM1NSQ3U4YW95d3hZc3RUZ3hkTgo2bDhLNHZsS1dsN3FYblBhWjZjb3lQSU9od0tCZ1FESENuRmRRdW5SMVI2dkxGaVFZMTRiT3QwT0tzVGJYMUJyCmpvUGZySkxvRm5mSCs4VDVnNUdxYkV5T2p0WG1tRXhmTFFpcDBQVXRtc1E0YXlJRFBZYWZtU3RpK2dtQXZFd1MKZTlKcVYxYlRuazUrYnVRZ2FlOW16REpJWkxaczRJUlhrd1Q5aDZ4Q2xKeS80TGJSRHdBU3dUVGJlY01hN3A4UgpQN0p0bjdsYnpRS0JnQzNOR2FjUTFuZktGb3N1VS9FOTQ5a2VHeEtvWjhMREpLcEp3WjgzYTlRdTF6bFhFdTlhCi9ZbklnaG1yam9VSy85VG0vOVpaMHVIUmNKcnNEdCtzTGFsaThsRC9JSDBzcEhDYzAyN2Y3cmhXc3M2N3BaRTIKY2RXNmJLL2xNWUpWQTQxRFhHNVEyZkFjUklsTHZaWFNNL3FsR21ZUEJVYlRaWUNPTnVqS000dzdBb0dBU1dBdwpLcEZnWVZxUDFVUWo0aGEvdW9vWXRBQlFVZzd4TnJWektDSVdoampDTDVkQkpqcTZtSGtVUC9tb0lUcEQ3VkpNCnYwMnBGUWJaRDNOdk5vS1gvbjRZNElRTXZNaXR3cUtqRDFEalVXQXF6N0ZScUNGbGdDQUc2V2szVnl2dG5kczEKRzhISVgwTXFCaEp4VXVDVXhsVXpoelY4RjVHZ1VsdUpDNkMyVklFQ2dZQkJWSkxpZlNVOTlHWGZtK3dPd0RWcgo2bHZoUFgxOTBGVktWQXY3aVVWTXBwWXg4Y0QxYkcyUjRLT29JbnkxYTlxdjA2ZGFzeGVQOStkVjJVMWU3MWl5CkFXWDRBVHIrYitvSGk2eUk1MXRHRk54RUxiNXZYMVpYM3VNaDlWM29iYUpuSFNjYllpKzBBNjlyRmNuNEZuLzUKWXJybWxLTzRlRHFVZkswbVFJVCtwUT09Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K"
filter="raw" />