diff --git a/.mokogitea/manifest.xml b/.mokogitea/manifest.xml
index d01fd65f..144acd5b 100644
--- a/.mokogitea/manifest.xml
+++ b/.mokogitea/manifest.xml
@@ -8,7 +8,7 @@
MokoWaaS
MokoConsulting
White-label identity, security hardening, and tenant restriction layer for WaaS-managed Joomla environments
- 02.20.00
+ 02.21.01
GNU General Public License v3
diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml
index f084fe1b..f18bc373 100644
--- a/.mokogitea/workflows/issue-branch.yml
+++ b/.mokogitea/workflows/issue-branch.yml
@@ -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"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 110aad70..feac508d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index 8930b430..72a81fd2 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -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
-->
diff --git a/GOVERNANCE.md b/GOVERNANCE.md
index ffd817a5..a0355f0f 100644
--- a/GOVERNANCE.md
+++ b/GOVERNANCE.md
@@ -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
-->
diff --git a/LICENSE.md b/LICENSE.md
index 25285186..1a2e15af 100644
--- a/LICENSE.md
+++ b/LICENSE.md
@@ -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
diff --git a/SECURITY.md b/SECURITY.md
index 6568c9a0..43c4652e 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -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
-->
diff --git a/docs/guides/build-guide.md b/docs/guides/build-guide.md
index 8f129653..db27c3d7 100644
--- a/docs/guides/build-guide.md
+++ b/docs/guides/build-guide.md
@@ -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
diff --git a/docs/guides/configuration-guide.md b/docs/guides/configuration-guide.md
index 2bcb5dc1..6f58a5ed 100644
--- a/docs/guides/configuration-guide.md
+++ b/docs/guides/configuration-guide.md
@@ -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
diff --git a/docs/guides/installation-guide.md b/docs/guides/installation-guide.md
index 2e3d7dfe..710cf2fd 100644
--- a/docs/guides/installation-guide.md
+++ b/docs/guides/installation-guide.md
@@ -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
diff --git a/docs/guides/operations-guide.md b/docs/guides/operations-guide.md
index 4aca9b32..3e14978e 100644
--- a/docs/guides/operations-guide.md
+++ b/docs/guides/operations-guide.md
@@ -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
diff --git a/docs/guides/rollback-and-recovery-guide.md b/docs/guides/rollback-and-recovery-guide.md
index 754d4b48..8b01d7ae 100644
--- a/docs/guides/rollback-and-recovery-guide.md
+++ b/docs/guides/rollback-and-recovery-guide.md
@@ -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
diff --git a/docs/guides/testing-guide.md b/docs/guides/testing-guide.md
index 8252d0dc..49c88c70 100644
--- a/docs/guides/testing-guide.md
+++ b/docs/guides/testing-guide.md
@@ -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
diff --git a/docs/guides/troubleshooting-guide.md b/docs/guides/troubleshooting-guide.md
index 451f438d..54c1554f 100644
--- a/docs/guides/troubleshooting-guide.md
+++ b/docs/guides/troubleshooting-guide.md
@@ -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
diff --git a/docs/guides/upgrade-and-versioning-guide.md b/docs/guides/upgrade-and-versioning-guide.md
index 13a440e9..e3e5b413 100644
--- a/docs/guides/upgrade-and-versioning-guide.md
+++ b/docs/guides/upgrade-and-versioning-guide.md
@@ -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
diff --git a/docs/index.md b/docs/index.md
index 50f7d918..36c8e541 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -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
diff --git a/docs/plugin-basic.md b/docs/plugin-basic.md
index 249b7b8a..57b8804c 100644
--- a/docs/plugin-basic.md
+++ b/docs/plugin-basic.md
@@ -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
diff --git a/docs/update-server.md b/docs/update-server.md
index 5bfe0327..0442b3ea 100644
--- a/docs/update-server.md
+++ b/docs/update-server.md
@@ -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
-->
diff --git a/src/packages/com_mokowaas/api/src/Controller/InstallController.php b/src/packages/com_mokowaas/api/src/Controller/InstallController.php
new file mode 100644
index 00000000..8b36c42a
--- /dev/null
+++ b/src/packages/com_mokowaas/api/src/Controller/InstallController.php
@@ -0,0 +1,283 @@
+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();
+ }
+}
diff --git a/src/packages/com_mokowaas/api/src/Controller/ResetController.php b/src/packages/com_mokowaas/api/src/Controller/ResetController.php
new file mode 100644
index 00000000..671332f9
--- /dev/null
+++ b/src/packages/com_mokowaas/api/src/Controller/ResetController.php
@@ -0,0 +1,126 @@
+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();
+ }
+}
diff --git a/src/packages/com_mokowaas/api/src/Controller/SnapshotController.php b/src/packages/com_mokowaas/api/src/Controller/SnapshotController.php
new file mode 100644
index 00000000..2577cb7f
--- /dev/null
+++ b/src/packages/com_mokowaas/api/src/Controller/SnapshotController.php
@@ -0,0 +1,153 @@
+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();
+ }
+}
diff --git a/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php b/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php
index 0886fb27..d8bb0c0c 100644
--- a/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php
+++ b/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php
@@ -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 = '{$message}' +
+ '" . ($showCountdown ? "" : "") . "' +
+ '';
+
+ 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.
*
diff --git a/src/packages/plg_system_mokowaas/Field/AllowedIpsField.php b/src/packages/plg_system_mokowaas/Field/AllowedIpsField.php
index 65bdef20..3fd8ef3a 100644
--- a/src/packages/plg_system_mokowaas/Field/AllowedIpsField.php
+++ b/src/packages/plg_system_mokowaas/Field/AllowedIpsField.php
@@ -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
*/
diff --git a/src/packages/plg_system_mokowaas/Field/CurrentIpField.php b/src/packages/plg_system_mokowaas/Field/CurrentIpField.php
index 6de6e00f..2a1a6b10 100644
--- a/src/packages/plg_system_mokowaas/Field/CurrentIpField.php
+++ b/src/packages/plg_system_mokowaas/Field/CurrentIpField.php
@@ -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
*/
diff --git a/src/packages/plg_system_mokowaas/Service/DemoResetService.php b/src/packages/plg_system_mokowaas/Service/DemoResetService.php
new file mode 100644
index 00000000..600ada42
--- /dev/null
+++ b/src/packages/plg_system_mokowaas/Service/DemoResetService.php
@@ -0,0 +1,714 @@
+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());
+ }
+ }
+ }
+}
diff --git a/src/packages/plg_system_mokowaas/Service/index.html b/src/packages/plg_system_mokowaas/Service/index.html
new file mode 100644
index 00000000..2efb97f3
--- /dev/null
+++ b/src/packages/plg_system_mokowaas/Service/index.html
@@ -0,0 +1 @@
+
diff --git a/src/packages/plg_system_mokowaas/language/en-GB/plg_system_mokowaas.ini b/src/packages/plg_system_mokowaas/language/en-GB/plg_system_mokowaas.ini
index 538d0cf8..81098e29 100644
--- a/src/packages/plg_system_mokowaas/language/en-GB/plg_system_mokowaas.ini
+++ b/src/packages/plg_system_mokowaas/language/en-GB/plg_system_mokowaas.ini
@@ -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."
diff --git a/src/packages/plg_system_mokowaas/language/en-US/plg_system_mokowaas.ini b/src/packages/plg_system_mokowaas/language/en-US/plg_system_mokowaas.ini
index 4adb06cf..b26a7b6a 100644
--- a/src/packages/plg_system_mokowaas/language/en-US/plg_system_mokowaas.ini
+++ b/src/packages/plg_system_mokowaas/language/en-US/plg_system_mokowaas.ini
@@ -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."
diff --git a/src/packages/plg_system_mokowaas/mokowaas.xml b/src/packages/plg_system_mokowaas/mokowaas.xml
index 41ac70f8..94b8dba3 100644
--- a/src/packages/plg_system_mokowaas/mokowaas.xml
+++ b/src/packages/plg_system_mokowaas/mokowaas.xml
@@ -30,7 +30,7 @@
GNU General Public License version 3 or later; see LICENSE.md
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.20.00
+ 02.21.01-dev
This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform.
Moko\Plugin\System\MokoWaaS
script.php
@@ -39,6 +39,7 @@
script.php
Extension
Field
+ Service
forms
payload
services
@@ -264,7 +265,68 @@
description="PLG_SYSTEM_MOKOWAAS_HIDDEN_MENUS_DESC"
rows="5" filter="raw" />
-
+