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 @@ +