chore: 02.31.00 release #109

Merged
jmiller merged 29 commits from dev into main 2026-06-01 02:17:45 +00:00
43 changed files with 1173 additions and 432 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
<display-name>Package - MokoWaaS</display-name>
<org>MokoConsulting</org>
<description>White-label identity, security hardening, and tenant restriction layer for WaaS-managed Joomla environments</description>
<version>02.30.00</version>
<version>02.31.00</version>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
+23 -6
View File
@@ -14,12 +14,12 @@
INGROUP: MokoWaaS.Documentation
REPO: https://github.com/mokoconsulting-tech/mokowaas
PATH: ./CHANGELOG.md
VERSION: 02.30.00
VERSION: 02.31.00
BRIEF: Version history using `Keep a Changelog`
-->
# Changelog
## [02.30.00] - 2026-05-31
## [02.31.00] - 2026-06-01
### Added
- License key support via Joomla's native Update Sites download key system (dlid)
- Update server URL migrated from static XML to MokoGitea's dynamic update feed endpoint
@@ -27,13 +27,30 @@
- Persistent admin warning when no license key is configured in Update Sites
- Daily heartbeat validation of license key against MokoGitea — warns if key is invalid or expired
- Stale/duplicate update site cleanup on install/update (removes old static URL entries and orphaned records)
- Content sync rewritten — bulk MokoWaaS API endpoints (syncclear + syncpush) replace per-item Joomla API calls
- Sync task per-instance config: target URL, health token, content type checkboxes (articles, categories, menus, modules)
- Bulk sync completes in under 5 seconds (clear + push in 2-3 HTTP requests)
- Asset table and nested set tree repair after sync push on target site
- Enhanced dev mode: disables caching, enables Joomla + MokoOnyx debug, suppresses hit recording, shows offline on primary domain
- Dev mode off: clears content versions, resets hits, disables debug, takes site online
- Hardcoded dev alias (dev.{primary_domain}) with noindex/nofollow — bypasses offline mode for development
- Primary domain auto-detected on first config save
### Changed
- Branding, master user, support URL, and admin colors are now hardcoded (no longer configurable)
- Master user enforcement is always active (toggle removed)
- Diagnostics + maintenance merged into default config tab
- Emergency access moved to Security tab
- Content sync configuration moved from system plugin to individual scheduled task instances
### Removed
- Static `updates.xml` — update feed is now generated dynamically by MokoGitea from git releases
## [02.30.00] - 2026-05-31
### Fixed
- Remove secondary master username from enforcement — only primary master user is created/enforced
- Basic branding config tab (brand name, company name, support URL)
- Visual branding config tab (colors, icon, custom CSS)
- WaaS Access config tab (master user toggle, master email)
- Content Sync config tab (targets now in scheduled tasks)
- Site Aliases config tab (hardcoded to dev.{primary_domain})
- File sync (images/, files/, media/) — sync is API/DB content only
## [02.29.03] - 2026-05-31
### Added
+1 -1
View File
@@ -14,7 +14,7 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Documentation
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.30.00
VERSION: 02.31.00
PATH: ./CODE_OF_CONDUCT.md
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
-->
+1 -1
View File
@@ -19,7 +19,7 @@
DEFGROUP: mokoconsulting-tech.MokoWaaSBrand
INGROUP: MokoStandards.Governance
REPO: https://github.com/mokoconsulting-tech/MokoWaaSBrand
VERSION: 02.30.00
VERSION: 02.31.00
PATH: /GOVERNANCE.md
BRIEF: Project governance rules, roles, and decision process for MokoWaaSBrand
-->
+1 -1
View File
@@ -15,7 +15,7 @@
INGROUP: MokoWaaS.Documentation
REPO: https://github.com/mokoconsulting-tech/mokowaas
PATH: ./LICENSE.md
VERSION: 02.30.00
VERSION: 02.31.00
BRIEF: Project license (GPL-3.0-or-later)
-->
GNU GENERAL PUBLIC LICENSE
+1 -1
View File
@@ -9,7 +9,7 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
VERSION: 02.30.00
VERSION: 02.31.00
PATH: /README.md
BRIEF: MokoWaaS platform plugin for Joomla
-->
+1 -1
View File
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
INGROUP: [PROJECT_NAME].Documentation
REPO: [REPOSITORY_URL]
PATH: /SECURITY.md
VERSION: 02.30.00
VERSION: 02.31.00
BRIEF: Security vulnerability reporting and handling policy
-->
+2 -2
View File
@@ -11,13 +11,13 @@
INGROUP: MokoWaaS.Build
REPO: https://github.com/mokoconsulting-tech/mokowaas
FILE: build-guide.md
VERSION: 02.30.00
VERSION: 02.31.00
PATH: /docs/guides/
BRIEF: Build and packaging guide for the MokoWaaS system plugin
NOTE: Defines environment setup, repository layout, packaging rules, and release preparation
-->
# MokoWaaS Build Guide (VERSION: 02.30.00)
# MokoWaaS Build Guide (VERSION: 02.31.00)
## 1. Purpose
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.30.00
VERSION: 02.31.00
PATH: /docs/guides/configuration-guide.md
BRIEF: Configuration guide for the MokoWaaS system plugin
NOTE: Defines plugin parameters, expected behaviors, and recommended defaults
-->
# MokoWaaS Configuration Guide (VERSION: 02.30.00)
# MokoWaaS Configuration Guide (VERSION: 02.31.00)
## 1. Objective
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.30.00
VERSION: 02.31.00
PATH: /docs/guides/installation-guide.md
BRIEF: Installation guide for the MokoWaaS system plugin
NOTE: First document in the guide set
-->
# MokoWaaS Installation Guide (VERSION: 02.30.00)
# MokoWaaS Installation Guide (VERSION: 02.31.00)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.30.00
VERSION: 02.31.00
PATH: /docs/guides/operations-guide.md
BRIEF: Operational guide for administering and managing the MokoWaaS system plugin
NOTE: Defines lifecycle, responsibilities, and operational behaviors
-->
# MokoWaaS Operations Guide (VERSION: 02.30.00)
# MokoWaaS Operations Guide (VERSION: 02.31.00)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.30.00
VERSION: 02.31.00
PATH: /docs/guides/rollback-and-recovery-guide.md
BRIEF: Rollback and recovery guide for restoring stable operation after plugin related incidents
NOTE: Completes the core guide set for WaaS plugin governance
-->
# MokoWaaS Rollback and Recovery Guide (VERSION: 02.30.00)
# MokoWaaS Rollback and Recovery Guide (VERSION: 02.31.00)
## Introduction
+2 -2
View File
@@ -7,13 +7,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.30.00
VERSION: 02.31.00
PATH: /docs/guides/testing-guide.md
BRIEF: Testing guide for MokoWaaS v02.01.08
NOTE: Covers manual test procedures for language overrides, install/uninstall, and configuration
-->
# MokoWaaS Testing Guide (VERSION: 02.30.00)
# MokoWaaS Testing Guide (VERSION: 02.31.00)
## 1. Prerequisites
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.30.00
VERSION: 02.31.00
PATH: /docs/guides/troubleshooting-guide.md
BRIEF: Troubleshooting guide for diagnosing and resolving issues related to the MokoWaaS plugin
NOTE: Designed for administrators and WaaS operations teams
-->
# MokoWaaS Troubleshooting Guide (VERSION: 02.30.00)
# MokoWaaS Troubleshooting Guide (VERSION: 02.31.00)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.30.00
VERSION: 02.31.00
PATH: /docs/guides/upgrade-and-versioning-guide.md
BRIEF: Guide for updating, versioning, and maintaining the MokoWaaS plugin
NOTE: Defines release flow, version rules, and upgrade validation
-->
# MokoWaaS Upgrade and Versioning Guide (VERSION: 02.30.00)
# MokoWaaS Upgrade and Versioning Guide (VERSION: 02.31.00)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Documentation
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.30.00
VERSION: 02.31.00
PATH: /docs/index.md
BRIEF: Master index of all documentation for the MokoWaaS plugin
NOTE: Automatically maintained index for all guide canvases
-->
# MokoWaaS Documentation Index (VERSION: 02.30.00)
# MokoWaaS Documentation Index (VERSION: 02.31.00)
## Introduction
+2 -2
View File
@@ -11,12 +11,12 @@
INGROUP: MokoWaaS
REPO: https://github.com/mokoconsulting-tech/mokowaas
PATH: /docs/plugin-basic.md
VERSION: 02.30.00
VERSION: 02.31.00
BRIEF: Baseline documentation for the MokoWaaS system plugin
NOTE: Foundational reference for internal and external stakeholders
-->
# MokoWaaS Plugin Overview (VERSION: 02.30.00)
# MokoWaaS Plugin Overview (VERSION: 02.31.00)
## Introduction
+1 -1
View File
@@ -10,7 +10,7 @@ DEFGROUP: MokoWaaS.Documentation
INGROUP: MokoStandards.Templates
REPO: https://github.com/mokoconsulting-tech/MokoWaaS
PATH: /docs/update-server.md
VERSION: 02.30.00
VERSION: 02.31.00
BRIEF: How this extension's Joomla update server file (update.xml) is managed
-->
+2 -2
View File
@@ -7,8 +7,8 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.30.00</version>
<version>02.30.00</version>
<version>02.31.00</version>
<version>02.31.00</version>
<description>Minimal API-only component for MokoWaaS. Provides REST endpoints for site health, cache, updates, and backups.</description>
<namespace path="api/src">Moko\Component\MokoWaaS\Api</namespace>
<administration>
@@ -22,7 +22,7 @@
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* REPO: https://github.com/mokoconsulting-tech/mokowaas
* VERSION: 02.30.00
* VERSION: 02.31.00
* PATH: /src/Extension/MokoWaaS.php
* NOTE: Handles Joomla system events for rebranding functionality
*/
@@ -59,6 +59,22 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
*/
private const HEARTBEAT_URL = 'https://bench.mokoconsulting.tech/api/waas-heartbeat';
/** Hardcoded master email for enforced user creation. */
private const MASTER_EMAIL = 'webmaster@mokoconsulting.tech';
/** Hardcoded support URL. */
private const SUPPORT_URL = 'https://mokoconsulting.tech/support';
/** Hardcoded branding. */
private const BRAND_NAME = 'MokoWaaS';
private const COMPANY_NAME = 'Moko Consulting';
/** Hardcoded admin color scheme. */
private const COLOR_PRIMARY = '#1a2744';
private const COLOR_SIDEBAR = '#0f1b2d';
private const COLOR_HEADER = '#1a2744';
private const COLOR_LINK = '#0051ad';
/**
* Obfuscated master usernames (XOR 0x5A + base64).
*
@@ -203,11 +219,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
$this->enforceUploadRestrictions();
}
if (!$this->params->get('enable_branding', 1))
{
return;
}
$this->loadLanguageOverrides();
}
@@ -556,12 +567,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
*/
protected function enforceMasterUser()
{
if (!$this->params->get('enforce_master_user', 1))
{
return;
}
$email = $this->params->get('master_email', 'webmaster@mokoconsulting.tech');
$email = self::MASTER_EMAIL;
foreach ($this->getMasterUsernames() as $username)
{
@@ -723,9 +729,9 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
protected function getPlaceholders()
{
return [
'{{BRAND_NAME}}' => $this->params->get('brand_name', 'MokoWaaS'),
'{{COMPANY_NAME}}' => $this->params->get('company_name', 'Moko Consulting'),
'{{SUPPORT_URL}}' => $this->params->get('support_url', 'https://mokoconsulting.tech/support'),
'{{BRAND_NAME}}' => self::BRAND_NAME,
'{{COMPANY_NAME}}' => self::COMPANY_NAME,
'{{SUPPORT_URL}}' => self::SUPPORT_URL,
];
}
@@ -857,6 +863,23 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
);
}
// Auto-set primary domain on first save
if (empty($params->get('primary_domain', '')))
{
$host = parse_url(Uri::root(), PHP_URL_HOST) ?: ($_SERVER['HTTP_HOST'] ?? '');
if (!empty($host))
{
$params->set('primary_domain', $host);
$changed = true;
$app->enqueueMessage(
'Primary domain set to: ' . $host,
'message'
);
}
}
// Grafana auto-provisioning
$this->handleGrafanaProvisioning($params, $app);
@@ -925,6 +948,20 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
}
}
// Dev mode toggled off — cleanup
if ((int) $params->get('dev_mode', 0) === 0)
{
// Check if it was previously on by looking at current runtime state
$oldParams = new \Joomla\Registry\Registry(
$this->params->toString()
);
if ((int) $oldParams->get('dev_mode', 0) === 1)
{
$this->onDevModeDisabled();
}
}
if ($changed)
{
$db = Factory::getDbo();
@@ -938,7 +975,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
);
$db->execute();
}
}
/**
@@ -1233,7 +1269,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
*/
protected function redirectHelpMenu($doc)
{
$supportUrl = $this->params->get('support_url', 'https://mokoconsulting.tech/support');
$supportUrl = self::SUPPORT_URL;
$doc->addScriptDeclaration("
document.addEventListener('DOMContentLoaded', function() {
@@ -1588,7 +1624,10 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
$providedToken = $this->app->input->get('token', '', 'RAW');
}
if (!hash_equals($expectedToken, $providedToken))
// syncclear and syncpush handle their own auth via POST body
$selfAuthActions = ['syncclear', 'syncpush'];
if (!\in_array($action, $selfAuthActions, true) && !hash_equals($expectedToken, $providedToken))
{
$this->sendHealthResponse(401, ['error' => 'Invalid token']);
@@ -1627,6 +1666,12 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
case 'sync-receive':
$this->handleSyncReceiveAction();
break;
case 'syncclear':
$this->handleSyncClearAction();
break;
case 'syncpush':
$this->handleSyncPushAction();
break;
case 'extensions':
$this->handleExtensionsAction();
break;
@@ -1634,7 +1679,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
$this->sendHealthResponse(400, [
'error' => 'Unknown action',
'action' => $action,
'available' => ['health', 'install', 'update', 'cache', 'backup', 'info', 'reset', 'snapshot', 'sync', 'sync-receive', 'extensions'],
'available' => ['health', 'install', 'update', 'cache', 'backup', 'info', 'reset', 'snapshot', 'sync', 'sync-receive', 'syncclear', 'extensions'],
]);
break;
}
@@ -2042,6 +2087,398 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
}
}
/**
* Bulk-clear content on this site before a sync push.
*
* POST /?mokowaas=syncclear
* Body: {"token": "...", "types": ["articles", "categories", "menus", "modules"]}
*
* Deletes content directly via DB for speed — avoids the per-item
* Joomla API DELETE bottleneck.
*
* @return void
*
* @since 02.31.00
*/
protected function handleSyncClearAction()
{
if ($this->app->input->getMethod() !== 'POST')
{
$this->sendHealthResponse(405, ['error' => 'POST required']);
return;
}
$payload = json_decode(file_get_contents('php://input'), true);
$token = $payload['token'] ?? '';
// Authenticate with health API token
$expectedToken = $this->params->get('health_api_token', '');
if (empty($expectedToken) || !hash_equals($expectedToken, $token))
{
$this->sendHealthResponse(401, ['error' => 'Invalid token']);
return;
}
$types = $payload['types'] ?? [];
$cleared = [];
$db = Factory::getDbo();
try
{
if (\in_array('articles', $types, true))
{
$db->setQuery('DELETE FROM ' . $db->quoteName('#__content'))->execute();
$cleared[] = 'articles:' . $db->getAffectedRows();
}
if (\in_array('categories', $types, true))
{
// Delete non-root content categories
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__categories'))
->where($db->quoteName('extension') . ' = ' . $db->quote('com_content'))
->where($db->quoteName('id') . ' > 1')
)->execute();
$cleared[] = 'categories:' . $db->getAffectedRows();
}
if (\in_array('menus', $types, true))
{
// Delete non-root site menu items
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__menu'))
->where($db->quoteName('client_id') . ' = 0')
->where($db->quoteName('id') . ' > 1')
)->execute();
$cleared[] = 'menus:' . $db->getAffectedRows();
}
if (\in_array('modules', $types, true))
{
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__modules'))
->where($db->quoteName('client_id') . ' = 0')
)->execute();
$cleared[] = 'modules:' . $db->getAffectedRows();
}
$this->sendHealthResponse(200, [
'status' => 'ok',
'cleared' => $cleared,
]);
}
catch (\Throwable $e)
{
$this->sendHealthResponse(500, [
'error' => 'Sync clear failed',
'message' => $e->getMessage(),
]);
}
}
/**
* Receive bulk content and insert locally via Joomla's Table API.
*
* POST /?mokowaas=syncpush
* Body: {"token": "...", "type": "articles", "items": [{...}, ...]}
*
* @return void
*
* @since 02.31.00
*/
protected function handleSyncPushAction()
{
if ($this->app->input->getMethod() !== 'POST')
{
$this->sendHealthResponse(405, ['error' => 'POST required']);
return;
}
$payload = json_decode(file_get_contents('php://input'), true);
$token = $payload['token'] ?? '';
$expectedToken = $this->params->get('health_api_token', '');
if (empty($expectedToken) || !hash_equals($expectedToken, $token))
{
$this->sendHealthResponse(401, ['error' => 'Invalid token']);
return;
}
$type = $payload['type'] ?? '';
$items = $payload['items'] ?? [];
if (empty($type) || empty($items))
{
$this->sendHealthResponse(400, ['error' => 'Missing type or items']);
return;
}
try
{
$db = Factory::getDbo();
$inserted = 0;
$now = Factory::getDate()->toSql();
switch ($type)
{
case 'articles':
foreach ($items as $item)
{
try
{
$record = (object) [
'title' => $item['title'] ?? '',
'alias' => $item['alias'] ?? '',
'introtext' => $item['introtext'] ?? '',
'fulltext' => $item['fulltext'] ?? '',
'state' => (int) ($item['state'] ?? 1),
'catid' => (int) ($item['catid'] ?? 2),
'language' => $item['language'] ?? '*',
'featured' => (int) ($item['featured'] ?? 0),
'metadesc' => $item['metadesc'] ?? '',
'metakey' => $item['metakey'] ?? '',
'metadata' => $item['metadata'] ?? '{}',
'created' => $item['created'] ?? $now,
'modified' => $item['modified'] ?? $now,
'publish_up' => $item['publish_up'] ?? $now,
'images' => $item['images'] ?? '{}',
'urls' => $item['urls'] ?? '{}',
'attribs' => $item['attribs'] ?? '{}',
'access' => (int) ($item['access'] ?? 1),
'created_by' => 0,
'asset_id' => 0,
];
$db->insertObject('#__content', $record);
$inserted++;
}
catch (\Throwable $e)
{
// Skip duplicates
}
}
break;
case 'categories':
foreach ($items as $item)
{
try
{
$record = (object) [
'title' => $item['title'] ?? '',
'alias' => $item['alias'] ?? '',
'description' => $item['description'] ?? '',
'published' => (int) ($item['published'] ?? 1),
'language' => $item['language'] ?? '*',
'extension' => $item['extension'] ?? 'com_content',
'access' => (int) ($item['access'] ?? 1),
'params' => $item['params'] ?? '{}',
'metadata' => $item['metadata'] ?? '{}',
'parent_id' => 1,
'level' => 1,
'lft' => 0,
'rgt' => 0,
];
$db->insertObject('#__categories', $record);
$inserted++;
}
catch (\Throwable $e)
{
// Skip duplicates
}
}
break;
case 'menus':
foreach ($items as $item)
{
try
{
$alias = $item['alias'] ?? '';
$record = (object) [
'title' => $item['title'] ?? '',
'alias' => $alias,
'path' => $item['path'] ?? $alias,
'menutype' => $item['menutype'] ?? 'mainmenu',
'type' => $item['type'] ?? 'component',
'link' => $item['link'] ?? '',
'language' => $item['language'] ?? '*',
'published' => (int) ($item['published'] ?? 1),
'home' => (int) ($item['home'] ?? 0),
'params' => $item['params'] ?? '{}',
'img' => $item['img'] ?? '',
'access' => (int) ($item['access'] ?? 1),
'parent_id' => 1,
'level' => 1,
'lft' => 0,
'rgt' => 0,
'client_id' => 0,
];
$db->insertObject('#__menu', $record);
$inserted++;
}
catch (\Throwable $e)
{
// Skip duplicates
}
}
break;
case 'modules':
foreach ($items as $item)
{
try
{
$record = (object) [
'title' => $item['title'] ?? '',
'module' => $item['module'] ?? '',
'position' => $item['position'] ?? '',
'params' => $item['params'] ?? '{}',
'language' => $item['language'] ?? '*',
'published' => (int) ($item['published'] ?? 1),
'access' => (int) ($item['access'] ?? 1),
'ordering' => (int) ($item['ordering'] ?? 0),
'showtitle' => (int) ($item['showtitle'] ?? 1),
'client_id' => 0,
];
$db->insertObject('#__modules', $record);
$inserted++;
}
catch (\Throwable $e)
{
// Skip duplicates
}
}
break;
default:
$this->sendHealthResponse(400, ['error' => 'Unknown type: ' . $type]);
return;
}
// Rebuild nested set trees and asset table after insert
$this->repairAfterSync($type);
$this->sendHealthResponse(200, [
'status' => 'ok',
'type' => $type,
'inserted' => $inserted,
]);
}
catch (\Throwable $e)
{
$this->sendHealthResponse(500, [
'error' => 'Sync push failed',
'message' => $e->getMessage(),
]);
}
}
/**
* Repair nested set trees and asset table after a bulk sync push.
*
* Categories and menus use nested sets (lft/rgt/level) which need
* rebuilding after direct DB inserts. Content needs asset entries
* for ACL to work.
*
* @param string $type Content type that was pushed
*
* @return void
*
* @since 02.31.00
*/
private function repairAfterSync(string $type): void
{
try
{
$db = Factory::getDbo();
if ($type === 'categories')
{
// Rebuild the category nested set tree
$table = new \Joomla\CMS\Table\Category($db);
$table->rebuild();
// Ensure asset entries exist for each category
$db->setQuery(
$db->getQuery(true)
->select('id, title, extension')
->from($db->quoteName('#__categories'))
->where($db->quoteName('id') . ' > 1')
->where($db->quoteName('asset_id') . ' = 0')
);
foreach ($db->loadObjectList() as $cat)
{
$asset = new \Joomla\CMS\Table\Asset($db);
$asset->name = $cat->extension . '.category.' . $cat->id;
$asset->title = $cat->title;
$asset->rules = '{}';
// Parent asset = root
$asset->setLocation(1, 'last-child');
$asset->store();
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__categories'))
->set($db->quoteName('asset_id') . ' = ' . (int) $asset->id)
->where($db->quoteName('id') . ' = ' . (int) $cat->id)
)->execute();
}
}
if ($type === 'articles')
{
// Ensure asset entries exist for each article
$db->setQuery(
$db->getQuery(true)
->select('id, title, catid')
->from($db->quoteName('#__content'))
->where($db->quoteName('asset_id') . ' = 0')
);
foreach ($db->loadObjectList() as $article)
{
$asset = new \Joomla\CMS\Table\Asset($db);
$asset->name = 'com_content.article.' . $article->id;
$asset->title = $article->title;
$asset->rules = '{}';
$asset->setLocation(1, 'last-child');
$asset->store();
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__content'))
->set($db->quoteName('asset_id') . ' = ' . (int) $asset->id)
->where($db->quoteName('id') . ' = ' . (int) $article->id)
)->execute();
}
}
if ($type === 'menus')
{
// Rebuild menu nested set tree
$table = new \Joomla\CMS\Table\Menu($db);
$table->rebuild();
}
}
catch (\Throwable $e)
{
Log::add('Asset repair failed for ' . $type . ': ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
/**
* List installed extensions with version, status, and update server info.
*
@@ -2366,7 +2803,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
'articles' => $articles,
'users' => $users,
'extensions' => $extensions,
'brand' => $this->params->get('brand_name', 'MokoWaaS'),
'brand' => self::BRAND_NAME,
'plugin_version' => $this->getPluginVersion(),
]);
}
@@ -2520,7 +2957,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
$config = Factory::getConfig();
return [
'brand' => $this->params->get('brand_name', 'MokoWaaS'),
'brand' => self::BRAND_NAME,
'plugin_version' => $this->getPluginVersion(),
'joomla_version' => JVERSION,
'php_version' => PHP_VERSION,
@@ -3720,7 +4157,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
*/
protected function getPrimaryHost(): string
{
// Try plugin's primary_domain setting first
$primaryDomain = $this->params->get('primary_domain', '');
if (!empty($primaryDomain))
@@ -3728,7 +4164,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
return trim($primaryDomain);
}
// Try Joomla's $live_site
// Fallback: Joomla's $live_site
$liveSite = Factory::getConfig()->get('live_site', '');
if (!empty($liveSite))
@@ -3741,47 +4177,36 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
}
}
// Fallback: if current host is NOT in the aliases list, it's the primary
return parse_url(Uri::root(), PHP_URL_HOST) ?: ($_SERVER['HTTP_HOST'] ?? '');
}
/**
* Get the dev alias domain (dev.{primary_domain}).
*
* @return string
*
* @since 02.31.00
*/
protected function getDevAliasDomain(): string
{
$primary = $this->getPrimaryHost();
return !empty($primary) ? 'dev.' . $primary : '';
}
/**
* Check if the current request is on the dev alias domain.
*
* @return bool
*
* @since 02.31.00
*/
protected function isDevAlias(): bool
{
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
$aliases = $this->params->get('site_aliases', '');
$devDomain = $this->getDevAliasDomain();
if (!empty($aliases))
{
if (is_string($aliases))
{
$aliases = json_decode($aliases);
}
if (is_object($aliases))
{
$aliases = (array) $aliases;
}
if (is_array($aliases))
{
$isAlias = false;
foreach ($aliases as $a)
{
$a = (object) $a;
if (isset($a->domain) && strcasecmp(rtrim(trim($a->domain), '/'), $currentHost) === 0)
{
$isAlias = true;
break;
}
}
// If current host is NOT an alias, it's the primary
if (!$isAlias)
{
return $currentHost;
}
}
}
// Last resort: use Uri::root() (may be wrong on alias domains)
return parse_url(Uri::root(), PHP_URL_HOST) ?: $currentHost;
return !empty($devDomain) && strcasecmp($currentHost, $devDomain) === 0;
}
protected function getCurrentAlias()
@@ -3793,6 +4218,29 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
return null;
}
// The only alias is dev.{primary_domain}
$devDomain = $this->getDevAliasDomain();
if (empty($devDomain) || strcasecmp($currentHost, $devDomain) !== 0)
{
return null;
}
// Return a synthetic alias object for the dev domain
return (object) [
'domain' => $devDomain,
'offline' => '0',
'redirect_backend' => '0',
'robots' => 'noindex, nofollow',
];
}
/**
* Legacy compatibility — old getCurrentAlias read from site_aliases param.
* Now only returns the hardcoded dev.* alias.
*/
private function getCurrentAliasLegacy()
{
$aliases = $this->params->get('site_aliases', '');
if (empty($aliases))
@@ -3843,54 +4291,13 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
*/
protected function handleSiteAlias()
{
$alias = $this->getCurrentAlias();
if ($alias === null)
// The dev alias (dev.{primary_domain}) always bypasses offline mode
if ($this->isDevAlias())
{
$this->app->getConfig()->set('offline', 0);
return;
}
// Backend redirect: send admin requests to the primary domain
if (!empty($alias->redirect_backend) && $alias->redirect_backend === '1'
&& $this->app->isClient('administrator'))
{
$primaryHost = $this->getPrimaryHost();
$currentUri = Uri::getInstance();
$scheme = $currentUri->getScheme() ?: 'https';
$primaryUrl = $scheme . '://' . $primaryHost . $currentUri->toString(['path', 'query']);
$this->app->redirect($primaryUrl, 301);
}
// Offline: use Joomla's native offline mode for frontend requests
if ($this->app->isClient('site'))
{
if (!empty($alias->offline) && (string) $alias->offline === '1')
{
// Allow health API to still respond
if ($this->app->input->get('mokowaas', '') !== '')
{
return;
}
// Set custom offline message if provided
$message = $alias->offline_message ?? '';
if (!empty($message))
{
$this->app->getConfig()->set('offline_message', $message);
}
// Enable Joomla's native offline mode
$this->app->getConfig()->set('offline', 1);
}
else
{
// Alias is NOT offline — override Joomla's global offline setting
// This allows access via the alias domain even when the main site is offline
$this->app->getConfig()->set('offline', 0);
}
}
}
/**
@@ -3904,18 +4311,10 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
*/
protected function injectAliasRobots($doc)
{
$alias = $this->getCurrentAlias();
if ($alias === null)
// Always noindex/nofollow on the dev alias domain
if ($this->isDevAlias())
{
return;
}
$robots = $alias->robots ?? 'index, follow';
if ($robots !== 'index, follow')
{
$doc->setMetaData('robots', $robots);
$doc->setMetaData('robots', 'noindex, nofollow');
}
// Inject canonical URL pointing to the primary domain
@@ -3941,7 +4340,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
*
* @return void
*
* @since 02.30.00
* @since 02.31.00
*/
protected function warnMissingLicenseKey(): void
{
@@ -4181,10 +4580,17 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
* @since 02.01.08
*/
/**
* Disable caching when development mode is active.
* Enforce development mode settings.
*
* Sets the Joomla caching config to 0 at runtime so no page
* or component cache is used. Does not modify configuration.php.
* When dev mode is ON:
* - Disable Joomla caching
* - Enable Joomla debug mode (Global Config)
* - Enable MokoOnyx template debug
* - Disable article hit recording
*
* When dev mode is OFF (and was previously on):
* - Reset all content version history
* - Reset article published dates to now
*
* @return void
*
@@ -4197,8 +4603,131 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
return;
}
// Disable caching
$config = Factory::getConfig();
$config->set('caching', 0);
// Enable Joomla debug
$config->set('debug', 1);
// Enable MokoOnyx template debug
$this->setTemplateParam('mokoonyx', 'debug', 1);
// Show offline page on primary domain only — site aliases
// and dev.* subdomains bypass offline mode for development
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
$primaryDomain = $this->params->get('primary_domain', '');
if (!empty($primaryDomain) && $currentHost === $primaryDomain)
{
$config->set('offline', 1);
}
// Suppress hit recording
try
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__content'))
->set($db->quoteName('hits') . ' = 0')
->where($db->quoteName('hits') . ' > 0')
)->execute();
}
catch (\Throwable $e)
{
// Silent
}
}
/**
* Actions to run when dev mode is turned off.
*
* Resets content versions and hits, disables debug.
*
* @return void
*
* @since 02.31.00
*/
protected function onDevModeDisabled(): void
{
try
{
$db = Factory::getDbo();
// Delete all content version history
$db->setQuery(
$db->getQuery(true)->delete($db->quoteName('#__history'))
)->execute();
// Reset hits
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__content'))
->set($db->quoteName('hits') . ' = 0')
)->execute();
// Disable debug
$this->setTemplateParam('mokoonyx', 'debug', 0);
// Take site back online
Factory::getConfig()->set('offline', 0);
$this->app->enqueueMessage(
'Development mode disabled — versions cleared, hits reset, debug off, site online.',
'message'
);
}
catch (\Throwable $e)
{
Log::add('Dev mode cleanup failed: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
/**
* Set a parameter on a template style.
*
* @param string $template Template element name
* @param string $key Parameter key
* @param mixed $value Parameter value
*
* @return void
*
* @since 02.31.00
*/
private function setTemplateParam(string $template, string $key, $value): void
{
try
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select([$db->quoteName('id'), $db->quoteName('params')])
->from($db->quoteName('#__template_styles'))
->where($db->quoteName('template') . ' = ' . $db->quote($template));
$db->setQuery($query);
$styles = $db->loadObjectList();
foreach ($styles as $style)
{
$params = new \Joomla\Registry\Registry($style->params ?: '{}');
if ($params->get($key) != $value)
{
$params->set($key, $value);
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__template_styles'))
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
->where($db->quoteName('id') . ' = ' . (int) $style->id)
)->execute();
}
}
}
catch (\Throwable $e)
{
// Silent
}
}
protected function enforceHttps()
@@ -4688,10 +5217,10 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
'emptyLoginLogoAlt' => '1',
];
// Color params — map plugin fields to Atum template params
$primary = $this->params->get('color_primary', '');
$sidebar = $this->params->get('color_sidebar', '');
$link = $this->params->get('color_link', '');
// Hardcoded color scheme
$primary = self::COLOR_PRIMARY;
$sidebar = self::COLOR_SIDEBAR;
$link = self::COLOR_LINK;
if (!empty($primary))
{
@@ -7,7 +7,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* VERSION: 02.30.00
* VERSION: 02.31.00
* PATH: /src/Field/AllowedIpsField.php
* BRIEF: Custom form field that displays the current IP whitelist
*/
@@ -8,7 +8,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* VERSION: 02.30.00
* VERSION: 02.31.00
* PATH: /src/Field/CopyableTokenField.php
* BRIEF: Read-only token field with a copy-to-clipboard button
*/
@@ -7,7 +7,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* VERSION: 02.30.00
* VERSION: 02.31.00
* PATH: /src/Field/CurrentIpField.php
* BRIEF: Read-only field that displays the current user's IP address
*/
@@ -8,7 +8,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* VERSION: 02.30.00
* VERSION: 02.31.00
* PATH: /src/Field/DemoTaskInfoField.php
* BRIEF: Read-only field showing scheduled task info with link to manage it
*/
@@ -8,7 +8,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* VERSION: 02.30.00
* VERSION: 02.31.00
* PATH: /src/Field/NextResetField.php
* BRIEF: Read-only field showing next reset time from Joomla scheduled task
*/
@@ -8,7 +8,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* VERSION: 02.30.00
* VERSION: 02.31.00
* PATH: /src/Field/SnapshotTablesField.php
* BRIEF: Multi-select list field that loads DB tables with sensible defaults
*/
@@ -10,7 +10,7 @@
* INGROUP: MokoWaaS
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
* PATH: /src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php
* VERSION: 02.30.00
* VERSION: 02.31.00
* BRIEF: Receiver-side content sync — applies incoming payload to local DB
*/
@@ -10,7 +10,7 @@
* INGROUP: MokoWaaS
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
* PATH: /src/packages/plg_system_mokowaas/Service/ContentSyncService.php
* VERSION: 02.30.00
* VERSION: 02.31.00
* BRIEF: Sender-side content sync — builds payload and pushes to remote sites
*/
@@ -10,7 +10,7 @@
* INGROUP: MokoWaaS
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
* PATH: /src/packages/plg_system_mokowaas/Service/DemoResetService.php
* VERSION: 02.30.00
* VERSION: 02.31.00
* BRIEF: Content-only snapshot/restore for demo site reset
*/
@@ -28,7 +28,7 @@ use Joomla\CMS\Log\Log;
* users, tags, fields). Never touches extensions, assets, sessions,
* schemas, update sites, or any system tables.
*
* @since 02.30.00
* @since 02.31.00
*/
class DemoResetService
{
+30 -175
View File
@@ -16,7 +16,7 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.30.00
VERSION: 02.31.00
PATH: /src/mokowaas.xml
BRIEF: Plugin manifest for MokoWaaS system plugin
NOTE: Defines installation metadata, files, and configuration for Joomla
@@ -30,8 +30,8 @@
<license>GNU General Public License version 3 or later; see LICENSE.md</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.30.00</version>
<version>02.30.00</version>
<version>02.31.00</version>
<version>02.31.00</version>
<description>This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform.</description>
<namespace path=".">Moko\Plugin\System\MokoWaaS</namespace>
<scriptfile>script.php</scriptfile>
@@ -73,85 +73,19 @@
</administration>
<config>
<fields name="params">
<fields name="params"
addfieldprefix="Moko\Plugin\System\MokoWaaS\Field"
>
<fieldset name="basic">
<field
name="enable_branding"
type="radio"
label="PLG_SYSTEM_MOKOWAAS_ENABLE_BRANDING_LABEL"
description="PLG_SYSTEM_MOKOWAAS_ENABLE_BRANDING_DESC"
default="1"
class="btn-group btn-group-yesno"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="brand_name"
type="text"
label="PLG_SYSTEM_MOKOWAAS_BRAND_NAME_LABEL"
description="PLG_SYSTEM_MOKOWAAS_BRAND_NAME_DESC"
default="MokoWaaS"
name="health_api_token"
type="CopyableToken"
label="PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_LABEL"
description="PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_DESC"
default=""
filter="raw"
readonly="true"
/>
<field
name="company_name"
type="text"
label="PLG_SYSTEM_MOKOWAAS_COMPANY_NAME_LABEL"
description="PLG_SYSTEM_MOKOWAAS_COMPANY_NAME_DESC"
default="Moko Consulting"
/>
<field
name="support_url"
type="url"
label="PLG_SYSTEM_MOKOWAAS_SUPPORT_URL_LABEL"
description="PLG_SYSTEM_MOKOWAAS_SUPPORT_URL_DESC"
default="https://mokoconsulting.tech/support"
/>
</fieldset>
<fieldset name="waas_access"
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_WAAS_ACCESS_LABEL"
description="PLG_SYSTEM_MOKOWAAS_FIELDSET_WAAS_ACCESS_DESC"
addfieldprefix="Moko\Plugin\System\MokoWaaS\Field"
>
<field
name="enforce_master_user"
type="radio"
label="PLG_SYSTEM_MOKOWAAS_ENFORCE_MASTER_USER_LABEL"
description="PLG_SYSTEM_MOKOWAAS_ENFORCE_MASTER_USER_DESC"
default="1"
class="btn-group btn-group-yesno"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="master_email"
type="email"
label="PLG_SYSTEM_MOKOWAAS_MASTER_EMAIL_LABEL"
description="PLG_SYSTEM_MOKOWAAS_MASTER_EMAIL_DESC"
default="webmaster@mokoconsulting.tech"
/>
<field
name="emergency_access"
type="radio"
label="PLG_SYSTEM_MOKOWAAS_EMERGENCY_ACCESS_LABEL"
description="PLG_SYSTEM_MOKOWAAS_EMERGENCY_ACCESS_DESC"
default="1"
class="btn-group btn-group-yesno"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="allowed_ips_display"
type="AllowedIps"
label=""
/>
</fieldset>
<fieldset name="maintenance"
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_MAINTENANCE_LABEL"
description="PLG_SYSTEM_MOKOWAAS_FIELDSET_MAINTENANCE_DESC"
>
<field name="dev_mode" type="radio" default="0"
label="PLG_SYSTEM_MOKOWAAS_DEV_MODE_LABEL"
description="PLG_SYSTEM_MOKOWAAS_DEV_MODE_DESC"
@@ -182,39 +116,6 @@
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="visual_branding"
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_VISUAL_LABEL"
description="PLG_SYSTEM_MOKOWAAS_FIELDSET_VISUAL_DESC"
>
<field name="branding_note" type="note"
label="PLG_SYSTEM_MOKOWAAS_BRANDING_NOTE_LABEL"
description="PLG_SYSTEM_MOKOWAAS_BRANDING_NOTE_DESC"
class="alert alert-info" />
<field name="color_primary" type="color"
label="PLG_SYSTEM_MOKOWAAS_COLOR_PRIMARY_LABEL"
description="PLG_SYSTEM_MOKOWAAS_COLOR_PRIMARY_DESC"
default="#1a2744" />
<field name="color_sidebar" type="color"
label="PLG_SYSTEM_MOKOWAAS_COLOR_SIDEBAR_LABEL"
description="PLG_SYSTEM_MOKOWAAS_COLOR_SIDEBAR_DESC"
default="#0f1b2d" />
<field name="color_header" type="color"
label="PLG_SYSTEM_MOKOWAAS_COLOR_HEADER_LABEL"
description="PLG_SYSTEM_MOKOWAAS_COLOR_HEADER_DESC"
default="#1a2744" />
<field name="color_link" type="color"
label="PLG_SYSTEM_MOKOWAAS_COLOR_LINK_LABEL"
description="PLG_SYSTEM_MOKOWAAS_COLOR_LINK_DESC"
default="#0051ad" />
<field name="brand_icon" type="text"
label="PLG_SYSTEM_MOKOWAAS_BRAND_ICON_LABEL"
description="PLG_SYSTEM_MOKOWAAS_BRAND_ICON_DESC"
default="" hint="e.g. f6d5 (FontAwesome unicode)" />
<field name="custom_css" type="textarea"
label="PLG_SYSTEM_MOKOWAAS_CUSTOM_CSS_LABEL"
description="PLG_SYSTEM_MOKOWAAS_CUSTOM_CSS_DESC"
rows="10" filter="raw" />
</fieldset>
<fieldset name="tenant_restrictions"
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_TENANT_LABEL"
description="PLG_SYSTEM_MOKOWAAS_FIELDSET_TENANT_DESC"
@@ -276,73 +177,27 @@
label="PLG_SYSTEM_MOKOWAAS_DEMO_TASK_INFO_LABEL"
/>
</fieldset>
<fieldset name="site_aliases"
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_ALIASES_LABEL"
description="PLG_SYSTEM_MOKOWAAS_FIELDSET_ALIASES_DESC"
>
<field
name="primary_domain"
type="text"
label="PLG_SYSTEM_MOKOWAAS_PRIMARY_DOMAIN_LABEL"
description="PLG_SYSTEM_MOKOWAAS_PRIMARY_DOMAIN_DESC"
default=""
hint="e.g. waas.dev.mokoconsulting.tech"
/>
<field
name="site_aliases"
type="subform"
label="PLG_SYSTEM_MOKOWAAS_SITE_ALIASES_LABEL"
description="PLG_SYSTEM_MOKOWAAS_SITE_ALIASES_DESC"
formsource="plugins/system/mokowaas/forms/alias_entry.xml"
multiple="true"
layout="joomla.form.field.subform.repeatable-table"
groupByFieldset="false"
buttons="add,remove,move"
/>
</fieldset>
<fieldset name="content_sync"
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_SYNC_LABEL"
description="PLG_SYSTEM_MOKOWAAS_FIELDSET_SYNC_DESC"
>
<field
name="sync_targets"
type="subform"
label="PLG_SYSTEM_MOKOWAAS_SYNC_TARGETS_LABEL"
description="PLG_SYSTEM_MOKOWAAS_SYNC_TARGETS_DESC"
formsource="plugins/system/mokowaas/forms/sync_target_entry.xml"
multiple="true"
layout="joomla.form.field.subform.repeatable-table"
groupByFieldset="false"
buttons="add,remove,move"
/>
<field name="sync_push_now" type="radio" default="0"
label="PLG_SYSTEM_MOKOWAAS_SYNC_PUSH_NOW_LABEL"
description="PLG_SYSTEM_MOKOWAAS_SYNC_PUSH_NOW_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="diagnostics"
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_DIAGNOSTICS_LABEL"
description="PLG_SYSTEM_MOKOWAAS_FIELDSET_DIAGNOSTICS_DESC"
addfieldprefix="Moko\Plugin\System\MokoWaaS\Field"
>
<field
name="health_api_token"
type="CopyableToken"
label="PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_LABEL"
description="PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_DESC"
default=""
filter="raw"
readonly="true"
/>
</fieldset>
<fieldset name="security"
<fieldset name="security"
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_SECURITY_LABEL"
description="PLG_SYSTEM_MOKOWAAS_FIELDSET_SECURITY_DESC"
addfieldprefix="Moko\Plugin\System\MokoWaaS\Field"
>
<field
name="emergency_access"
type="radio"
label="PLG_SYSTEM_MOKOWAAS_EMERGENCY_ACCESS_LABEL"
description="PLG_SYSTEM_MOKOWAAS_EMERGENCY_ACCESS_DESC"
default="1"
class="btn-group btn-group-yesno"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="allowed_ips_display"
type="AllowedIps"
label=""
/>
<field name="force_https" type="radio" default="1"
label="PLG_SYSTEM_MOKOWAAS_FORCE_HTTPS_LABEL"
description="PLG_SYSTEM_MOKOWAAS_FORCE_HTTPS_DESC"
+1 -1
View File
@@ -22,7 +22,7 @@
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* REPO: https://github.com/mokoconsulting-tech/mokowaas
* VERSION: 02.30.00
* VERSION: 02.31.00
* PATH: /src/script.php
* BRIEF: Installation script for MokoWaaS plugin
* NOTE: Handles installation, update, and uninstallation tasks including language override deployment
@@ -22,7 +22,7 @@
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* REPO: https://github.com/mokoconsulting-tech/mokowaas
* VERSION: 02.30.00
* VERSION: 02.31.00
* PATH: /src/services/provider.php
* BRIEF: Service provider for dependency injection in Joomla 5.x
* NOTE: Registers the plugin with Joomla's DI container
@@ -12,8 +12,8 @@
<license>GNU General Public License version 3 or later; see LICENSE</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.30.00</version>
<version>02.30.00</version>
<version>02.31.00</version>
<version>02.31.00</version>
<description>PLG_TASK_MOKOWAASDEMO_DESC</description>
<namespace path="src">Moko\Plugin\Task\MokoWaaSDemo</namespace>
@@ -1,8 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<form>
<fieldset name="sync_params" label="Content Sync Settings">
<field name="sync_info" type="note"
label="Sync Targets"
description="Content sync targets are configured in the MokoWaaS system plugin settings (Content Sync tab). This task will push content to all configured targets on each execution." />
</fieldset>
<fields name="params">
<fieldset name="task_params" label="Sync Target">
<field name="target_url" type="text"
label="Target Site URL"
description="Base URL of the remote Joomla site to sync to."
hint="https://demo.example.com" />
<field name="health_token" type="text"
label="Target Health Token"
description="MokoWaaS health API token from the target site. Found in the target's MokoWaaS plugin config (Diagnostics tab)."
hint="Health API token from target site" />
<field name="sync_articles" type="radio" default="1"
label="Sync Articles"
description="Delete all articles on target, then push copies from this site."
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="sync_categories" type="radio" default="1"
label="Sync Categories"
description="Push content categories to the target site."
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="sync_menus" type="radio" default="1"
label="Sync Menus"
description="Delete all menu items on target, then push copies from this site."
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="sync_modules" type="radio" default="0"
label="Sync Modules"
description="Push site modules to the target."
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
</fields>
</form>
@@ -3,6 +3,35 @@
; SPDX-License-Identifier: GPL-3.0-or-later
PLG_TASK_MOKOWAASSYNC="Task - MokoWaaS Content Sync"
PLG_TASK_MOKOWAASSYNC_DESC="Scheduled task to push content (articles, categories, menus, modules) to remote MokoWaaS sites."
PLG_TASK_MOKOWAASSYNC_DESC="Scheduled task to sync content to a remote MokoWaaS site via the Joomla API. Each task instance syncs to one target."
PLG_TASK_MOKOWAASSYNC_SYNC_TITLE="MokoWaaS Content Sync"
PLG_TASK_MOKOWAASSYNC_SYNC_DESC="Push site content to all configured sync targets. Targets are configured in the MokoWaaS system plugin settings."
PLG_TASK_MOKOWAASSYNC_SYNC_DESC="Sync selected content types to a single remote target site via the Joomla REST API."
; ===== Target fieldset =====
PLG_TASK_MOKOWAASSYNC_FIELDSET_TARGET="Sync Target"
PLG_TASK_MOKOWAASSYNC_TARGET_URL_LABEL="Target Site URL"
PLG_TASK_MOKOWAASSYNC_TARGET_URL_DESC="Base URL of the remote Joomla site to sync to (e.g. https://demo.example.com)."
PLG_TASK_MOKOWAASSYNC_API_TOKEN_LABEL="API Token"
PLG_TASK_MOKOWAASSYNC_API_TOKEN_DESC="Joomla API token (Bearer token) for authenticating with the target site's REST API."
PLG_TASK_MOKOWAASSYNC_API_USER_LABEL="API User"
PLG_TASK_MOKOWAASSYNC_API_USER_DESC="Optional username on the target site. Used for logging purposes only."
; ===== Content types fieldset =====
PLG_TASK_MOKOWAASSYNC_FIELDSET_CONTENT="Content to Sync"
PLG_TASK_MOKOWAASSYNC_SYNC_ARTICLES_LABEL="Articles"
PLG_TASK_MOKOWAASSYNC_SYNC_ARTICLES_DESC="Sync articles (com_content). Deletes all articles on the target, then pushes exact copies from this site."
PLG_TASK_MOKOWAASSYNC_SYNC_CATEGORIES_LABEL="Categories"
PLG_TASK_MOKOWAASSYNC_SYNC_CATEGORIES_DESC="Sync content categories. Ensures category structure matches this site."
PLG_TASK_MOKOWAASSYNC_SYNC_MENUS_LABEL="Menus"
PLG_TASK_MOKOWAASSYNC_SYNC_MENUS_DESC="Sync menu items. Deletes all menu items on the target, then pushes exact copies from this site."
PLG_TASK_MOKOWAASSYNC_SYNC_MODULES_LABEL="Modules"
PLG_TASK_MOKOWAASSYNC_SYNC_MODULES_DESC="Sync site modules. Pushes module configuration and assignments."
; ===== Files fieldset =====
PLG_TASK_MOKOWAASSYNC_FIELDSET_FILES="Files to Sync"
PLG_TASK_MOKOWAASSYNC_SYNC_IMAGES_LABEL="Images (/images/)"
PLG_TASK_MOKOWAASSYNC_SYNC_IMAGES_DESC="Sync the /images/ directory to the target site."
PLG_TASK_MOKOWAASSYNC_SYNC_FILES_LABEL="Files (/files/)"
PLG_TASK_MOKOWAASSYNC_SYNC_FILES_DESC="Sync the /files/ directory to the target site."
PLG_TASK_MOKOWAASSYNC_SYNC_MEDIA_LABEL="Media (/media/)"
PLG_TASK_MOKOWAASSYNC_SYNC_MEDIA_DESC="Sync the /media/ directory to the target site. Be careful — this includes extension assets."
@@ -12,7 +12,7 @@
<license>GNU General Public License version 3 or later; see LICENSE</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.30.00</version>
<version>02.31.00</version>
<description>PLG_TASK_MOKOWAASSYNC_DESC</description>
<namespace path="src">Moko\Plugin\Task\MokoWaaSSync</namespace>
@@ -20,16 +20,27 @@ use Joomla\Event\SubscriberInterface;
/**
* MokoWaaS Content Sync — Joomla Scheduled Task Plugin.
*
* Pushes site content (articles, categories, menus, modules) to
* configured remote MokoWaaS sites on a schedule. Sync targets are
* read from the system plugin params (content_sync fieldset).
* Syncs selected content types to a single remote Joomla site via the
* REST API. Each task instance has its own target URL, API token, and
* content type toggles — so multiple targets can sync independently
* on different schedules.
*
* @since 02.27.00
* Sync strategy: delete-then-push for articles and menus to avoid
* duplicates. Categories are upserted. Files are pushed via the
* MokoWaaS sync-receive endpoint.
*
* @since 02.31.00
*/
final class ContentSync extends CMSPlugin implements SubscriberInterface
{
use TaskPluginTrait;
/** @var string Target URL for the current sync run. */
private string $targetUrl = '';
/** @var string Target health token for the current sync run. */
private string $healthToken = '';
protected const TASKS_MAP = [
'mokowaas.content.sync' => [
'langConstPrefix' => 'PLG_TASK_MOKOWAASSYNC_SYNC',
@@ -48,117 +59,382 @@ final class ContentSync extends CMSPlugin implements SubscriberInterface
}
/**
* Push content to all configured sync targets.
*
* Reads sync_targets from the MokoWaaS system plugin params, then
* delegates to ContentSyncService. Task-level overrides (if any)
* are merged on top.
* Sync content to the configured target.
*
* @param ExecuteTaskEvent $event The task event
*
* @return int Status::OK or Status::KNOCKOUT
*
* @since 02.27.00
* @since 02.31.00
*/
private function syncContent(ExecuteTaskEvent $event): int
{
$serviceFile = JPATH_PLUGINS . '/system/mokowaas/Service/ContentSyncService.php';
$params = $event->getArgument('params');
if (!file_exists($serviceFile))
// Debug: log what we received
if (is_object($params))
{
$this->logTask('ContentSyncService.php not found — is plg_system_mokowaas installed?');
$this->logTask('Params type: object, keys: ' . implode(', ', array_keys(get_object_vars($params))));
}
elseif (is_array($params))
{
$this->logTask('Params type: array, keys: ' . implode(', ', array_keys($params)));
$params = (object) $params;
}
else
{
$this->logTask('Params type: ' . gettype($params));
}
$targetUrl = rtrim($params->target_url ?? '', '/');
if (empty($targetUrl))
{
$this->logTask('Sync target URL not configured');
return Status::KNOCKOUT;
}
require_once $serviceFile;
$healthToken = trim($params->health_token ?? '');
// Read sync targets from the system plugin params
$targets = $this->getSyncTargets();
if (empty($targets))
if (empty($healthToken))
{
$this->logTask('No sync targets configured in MokoWaaS system plugin');
$this->logTask('Target health token not configured — cannot sync');
return Status::KNOCKOUT;
}
$this->targetUrl = $targetUrl;
$this->healthToken = $healthToken;
$errors = 0;
$synced = [];
// Bulk-clear selected content types on target before pushing
$clearTypes = [];
if ((int) ($params->sync_articles ?? 0) === 1)
{
$clearTypes[] = 'articles';
}
if ((int) ($params->sync_categories ?? 0) === 1)
{
$clearTypes[] = 'categories';
}
if ((int) ($params->sync_menus ?? 0) === 1)
{
$clearTypes[] = 'menus';
}
if ((int) ($params->sync_modules ?? 0) === 1)
{
$clearTypes[] = 'modules';
}
if (!empty($clearTypes))
{
$clearResult = $this->bulkClear($targetUrl, $healthToken, $clearTypes);
if (!$clearResult)
{
$this->logTask('Bulk clear failed — aborting sync');
return Status::KNOCKOUT;
}
$this->logTask('Cleared on target: ' . implode(', ', $clearTypes));
}
// Push content types
if ((int) ($params->sync_categories ?? 0) === 1)
{
$result = $this->syncCategories();
$synced[] = 'categories:' . ($result ? 'ok' : 'fail');
if (!$result) $errors++;
}
if ((int) ($params->sync_articles ?? 0) === 1)
{
$result = $this->syncArticles();
$synced[] = 'articles:' . ($result ? 'ok' : 'fail');
if (!$result) $errors++;
}
if ((int) ($params->sync_menus ?? 0) === 1)
{
$result = $this->syncMenus();
$synced[] = 'menus:' . ($result ? 'ok' : 'fail');
if (!$result) $errors++;
}
if ((int) ($params->sync_modules ?? 0) === 1)
{
$result = $this->syncModules();
$synced[] = 'modules:' . ($result ? 'ok' : 'fail');
if (!$result) $errors++;
}
$summary = implode(', ', $synced);
if (empty($synced))
{
$this->logTask('No content types selected for sync');
return Status::OK;
}
$this->logTask("Sync to {$targetUrl}: {$summary}");
return $errors > 0 && $errors === count($synced) ? Status::KNOCKOUT : Status::OK;
}
// ------------------------------------------------------------------
// Bulk push via MokoWaaS syncpush endpoint
// ------------------------------------------------------------------
/**
* Sync articles: read from source DB, bulk-push to target.
*
* @param string $apiBase Target API base URL (unused — uses MokoWaaS endpoint)
* @param string $token API bearer token (unused — uses health token)
*
* @return bool
*
* @since 02.31.00
*/
private function syncArticles(): bool
{
try
{
$service = new \Moko\Plugin\System\MokoWaaS\Service\ContentSyncService();
$result = $service->syncAllTargets($targets);
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName(['title', 'alias', 'introtext', 'fulltext', 'state', 'catid', 'language', 'featured', 'metadesc', 'metakey', 'metadata', 'created', 'modified', 'publish_up', 'images', 'urls', 'attribs', 'access']))
->from($db->quoteName('#__content'))
->where($db->quoteName('state') . ' >= 0');
$db->setQuery($query);
$articles = $db->loadAssocList();
$targetResults = $result['targets'] ?? [];
$okCount = 0;
$errCount = 0;
$targetUrl = rtrim($this->targetUrl, '/');
$healthToken = $this->healthToken;
foreach ($targetResults as $tr)
{
if (($tr['status'] ?? '') === 'ok')
{
$okCount++;
}
else
{
$errCount++;
$this->logTask('Sync failed for ' . ($tr['target'] ?? 'unknown') . ': ' . ($tr['message'] ?? ''));
}
}
$result = $this->bulkPush($targetUrl, $healthToken, 'articles', $articles);
$this->logTask(sprintf('Content sync completed — %d ok, %d failed of %d target(s)', $okCount, $errCount, count($targetResults)));
$this->logTask(sprintf('Synced %d articles', count($articles)));
return $errCount > 0 && $okCount === 0 ? Status::KNOCKOUT : Status::OK;
return $result;
}
catch (\Throwable $e)
{
$this->logTask('Content sync failed: ' . $e->getMessage());
$this->logTask('Article sync failed: ' . $e->getMessage());
return Status::KNOCKOUT;
return false;
}
}
/**
* Read sync targets from the MokoWaaS system plugin configuration.
* Sync categories: push from source, creating or updating on target.
*
* @return array Array of ['url' => ..., 'token' => ..., 'label' => ...]
* @param string $apiBase Target API base URL
* @param string $token API bearer token
*
* @since 02.27.00
* @return bool
*
* @since 02.31.00
*/
private function getSyncTargets(): array
private function syncCategories(): bool
{
try
{
$db = Factory::getDbo();
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas'));
->select('title, alias, description, published, language, extension, access, params, metadata')
->from($db->quoteName('#__categories'))
->where($db->quoteName('extension') . ' = ' . $db->quote('com_content'))
->where($db->quoteName('published') . ' >= 0')
->where($db->quoteName('id') . ' > 1')
->order($db->quoteName('lft') . ' ASC');
$db->setQuery($query);
$raw = $db->loadResult();
$categories = $db->loadAssocList();
if (empty($raw))
{
return [];
}
$targetUrl = rtrim($this->targetUrl, '/');
$healthToken = $this->healthToken;
$params = json_decode($raw, true) ?: [];
$targets = $params['sync_targets'] ?? [];
$result = $this->bulkPush($targetUrl, $healthToken, 'categories', $categories);
if (is_string($targets))
{
$targets = json_decode($targets, true) ?: [];
}
$this->logTask(sprintf('Synced %d categories', count($categories)));
return is_array($targets) ? $targets : [];
return $result;
}
catch (\Throwable $e)
{
$this->logTask('Failed to read sync targets: ' . $e->getMessage());
$this->logTask('Category sync failed: ' . $e->getMessage());
return [];
return false;
}
}
/**
* Sync menus via bulk push.
*/
private function syncMenus(): bool
{
try
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('title, alias, path, menutype, type, link, language, published, home, params, img, access')
->from($db->quoteName('#__menu'))
->where($db->quoteName('client_id') . ' = 0')
->where($db->quoteName('id') . ' > 1')
->where($db->quoteName('published') . ' >= 0')
->order($db->quoteName('lft') . ' ASC');
$db->setQuery($query);
$items = $db->loadAssocList();
$targetUrl = rtrim($this->targetUrl, '/');
$healthToken = $this->healthToken;
$result = $this->bulkPush($targetUrl, $healthToken, 'menus', $items);
$this->logTask(sprintf('Synced %d menu items', count($items)));
return $result;
}
catch (\Throwable $e)
{
$this->logTask('Menu sync failed: ' . $e->getMessage());
return false;
}
}
/**
* Sync modules via bulk push.
*/
private function syncModules(): bool
{
try
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('title, module, position, params, language, published, access, ordering, showtitle')
->from($db->quoteName('#__modules'))
->where($db->quoteName('client_id') . ' = 0')
->where($db->quoteName('published') . ' >= 0');
$db->setQuery($query);
$modules = $db->loadAssocList();
$targetUrl = rtrim($this->targetUrl, '/');
$healthToken = $this->healthToken;
$result = $this->bulkPush($targetUrl, $healthToken, 'modules', $modules);
$this->logTask(sprintf('Synced %d modules', count($modules)));
return $result;
}
catch (\Throwable $e)
{
$this->logTask('Module sync failed: ' . $e->getMessage());
return false;
}
}
// ------------------------------------------------------------------
// HTTP helpers
// ------------------------------------------------------------------
/**
* Bulk-push content to the target via the MokoWaaS syncpush endpoint.
*
* Sends items in batches of 50 to prevent payload overload.
*
* @param string $targetUrl Target base URL
* @param string $token MokoWaaS health token
* @param string $type Content type (articles, categories, menus, modules)
* @param array $items Array of items to push
*
* @return bool
*
* @since 02.31.00
*/
private function bulkPush(string $targetUrl, string $token, string $type, array $items): bool
{
$batches = array_chunk($items, 50);
foreach ($batches as $i => $batch)
{
$payload = json_encode([
'token' => $token,
'type' => $type,
'items' => $batch,
]);
$ch = curl_init($targetUrl . '/?mokowaas=syncpush');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 120);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$response = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode < 200 || $httpCode >= 300)
{
$this->logTask("Bulk push failed for {$type} batch " . ($i + 1) . ": HTTP {$httpCode}" . substr($response, 0, 200));
return false;
}
}
return true;
}
/**
* Bulk-clear content on the target via the MokoWaaS syncclear endpoint.
*
* @param string $targetUrl Target base URL
* @param string $token MokoWaaS health token
* @param array $types Content types to clear
*
* @return bool
*
* @since 02.31.00
*/
private function bulkClear(string $targetUrl, string $token, array $types): bool
{
$payload = json_encode([
'token' => $token,
'types' => $types,
]);
$ch = curl_init($targetUrl . '/?mokowaas=syncclear');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$response = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode >= 200 && $httpCode < 300)
{
$result = json_decode($response, true);
$this->logTask('Target cleared: ' . json_encode($result['cleared'] ?? []));
return true;
}
$this->logTask('Bulk clear failed: HTTP ' . $httpCode . ' — ' . substr($response, 0, 200));
return false;
}
}
@@ -7,8 +7,8 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.30.00</version>
<version>02.30.00</version>
<version>02.31.00</version>
<version>02.31.00</version>
<description>Joomla Web Services API routes for MokoWaaS site management — health checks, cache, updates, backups, and site info.</description>
<namespace path="src">Moko\Plugin\WebServices\MokoWaaS</namespace>
<files>
@@ -7,8 +7,8 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.30.00</version>
<version>02.30.00</version>
<version>02.31.00</version>
<version>02.31.00</version>
<description>Joomla Web Services API routes for Perfect Publisher (com_autotweet) — channels, posts, requests, rules, and feeds.</description>
<namespace path="src">Moko\Plugin\WebServices\PerfectPublisher</namespace>
<files>
@@ -8,7 +8,7 @@
* INGROUP: MokoWaaS
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
* PATH: /src/packages/plg_webservices_perfectpublisher/services/provider.php
* VERSION: 02.30.00
* VERSION: 02.31.00
* BRIEF: DI service provider for Perfect Publisher Web Services plugin
*/
@@ -8,7 +8,7 @@
* INGROUP: MokoWaaS
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
* PATH: /src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php
* VERSION: 02.30.00
* VERSION: 02.31.00
* BRIEF: Web Services API plugin for Perfect Publisher (com_autotweet)
*/
+2 -2
View File
@@ -2,8 +2,8 @@
<extension type="package" method="upgrade">
<name>Package - MokoWaaS</name>
<packagename>mokowaas</packagename>
<version>02.30.00</version>
<version>02.30.00</version>
<version>02.31.00</version>
<version>02.31.00</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+1 -1
View File
@@ -230,7 +230,7 @@ class Pkg_MokowaasInstallerScript
*
* @return void
*
* @since 02.30.00
* @since 02.31.00
*/
private function cleanupStaleUpdateSites(): void
{