diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d9ddb7..104e3b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ ## [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/src/packages/com_mokowaas/api/src/Controller/ResetController.php b/src/packages/com_mokowaas/api/src/Controller/ResetController.php new file mode 100644 index 0000000..671332f --- /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 0000000..2577cb7 --- /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 7528f4f..62648f4 100644 --- a/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php +++ b/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php @@ -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/Service/DemoResetService.php b/src/packages/plg_system_mokowaas/Service/DemoResetService.php new file mode 100644 index 0000000..bb0bb59 --- /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 0000000..2efb97f --- /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 538d0cf..81098e2 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 4adb06c..b26a7b6 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 f55d887..458ca50 100644 --- a/src/packages/plg_system_mokowaas/mokowaas.xml +++ b/src/packages/plg_system_mokowaas/mokowaas.xml @@ -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" /> -
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
diff --git a/src/packages/plg_task_mokowaas/forms/reset_params.xml b/src/packages/plg_task_mokowaas/forms/reset_params.xml new file mode 100644 index 0000000..f284493 --- /dev/null +++ b/src/packages/plg_task_mokowaas/forms/reset_params.xml @@ -0,0 +1,9 @@ + +
+
+ +
+
diff --git a/src/packages/plg_task_mokowaas/language/en-GB/plg_task_mokowaasdemo.ini b/src/packages/plg_task_mokowaas/language/en-GB/plg_task_mokowaasdemo.ini new file mode 100644 index 0000000..112d51f --- /dev/null +++ b/src/packages/plg_task_mokowaas/language/en-GB/plg_task_mokowaasdemo.ini @@ -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." diff --git a/src/packages/plg_task_mokowaas/language/en-GB/plg_task_mokowaasdemo.sys.ini b/src/packages/plg_task_mokowaas/language/en-GB/plg_task_mokowaasdemo.sys.ini new file mode 100644 index 0000000..12c08f8 --- /dev/null +++ b/src/packages/plg_task_mokowaas/language/en-GB/plg_task_mokowaasdemo.sys.ini @@ -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." diff --git a/src/packages/plg_task_mokowaas/mokowaasdemo.xml b/src/packages/plg_task_mokowaas/mokowaasdemo.xml new file mode 100644 index 0000000..cb008be --- /dev/null +++ b/src/packages/plg_task_mokowaas/mokowaasdemo.xml @@ -0,0 +1,30 @@ + + + + Task - MokoWaaS Demo Reset + mokowaasdemo + Moko Consulting + 2026-05-30 + Copyright (C) 2026 Moko Consulting. All rights reserved. + GNU General Public License version 3 or later; see LICENSE + hello@mokoconsulting.tech + https://mokoconsulting.tech + 02.21.00 + PLG_TASK_MOKOWAASDEMO_DESC + Moko\Plugin\Task\MokoWaaSDemo + + + src + services + forms + language + + + + en-GB/plg_task_mokowaasdemo.ini + en-GB/plg_task_mokowaasdemo.sys.ini + + diff --git a/src/packages/plg_task_mokowaas/services/provider.php b/src/packages/plg_task_mokowaas/services/provider.php new file mode 100644 index 0000000..b8af1d3 --- /dev/null +++ b/src/packages/plg_task_mokowaas/services/provider.php @@ -0,0 +1,37 @@ +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; + } + ); + } +}; diff --git a/src/packages/plg_task_mokowaas/src/Extension/DemoReset.php b/src/packages/plg_task_mokowaas/src/Extension/DemoReset.php new file mode 100644 index 0000000..24d2f2f --- /dev/null +++ b/src/packages/plg_task_mokowaas/src/Extension/DemoReset.php @@ -0,0 +1,125 @@ + [ + '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; + } + } +} diff --git a/src/packages/plg_webservices_mokowaas/src/Extension/MokoWaaSApi.php b/src/packages/plg_webservices_mokowaas/src/Extension/MokoWaaSApi.php index 7955888..7452339 100644 --- a/src/packages/plg_webservices_mokowaas/src/Extension/MokoWaaSApi.php +++ b/src/packages/plg_webservices_mokowaas/src/Extension/MokoWaaSApi.php @@ -70,5 +70,17 @@ final class MokoWaaSApi extends CMSPlugin implements SubscriberInterface 'install', ['component' => 'com_mokowaas'] ); + + $router->createCRUDRoutes( + 'v1/mokowaas/reset', + 'reset', + ['component' => 'com_mokowaas'] + ); + + $router->createCRUDRoutes( + 'v1/mokowaas/snapshot', + 'snapshot', + ['component' => 'com_mokowaas'] + ); } } diff --git a/src/pkg_mokowaas.xml b/src/pkg_mokowaas.xml index 1943c43..d5de346 100644 --- a/src/pkg_mokowaas.xml +++ b/src/pkg_mokowaas.xml @@ -17,6 +17,7 @@ com_mokowaas.zip plg_webservices_mokowaas.zip plg_webservices_perfectpublisher.zip + plg_task_mokowaasdemo.zip diff --git a/src/script.php b/src/script.php index cc58abf..8bf5daa 100644 --- a/src/script.php +++ b/src/script.php @@ -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(); diff --git a/wiki/api-endpoints.md b/wiki/api-endpoints.md index f1fd79e..aeab2a1 100644 --- a/wiki/api-endpoints.md +++ b/wiki/api-endpoints.md @@ -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"] } ``` @@ -178,6 +178,8 @@ In addition to the query-string endpoints above, MokoWaaS registers standard Joo | `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. @@ -221,3 +223,62 @@ Downloads the ZIP from the given URL and installs it via Joomla's Installer. Req | 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: +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: +``` + +**Create snapshot:** + +``` +POST /api/index.php/v1/mokowaas/snapshot +X-Joomla-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 +} +```