feat(api): add install-from-URL endpoint #87

Merged
jmiller merged 13 commits from dev into main 2026-05-30 17:58:36 +00:00
43 changed files with 2018 additions and 45 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
<name>MokoWaaS</name>
<org>MokoConsulting</org>
<description>White-label identity, security hardening, and tenant restriction layer for WaaS-managed Joomla environments</description>
<version>02.20.00</version>
<version>02.21.01</version>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# VERSION: 01.00.00
# VERSION: 02.21.01
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
+9 -1
View File
@@ -14,12 +14,20 @@
INGROUP: MokoWaaS.Documentation
REPO: https://github.com/mokoconsulting-tech/mokowaas
PATH: ./CHANGELOG.md
VERSION: 02.01.08
VERSION: 02.21.01
BRIEF: Version history using `Keep a Changelog`
-->
# Changelog
## [Unreleased]
### Added
- API endpoint `POST /api/index.php/v1/mokowaas/install` — install extensions from a remote ZIP URL
- Demo Mode with configurable warning banner on frontend when enabled
- `DemoResetService` — baseline snapshot and restore for DB tables + media files
- API endpoints `POST /?mokowaas=reset` and `POST /?mokowaas=snapshot` (query-string)
- REST endpoints `POST /api/v1/mokowaas/reset` and `GET/POST /api/v1/mokowaas/snapshot`
- `plg_task_mokowaasdemo` — Joomla Scheduled Task plugin for automatic demo site reset
- Admin toggles: Take Snapshot Now and Restore Baseline Now in plugin config
## [02.20.00] --- 2026-05-28
+1 -1
View File
@@ -14,7 +14,7 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Documentation
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.01.08
VERSION: 02.21.01
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.01.08
VERSION: 02.21.01
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.01.08
VERSION: 02.21.01
BRIEF: Project license (GPL-3.0-or-later)
-->
GNU GENERAL PUBLIC LICENSE
+1 -1
View File
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
INGROUP: [PROJECT_NAME].Documentation
REPO: [REPOSITORY_URL]
PATH: /SECURITY.md
VERSION: 02.01.08
VERSION: 02.21.01
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.01.08
VERSION: 02.21.01
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.01.08)
# MokoWaaS Build Guide (VERSION: 02.21.01)
## 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.01.08
VERSION: 02.21.01
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.01.08)
# MokoWaaS Configuration Guide (VERSION: 02.21.01)
## 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.01.08
VERSION: 02.21.01
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.01.08)
# MokoWaaS Installation Guide (VERSION: 02.21.01)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.01.08
VERSION: 02.21.01
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.01.08)
# MokoWaaS Operations Guide (VERSION: 02.21.01)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.01.08
VERSION: 02.21.01
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.01.08)
# MokoWaaS Rollback and Recovery Guide (VERSION: 02.21.01)
## Introduction
+2 -2
View File
@@ -7,13 +7,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.01.08
VERSION: 02.21.01
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.01.08)
# MokoWaaS Testing Guide (VERSION: 02.21.01)
## 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.01.08
VERSION: 02.21.01
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.01.08)
# MokoWaaS Troubleshooting Guide (VERSION: 02.21.01)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.01.08
VERSION: 02.21.01
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.01.08)
# MokoWaaS Upgrade and Versioning Guide (VERSION: 02.21.01)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Documentation
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.01.08
VERSION: 02.21.01
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.01.08)
# MokoWaaS Documentation Index (VERSION: 02.21.01)
## 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.01.08
VERSION: 02.21.01
BRIEF: Baseline documentation for the MokoWaaS system plugin
NOTE: Foundational reference for internal and external stakeholders
-->
# MokoWaaS Plugin Overview (VERSION: 02.01.08)
# MokoWaaS Plugin Overview (VERSION: 02.21.01)
## 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.01.08
VERSION: 02.21.01
BRIEF: How this extension's Joomla update server file (update.xml) is managed
-->
@@ -0,0 +1,283 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Api\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Installer\Installer;
use Joomla\CMS\MVC\Controller\BaseController;
/**
* Extension install-from-URL API controller.
*
* POST /api/index.php/v1/mokowaas/install
* Body: {"url": "https://example.com/path/to/extension.zip"}
*
* Downloads a ZIP from the given URL and installs it via Joomla's Installer.
* Requires a Joomla API token with core.manage on com_installer.
*
* @since 02.21.00
*/
class InstallController extends BaseController
{
/**
* Maximum allowed download size in bytes (64 MB).
*
* @var int
* @since 02.21.00
*/
private const MAX_DOWNLOAD_BYTES = 67108864;
/**
* Install an extension from a remote ZIP URL.
*
* @return void
*
* @since 02.21.00
*/
public function execute(): void
{
$app = Factory::getApplication();
if ($app->input->getMethod() !== 'POST')
{
$this->sendJson(405, ['error' => 'POST required']);
return;
}
$user = $app->getIdentity();
if (!$user->authorise('core.manage', 'com_installer'))
{
$this->sendJson(403, ['error' => 'Not authorized — requires core.manage on com_installer']);
return;
}
// Parse JSON body
$body = json_decode($app->input->json->getRaw(), true);
$url = $body['url'] ?? '';
if ($url === '')
{
$this->sendJson(400, ['error' => 'Missing "url" in request body']);
return;
}
// Validate URL scheme
if (!preg_match('#^https?://#i', $url))
{
$this->sendJson(400, ['error' => 'URL must use http or https scheme']);
return;
}
// Must point to a .zip file
$path = parse_url($url, PHP_URL_PATH);
if (!$path || !str_ends_with(strtolower($path), '.zip'))
{
$this->sendJson(400, ['error' => 'URL must point to a .zip file']);
return;
}
try
{
$result = $this->downloadAndInstall($url);
$this->sendJson(200, $result);
}
catch (\Throwable $e)
{
$this->sendJson(500, [
'error' => 'Installation failed',
'message' => $e->getMessage(),
]);
}
}
/**
* Download ZIP from URL, extract, and install via Joomla Installer.
*
* @param string $url The remote ZIP URL
*
* @return array Result payload
*
* @throws \RuntimeException on failure
*
* @since 02.21.00
*/
private function downloadAndInstall(string $url): array
{
$config = Factory::getConfig();
$tmpPath = $config->get('tmp_path', JPATH_ROOT . '/tmp');
$zipFile = $tmpPath . '/mokowaas_install_' . bin2hex(random_bytes(8)) . '.zip';
// Download
$this->downloadFile($url, $zipFile);
try
{
// Extract
$extractDir = $tmpPath . '/mokowaas_extract_' . bin2hex(random_bytes(8));
if (!mkdir($extractDir, 0755, true))
{
throw new \RuntimeException('Failed to create extraction directory');
}
$archive = new \Joomla\Archive\Archive;
$archive->extract($zipFile, $extractDir);
// Install
$installer = Installer::getInstance();
$result = $installer->install($extractDir);
if (!$result)
{
throw new \RuntimeException('Joomla Installer returned failure — check server logs for details');
}
// Read installed extension info from the installer
$manifest = $installer->getManifest();
$name = $manifest ? (string) $manifest->name : 'Unknown';
$version = $manifest ? (string) $manifest->version : 'Unknown';
$type = $installer->get('extension.type', 'Unknown');
return [
'status' => 'ok',
'message' => 'Extension installed successfully',
'extension' => [
'name' => $name,
'version' => $version,
'type' => $type,
],
'source_url' => $url,
];
}
finally
{
// Clean up temp files
@unlink($zipFile);
if (isset($extractDir) && is_dir($extractDir))
{
$this->removeDirectory($extractDir);
}
}
}
/**
* Download a file from a URL with size limit enforcement.
*
* @param string $url Remote URL
* @param string $destPath Local destination path
*
* @return void
*
* @throws \RuntimeException on failure
*
* @since 02.21.00
*/
private function downloadFile(string $url, string $destPath): void
{
$ch = curl_init($url);
if ($ch === false)
{
throw new \RuntimeException('Failed to initialise cURL');
}
$fp = fopen($destPath, 'wb');
if ($fp === false)
{
curl_close($ch);
throw new \RuntimeException('Failed to open temp file for writing');
}
curl_setopt_array($ch, [
CURLOPT_FILE => $fp,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => 120,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_FAILONERROR => true,
CURLOPT_USERAGENT => 'MokoWaaS-Installer/1.0',
]);
$success = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
$fileSize = curl_getinfo($ch, CURLINFO_SIZE_DOWNLOAD);
curl_close($ch);
fclose($fp);
if (!$success)
{
@unlink($destPath);
throw new \RuntimeException('Download failed (HTTP ' . $httpCode . '): ' . $error);
}
if ($fileSize > self::MAX_DOWNLOAD_BYTES)
{
@unlink($destPath);
throw new \RuntimeException('Download exceeds maximum size of ' . (self::MAX_DOWNLOAD_BYTES / 1048576) . ' MB');
}
}
/**
* Recursively remove a directory and its contents.
*
* @param string $dir Directory path
*
* @return void
*
* @since 02.21.00
*/
private function removeDirectory(string $dir): void
{
$items = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($items as $item)
{
if ($item->isDir())
{
@rmdir($item->getPathname());
}
else
{
@unlink($item->getPathname());
}
}
@rmdir($dir);
}
/**
* Send a JSON response and close.
*
* @param int $code HTTP status code
* @param array $payload Response data
*
* @return void
*
* @since 02.21.00
*/
private function sendJson(int $code, array $payload): void
{
$app = Factory::getApplication();
$app->setHeader('Content-Type', 'application/json', true);
$app->setHeader('Status', (string) $code, true);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
$app->close();
}
}
@@ -0,0 +1,126 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Api\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\Registry\Registry;
/**
* Demo site reset API controller.
*
* POST /api/index.php/v1/mokowaas/reset
* Body: {"baseline": "default"}
*
* Restores the site to a named baseline snapshot.
* Requires a Joomla API token with core.manage on com_plugins.
*
* @since 02.21.00
*/
class ResetController extends BaseController
{
/**
* Restore site to a baseline snapshot.
*
* @return void
*
* @since 02.21.00
*/
public function execute(): void
{
$app = Factory::getApplication();
if ($app->input->getMethod() !== 'POST')
{
$this->sendJson(405, ['error' => 'POST required']);
return;
}
$user = $app->getIdentity();
if (!$user->authorise('core.manage', 'com_plugins'))
{
$this->sendJson(403, ['error' => 'Not authorized']);
return;
}
$plugin = PluginHelper::getPlugin('system', 'mokowaas');
if (!$plugin)
{
$this->sendJson(503, ['error' => 'MokoWaaS system plugin not enabled']);
return;
}
$params = new Registry($plugin->params);
try
{
$body = json_decode($app->input->json->getRaw(), true);
$baseline = $body['baseline']
?? $params->get('demo_active_baseline', 'default');
$service = $this->createService($params);
$result = $service->restoreSnapshot($baseline);
$this->sendJson(200, $result);
}
catch (\Throwable $e)
{
$this->sendJson(500, [
'error' => 'Reset failed',
'message' => $e->getMessage(),
]);
}
}
/**
* Create DemoResetService from plugin params.
*
* @param Registry $params Plugin parameters
*
* @return \Moko\Plugin\System\MokoWaaS\Service\DemoResetService
*
* @since 02.21.00
*/
private function createService(Registry $params)
{
$serviceFile = JPATH_PLUGINS . '/system/mokowaas/Service/DemoResetService.php';
if (!file_exists($serviceFile))
{
throw new \RuntimeException('DemoResetService not found — is the MokoWaaS plugin installed?');
}
require_once $serviceFile;
$tablesRaw = $params->get('demo_snapshot_tables', '');
$tables = array_filter(array_map('trim', explode("\n", $tablesRaw)));
$media = (bool) $params->get('demo_snapshot_include_media', 1);
return new \Moko\Plugin\System\MokoWaaS\Service\DemoResetService($tables, $media);
}
/**
* @param int $code HTTP status code
* @param array $payload Response data
* @return void
*/
private function sendJson(int $code, array $payload): void
{
$app = Factory::getApplication();
$app->setHeader('Content-Type', 'application/json', true);
$app->setHeader('Status', (string) $code, true);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
$app->close();
}
}
@@ -0,0 +1,153 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Api\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\Registry\Registry;
/**
* Snapshot management API controller.
*
* GET /api/index.php/v1/mokowaas/snapshot — list snapshots
* POST /api/index.php/v1/mokowaas/snapshot — create snapshot
*
* @since 02.21.00
*/
class SnapshotController extends BaseController
{
/**
* List all available snapshots.
*
* @return void
*
* @since 02.21.00
*/
public function displayList(): void
{
$app = Factory::getApplication();
$user = $app->getIdentity();
if (!$user->authorise('core.manage', 'com_plugins'))
{
$this->sendJson(403, ['error' => 'Not authorized']);
return;
}
try
{
$service = $this->createService();
$this->sendJson(200, [
'status' => 'ok',
'snapshots' => $service->listSnapshots(),
]);
}
catch (\Throwable $e)
{
$this->sendJson(500, [
'error' => 'Failed to list snapshots',
'message' => $e->getMessage(),
]);
}
}
/**
* Create a new snapshot.
*
* @return void
*
* @since 02.21.00
*/
public function execute(): void
{
$app = Factory::getApplication();
if ($app->input->getMethod() !== 'POST')
{
$this->sendJson(405, ['error' => 'POST required']);
return;
}
$user = $app->getIdentity();
if (!$user->authorise('core.manage', 'com_plugins'))
{
$this->sendJson(403, ['error' => 'Not authorized']);
return;
}
try
{
$plugin = PluginHelper::getPlugin('system', 'mokowaas');
$params = $plugin ? new Registry($plugin->params) : new Registry;
$body = json_decode($app->input->json->getRaw(), true);
$name = $body['name']
?? $params->get('demo_active_baseline', 'default');
$service = $this->createService();
$result = $service->createSnapshot($name);
$this->sendJson(200, $result);
}
catch (\Throwable $e)
{
$this->sendJson(500, [
'error' => 'Snapshot failed',
'message' => $e->getMessage(),
]);
}
}
/**
* Create DemoResetService from plugin params.
*
* @return \Moko\Plugin\System\MokoWaaS\Service\DemoResetService
*
* @since 02.21.00
*/
private function createService()
{
$serviceFile = JPATH_PLUGINS . '/system/mokowaas/Service/DemoResetService.php';
if (!file_exists($serviceFile))
{
throw new \RuntimeException('DemoResetService not found');
}
require_once $serviceFile;
$plugin = PluginHelper::getPlugin('system', 'mokowaas');
$params = $plugin ? new Registry($plugin->params) : new Registry;
$tablesRaw = $params->get('demo_snapshot_tables', '');
$tables = array_filter(array_map('trim', explode("\n", $tablesRaw)));
$media = (bool) $params->get('demo_snapshot_include_media', 1);
return new \Moko\Plugin\System\MokoWaaS\Service\DemoResetService($tables, $media);
}
/**
* @param int $code HTTP status code
* @param array $payload Response data
* @return void
*/
private function sendJson(int $code, array $payload): void
{
$app = Factory::getApplication();
$app->setHeader('Content-Type', 'application/json', true);
$app->setHeader('Status', (string) $code, true);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
$app->close();
}
}
@@ -22,7 +22,7 @@
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* REPO: https://github.com/mokoconsulting-tech/mokowaas
* VERSION: 02.01.08
* VERSION: 02.21.01
* PATH: /src/Extension/MokoWaaS.php
* NOTE: Handles Joomla system events for rebranding functionality
*/
@@ -862,6 +862,60 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
);
}
// Demo Mode: Take Snapshot Now
if ((int) $params->get('demo_take_snapshot_now', 0) === 1)
{
$params->set('demo_take_snapshot_now', '0');
$changed = true;
try
{
$this->params = $params;
$service = $this->createDemoResetService();
$baseline = $params->get('demo_active_baseline', 'default');
$result = $service->createSnapshot($baseline);
$app->enqueueMessage(
sprintf('Demo snapshot "%s" created (%d tables).', $baseline, $result['tables']),
'message'
);
}
catch (\Throwable $e)
{
$app->enqueueMessage(
'Snapshot failed: ' . $e->getMessage(),
'error'
);
}
}
// Demo Mode: Restore Baseline Now
if ((int) $params->get('demo_restore_now', 0) === 1)
{
$params->set('demo_restore_now', '0');
$changed = true;
try
{
$this->params = $params;
$service = $this->createDemoResetService();
$baseline = $params->get('demo_active_baseline', 'default');
$result = $service->restoreSnapshot($baseline);
$app->enqueueMessage(
sprintf('Site restored to baseline "%s" (%d tables).', $baseline, $result['restored_tables']),
'message'
);
}
catch (\Throwable $e)
{
$app->enqueueMessage(
'Restore failed: ' . $e->getMessage(),
'error'
);
}
}
if ($changed)
{
$db = Factory::getDbo();
@@ -965,6 +1019,12 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
$this->injectAliasRobots($doc);
}
// Demo mode banner (frontend only)
if ($this->app->isClient('site') && (int) $this->params->get('demo_mode_enabled', 0))
{
$this->injectDemoBanner($doc);
}
if (!$this->app->isClient('administrator'))
{
return;
@@ -980,6 +1040,65 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
}
}
/**
* Inject demo mode warning banner into the frontend site.
*
* Renders a fixed-position bar at the top of the page with a configurable
* message, color, optional countdown, and session-dismissable behavior.
*
* @param \Joomla\CMS\Document\HtmlDocument $doc Document object
*
* @return void
*
* @since 02.21.00
*/
protected function injectDemoBanner($doc)
{
$message = htmlspecialchars($this->params->get('demo_banner_message', 'This is a demo site. All changes will be reset periodically.'), ENT_QUOTES, 'UTF-8');
$bgColor = htmlspecialchars($this->params->get('demo_banner_color', '#d9534f'), ENT_QUOTES, 'UTF-8');
$showCountdown = (int) $this->params->get('demo_banner_show_countdown', 0);
$intervalHours = (int) $this->params->get('demo_reset_interval_hours', 24);
$resetAt = time() + ($intervalHours * 3600);
$countdownJs = '';
if ($showCountdown)
{
$countdownJs = "
var resetAt = {$resetAt} * 1000;
var cdSpan = document.getElementById('mokowaas-demo-countdown');
if (cdSpan) {
setInterval(function() {
var now = Date.now();
var diff = Math.max(0, Math.floor((resetAt - now) / 1000));
var h = Math.floor(diff / 3600);
var m = Math.floor((diff % 3600) / 60);
var s = diff % 60;
cdSpan.textContent = ' — Resets in ' + h + 'h ' + m + 'm ' + s + 's';
}, 1000);
}
";
}
$doc->addScriptDeclaration("
document.addEventListener('DOMContentLoaded', function() {
if (sessionStorage.getItem('mokowaas_banner_dismissed') === '1') return;
var bar = document.createElement('div');
bar.id = 'mokowaas-demo-banner';
bar.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:999999;background:{$bgColor};color:#fff;padding:10px 40px 10px 20px;font-family:-apple-system,BlinkMacSystemFont,sans-serif;font-size:14px;text-align:center;box-shadow:0 2px 8px rgba(0,0,0,.3);';
bar.innerHTML = '<span>{$message}</span>' +
'" . ($showCountdown ? "<span id=\"mokowaas-demo-countdown\"></span>" : "") . "' +
'<button onclick=\"this.parentNode.remove();sessionStorage.setItem(\\x27mokowaas_banner_dismissed\\x27,\\x271\\x27);document.body.style.paddingTop=\\x270\\x27\" style=\"position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;color:#fff;font-size:20px;cursor:pointer;line-height:1;padding:0 5px\">&times;</button>';
document.body.insertBefore(bar, document.body.firstChild);
document.body.style.paddingTop = bar.offsetHeight + 'px';
{$countdownJs}
});
");
}
/**
* Hide MokoWaaS plugin and package from the extensions list via JS.
*
@@ -1407,11 +1526,17 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
case 'info':
$this->handleInfoAction();
break;
case 'reset':
$this->handleDemoResetAction();
break;
case 'snapshot':
$this->handleSnapshotAction();
break;
default:
$this->sendHealthResponse(400, [
'error' => 'Unknown action',
'action' => $action,
'available' => ['health', 'install', 'update', 'cache', 'backup', 'info'],
'available' => ['health', 'install', 'update', 'cache', 'backup', 'info', 'reset', 'snapshot'],
]);
break;
}
@@ -1421,6 +1546,117 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
// API Actions
// ------------------------------------------------------------------
/**
* Handle demo site reset via API.
*
* POST /?mokowaas=reset
* Body: {"baseline": "default"} (optional, defaults to active baseline)
*
* @return void
* @since 02.21.00
*/
protected function handleDemoResetAction()
{
if ($this->app->input->getMethod() !== 'POST')
{
$this->sendHealthResponse(405, ['error' => 'POST required']);
return;
}
try
{
$body = json_decode(file_get_contents('php://input'), true);
$baseline = $body['baseline']
?? $this->params->get('demo_active_baseline', 'default');
$service = $this->createDemoResetService();
$result = $service->restoreSnapshot($baseline);
$this->sendHealthResponse(200, $result);
}
catch (\Throwable $e)
{
$this->sendHealthResponse(500, [
'error' => 'Reset failed',
'message' => $e->getMessage(),
]);
}
}
/**
* Handle snapshot create/list via API.
*
* GET /?mokowaas=snapshot — list snapshots
* POST /?mokowaas=snapshot — create snapshot
* Body: {"name": "my-baseline"} (optional, defaults to active baseline)
*
* @return void
* @since 02.21.00
*/
protected function handleSnapshotAction()
{
$service = $this->createDemoResetService();
if ($this->app->input->getMethod() === 'GET')
{
$this->sendHealthResponse(200, [
'status' => 'ok',
'snapshots' => $service->listSnapshots(),
]);
return;
}
if ($this->app->input->getMethod() !== 'POST')
{
$this->sendHealthResponse(405, ['error' => 'GET or POST required']);
return;
}
try
{
$body = json_decode(file_get_contents('php://input'), true);
$name = $body['name']
?? $this->params->get('demo_active_baseline', 'default');
$result = $service->createSnapshot($name);
$this->sendHealthResponse(200, $result);
}
catch (\Throwable $e)
{
$this->sendHealthResponse(500, [
'error' => 'Snapshot failed',
'message' => $e->getMessage(),
]);
}
}
/**
* Create a DemoResetService instance from current plugin params.
*
* @return \Moko\Plugin\System\MokoWaaS\Service\DemoResetService
* @since 02.21.00
*/
protected function createDemoResetService()
{
require_once __DIR__ . '/../Service/DemoResetService.php';
$tablesRaw = $this->params->get('demo_snapshot_tables', '');
$tables = array_filter(
array_map('trim', explode("\n", $tablesRaw))
);
$includeMedia = (bool) $this->params->get('demo_snapshot_include_media', 1);
return new \Moko\Plugin\System\MokoWaaS\Service\DemoResetService(
$tables,
$includeMedia
);
}
/**
* Trigger Joomla update finder check.
*
@@ -7,7 +7,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* VERSION: 02.01.08
* VERSION: 02.21.01
* PATH: /src/Field/AllowedIpsField.php
* BRIEF: Custom form field that displays the current IP whitelist
*/
@@ -7,7 +7,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* VERSION: 02.11.00
* VERSION: 02.21.01
* PATH: /src/Field/CurrentIpField.php
* BRIEF: Read-only field that displays the current user's IP address
*/
@@ -0,0 +1,714 @@
<?php
/**
* @package MokoWaaS
* @subpackage plg_system_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
* PATH: /src/packages/plg_system_mokowaas/Service/DemoResetService.php
* VERSION: 02.21.01
* BRIEF: Core snapshot/restore service for demo site reset
*/
namespace Moko\Plugin\System\MokoWaaS\Service;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
/**
* Demo Reset Service — manages baseline snapshots and restore operations.
*
* This is the single source of truth for all snapshot logic. Called by:
* - Admin one-shot toggles (onExtensionAfterSave)
* - Query-string API (?mokowaas=reset / ?mokowaas=snapshot)
* - REST API (/api/v1/mokowaas/reset, /api/v1/mokowaas/snapshot)
* - Joomla Scheduled Task (plg_task_mokowaasdemo)
*
* @since 02.21.00
*/
class DemoResetService
{
/**
* Maximum snapshot name length.
*
* @var int
* @since 02.21.00
*/
private const MAX_NAME_LENGTH = 64;
/**
* Rows per batch for paginated table dump/restore.
*
* @var int
* @since 02.21.00
*/
private const BATCH_SIZE = 500;
/**
* Default tables to snapshot if none configured.
*
* @var array
* @since 02.21.00
*/
private const DEFAULT_TABLES = [
'#__content',
'#__categories',
'#__fields',
'#__fields_values',
'#__fields_groups',
'#__menu',
'#__menu_types',
'#__modules',
'#__modules_menu',
'#__users',
'#__user_usergroup_map',
'#__user_profiles',
'#__tags',
'#__contentitem_tag_map',
'#__assets',
];
/**
* Root directory for all snapshots.
*
* @var string
* @since 02.21.00
*/
private string $snapshotDir;
/**
* Tables to include in snapshots.
*
* @var array
* @since 02.21.00
*/
private array $tables;
/**
* Whether to include media files in snapshots.
*
* @var bool
* @since 02.21.00
*/
private bool $includeMedia;
/**
* Constructor.
*
* @param array $tables Table names with #__ prefix
* @param bool $includeMedia Include /images/ directory in snapshot
* @param string $baseDir Override snapshot root (for testing)
*
* @since 02.21.00
*/
public function __construct(array $tables = [], bool $includeMedia = true, string $baseDir = '')
{
$this->tables = !empty($tables) ? $tables : self::DEFAULT_TABLES;
$this->includeMedia = $includeMedia;
$this->snapshotDir = $baseDir ?: JPATH_ROOT . '/mokowaas-snapshots';
}
/**
* List all available snapshots.
*
* @return array Array of manifest data keyed by snapshot name
*
* @since 02.21.00
*/
public function listSnapshots(): array
{
$snapshots = [];
if (!is_dir($this->snapshotDir))
{
return $snapshots;
}
$dirs = glob($this->snapshotDir . '/*/manifest.json');
foreach ($dirs as $manifestPath)
{
$data = json_decode(file_get_contents($manifestPath), true);
if ($data && isset($data['name']))
{
$snapshots[$data['name']] = $data;
}
}
return $snapshots;
}
/**
* Create a named snapshot of the current site state.
*
* @param string $name Snapshot name (alphanumeric, hyphens, underscores)
*
* @return array Result payload with status, tables count, size info
*
* @throws \InvalidArgumentException On invalid snapshot name
* @throws \RuntimeException On filesystem/database failures
*
* @since 02.21.00
*/
public function createSnapshot(string $name): array
{
$this->validateSnapshotName($name);
$this->ensureSnapshotDir();
$path = $this->getSnapshotPath($name);
// Remove existing snapshot with the same name
if (is_dir($path))
{
$this->removeDirectory($path);
}
if (!mkdir($path, 0755, true))
{
throw new \RuntimeException('Failed to create snapshot directory: ' . $path);
}
$db = Factory::getDbo();
$prefix = $db->getPrefix();
$tables = $db->getTableList();
$dumped = 0;
foreach ($this->tables as $tableName)
{
$realTable = str_replace('#__', $prefix, $tableName);
if (!in_array($realTable, $tables))
{
continue;
}
$this->dumpTable($tableName, $realTable, $path, $db);
$dumped++;
}
// Media snapshot
$hasMedia = false;
if ($this->includeMedia)
{
$hasMedia = $this->snapshotMedia($path);
}
// Write manifest
$manifest = [
'name' => $name,
'created_at' => gmdate('Y-m-d\TH:i:s\Z'),
'tables' => $dumped,
'table_list' => $this->tables,
'has_media' => $hasMedia,
'joomla_version' => JVERSION,
];
file_put_contents(
$path . '/manifest.json',
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
);
Log::add(
sprintf('Demo snapshot "%s" created (%d tables, media=%s)', $name, $dumped, $hasMedia ? 'yes' : 'no'),
Log::INFO,
'mokowaas'
);
return [
'status' => 'ok',
'message' => 'Snapshot created',
'name' => $name,
'tables' => $dumped,
'has_media' => $hasMedia,
];
}
/**
* Restore the site to a named snapshot.
*
* @param string $name Snapshot name to restore
*
* @return array Result payload
*
* @throws \InvalidArgumentException On invalid name
* @throws \RuntimeException On missing snapshot or restore failure
*
* @since 02.21.00
*/
public function restoreSnapshot(string $name): array
{
$this->validateSnapshotName($name);
$path = $this->getSnapshotPath($name);
$manifestFile = $path . '/manifest.json';
if (!file_exists($manifestFile))
{
throw new \RuntimeException('Snapshot not found: ' . $name);
}
$manifest = json_decode(file_get_contents($manifestFile), true);
if (!$manifest)
{
throw new \RuntimeException('Invalid manifest for snapshot: ' . $name);
}
// Clear Joomla cache before restore
try
{
$cache = Factory::getCache('');
$cache->clean('');
}
catch (\Throwable $e)
{
// Cache clear is best-effort
}
$db = Factory::getDbo();
$prefix = $db->getPrefix();
$restored = 0;
// Restore tables — assets first for ACL integrity
$sqlFiles = glob($path . '/*.sql');
// Sort: #__assets first
usort($sqlFiles, function ($a, $b) {
$aIsAssets = str_contains(basename($a), '__assets');
$bIsAssets = str_contains(basename($b), '__assets');
if ($aIsAssets) return -1;
if ($bIsAssets) return 1;
return strcmp($a, $b);
});
foreach ($sqlFiles as $sqlFile)
{
try
{
$this->restoreTable($sqlFile, $db, $prefix);
$restored++;
}
catch (\Throwable $e)
{
Log::add(
sprintf('Demo reset: failed to restore %s: %s', basename($sqlFile), $e->getMessage()),
Log::ERROR,
'mokowaas'
);
}
}
// Restore media
$mediaRestored = false;
if ($manifest['has_media'] ?? false)
{
$mediaRestored = $this->restoreMedia($path);
}
Log::add(
sprintf('Demo site reset to baseline "%s" (%d tables, media=%s)', $name, $restored, $mediaRestored ? 'yes' : 'no'),
Log::WARNING,
'mokowaas'
);
return [
'status' => 'ok',
'message' => 'Site restored to baseline: ' . $name,
'baseline' => $name,
'restored_tables' => $restored,
'media_restored' => $mediaRestored,
];
}
/**
* Delete a named snapshot.
*
* @param string $name Snapshot name
*
* @return bool True on success
*
* @since 02.21.00
*/
public function deleteSnapshot(string $name): bool
{
$this->validateSnapshotName($name);
$path = $this->getSnapshotPath($name);
if (!is_dir($path))
{
return false;
}
$this->removeDirectory($path);
Log::add(
sprintf('Demo snapshot "%s" deleted', $name),
Log::INFO,
'mokowaas'
);
return true;
}
/**
* Dump a single table to a SQL file using paginated reads.
*
* @param string $logicalName Table name with #__ prefix
* @param string $realName Actual table name with prefix
* @param string $dir Snapshot directory
* @param \Joomla\Database\DatabaseInterface $db Database driver
*
* @return void
*
* @since 02.21.00
*/
private function dumpTable(string $logicalName, string $realName, string $dir, $db): void
{
$safeFileName = str_replace('#__', 'jml__', $logicalName);
$fp = fopen($dir . '/' . $safeFileName . '.sql', 'w');
if ($fp === false)
{
throw new \RuntimeException('Cannot write dump file for: ' . $logicalName);
}
// Get column names for consistent INSERT statements
$columns = $db->getTableColumns($realName, false);
$colNames = array_keys($columns);
$quotedCols = array_map([$db, 'quoteName'], $colNames);
$colList = implode(', ', $quotedCols);
$offset = 0;
while (true)
{
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName($realName))
->setLimit(self::BATCH_SIZE, $offset);
$db->setQuery($query);
$rows = $db->loadAssocList();
if (empty($rows))
{
break;
}
// Build multi-value INSERT
$values = [];
foreach ($rows as $row)
{
$vals = [];
foreach ($colNames as $col)
{
$val = $row[$col];
if ($val === null)
{
$vals[] = 'NULL';
}
else
{
$vals[] = $db->quote($val);
}
}
$values[] = '(' . implode(', ', $vals) . ')';
}
fwrite($fp, 'INSERT INTO ' . $db->quoteName($realName)
. ' (' . $colList . ') VALUES ' . "\n"
. implode(",\n", $values) . ";\n\n");
$offset += self::BATCH_SIZE;
if (count($rows) < self::BATCH_SIZE)
{
break;
}
}
fclose($fp);
}
/**
* Restore a table from a SQL dump file.
*
* @param string $sqlFile Path to the .sql file
* @param \Joomla\Database\DatabaseInterface $db Database driver
* @param string $prefix Table prefix
*
* @return void
*
* @since 02.21.00
*/
private function restoreTable(string $sqlFile, $db, string $prefix): void
{
// Derive table name from filename: jml__content.sql -> {prefix}content
$baseName = basename($sqlFile, '.sql');
$realTable = str_replace('jml__', $prefix, $baseName);
// Truncate the table first
$db->setQuery('TRUNCATE TABLE ' . $db->quoteName($realTable));
$db->execute();
$sql = file_get_contents($sqlFile);
if (empty(trim($sql)))
{
return;
}
// Split by semicolons and execute each statement
$statements = array_filter(
array_map('trim', explode(";\n", $sql)),
function ($s) { return !empty($s) && $s !== ';'; }
);
foreach ($statements as $statement)
{
$statement = rtrim($statement, ';');
if (empty($statement))
{
continue;
}
$db->setQuery($statement);
$db->execute();
}
}
/**
* Create a ZIP archive of the /images/ directory.
*
* @param string $snapshotDir Snapshot directory path
*
* @return bool True if media was archived
*
* @since 02.21.00
*/
private function snapshotMedia(string $snapshotDir): bool
{
$imagesDir = JPATH_ROOT . '/images';
if (!is_dir($imagesDir))
{
return false;
}
$zipPath = $snapshotDir . '/media.zip';
$zip = new \ZipArchive();
if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true)
{
return false;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($imagesDir, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $item)
{
$relativePath = substr($item->getPathname(), strlen($imagesDir) + 1);
$relativePath = str_replace('\\', '/', $relativePath);
if ($item->isDir())
{
$zip->addEmptyDir($relativePath);
}
else
{
$zip->addFile($item->getPathname(), $relativePath);
}
}
$zip->close();
return true;
}
/**
* Restore media files from a snapshot ZIP.
*
* @param string $snapshotDir Snapshot directory path
*
* @return bool True if media was restored
*
* @since 02.21.00
*/
private function restoreMedia(string $snapshotDir): bool
{
$zipPath = $snapshotDir . '/media.zip';
$imagesDir = JPATH_ROOT . '/images';
if (!file_exists($zipPath))
{
return false;
}
// Clear existing images directory contents (keep the directory itself)
$this->clearDirectory($imagesDir);
$zip = new \ZipArchive();
if ($zip->open($zipPath) !== true)
{
return false;
}
$zip->extractTo($imagesDir);
$zip->close();
return true;
}
/**
* Ensure the snapshot root directory exists with .htaccess protection.
*
* @return void
*
* @since 02.21.00
*/
private function ensureSnapshotDir(): void
{
if (!is_dir($this->snapshotDir))
{
if (!mkdir($this->snapshotDir, 0755, true))
{
throw new \RuntimeException('Cannot create snapshot directory: ' . $this->snapshotDir);
}
}
$htaccess = $this->snapshotDir . '/.htaccess';
if (!file_exists($htaccess))
{
file_put_contents($htaccess, "Deny from all\n");
}
}
/**
* Get the full path for a named snapshot.
*
* @param string $name Snapshot name
*
* @return string Full directory path
*
* @since 02.21.00
*/
private function getSnapshotPath(string $name): string
{
return $this->snapshotDir . '/' . $name;
}
/**
* Validate a snapshot name to prevent path traversal.
*
* @param string $name Snapshot name to validate
*
* @return void
*
* @throws \InvalidArgumentException On invalid name
*
* @since 02.21.00
*/
private function validateSnapshotName(string $name): void
{
if ($name === '' || strlen($name) > self::MAX_NAME_LENGTH)
{
throw new \InvalidArgumentException(
'Snapshot name must be 1-' . self::MAX_NAME_LENGTH . ' characters'
);
}
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $name))
{
throw new \InvalidArgumentException(
'Snapshot name must contain only letters, numbers, hyphens, and underscores'
);
}
}
/**
* Recursively remove a directory and all contents.
*
* @param string $dir Directory to remove
*
* @return void
*
* @since 02.21.00
*/
private function removeDirectory(string $dir): void
{
$items = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($items as $item)
{
if ($item->isDir())
{
@rmdir($item->getPathname());
}
else
{
@unlink($item->getPathname());
}
}
@rmdir($dir);
}
/**
* Clear all contents of a directory without removing the directory itself.
*
* @param string $dir Directory to clear
*
* @return void
*
* @since 02.21.00
*/
private function clearDirectory(string $dir): void
{
if (!is_dir($dir))
{
return;
}
$items = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($items as $item)
{
if ($item->isDir())
{
@rmdir($item->getPathname());
}
else
{
@unlink($item->getPathname());
}
}
}
}
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -137,6 +137,31 @@ PLG_SYSTEM_MOKOWAAS_UPLOAD_TYPES_DESC="Comma-separated list of allowed file exte
PLG_SYSTEM_MOKOWAAS_UPLOAD_SIZE_LABEL="Max Upload Size (MB)"
PLG_SYSTEM_MOKOWAAS_UPLOAD_SIZE_DESC="Maximum file upload size in megabytes."
; ===== Demo Mode fieldset =====
PLG_SYSTEM_MOKOWAAS_FIELDSET_DEMO_LABEL="Demo Mode"
PLG_SYSTEM_MOKOWAAS_FIELDSET_DEMO_DESC="Configure demo site behavior with baseline snapshots and automatic periodic reset. When enabled, a warning banner is shown on the frontend."
PLG_SYSTEM_MOKOWAAS_DEMO_ENABLED_LABEL="Enable Demo Mode"
PLG_SYSTEM_MOKOWAAS_DEMO_ENABLED_DESC="When enabled, shows a warning banner on the frontend and enables snapshot/restore functionality."
PLG_SYSTEM_MOKOWAAS_DEMO_BANNER_MSG_LABEL="Banner Message"
PLG_SYSTEM_MOKOWAAS_DEMO_BANNER_MSG_DESC="Message displayed in the demo warning banner on the frontend."
PLG_SYSTEM_MOKOWAAS_DEMO_BANNER_COLOR_LABEL="Banner Color"
PLG_SYSTEM_MOKOWAAS_DEMO_BANNER_COLOR_DESC="Background color for the demo warning banner."
PLG_SYSTEM_MOKOWAAS_DEMO_COUNTDOWN_LABEL="Show Reset Countdown"
PLG_SYSTEM_MOKOWAAS_DEMO_COUNTDOWN_DESC="Display a countdown timer in the banner showing time until the next scheduled reset."
PLG_SYSTEM_MOKOWAAS_DEMO_INTERVAL_LABEL="Reset Interval (Hours)"
PLG_SYSTEM_MOKOWAAS_DEMO_INTERVAL_DESC="Hours between scheduled demo resets. Used for countdown display and scheduled task interval."
PLG_SYSTEM_MOKOWAAS_DEMO_TABLES_LABEL="Snapshot Tables"
PLG_SYSTEM_MOKOWAAS_DEMO_TABLES_DESC="Database tables to include in snapshots. One per line, using #__ prefix. These tables will be truncated and restored during a reset."
PLG_SYSTEM_MOKOWAAS_DEMO_MEDIA_LABEL="Include Media Files"
PLG_SYSTEM_MOKOWAAS_DEMO_MEDIA_DESC="Include the /images/ directory in snapshots. Disabling this speeds up snapshot/restore for sites with large media libraries."
PLG_SYSTEM_MOKOWAAS_DEMO_ACTIVE_BASELINE_LABEL="Active Baseline Name"
PLG_SYSTEM_MOKOWAAS_DEMO_ACTIVE_BASELINE_DESC="Name of the baseline snapshot used by admin toggles and scheduled tasks. Alphanumeric, hyphens, and underscores only."
PLG_SYSTEM_MOKOWAAS_DEMO_TAKE_SNAPSHOT_LABEL="Take Snapshot Now"
PLG_SYSTEM_MOKOWAAS_DEMO_TAKE_SNAPSHOT_DESC="Save the current site state as a baseline snapshot. Uses the Active Baseline Name above. Resets to No after execution."
PLG_SYSTEM_MOKOWAAS_DEMO_RESTORE_NOW_LABEL="Restore Baseline Now"
PLG_SYSTEM_MOKOWAAS_DEMO_RESTORE_NOW_DESC="Immediately restore the site to the active baseline snapshot. WARNING: This will overwrite current content. Resets to No after execution."
; ===== Site Aliases fieldset =====
PLG_SYSTEM_MOKOWAAS_FIELDSET_ALIASES_LABEL="Site Aliases"
PLG_SYSTEM_MOKOWAAS_FIELDSET_ALIASES_DESC="Configure additional domains that mirror this site. Each alias can have its own offline status, robots directive, and backend redirect behavior."
@@ -137,6 +137,31 @@ PLG_SYSTEM_MOKOWAAS_UPLOAD_TYPES_DESC="Comma-separated list of allowed file exte
PLG_SYSTEM_MOKOWAAS_UPLOAD_SIZE_LABEL="Max Upload Size (MB)"
PLG_SYSTEM_MOKOWAAS_UPLOAD_SIZE_DESC="Maximum file upload size in megabytes."
; ===== Demo Mode fieldset =====
PLG_SYSTEM_MOKOWAAS_FIELDSET_DEMO_LABEL="Demo Mode"
PLG_SYSTEM_MOKOWAAS_FIELDSET_DEMO_DESC="Configure demo site behavior with baseline snapshots and automatic periodic reset. When enabled, a warning banner is shown on the frontend."
PLG_SYSTEM_MOKOWAAS_DEMO_ENABLED_LABEL="Enable Demo Mode"
PLG_SYSTEM_MOKOWAAS_DEMO_ENABLED_DESC="When enabled, shows a warning banner on the frontend and enables snapshot/restore functionality."
PLG_SYSTEM_MOKOWAAS_DEMO_BANNER_MSG_LABEL="Banner Message"
PLG_SYSTEM_MOKOWAAS_DEMO_BANNER_MSG_DESC="Message displayed in the demo warning banner on the frontend."
PLG_SYSTEM_MOKOWAAS_DEMO_BANNER_COLOR_LABEL="Banner Color"
PLG_SYSTEM_MOKOWAAS_DEMO_BANNER_COLOR_DESC="Background color for the demo warning banner."
PLG_SYSTEM_MOKOWAAS_DEMO_COUNTDOWN_LABEL="Show Reset Countdown"
PLG_SYSTEM_MOKOWAAS_DEMO_COUNTDOWN_DESC="Display a countdown timer in the banner showing time until the next scheduled reset."
PLG_SYSTEM_MOKOWAAS_DEMO_INTERVAL_LABEL="Reset Interval (Hours)"
PLG_SYSTEM_MOKOWAAS_DEMO_INTERVAL_DESC="Hours between scheduled demo resets. Used for countdown display and scheduled task interval."
PLG_SYSTEM_MOKOWAAS_DEMO_TABLES_LABEL="Snapshot Tables"
PLG_SYSTEM_MOKOWAAS_DEMO_TABLES_DESC="Database tables to include in snapshots. One per line, using #__ prefix. These tables will be truncated and restored during a reset."
PLG_SYSTEM_MOKOWAAS_DEMO_MEDIA_LABEL="Include Media Files"
PLG_SYSTEM_MOKOWAAS_DEMO_MEDIA_DESC="Include the /images/ directory in snapshots. Disabling this speeds up snapshot/restore for sites with large media libraries."
PLG_SYSTEM_MOKOWAAS_DEMO_ACTIVE_BASELINE_LABEL="Active Baseline Name"
PLG_SYSTEM_MOKOWAAS_DEMO_ACTIVE_BASELINE_DESC="Name of the baseline snapshot used by admin toggles and scheduled tasks. Alphanumeric, hyphens, and underscores only."
PLG_SYSTEM_MOKOWAAS_DEMO_TAKE_SNAPSHOT_LABEL="Take Snapshot Now"
PLG_SYSTEM_MOKOWAAS_DEMO_TAKE_SNAPSHOT_DESC="Save the current site state as a baseline snapshot. Uses the Active Baseline Name above. Resets to No after execution."
PLG_SYSTEM_MOKOWAAS_DEMO_RESTORE_NOW_LABEL="Restore Baseline Now"
PLG_SYSTEM_MOKOWAAS_DEMO_RESTORE_NOW_DESC="Immediately restore the site to the active baseline snapshot. WARNING: This will overwrite current content. Resets to No after execution."
; ===== Site Aliases fieldset =====
PLG_SYSTEM_MOKOWAAS_FIELDSET_ALIASES_LABEL="Site Aliases"
PLG_SYSTEM_MOKOWAAS_FIELDSET_ALIASES_DESC="Configure additional domains that mirror this site. Each alias can have its own offline status, robots directive, and backend redirect behavior."
+64 -2
View File
@@ -30,7 +30,7 @@
<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.20.00</version>
<version>02.21.01-dev</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>
@@ -39,6 +39,7 @@
<filename plugin="mokowaas">script.php</filename>
<folder>Extension</folder>
<folder>Field</folder>
<folder>Service</folder>
<folder>forms</folder>
<folder>payload</folder>
<folder>services</folder>
@@ -264,7 +265,68 @@
description="PLG_SYSTEM_MOKOWAAS_HIDDEN_MENUS_DESC"
rows="5" filter="raw" />
</fieldset>
<fieldset name="site_aliases"
<fieldset name="demo_mode"
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_DEMO_LABEL"
description="PLG_SYSTEM_MOKOWAAS_FIELDSET_DEMO_DESC"
>
<field name="demo_mode_enabled" type="radio" default="0"
label="PLG_SYSTEM_MOKOWAAS_DEMO_ENABLED_LABEL"
description="PLG_SYSTEM_MOKOWAAS_DEMO_ENABLED_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="demo_banner_message" type="text"
label="PLG_SYSTEM_MOKOWAAS_DEMO_BANNER_MSG_LABEL"
description="PLG_SYSTEM_MOKOWAAS_DEMO_BANNER_MSG_DESC"
default="This is a demo site. All changes will be reset periodically." />
<field name="demo_banner_color" type="color"
label="PLG_SYSTEM_MOKOWAAS_DEMO_BANNER_COLOR_LABEL"
description="PLG_SYSTEM_MOKOWAAS_DEMO_BANNER_COLOR_DESC"
default="#d9534f" />
<field name="demo_banner_show_countdown" type="radio" default="0"
label="PLG_SYSTEM_MOKOWAAS_DEMO_COUNTDOWN_LABEL"
description="PLG_SYSTEM_MOKOWAAS_DEMO_COUNTDOWN_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="demo_reset_interval_hours" type="number"
label="PLG_SYSTEM_MOKOWAAS_DEMO_INTERVAL_LABEL"
description="PLG_SYSTEM_MOKOWAAS_DEMO_INTERVAL_DESC"
default="24" hint="Hours between scheduled resets" />
<field name="demo_snapshot_tables" type="textarea"
label="PLG_SYSTEM_MOKOWAAS_DEMO_TABLES_LABEL"
description="PLG_SYSTEM_MOKOWAAS_DEMO_TABLES_DESC"
rows="8" filter="raw"
default="#__content&#10;#__categories&#10;#__fields&#10;#__fields_values&#10;#__fields_groups&#10;#__menu&#10;#__menu_types&#10;#__modules&#10;#__modules_menu&#10;#__users&#10;#__user_usergroup_map&#10;#__user_profiles&#10;#__tags&#10;#__contentitem_tag_map&#10;#__assets" />
<field name="demo_snapshot_include_media" type="radio" default="1"
label="PLG_SYSTEM_MOKOWAAS_DEMO_MEDIA_LABEL"
description="PLG_SYSTEM_MOKOWAAS_DEMO_MEDIA_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="demo_active_baseline" type="text"
label="PLG_SYSTEM_MOKOWAAS_DEMO_ACTIVE_BASELINE_LABEL"
description="PLG_SYSTEM_MOKOWAAS_DEMO_ACTIVE_BASELINE_DESC"
default="default" />
<field name="demo_take_snapshot_now" type="radio" default="0"
label="PLG_SYSTEM_MOKOWAAS_DEMO_TAKE_SNAPSHOT_LABEL"
description="PLG_SYSTEM_MOKOWAAS_DEMO_TAKE_SNAPSHOT_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="demo_restore_now" type="radio" default="0"
label="PLG_SYSTEM_MOKOWAAS_DEMO_RESTORE_NOW_LABEL"
description="PLG_SYSTEM_MOKOWAAS_DEMO_RESTORE_NOW_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="site_aliases"
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_ALIASES_LABEL"
description="PLG_SYSTEM_MOKOWAAS_FIELDSET_ALIASES_DESC"
>
+1 -1
View File
@@ -22,7 +22,7 @@
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* REPO: https://github.com/mokoconsulting-tech/mokowaas
* VERSION: 02.01.08
* VERSION: 02.21.01
* 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.01.08
* VERSION: 02.21.01
* PATH: /src/services/provider.php
* BRIEF: Service provider for dependency injection in Joomla 5.x
* NOTE: Registers the plugin with Joomla's DI container
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<form>
<fieldset name="reset_params">
<field name="baseline" type="text"
label="PLG_TASK_MOKOWAASDEMO_BASELINE_LABEL"
description="PLG_TASK_MOKOWAASDEMO_BASELINE_DESC"
default="default" />
</fieldset>
</form>
@@ -0,0 +1,10 @@
; MokoWaaS Demo Reset Task Plugin
; Copyright (C) 2026 Moko Consulting
; SPDX-License-Identifier: GPL-3.0-or-later
PLG_TASK_MOKOWAASDEMO="Task - MokoWaaS Demo Reset"
PLG_TASK_MOKOWAASDEMO_DESC="Scheduled task to periodically reset a demo site to a saved baseline snapshot."
PLG_TASK_MOKOWAASDEMO_RESET_TITLE="MokoWaaS Demo Reset"
PLG_TASK_MOKOWAASDEMO_RESET_DESC="Restore the site to a named baseline snapshot. Configure the baseline name and schedule interval."
PLG_TASK_MOKOWAASDEMO_BASELINE_LABEL="Baseline Name"
PLG_TASK_MOKOWAASDEMO_BASELINE_DESC="Name of the snapshot to restore. Must match a snapshot created via the MokoWaaS Demo Mode settings."
@@ -0,0 +1,6 @@
; MokoWaaS Demo Reset Task Plugin (sys)
; Copyright (C) 2026 Moko Consulting
; SPDX-License-Identifier: GPL-3.0-or-later
PLG_TASK_MOKOWAASDEMO="Task - MokoWaaS Demo Reset"
PLG_TASK_MOKOWAASDEMO_DESC="Scheduled task to periodically reset a demo site to a saved baseline snapshot."
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
-->
<extension type="plugin" group="task" method="upgrade">
<name>Task - MokoWaaS Demo Reset</name>
<element>mokowaasdemo</element>
<author>Moko Consulting</author>
<creationDate>2026-05-30</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GNU General Public License version 3 or later; see LICENSE</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.21.01-dev</version>
<description>PLG_TASK_MOKOWAASDEMO_DESC</description>
<namespace path="src">Moko\Plugin\Task\MokoWaaSDemo</namespace>
<files>
<folder>src</folder>
<folder>services</folder>
<folder>forms</folder>
<folder>language</folder>
</files>
<languages folder="language">
<language tag="en-GB">en-GB/plg_task_mokowaasdemo.ini</language>
<language tag="en-GB">en-GB/plg_task_mokowaasdemo.sys.ini</language>
</languages>
</extension>
@@ -0,0 +1,37 @@
<?php
/**
* @package MokoWaaS
* @subpackage plg_task_mokowaasdemo
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Moko\Plugin\Task\MokoWaaSDemo\Extension\DemoReset;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$dispatcher = $container->get(DispatcherInterface::class);
$plugin = new DemoReset(
$dispatcher,
(array) PluginHelper::getPlugin('task', 'mokowaasdemo')
);
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -0,0 +1,125 @@
<?php
/**
* @package MokoWaaS
* @subpackage plg_task_mokowaasdemo
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Plugin\Task\MokoWaaSDemo\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent;
use Joomla\Component\Scheduler\Administrator\Task\Status;
use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait;
use Joomla\Event\DispatcherInterface;
use Joomla\Event\SubscriberInterface;
use Joomla\Registry\Registry;
/**
* MokoWaaS Demo Reset — Joomla Scheduled Task Plugin.
*
* Registers a task routine that restores the site to a named baseline
* snapshot on a configurable schedule (e.g. every 24 hours).
*
* @since 02.21.00
*/
final class DemoReset extends CMSPlugin implements SubscriberInterface
{
use TaskPluginTrait;
/**
* Task routine map.
*
* @var array
* @since 02.21.00
*/
protected const TASKS_MAP = [
'mokowaas.demo.reset' => [
'langConstPrefix' => 'PLG_TASK_MOKOWAASDEMO_RESET',
'method' => 'resetDemoSite',
'form' => 'reset_params',
],
];
/**
* @return array
*
* @since 02.21.00
*/
public static function getSubscribedEvents(): array
{
return [
'onTaskOptionsList' => 'advertiseRoutines',
'onExecuteTask' => 'standardRoutineHandler',
'onContentPrepareForm' => 'enhanceTaskItemForm',
];
}
/**
* Reset the demo site to a baseline snapshot.
*
* @param ExecuteTaskEvent $event The task event
*
* @return int Status::OK or Status::KNOCKOUT
*
* @since 02.21.00
*/
private function resetDemoSite(ExecuteTaskEvent $event): int
{
$params = $event->getArgument('params');
$baseline = $params->baseline ?? 'default';
// Load system plugin params for table list and media setting
$sysPlugin = PluginHelper::getPlugin('system', 'mokowaas');
if (!$sysPlugin)
{
$this->logTask('MokoWaaS system plugin not enabled — cannot reset');
return Status::KNOCKOUT;
}
$sysParams = new Registry($sysPlugin->params);
// Load the service
$serviceFile = JPATH_PLUGINS . '/system/mokowaas/Service/DemoResetService.php';
if (!file_exists($serviceFile))
{
$this->logTask('DemoResetService.php not found');
return Status::KNOCKOUT;
}
require_once $serviceFile;
$tablesRaw = $sysParams->get('demo_snapshot_tables', '');
$tables = array_filter(array_map('trim', explode("\n", $tablesRaw)));
$media = (bool) $sysParams->get('demo_snapshot_include_media', 1);
$service = new \Moko\Plugin\System\MokoWaaS\Service\DemoResetService($tables, $media);
try
{
$result = $service->restoreSnapshot($baseline);
$this->logTask(sprintf(
'Demo site reset to "%s" — %d tables restored, media=%s',
$baseline,
$result['restored_tables'],
$result['media_restored'] ? 'yes' : 'no'
));
return Status::OK;
}
catch (\Throwable $e)
{
$this->logTask('Demo reset failed: ' . $e->getMessage());
return Status::KNOCKOUT;
}
}
}
@@ -64,5 +64,23 @@ final class MokoWaaSApi extends CMSPlugin implements SubscriberInterface
'update',
['component' => 'com_mokowaas']
);
$router->createCRUDRoutes(
'v1/mokowaas/install',
'install',
['component' => 'com_mokowaas']
);
$router->createCRUDRoutes(
'v1/mokowaas/reset',
'reset',
['component' => 'com_mokowaas']
);
$router->createCRUDRoutes(
'v1/mokowaas/snapshot',
'snapshot',
['component' => 'com_mokowaas']
);
}
}
@@ -8,7 +8,7 @@
* INGROUP: MokoWaaS
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
* PATH: /src/packages/plg_webservices_perfectpublisher/services/provider.php
* VERSION: 02.13.01
* VERSION: 02.21.01
* 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.13.01
* VERSION: 02.21.01
* BRIEF: Web Services API plugin for Perfect Publisher (com_autotweet)
*/
+2 -1
View File
@@ -2,7 +2,7 @@
<extension type="package" method="upgrade">
<name>MokoWaaS</name>
<packagename>mokowaas</packagename>
<version>02.20.00</version>
<version>02.21.01-dev</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -17,6 +17,7 @@
<file type="component" id="com_mokowaas">com_mokowaas.zip</file>
<file type="plugin" id="plg_webservices_mokowaas" group="webservices">plg_webservices_mokowaas.zip</file>
<file type="plugin" id="plg_webservices_perfectpublisher" group="webservices">plg_webservices_perfectpublisher.zip</file>
<file type="plugin" id="plg_task_mokowaasdemo" group="task">plg_task_mokowaasdemo.zip</file>
</files>
<updateservers>
+1
View File
@@ -36,6 +36,7 @@ class Pkg_MokowaasInstallerScript
{
$this->enablePlugin('system', 'mokowaas');
$this->enablePlugin('webservices', 'mokowaas');
$this->enablePlugin('task', 'mokowaasdemo');
// Mark MokoWaaS extensions as protected (prevents disable/uninstall at framework level)
$this->protectExtensions();
+109 -6
View File
@@ -164,7 +164,7 @@ Requesting an unknown action returns HTTP 400 with the list of available actions
{
"error": "Unknown action",
"action": "invalid",
"available": ["health", "install", "update", "cache", "backup", "info"]
"available": ["health", "install", "update", "cache", "backup", "info", "reset", "snapshot"]
}
```
@@ -172,10 +172,113 @@ Requesting an unknown action returns HTTP 400 with the list of available actions
In addition to the query-string endpoints above, MokoWaaS registers standard Joomla API routes via the `plg_webservices_mokowaas` plugin:
| Route | Controller |
|---|---|
| `GET /api/v1/mokowaas/health` | HealthController |
| `POST /api/v1/mokowaas/cache` | CacheController |
| `POST /api/v1/mokowaas/update` | UpdateController |
| Route | Controller | Description |
|---|---|---|
| `GET /api/v1/mokowaas/health` | HealthController | Full site health diagnostics |
| `POST /api/v1/mokowaas/cache` | CacheController | Clear all caches |
| `POST /api/v1/mokowaas/update` | UpdateController | Trigger update check |
| `POST /api/v1/mokowaas/install` | InstallController | Install extension from ZIP URL |
| `POST /api/v1/mokowaas/reset` | ResetController | Restore site to baseline snapshot |
| `GET/POST /api/v1/mokowaas/snapshot` | SnapshotController | List or create snapshots |
These routes use Joomla's standard API authentication (API token in `X-Joomla-Token` header) and are useful for integrations that already use the Joomla API framework.
### Install Endpoint (REST API)
```
POST /api/index.php/v1/mokowaas/install
X-Joomla-Token: <api-token>
Content-Type: application/json
{"url": "https://git.mokoconsulting.tech/.../plg_system_mokowaas-02.20.00.zip"}
```
Downloads the ZIP from the given URL and installs it via Joomla's Installer. Requires a Joomla API token with `core.manage` permission on `com_installer`.
**Constraints:**
- URL must use `http` or `https` scheme
- URL path must end in `.zip`
- Maximum download size: 64 MB
**Success Response** (HTTP 200):
```json
{
"status": "ok",
"message": "Extension installed successfully",
"extension": {
"name": "MokoWaaS",
"version": "02.20.00",
"type": "plugin"
},
"source_url": "https://git.mokoconsulting.tech/.../plg_system_mokowaas-02.20.00.zip"
}
```
**Error Responses:**
| HTTP Status | Condition |
|---|---|
| 400 | Missing `url` field, non-HTTPS URL, or URL not ending in `.zip` |
| 403 | API token lacks `core.manage` on `com_installer` |
| 405 | Request method is not POST |
| 500 | Download or installation failed |
### Reset Endpoint (REST API)
```
POST /api/index.php/v1/mokowaas/reset
X-Joomla-Token: <api-token>
Content-Type: application/json
{"baseline": "default"}
```
Restores the site to a named baseline snapshot. The `baseline` field is optional and defaults to the active baseline configured in the plugin.
Requires `core.manage` permission on `com_plugins`.
**Success Response** (HTTP 200):
```json
{
"status": "ok",
"message": "Site restored to baseline: default",
"baseline": "default",
"restored_tables": 15,
"media_restored": true
}
```
### Snapshot Endpoint (REST API)
**List snapshots:**
```
GET /api/index.php/v1/mokowaas/snapshot
X-Joomla-Token: <api-token>
```
**Create snapshot:**
```
POST /api/index.php/v1/mokowaas/snapshot
X-Joomla-Token: <api-token>
Content-Type: application/json
{"name": "my-baseline"}
```
The `name` field is optional and defaults to the active baseline name.
**Success Response** (HTTP 200):
```json
{
"status": "ok",
"message": "Snapshot created",
"name": "my-baseline",
"tables": 15,
"has_media": true
}
```