chore: merge dev to main #19
@@ -38,5 +38,5 @@ This is a Joomla extension. Key directories:
|
||||
- **Attribution**: use `Authored-by: Moko Consulting` in commits
|
||||
- **Branch strategy**: develop on `dev`, merge to `main` for release
|
||||
- **Minification**: handled at build time (CI) and runtime (MokoMinifyHelper for Joomla templates)
|
||||
- **Wiki**: documentation lives in the Gitea wiki, not in `docs/` files
|
||||
- **Wiki**: documentation lives in the MokoGitea wiki, not in `docs/` files
|
||||
- **Standards**: this repo follows [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.01.35
|
||||
VERSION: 02.01.39
|
||||
PATH: /README.md
|
||||
BRIEF: Rebranding plugin for MokoWaaS platform
|
||||
NOTE: Internal WaaS identity abstraction layer
|
||||
|
||||
@@ -52,7 +52,7 @@ This ensures Joomla sites on any "Minimum Stability" setting always see the late
|
||||
|
||||
### Sync to Main
|
||||
|
||||
Since Joomla sites read `updates.xml` from the `main` branch, the `update-server.yml` workflow **syncs `updates.xml` to `main` via the Gitea API** after building on non-main branches. This ensures pre-release channel entries are visible to sites checking for updates without requiring a PR merge to main.
|
||||
Since Joomla sites read `updates.xml` from the `main` branch, the `update-server.yml` workflow **syncs `updates.xml` to `main` via the MokoGitea API** after building on non-main branches. This ensures pre-release channel entries are visible to sites checking for updates without requiring a PR merge to main.
|
||||
|
||||
### Generated XML Structure
|
||||
|
||||
@@ -120,7 +120,7 @@ dev → [alpha] → [beta] → rc → version/XX → main → dev
|
||||
optional optional (integration) (production) (feedback)
|
||||
```
|
||||
|
||||
1. **Development** (`dev` or `dev/**`): `updates.xml` with `<tag>development</tag>`, download points to Gitea release ZIP
|
||||
1. **Development** (`dev` or `dev/**`): `updates.xml` with `<tag>development</tag>`, download points to MokoGitea release ZIP
|
||||
2. **Alpha** (`alpha/**`): `updates.xml` with `<tag>alpha</tag>`, cascades to development channel
|
||||
3. **Beta** (`beta/**`): `updates.xml` with `<tag>beta</tag>`, cascades to alpha + development channels
|
||||
4. **Release Candidate** (`rc/**`): `updates.xml` with `<tag>rc</tag>`, cascades to beta + alpha + development channels
|
||||
|
||||
+210
-614
@@ -50,51 +50,20 @@ use Joomla\CMS\User\UserHelper;
|
||||
class MokoWaaS extends CMSPlugin
|
||||
{
|
||||
/**
|
||||
* Obfuscated Grafana URL (XOR + base64).
|
||||
* Heartbeat receiver URL for Grafana provisioning.
|
||||
*
|
||||
* @var string
|
||||
* @since 02.01.26
|
||||
* @since 02.01.36
|
||||
*/
|
||||
private const G_URL = 'JRsfHyRbTnxPIhwCDk8DDkY/EQAYGgYFGwcjCEUbMgIJ';
|
||||
private const HEARTBEAT_URL = 'https://bench.mokoconsulting.tech/api/waas-heartbeat';
|
||||
|
||||
/**
|
||||
* Obfuscated Grafana service account token (XOR + base64).
|
||||
* Shared secret for heartbeat authentication.
|
||||
*
|
||||
* @var string
|
||||
* @since 02.01.26
|
||||
* @since 02.01.36
|
||||
*/
|
||||
private const G_KEY = 'KgMYDggFCSFoLxskMSUsMGoaKAgyXCIjKzh1AhwCYwIqA1pzHz5XVwwCHWdHWg==';
|
||||
|
||||
/**
|
||||
* XOR key for credential deobfuscation.
|
||||
*
|
||||
* @var string
|
||||
* @since 02.01.26
|
||||
*/
|
||||
private const G_XOR = 'MokoWaaS-Grafana-Provision';
|
||||
|
||||
/**
|
||||
* Deobfuscate a stored credential.
|
||||
*
|
||||
* @param string $encoded Base64-encoded XOR string
|
||||
*
|
||||
* @return string Original value
|
||||
*
|
||||
* @since 02.01.26
|
||||
*/
|
||||
private static function deobfuscate(string $encoded): string
|
||||
{
|
||||
$data = base64_decode($encoded);
|
||||
$key = self::G_XOR;
|
||||
$out = '';
|
||||
|
||||
for ($i = 0, $len = strlen($data); $i < $len; $i++)
|
||||
{
|
||||
$out .= chr(ord($data[$i]) ^ ord($key[$i % strlen($key)]));
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
private const HEARTBEAT_KEY = 'moko-waas-hb-2026-x9k4m';
|
||||
|
||||
/**
|
||||
* Load the language file on instantiation.
|
||||
@@ -133,6 +102,9 @@ class MokoWaaS extends CMSPlugin
|
||||
// Dev mode: disable caching
|
||||
$this->enforceDevMode();
|
||||
|
||||
// Self-healing: re-lock if someone tampered
|
||||
$this->enforceLocked();
|
||||
|
||||
// Admin-only WaaS controls
|
||||
if ($this->app->isClient('administrator'))
|
||||
{
|
||||
@@ -919,6 +891,12 @@ class MokoWaaS extends CMSPlugin
|
||||
}
|
||||
|
||||
$this->injectFavicon($doc);
|
||||
|
||||
// Hide MokoWaaS from plugin list for non-master users
|
||||
if (!$this->isMasterUser())
|
||||
{
|
||||
$this->hidePluginFromList($doc);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1341,636 +1319,163 @@ class MokoWaaS extends CMSPlugin
|
||||
$this->app->close();
|
||||
}
|
||||
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Grafana Provisioning (called from onExtensionAfterSave)
|
||||
// Heartbeat (called from onExtensionAfterSave)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handle Grafana datasource and dashboard provisioning.
|
||||
* Send heartbeat to the MokoWaaS monitoring receiver.
|
||||
*
|
||||
* When the health endpoint is enabled and Grafana credentials are
|
||||
* configured, auto-provisions an Infinity datasource and a MokoWaaS
|
||||
* dashboard in the target Grafana instance. Removes them when disabled.
|
||||
* Registers this site with the Grafana provisioning system.
|
||||
* The receiver writes a datasource YAML file and restarts Grafana.
|
||||
*
|
||||
* @param \Joomla\Registry\Registry $params Plugin params
|
||||
* @param \Joomla\CMS\Application\CMSApplication $app Application
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.01.22
|
||||
* @since 02.01.36
|
||||
*/
|
||||
protected function handleGrafanaProvisioning($params, $app)
|
||||
{
|
||||
$grafanaUrl = rtrim(self::deobfuscate(self::G_URL), '/');
|
||||
$grafanaKey = self::deobfuscate(self::G_KEY);
|
||||
$healthToken = $params->get('health_api_token', '');
|
||||
$siteUrl = rtrim(Uri::root(), '/');
|
||||
$siteName = Factory::getConfig()->get('sitename', 'Joomla');
|
||||
$dsUid = 'mokowaas-' . md5($siteUrl);
|
||||
|
||||
if (empty($healthToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure Infinity datasource plugin is installed
|
||||
$pluginOk = $this->ensureGrafanaPlugin(
|
||||
$grafanaUrl, $grafanaKey,
|
||||
'yesoreyeram-infinity-datasource'
|
||||
);
|
||||
$siteUrl = rtrim(Uri::root(), '/');
|
||||
$siteName = Factory::getConfig()->get('sitename', 'Joomla');
|
||||
|
||||
if ($pluginOk !== true)
|
||||
{
|
||||
$app->enqueueMessage(
|
||||
'Grafana plugin install failed: ' . $pluginOk
|
||||
. ' — install the Infinity plugin manually.',
|
||||
'warning'
|
||||
);
|
||||
$payload = json_encode([
|
||||
'site_url' => $siteUrl,
|
||||
'site_name' => $siteName,
|
||||
'health_token' => $healthToken,
|
||||
'action' => 'register',
|
||||
], JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$dsResult = $this->provisionGrafanaDatasource(
|
||||
$grafanaUrl, $grafanaKey, $dsUid,
|
||||
$siteUrl, $healthToken, $siteName
|
||||
);
|
||||
|
||||
if ($dsResult === true)
|
||||
{
|
||||
$dbResult = $this->provisionGrafanaDashboard(
|
||||
$grafanaUrl, $grafanaKey, $dsUid, $siteName
|
||||
);
|
||||
|
||||
if ($dbResult === true)
|
||||
{
|
||||
$app->enqueueMessage(
|
||||
'Grafana datasource and dashboard provisioned.',
|
||||
'message'
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
$app->enqueueMessage(
|
||||
'Grafana datasource created but dashboard failed: '
|
||||
. $dbResult,
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
$app->enqueueMessage(
|
||||
'Grafana provisioning failed: ' . $dsResult,
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update an Infinity datasource in Grafana.
|
||||
*
|
||||
* @param string $grafanaUrl Grafana base URL
|
||||
* @param string $grafanaKey Grafana API key
|
||||
* @param string $dsUid Datasource UID
|
||||
* @param string $siteUrl Site base URL
|
||||
* @param string $healthToken Health API bearer token
|
||||
* @param string $siteName Joomla site name
|
||||
*
|
||||
* @return true|string True on success, error message on failure
|
||||
*
|
||||
* @since 02.01.22
|
||||
*/
|
||||
protected function provisionGrafanaDatasource(
|
||||
$grafanaUrl, $grafanaKey, $dsUid,
|
||||
$siteUrl, $healthToken, $siteName
|
||||
)
|
||||
{
|
||||
$dsPayload = [
|
||||
'uid' => $dsUid,
|
||||
'name' => 'MokoWaaS — ' . $siteName,
|
||||
'type' => 'yesoreyeram-infinity-datasource',
|
||||
'access' => 'proxy',
|
||||
'url' => $siteUrl,
|
||||
'jsonData' => [
|
||||
'auth_method' => 'bearerToken',
|
||||
'global_queries' => [],
|
||||
],
|
||||
'secureJsonData' => [
|
||||
'bearerToken' => $healthToken,
|
||||
],
|
||||
];
|
||||
|
||||
// Try update first (PUT), fall back to create (POST)
|
||||
$response = $this->grafanaRequest(
|
||||
$grafanaUrl, $grafanaKey,
|
||||
'PUT', '/api/datasources/uid/' . $dsUid,
|
||||
$dsPayload
|
||||
);
|
||||
|
||||
if ($response['code'] === 200)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($response['code'] === 404)
|
||||
{
|
||||
// Datasource doesn't exist — create it
|
||||
$response = $this->grafanaRequest(
|
||||
$grafanaUrl, $grafanaKey,
|
||||
'POST', '/api/datasources',
|
||||
$dsPayload
|
||||
);
|
||||
|
||||
if ($response['code'] === 200 || $response['code'] === 409)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return 'HTTP ' . $response['code'] . ': '
|
||||
. ($response['body']['message'] ?? 'Unknown error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update the MokoWaaS dashboard in Grafana.
|
||||
*
|
||||
* @param string $grafanaUrl Grafana base URL
|
||||
* @param string $grafanaKey Grafana API key
|
||||
* @param string $dsUid Datasource UID
|
||||
* @param string $siteName Joomla site name
|
||||
*
|
||||
* @return true|string True on success, error message on failure
|
||||
*
|
||||
* @since 02.01.22
|
||||
*/
|
||||
protected function provisionGrafanaDashboard(
|
||||
$grafanaUrl, $grafanaKey, $dsUid, $siteName
|
||||
)
|
||||
{
|
||||
$dashboard = $this->buildDashboardModel($dsUid, $siteName);
|
||||
|
||||
$payload = [
|
||||
'dashboard' => $dashboard,
|
||||
'folderUid' => 'cfldz4r88nhfkc',
|
||||
'overwrite' => true,
|
||||
'message' => 'Auto-provisioned by MokoWaaS plugin',
|
||||
];
|
||||
|
||||
$response = $this->grafanaRequest(
|
||||
$grafanaUrl, $grafanaKey,
|
||||
'POST', '/api/dashboards/db',
|
||||
$payload
|
||||
);
|
||||
|
||||
if ($response['code'] === 200)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return 'HTTP ' . $response['code'] . ': '
|
||||
. ($response['body']['message'] ?? 'Unknown error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove MokoWaaS datasource and dashboard from Grafana.
|
||||
*
|
||||
* @param string $grafanaUrl Grafana base URL
|
||||
* @param string $grafanaKey Grafana API key
|
||||
* @param string $dsUid Datasource UID
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.01.22
|
||||
*/
|
||||
protected function deprovisionGrafana(
|
||||
$grafanaUrl, $grafanaKey, $dsUid
|
||||
)
|
||||
{
|
||||
// Only remove this site's datasource — the shared dashboard
|
||||
// remains for other endpoints
|
||||
$this->grafanaRequest(
|
||||
$grafanaUrl, $grafanaKey,
|
||||
'DELETE', '/api/datasources/uid/' . $dsUid
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a Grafana plugin is installed, installing via API if needed.
|
||||
*
|
||||
* Checks the Grafana Plugin API for the given plugin ID. If not
|
||||
* found, attempts installation via POST /api/plugins/<id>/install
|
||||
* (Grafana 10+). This replaces the deprecated grafana-cli approach.
|
||||
*
|
||||
* @param string $grafanaUrl Grafana base URL
|
||||
* @param string $grafanaKey Grafana API key
|
||||
* @param string $pluginId Plugin ID (e.g. yesoreyeram-infinity-datasource)
|
||||
*
|
||||
* @return true|string True if installed, error message otherwise
|
||||
*
|
||||
* @since 02.01.22
|
||||
*/
|
||||
protected function ensureGrafanaPlugin(
|
||||
$grafanaUrl, $grafanaKey, $pluginId
|
||||
)
|
||||
{
|
||||
// Check if plugin is already installed
|
||||
$response = $this->grafanaRequest(
|
||||
$grafanaUrl, $grafanaKey,
|
||||
'GET', '/api/plugins/' . $pluginId . '/settings'
|
||||
);
|
||||
|
||||
if ($response['code'] === 200)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Plugin not installed — install via API
|
||||
$response = $this->grafanaRequest(
|
||||
$grafanaUrl, $grafanaKey,
|
||||
'POST', '/api/plugins/' . $pluginId . '/install',
|
||||
new \stdClass()
|
||||
);
|
||||
|
||||
if ($response['code'] === 200)
|
||||
{
|
||||
Log::add(
|
||||
'Grafana plugin ' . $pluginId . ' installed via API',
|
||||
Log::INFO,
|
||||
'mokowaas'
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return 'HTTP ' . $response['code'] . ': '
|
||||
. ($response['body']['message'] ?? 'Unknown error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an HTTP request to the Grafana API.
|
||||
*
|
||||
* Uses cURL to communicate with the Grafana instance. All inputs
|
||||
* are passed as structured data (JSON body, HTTP headers) — no
|
||||
* shell commands are invoked.
|
||||
*
|
||||
* @param string $grafanaUrl Grafana base URL
|
||||
* @param string $grafanaKey Grafana API key
|
||||
* @param string $method HTTP method
|
||||
* @param string $path API path
|
||||
* @param array|null $data Request body (JSON-encoded)
|
||||
*
|
||||
* @return array ['code' => int, 'body' => array]
|
||||
*
|
||||
* @since 02.01.22
|
||||
*/
|
||||
protected function grafanaRequest(
|
||||
$grafanaUrl, $grafanaKey, $method, $path, $data = null
|
||||
)
|
||||
{
|
||||
$url = $grafanaUrl . $path;
|
||||
|
||||
$headers = [
|
||||
'Authorization: Bearer ' . $grafanaKey,
|
||||
$ch = curl_init(self::HEARTBEAT_URL . '/register');
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
'X-MokoWaaS-Key: ' . self::HEARTBEAT_KEY,
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
|
||||
if ($data !== null)
|
||||
{
|
||||
curl_setopt(
|
||||
$ch, CURLOPT_POSTFIELDS,
|
||||
json_encode($data, JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
}
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
$response = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($curlError)
|
||||
if ($error)
|
||||
{
|
||||
Log::add(
|
||||
'Grafana API error: ' . $curlError,
|
||||
Log::WARNING,
|
||||
'mokowaas'
|
||||
$app->enqueueMessage(
|
||||
'Grafana heartbeat failed: ' . $error,
|
||||
'warning'
|
||||
);
|
||||
Log::add('Heartbeat failed: ' . $error, Log::WARNING, 'mokowaas');
|
||||
|
||||
return [
|
||||
'code' => 0,
|
||||
'body' => ['message' => $curlError],
|
||||
];
|
||||
return;
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => $httpCode,
|
||||
'body' => json_decode($responseBody, true) ?: [],
|
||||
];
|
||||
$body = json_decode($response, true);
|
||||
|
||||
if ($code === 200 && ($body['status'] ?? '') === 'registered')
|
||||
{
|
||||
$app->enqueueMessage(
|
||||
'Grafana heartbeat: site registered (' . ($body['ds_uid'] ?? '') . ')',
|
||||
'message'
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
$msg = sprintf(
|
||||
'Grafana heartbeat failed: HTTP %d — %s',
|
||||
$code, $body['error'] ?? $body['message'] ?? 'Unknown'
|
||||
);
|
||||
$app->enqueueMessage($msg, 'warning');
|
||||
Log::add($msg, Log::WARNING, 'mokowaas');
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Self-Protection (called from onAfterInitialise)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the Grafana dashboard JSON model.
|
||||
* Ensure the plugin stays enabled, locked, and protected.
|
||||
*
|
||||
* Creates a dashboard with panels for all health check metrics:
|
||||
* overall status, database latency, disk space, extensions,
|
||||
* Joomla/PHP versions, and cache status.
|
||||
* Re-applies protection flags on every request. If someone manually
|
||||
* disables or unlocks the plugin via the database, this re-enables
|
||||
* it on the next page load.
|
||||
*
|
||||
* @param string $dsUid Datasource UID
|
||||
* @param string $siteName Joomla site name
|
||||
* @return void
|
||||
*
|
||||
* @return array Grafana dashboard model
|
||||
*
|
||||
* @since 02.01.22
|
||||
* @since 02.01.36
|
||||
*/
|
||||
protected function buildDashboardModel($dsUid, $siteName)
|
||||
protected function enforceLocked()
|
||||
{
|
||||
// Shared dashboard — uses a datasource variable dropdown
|
||||
// so all WaaS sites share one dashboard. Each site only
|
||||
// provisions its own Infinity datasource; the dashboard
|
||||
// is created/updated idempotently.
|
||||
static $checked = false;
|
||||
|
||||
$mkQuery = function ($refId, $jsonPath, $type = 'string')
|
||||
if ($checked)
|
||||
{
|
||||
return [
|
||||
'refId' => $refId,
|
||||
'datasource' => [
|
||||
'type' => 'yesoreyeram-infinity-datasource',
|
||||
'uid' => '${endpoint}',
|
||||
],
|
||||
'type' => 'json',
|
||||
'source' => 'url',
|
||||
'url' => '/?mokowaas=health',
|
||||
'format' => 'table',
|
||||
'parser' => 'backend',
|
||||
'root_selector' => '',
|
||||
'columns' => [
|
||||
[
|
||||
'selector' => $jsonPath,
|
||||
'text' => $refId,
|
||||
'type' => $type,
|
||||
],
|
||||
],
|
||||
];
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
$panels = [];
|
||||
$panelId = 1;
|
||||
$checked = true;
|
||||
|
||||
// --- Row 1: Status overview ---
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('enabled'),
|
||||
$db->quoteName('locked'),
|
||||
$db->quoteName('protected'),
|
||||
])
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = '
|
||||
. $db->quote('mokowaas'))
|
||||
->where($db->quoteName('folder') . ' = '
|
||||
. $db->quote('system'));
|
||||
|
||||
$panels[] = [
|
||||
'id' => $panelId++,
|
||||
'type' => 'stat',
|
||||
'title' => 'Health Status',
|
||||
'gridPos' => ['h' => 6, 'w' => 6, 'x' => 0, 'y' => 0],
|
||||
'targets' => [$mkQuery('status', 'status')],
|
||||
'fieldConfig' => [
|
||||
'defaults' => [
|
||||
'mappings' => [
|
||||
['type' => 'value', 'options' => [
|
||||
'ok' => ['text' => 'HEALTHY', 'color' => 'green'],
|
||||
'degraded' => ['text' => 'DEGRADED', 'color' => 'orange'],
|
||||
'error' => ['text' => 'ERROR', 'color' => 'red'],
|
||||
]],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
$db->setQuery($query);
|
||||
$ext = $db->loadObject();
|
||||
|
||||
$panels[] = [
|
||||
'id' => $panelId++,
|
||||
'type' => 'gauge',
|
||||
'title' => 'DB Latency (ms)',
|
||||
'gridPos' => ['h' => 6, 'w' => 6, 'x' => 6, 'y' => 0],
|
||||
'targets' => [$mkQuery('latency', 'checks.database.latency_ms', 'number')],
|
||||
'fieldConfig' => [
|
||||
'defaults' => [
|
||||
'unit' => 'ms',
|
||||
'min' => 0,
|
||||
'max' => 500,
|
||||
'thresholds' => [
|
||||
'mode' => 'absolute',
|
||||
'steps' => [
|
||||
['value' => null, 'color' => 'green'],
|
||||
['value' => 50, 'color' => 'orange'],
|
||||
['value' => 200, 'color' => 'red'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
if (!$ext)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$panels[] = [
|
||||
'id' => $panelId++,
|
||||
'type' => 'gauge',
|
||||
'title' => 'Free Disk Space',
|
||||
'gridPos' => ['h' => 6, 'w' => 6, 'x' => 12, 'y' => 0],
|
||||
'targets' => [$mkQuery('disk', 'checks.filesystem.free_disk_mb', 'number')],
|
||||
'fieldConfig' => [
|
||||
'defaults' => [
|
||||
'unit' => 'decmbytes',
|
||||
'min' => 0,
|
||||
'thresholds' => [
|
||||
'mode' => 'absolute',
|
||||
'steps' => [
|
||||
['value' => null, 'color' => 'red'],
|
||||
['value' => 100, 'color' => 'orange'],
|
||||
['value' => 500, 'color' => 'green'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
if ((int) $ext->enabled === 1
|
||||
&& (int) $ext->locked === 1
|
||||
&& (int) $ext->protected === 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$panels[] = [
|
||||
'id' => $panelId++,
|
||||
'type' => 'stat',
|
||||
'title' => 'Pending Updates',
|
||||
'gridPos' => ['h' => 6, 'w' => 6, 'x' => 18, 'y' => 0],
|
||||
'targets' => [$mkQuery('updates', 'checks.extensions.pending_updates', 'number')],
|
||||
'fieldConfig' => [
|
||||
'defaults' => [
|
||||
'thresholds' => [
|
||||
'mode' => 'absolute',
|
||||
'steps' => [
|
||||
['value' => null, 'color' => 'green'],
|
||||
['value' => 1, 'color' => 'orange'],
|
||||
['value' => 5, 'color' => 'red'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('enabled') . ' = 1')
|
||||
->set($db->quoteName('locked') . ' = 1')
|
||||
->set($db->quoteName('protected') . ' = 1')
|
||||
->where($db->quoteName('element') . ' = '
|
||||
. $db->quote('mokowaas'))
|
||||
->where($db->quoteName('folder') . ' = '
|
||||
. $db->quote('system'))
|
||||
);
|
||||
$db->execute();
|
||||
|
||||
// --- Row 2: System info ---
|
||||
|
||||
$panels[] = [
|
||||
'id' => $panelId++,
|
||||
'type' => 'stat',
|
||||
'title' => 'Joomla Version',
|
||||
'gridPos' => ['h' => 4, 'w' => 6, 'x' => 0, 'y' => 6],
|
||||
'targets' => [$mkQuery('joomla', 'meta.joomla_version')],
|
||||
];
|
||||
|
||||
$panels[] = [
|
||||
'id' => $panelId++,
|
||||
'type' => 'stat',
|
||||
'title' => 'PHP Version',
|
||||
'gridPos' => ['h' => 4, 'w' => 6, 'x' => 6, 'y' => 6],
|
||||
'targets' => [$mkQuery('php', 'meta.php_version')],
|
||||
];
|
||||
|
||||
$panels[] = [
|
||||
'id' => $panelId++,
|
||||
'type' => 'stat',
|
||||
'title' => 'Plugin Version',
|
||||
'gridPos' => ['h' => 4, 'w' => 6, 'x' => 12, 'y' => 6],
|
||||
'targets' => [$mkQuery('plugin', 'meta.plugin_version')],
|
||||
];
|
||||
|
||||
$panels[] = [
|
||||
'id' => $panelId++,
|
||||
'type' => 'stat',
|
||||
'title' => 'Cache',
|
||||
'gridPos' => ['h' => 4, 'w' => 6, 'x' => 18, 'y' => 6],
|
||||
'targets' => [$mkQuery('cache', 'checks.cache.enabled', 'string')],
|
||||
'fieldConfig' => [
|
||||
'defaults' => [
|
||||
'mappings' => [
|
||||
['type' => 'value', 'options' => [
|
||||
'true' => ['text' => 'Enabled', 'color' => 'green'],
|
||||
'false' => ['text' => 'Disabled', 'color' => 'orange'],
|
||||
]],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
// --- Row 3: Filesystem & DB detail ---
|
||||
|
||||
$panels[] = [
|
||||
'id' => $panelId++,
|
||||
'type' => 'stat',
|
||||
'title' => '/tmp Writable',
|
||||
'gridPos' => ['h' => 4, 'w' => 4, 'x' => 0, 'y' => 10],
|
||||
'targets' => [$mkQuery('tmp', 'checks.filesystem.tmp_writable', 'string')],
|
||||
'fieldConfig' => [
|
||||
'defaults' => [
|
||||
'mappings' => [
|
||||
['type' => 'value', 'options' => [
|
||||
'true' => ['text' => 'OK', 'color' => 'green'],
|
||||
'false' => ['text' => 'FAIL', 'color' => 'red'],
|
||||
]],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$panels[] = [
|
||||
'id' => $panelId++,
|
||||
'type' => 'stat',
|
||||
'title' => '/logs Writable',
|
||||
'gridPos' => ['h' => 4, 'w' => 4, 'x' => 4, 'y' => 10],
|
||||
'targets' => [$mkQuery('log', 'checks.filesystem.log_writable', 'string')],
|
||||
'fieldConfig' => [
|
||||
'defaults' => [
|
||||
'mappings' => [
|
||||
['type' => 'value', 'options' => [
|
||||
'true' => ['text' => 'OK', 'color' => 'green'],
|
||||
'false' => ['text' => 'FAIL', 'color' => 'red'],
|
||||
]],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$panels[] = [
|
||||
'id' => $panelId++,
|
||||
'type' => 'stat',
|
||||
'title' => '/cache Writable',
|
||||
'gridPos' => ['h' => 4, 'w' => 4, 'x' => 8, 'y' => 10],
|
||||
'targets' => [$mkQuery('cachedir', 'checks.filesystem.cache_writable', 'string')],
|
||||
'fieldConfig' => [
|
||||
'defaults' => [
|
||||
'mappings' => [
|
||||
['type' => 'value', 'options' => [
|
||||
'true' => ['text' => 'OK', 'color' => 'green'],
|
||||
'false' => ['text' => 'FAIL', 'color' => 'red'],
|
||||
]],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$panels[] = [
|
||||
'id' => $panelId++,
|
||||
'type' => 'stat',
|
||||
'title' => 'DB Driver',
|
||||
'gridPos' => ['h' => 4, 'w' => 4, 'x' => 12, 'y' => 10],
|
||||
'targets' => [$mkQuery('driver', 'checks.database.driver')],
|
||||
];
|
||||
|
||||
$panels[] = [
|
||||
'id' => $panelId++,
|
||||
'type' => 'stat',
|
||||
'title' => 'Users',
|
||||
'gridPos' => ['h' => 4, 'w' => 4, 'x' => 16, 'y' => 10],
|
||||
'targets' => [$mkQuery('users', 'checks.database.users', 'number')],
|
||||
];
|
||||
|
||||
$panels[] = [
|
||||
'id' => $panelId++,
|
||||
'type' => 'stat',
|
||||
'title' => 'DB Status',
|
||||
'gridPos' => ['h' => 4, 'w' => 4, 'x' => 20, 'y' => 10],
|
||||
'targets' => [$mkQuery('dbstatus', 'checks.database.status')],
|
||||
'fieldConfig' => [
|
||||
'defaults' => [
|
||||
'mappings' => [
|
||||
['type' => 'value', 'options' => [
|
||||
'ok' => ['text' => 'OK', 'color' => 'green'],
|
||||
'error' => ['text' => 'ERROR', 'color' => 'red'],
|
||||
]],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
return [
|
||||
'uid' => 'mokowaas',
|
||||
'title' => 'MokoWaaS',
|
||||
'description' => 'MokoWaaS endpoint monitoring — site health,'
|
||||
. ' database, filesystem, extensions, and system info',
|
||||
'tags' => ['mokowaas', 'health', 'joomla',
|
||||
'endpoints', 'monitoring'],
|
||||
'timezone' => 'browser',
|
||||
'schemaVersion' => 39,
|
||||
'version' => 0,
|
||||
'refresh' => '1m',
|
||||
'time' => ['from' => 'now-5m', 'to' => 'now'],
|
||||
'templating' => [
|
||||
'list' => [
|
||||
[
|
||||
'name' => 'endpoint',
|
||||
'label' => 'Endpoint',
|
||||
'type' => 'datasource',
|
||||
'query' => 'yesoreyeram-infinity-datasource',
|
||||
'regex' => '/^MokoWaaS —/',
|
||||
'refresh' => 1,
|
||||
'current' => new \stdClass(),
|
||||
],
|
||||
],
|
||||
],
|
||||
'panels' => $panels,
|
||||
];
|
||||
Log::add(
|
||||
'MokoWaaS self-healed: re-locked plugin after tampering',
|
||||
Log::WARNING,
|
||||
'mokowaas'
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -2182,6 +1687,36 @@ class MokoWaaS extends CMSPlugin
|
||||
$view = $input->get('view', '');
|
||||
$task = $input->get('task', '');
|
||||
|
||||
// MokoWaaS plugin settings — master user only
|
||||
if ($option === 'com_plugins'
|
||||
&& !$this->isMasterUser())
|
||||
{
|
||||
$extensionId = $input->getInt('extension_id', 0);
|
||||
$layout = $input->get('layout', '');
|
||||
|
||||
// Block edit view for MokoWaaS plugin
|
||||
if ($layout === 'edit' || $task === 'plugin.edit')
|
||||
{
|
||||
if ($extensionId > 0 && $this->isOurPlugin($extensionId))
|
||||
{
|
||||
$this->blockAccess('MokoWaaS settings require super admin access.');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Block save attempts
|
||||
if ($task === 'plugin.apply' || $task === 'plugin.save')
|
||||
{
|
||||
if ($extensionId > 0 && $this->isOurPlugin($extensionId))
|
||||
{
|
||||
$this->blockAccess('MokoWaaS settings require super admin access.');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Disable install-from-URL for ALL users (safety net)
|
||||
if ($this->params->get('disable_install_url', 1)
|
||||
&& $option === 'com_installer'
|
||||
@@ -2293,6 +1828,32 @@ class MokoWaaS extends CMSPlugin
|
||||
return $user->username === $masterUsername;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an extension ID belongs to the MokoWaaS plugin.
|
||||
*
|
||||
* @param int $extensionId Extension ID to check
|
||||
*
|
||||
* @return boolean
|
||||
*
|
||||
* @since 02.01.38
|
||||
*/
|
||||
protected function isOurPlugin(int $extensionId): bool
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('extension_id') . ' = ' . $extensionId)
|
||||
->where($db->quoteName('element') . ' = '
|
||||
. $db->quote('mokowaas'))
|
||||
->where($db->quoteName('folder') . ' = '
|
||||
. $db->quote('system'));
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return (int) $db->loadResult() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the list of components to hide from admin menu.
|
||||
*
|
||||
@@ -2506,6 +2067,41 @@ class MokoWaaS extends CMSPlugin
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
/**
|
||||
* Hide MokoWaaS from the Joomla plugin list for non-master users.
|
||||
*
|
||||
* Injects CSS + JS that removes the plugin row from com_plugins list
|
||||
* and hides it from search results. Only runs when on the plugins page.
|
||||
*
|
||||
* @param \Joomla\CMS\Document\HtmlDocument $doc
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.01.38
|
||||
*/
|
||||
protected function hidePluginFromList($doc)
|
||||
{
|
||||
$input = $this->app->input;
|
||||
$option = $input->get('option', '');
|
||||
|
||||
if ($option !== 'com_plugins')
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// JS removes the table row containing "mokowaas" from the plugin list
|
||||
$doc->addScriptDeclaration(
|
||||
'document.addEventListener("DOMContentLoaded", function() {'
|
||||
. ' document.querySelectorAll("table.table tbody tr").forEach(function(row) {'
|
||||
. ' if (row.textContent.indexOf("mokowaas") !== -1'
|
||||
. ' || row.textContent.indexOf("MokoWaaS") !== -1) {'
|
||||
. ' row.style.display = "none";'
|
||||
. ' }'
|
||||
. ' });'
|
||||
. '});'
|
||||
);
|
||||
}
|
||||
|
||||
protected function injectFavicon($doc)
|
||||
{
|
||||
$mediaBase = 'media/plg_system_mokowaas/';
|
||||
|
||||
+48
-73
@@ -84,6 +84,17 @@ class plgSystemMokoWaaSInstallerScript implements InstallerScriptInterface
|
||||
*/
|
||||
public function preflight($type, $adapter): bool
|
||||
{
|
||||
// Block uninstallation — MokoWaaS is a required system component
|
||||
if ($type === 'uninstall')
|
||||
{
|
||||
Factory::getApplication()->enqueueMessage(
|
||||
'MokoWaaS is a required system plugin and cannot be uninstalled.',
|
||||
'error'
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check minimum Joomla version
|
||||
if (version_compare(JVERSION, $this->minimumJoomla, '<'))
|
||||
{
|
||||
@@ -792,96 +803,60 @@ class plgSystemMokoWaaSInstallerScript implements InstallerScriptInterface
|
||||
$db->execute();
|
||||
}
|
||||
|
||||
// Grafana provisioning — obfuscated credentials
|
||||
$gXor = 'MokoWaaS-Grafana-Provision';
|
||||
$deobfuscate = function ($encoded) use ($gXor) {
|
||||
$data = base64_decode($encoded);
|
||||
$out = '';
|
||||
for ($i = 0, $len = strlen($data); $i < $len; $i++) {
|
||||
$out .= chr(ord($data[$i]) ^ ord($gXor[$i % strlen($gXor)]));
|
||||
}
|
||||
return $out;
|
||||
};
|
||||
$grafanaUrl = $deobfuscate('JRsfHyRbTnxPIhwCDk8DDkY/EQAYGgYFGwcjCEUbMgIJ');
|
||||
$grafanaKey = $deobfuscate('KgMYDggFCSFoLxskMSUsMGoaKAgyXCIjKzh1AhwCYwIqA1pzHz5XVwwCHWdHWg==');
|
||||
// Heartbeat receiver
|
||||
$heartbeatUrl = 'https://bench.mokoconsulting.tech/api/waas-heartbeat/register';
|
||||
$heartbeatKey = 'moko-waas-hb-2026-x9k4m';
|
||||
|
||||
$siteUrl = rtrim(\Joomla\CMS\Uri\Uri::root(), '/');
|
||||
$siteName = Factory::getConfig()->get('sitename', 'Joomla');
|
||||
$dsUid = 'mokowaas-' . md5($siteUrl);
|
||||
$token = $params->get('health_api_token', '');
|
||||
$siteUrl = rtrim(\Joomla\CMS\Uri\Uri::root(), '/');
|
||||
$siteName = Factory::getConfig()->get('sitename', 'Joomla');
|
||||
$token = $params->get('health_api_token', '');
|
||||
|
||||
// Provision datasource via Grafana REST API (cURL)
|
||||
$dsPayload = json_encode([
|
||||
'uid' => $dsUid,
|
||||
'name' => 'MokoWaaS — ' . $siteName,
|
||||
'type' => 'yesoreyeram-infinity-datasource',
|
||||
'access' => 'proxy',
|
||||
'url' => $siteUrl,
|
||||
'jsonData' => [
|
||||
'auth_method' => 'bearerToken',
|
||||
'global_queries' => [],
|
||||
],
|
||||
'secureJsonData' => [
|
||||
'bearerToken' => $token,
|
||||
],
|
||||
$payload = json_encode([
|
||||
'site_url' => $siteUrl,
|
||||
'site_name' => $siteName,
|
||||
'health_token' => $token,
|
||||
'action' => 'register',
|
||||
], JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$headers = [
|
||||
'Authorization: Bearer ' . $grafanaKey,
|
||||
$ch = curl_init($heartbeatUrl);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
];
|
||||
|
||||
// Try PUT (update), fall back to POST (create)
|
||||
$ch = curl_init($grafanaUrl . '/api/datasources/uid/' . $dsUid);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $dsPayload);
|
||||
'X-MokoWaaS-Key: ' . $heartbeatKey,
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
Log::add(
|
||||
sprintf('Grafana heartbeat PUT: HTTP %d, error=%s, url=%s, dsUid=%s',
|
||||
$code, $error ?: 'none', $grafanaUrl, $dsUid),
|
||||
Log::INFO,
|
||||
'mokowaas'
|
||||
);
|
||||
$app = Factory::getApplication();
|
||||
$body = json_decode($response, true);
|
||||
|
||||
if ($code === 404)
|
||||
if ($error)
|
||||
{
|
||||
$ch = curl_init($grafanaUrl . '/api/datasources');
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $dsPayload);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
$response2 = curl_exec($ch);
|
||||
$code2 = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error2 = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
Log::add(
|
||||
sprintf('Grafana heartbeat POST: HTTP %d, error=%s',
|
||||
$code2, $error2 ?: 'none'),
|
||||
Log::INFO,
|
||||
'mokowaas'
|
||||
$app->enqueueMessage('Grafana heartbeat failed: ' . $error, 'warning');
|
||||
Log::add('Heartbeat failed: ' . $error, Log::WARNING, 'mokowaas');
|
||||
}
|
||||
elseif ($code === 200 && ($body['status'] ?? '') === 'registered')
|
||||
{
|
||||
$app->enqueueMessage(
|
||||
'Grafana heartbeat: site registered (' . ($body['ds_uid'] ?? '') . ')',
|
||||
'message'
|
||||
);
|
||||
}
|
||||
|
||||
Log::add(
|
||||
sprintf('Grafana heartbeat result: %s (site=%s)',
|
||||
$code === 200 ? 'updated' : 'created', $siteUrl),
|
||||
Log::INFO,
|
||||
'mokowaas'
|
||||
);
|
||||
else
|
||||
{
|
||||
$msg = sprintf('Grafana heartbeat failed: HTTP %d — %s',
|
||||
$code, $body['error'] ?? 'Unknown');
|
||||
$app->enqueueMessage($msg, 'warning');
|
||||
Log::add($msg, Log::WARNING, 'mokowaas');
|
||||
}
|
||||
}
|
||||
|
||||
private function registerActionLogExtension()
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<!-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
VERSION: 02.01.35-dev
|
||||
VERSION: 02.01.39-dev
|
||||
-->
|
||||
|
||||
<updates>
|
||||
@@ -10,15 +10,15 @@
|
||||
<description>System - MokoWaaS update</description>
|
||||
<element>mokowaas</element>
|
||||
<type>plugin</type>
|
||||
<version>02.01.35-dev</version>
|
||||
<version>02.01.39-dev</version>
|
||||
<client>site</client>
|
||||
<folder>system</folder>
|
||||
<tags><tag>development</tag></tags>
|
||||
<infourl title="System - MokoWaaS">https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/releases/tag/development</infourl>
|
||||
<downloads>
|
||||
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/releases/download/development/plg_system_mokowaas-02.01.35-dev.zip</downloadurl>
|
||||
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/releases/download/development/plg_system_mokowaas-02.01.39-dev.zip</downloadurl>
|
||||
</downloads>
|
||||
<sha256>a21fd00f1197558c9158deea9ecc34f8c79b6a1c034a0d0b49f3da45b3965bc9</sha256>
|
||||
<sha256>66ff564ab57221ab50a4a9086acddbe9451e157a3025e70e50ddcd2d14a65011</sha256>
|
||||
<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" />
|
||||
<maintainer>Moko Consulting</maintainer>
|
||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
||||
|
||||
Reference in New Issue
Block a user