Files
MokoSuiteClient/source/packages/com_mokosuite/admin/src/Model/DashboardModel.php
T
Jonathan Miller 00d44256b4 refactor: rename MokoWaaS to MokoSuite across entire codebase
Rebrand all 17 sub-extensions from mokowaas to mokosuite naming,
including component, plugins, modules, task plugins, and webservices.
Updates package manifest, workflows, docs, wiki, and issue templates.
Adds new plg_system_mokosuite_license extension.
2026-06-07 09:25:45 -05:00

640 lines
18 KiB
PHP

<?php
/**
* @package MokoSuite
* @subpackage com_mokosuite
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuite\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Joomla\CMS\Version;
class DashboardModel extends BaseDatabaseModel
{
/**
* Feature plugin metadata keyed by element name.
* Provides icon, category, and description for dashboard display.
*/
private const PLUGIN_META = [
'mokosuite' => [
'icon' => 'icon-shield-alt',
'category' => 'core',
'label' => 'Core',
'description' => 'Heartbeat, health monitoring, site aliases, extension coordination, and download key preservation.',
'protected' => true,
'configure_only' => false,
],
'mokosuite_firewall' => [
'icon' => 'icon-lock',
'category' => 'security',
'label' => 'Firewall',
'description' => 'Web Application Firewall — SQLi, XSS, RFI, DFI shields, IP blocklist, admin secret URL, file protection.',
'protected' => false,
'configure_only' => false,
],
'mokosuite_tenant' => [
'icon' => 'icon-users',
'category' => 'security',
'label' => 'Tenant Restrictions',
'description' => 'Installer, sysinfo, config, and template access restrictions for non-master users.',
'protected' => false,
'configure_only' => false,
],
'mokosuite_offline' => [
'icon' => 'icon-globe',
'category' => 'security',
'label' => 'Offline Bypass',
'description' => 'Keep selected pages (TOS, Privacy Policy) accessible during offline mode.',
'protected' => false,
'configure_only' => true,
],
'mokosuite_devtools' => [
'icon' => 'icon-wrench',
'category' => 'tools',
'label' => 'Developer Tools',
'description' => 'Dev mode, hit counter reset, content version cleanup. Features are controlled inside the plugin settings.',
'protected' => false,
'configure_only' => true,
],
'mokosuitedemo' => [
'icon' => 'icon-undo',
'category' => 'content',
'label' => 'Demo Reset Task',
'description' => 'Scheduled demo site reset with content snapshots.',
'protected' => false,
'configure_only' => true,
],
'mokosuitesync' => [
'icon' => 'icon-sync',
'category' => 'content',
'label' => 'Content Sync Task',
'description' => 'Scheduled content synchronisation to remote MokoSuite sites.',
'protected' => false,
'configure_only' => true,
],
];
/**
* Category display labels and colours.
*/
private const CATEGORIES = [
'core' => ['label' => 'Core', 'badge' => 'bg-dark'],
'security' => ['label' => 'Security', 'badge' => 'bg-danger'],
'tools' => ['label' => 'Tools', 'badge' => 'bg-info'],
'monitoring' => ['label' => 'Monitoring', 'badge' => 'bg-success'],
'content' => ['label' => 'Content', 'badge' => 'bg-primary'],
'api' => ['label' => 'API', 'badge' => 'bg-secondary'],
];
/**
* Discover all installed MokoSuite plugins.
*
* @return array Plugin rows enriched with dashboard metadata.
*/
public function getFeaturePlugins(): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select([
$db->quoteName('extension_id'),
$db->quoteName('name'),
$db->quoteName('element'),
$db->quoteName('folder'),
$db->quoteName('type'),
$db->quoteName('enabled'),
$db->quoteName('protected'),
$db->quoteName('params'),
$db->quoteName('manifest_cache'),
])
->from($db->quoteName('#__extensions'))
->where([
'(' .
// System plugins: mokosuite, mokosuite_*
'(' . $db->quoteName('type') . ' = ' . $db->quote('plugin')
. ' AND ' . $db->quoteName('folder') . ' = ' . $db->quote('system')
. ' AND (' . $db->quoteName('element') . ' = ' . $db->quote('mokosuite')
. ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokosuite\_%') . ')'
. ' AND ' . $db->quoteName('element') . ' != ' . $db->quote('mokosuite_monitor') . ')'
// Webservices plugins
. ' OR (' . $db->quoteName('type') . ' = ' . $db->quote('plugin')
. ' AND ' . $db->quoteName('folder') . ' = ' . $db->quote('webservices')
. ' AND ' . $db->quoteName('element') . ' = ' . $db->quote('mokosuite') . ')'
// Task plugins
. ' OR (' . $db->quoteName('type') . ' = ' . $db->quote('plugin')
. ' AND ' . $db->quoteName('folder') . ' = ' . $db->quote('task')
. ' AND ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokosuite%') . ')'
. ')',
])
->order($db->quoteName('folder') . ' ASC, ' . $db->quoteName('element') . ' ASC');
$db->setQuery($query);
$rows = $db->loadObjectList() ?: [];
$plugins = [];
foreach ($rows as $row)
{
$manifest = json_decode($row->manifest_cache ?? '{}');
$version = $manifest->version ?? '';
// Only system plugins and task plugins match PLUGIN_META by element
$metaKey = ($row->folder === 'system' || $row->folder === 'task')
? $row->element
: $row->folder . '_' . $row->element;
$meta = self::PLUGIN_META[$metaKey] ?? null;
// Auto-generate meta for task/webservices plugins not in the map
if (!$meta)
{
$meta = $this->guessPluginMeta($row);
}
$categoryKey = $meta['category'] ?? 'tools';
$categoryInfo = self::CATEGORIES[$categoryKey] ?? self::CATEGORIES['tools'];
$plugins[] = (object) [
'extension_id' => (int) $row->extension_id,
'name' => $meta['label'] ?? $row->name,
'element' => $row->element,
'folder' => $row->folder,
'type' => $row->type,
'enabled' => (int) $row->enabled,
'protected' => (bool) ($meta['protected'] ?? false),
'configure_only' => (bool) ($meta['configure_only'] ?? false),
'version' => $version,
'icon' => $meta['icon'] ?? 'icon-puzzle-piece',
'category' => $categoryKey,
'categoryLabel' => $categoryInfo['label'],
'categoryBadge' => $categoryInfo['badge'],
'description' => $meta['description'] ?? '',
];
}
return $plugins;
}
/**
* Get basic site information for the info bar.
*
* @return object
*/
public function getSiteInfo(): object
{
$app = Factory::getApplication();
$config = $app->getConfig();
$db = $this->getDatabase();
// Get MokoSuite package version
$query = $db->getQuery(true)
->select($db->quoteName('manifest_cache'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('pkg_mokosuite'))
->where($db->quoteName('type') . ' = ' . $db->quote('package'));
$db->setQuery($query);
$pkgCache = json_decode($db->loadResult() ?? '{}');
return (object) [
'sitename' => $config->get('sitename', ''),
'joomla_version' => (new Version())->getShortVersion(),
'php_version' => PHP_VERSION,
'db_type' => $db->getServerType(),
'mokosuite_version' => $pkgCache->version ?? '—',
'debug' => (bool) $config->get('debug'),
'offline' => (bool) $config->get('offline'),
'sef' => (bool) $config->get('sef'),
'caching' => (int) $config->get('caching'),
];
}
/**
* Get installed MokoSuite component and modules with versions.
*
* @return array Array of extension objects with name, element, type, version.
*/
public function getMokoExtensions(): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select([
$db->quoteName('element'),
$db->quoteName('name'),
$db->quoteName('type'),
$db->quoteName('enabled'),
$db->quoteName('manifest_cache'),
])
->from($db->quoteName('#__extensions'))
->where('('
// The component
. '(' . $db->quoteName('type') . ' = ' . $db->quote('component')
. ' AND ' . $db->quoteName('element') . ' = ' . $db->quote('com_mokosuite') . ')'
// Admin modules
. ' OR (' . $db->quoteName('type') . ' = ' . $db->quote('module')
. ' AND ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mod_mokosuite%') . ')'
. ')')
->order($db->quoteName('type') . ' ASC, ' . $db->quoteName('element') . ' ASC');
$db->setQuery($query);
$rows = $db->loadObjectList() ?: [];
$extensions = [];
foreach ($rows as $row)
{
$manifest = json_decode($row->manifest_cache ?? '{}');
$extensions[] = (object) [
'element' => $row->element,
'name' => $manifest->name ?? $row->name,
'type' => $row->type,
'version' => $manifest->version ?? '',
'enabled' => (int) $row->enabled,
];
}
return $extensions;
}
/**
* Toggle a plugin's enabled state.
*
* @param int $extensionId The extension ID.
* @param int $enabled 1 = enable, 0 = disable.
*
* @return array Result with success and message keys.
*/
public function togglePlugin(int $extensionId, int $enabled): array
{
$db = $this->getDatabase();
// Verify the extension exists and is a MokoSuite plugin
$query = $db->getQuery(true)
->select([$db->quoteName('element'), $db->quoteName('protected')])
->from($db->quoteName('#__extensions'))
->where($db->quoteName('extension_id') . ' = ' . $extensionId)
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'));
$db->setQuery($query);
$ext = $db->loadObject();
if (!$ext)
{
return ['success' => false, 'message' => 'Extension not found.'];
}
// Don't allow disabling protected/core plugins
if (!$enabled && ((int) $ext->protected || $ext->element === 'mokosuite'))
{
return ['success' => false, 'message' => 'This plugin is protected and cannot be disabled.'];
}
$query = $db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = ' . ($enabled ? 1 : 0))
->where($db->quoteName('extension_id') . ' = ' . $extensionId);
$db->setQuery($query);
$db->execute();
return [
'success' => true,
'message' => $ext->element . ($enabled ? ' enabled.' : ' disabled.'),
'enabled' => $enabled,
];
}
/**
* Clear all Joomla caches.
*
* @return array Result with success and message keys.
*/
public function clearCache(): array
{
try
{
// Use Joomla's native cache API — same as com_cache
$cache = Factory::getContainer()->get(\Joomla\CMS\Cache\CacheControllerFactoryInterface::class);
$cache->createCacheController('', ['defaultgroup' => ''])->cache->clean('');
// Also clean admin cache
$conf = Factory::getApplication()->get('cache_handler', 'file');
$options = [
'defaultgroup' => '',
'cachebase' => JPATH_ADMINISTRATOR . '/cache',
'storage' => $conf,
];
$cache->createCacheController('', $options)->cache->clean('');
// Clear opcache if available
if (\function_exists('opcache_reset'))
{
\opcache_reset();
}
return ['success' => true, 'message' => 'All cache cleared successfully.'];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Cache clear failed: ' . $e->getMessage()];
}
}
/**
* Clear the Joomla tmp directory.
*
* Removes all files and subdirectories from the configured tmp_path,
* preserving the directory itself and any .htaccess / web.config files.
*
* @return array Result with success and message keys.
*/
public function clearTemp(): array
{
try
{
$tmpPath = Factory::getApplication()->get('tmp_path', JPATH_ROOT . '/tmp');
if (!is_dir($tmpPath))
{
return ['success' => false, 'message' => 'Temp directory does not exist: ' . $tmpPath];
}
$count = 0;
$protected = ['.htaccess', 'web.config', 'index.html', '.gitkeep'];
$items = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($tmpPath, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($items as $item)
{
$basename = $item->getBasename();
// Skip protected files in the root tmp directory
if ($item->getPath() === $tmpPath && \in_array($basename, $protected, true))
{
continue;
}
if ($item->isDir())
{
@rmdir($item->getPathname());
}
else
{
@unlink($item->getPathname());
$count++;
}
}
return ['success' => true, 'message' => sprintf('Temp directory cleaned (%d files removed).', $count)];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Temp clear failed: ' . $e->getMessage()];
}
}
/**
* Auto-generate dashboard metadata for plugins not in the static map.
*/
private function guessPluginMeta(object $row): array
{
$meta = [
'icon' => 'icon-puzzle-piece',
'category' => 'tools',
'label' => $row->name,
'description' => '',
'protected' => false,
];
if ($row->folder === 'webservices')
{
$meta['icon'] = 'icon-plug';
$meta['category'] = 'api';
$meta['label'] = 'Web Services — ' . ucfirst($row->element);
}
elseif ($row->folder === 'task')
{
$meta['icon'] = 'icon-clock';
$meta['category'] = 'content';
if (str_contains($row->element, 'sync'))
{
$meta['label'] = 'Content Sync Task';
$meta['description'] = 'Scheduled content synchronisation to remote MokoSuite sites.';
}
elseif (str_contains($row->element, 'demo'))
{
$meta['label'] = 'Demo Reset Task';
$meta['description'] = 'Scheduled demo site reset with content snapshots.';
}
}
return $meta;
}
/**
* Get recent admin login attempts from action logs.
*/
public function getRecentLogins(int $limit = 10): array
{
try
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select([
$db->quoteName('a.message'),
$db->quoteName('a.log_date'),
$db->quoteName('a.ip_address'),
$db->quoteName('u.username'),
])
->from($db->quoteName('#__action_logs', 'a'))
->leftJoin($db->quoteName('#__users', 'u') . ' ON ' . $db->quoteName('u.id') . ' = ' . $db->quoteName('a.user_id'))
->where($db->quoteName('a.message_language_key') . ' LIKE ' . $db->quote('%LOGIN%'))
->order($db->quoteName('a.log_date') . ' DESC')
->setLimit($limit);
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
catch (\Throwable $e)
{
return [];
}
}
/**
* Get pending extension updates.
*/
public function getPendingUpdates(): array
{
try
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select([
$db->quoteName('u.name'),
$db->quoteName('u.version'),
$db->quoteName('u.type'),
$db->quoteName('e.manifest_cache'),
])
->from($db->quoteName('#__updates', 'u'))
->leftJoin($db->quoteName('#__extensions', 'e') . ' ON ' . $db->quoteName('e.extension_id') . ' = ' . $db->quoteName('u.extension_id'))
->where($db->quoteName('u.extension_id') . ' != 0')
->order($db->quoteName('u.name') . ' ASC');
$db->setQuery($query);
$rows = $db->loadObjectList() ?: [];
foreach ($rows as $row)
{
$mc = json_decode($row->manifest_cache ?? '{}');
$row->current_version = $mc->version ?? '';
}
return $rows;
}
catch (\Throwable $e)
{
return [];
}
}
/**
* Get checked-out items count and details.
*/
public function getCheckedOutItems(): array
{
try
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select([
$db->quoteName('c.title'),
$db->quoteName('c.checked_out'),
$db->quoteName('c.checked_out_time'),
$db->quoteName('u.username'),
])
->from($db->quoteName('#__content', 'c'))
->leftJoin($db->quoteName('#__users', 'u') . ' ON ' . $db->quoteName('u.id') . ' = ' . $db->quoteName('c.checked_out'))
->where($db->quoteName('c.checked_out') . ' IS NOT NULL')
->where($db->quoteName('c.checked_out') . ' != 0')
->order($db->quoteName('c.checked_out_time') . ' DESC')
->setLimit(10);
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
catch (\Throwable $e)
{
return [];
}
}
/**
* Get recent WAF blocks from the log table.
*/
public function getRecentWafBlocks(int $limit = 10): array
{
try
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuite_waf_log'))
->order($db->quoteName('created') . ' DESC')
->setLimit($limit);
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
catch (\Throwable $e)
{
return [];
}
}
/**
* WAF blocks per day for the last 14 days.
*/
public function getWafBlocksByDay(int $days = 14): array
{
try
{
$db = $this->getDatabase();
$db->setQuery(
"SELECT DATE(" . $db->quoteName('created') . ") AS day, COUNT(*) AS total"
. " FROM " . $db->quoteName('#__mokosuite_waf_log')
. " WHERE " . $db->quoteName('created') . " >= DATE_SUB(NOW(), INTERVAL $days DAY)"
. " GROUP BY day ORDER BY day"
);
$rows = $db->loadObjectList() ?: [];
// Fill in missing days with zero
$result = [];
$date = new \DateTime("-{$days} days");
$now = new \DateTime('now');
$map = [];
foreach ($rows as $r)
{
$map[$r->day] = (int) $r->total;
}
while ($date <= $now)
{
$key = $date->format('Y-m-d');
$result[] = (object) ['day' => $date->format('M d'), 'total' => $map[$key] ?? 0];
$date->modify('+1 day');
}
return $result;
}
catch (\Throwable $e)
{
return [];
}
}
/**
* Admin logins per day for the last 14 days.
*/
public function getLoginsByDay(int $days = 14): array
{
try
{
$db = $this->getDatabase();
$db->setQuery(
"SELECT DATE(" . $db->quoteName('log_date') . ") AS day, COUNT(*) AS total"
. " FROM " . $db->quoteName('#__action_logs')
. " WHERE " . $db->quoteName('message_language_key') . " = 'PLG_ACTIONLOG_JOOMLA_USER_LOGGED_IN'"
. " AND " . $db->quoteName('log_date') . " >= DATE_SUB(NOW(), INTERVAL $days DAY)"
. " GROUP BY day ORDER BY day"
);
$rows = $db->loadObjectList() ?: [];
$result = [];
$date = new \DateTime("-{$days} days");
$now = new \DateTime('now');
$map = [];
foreach ($rows as $r)
{
$map[$r->day] = (int) $r->total;
}
while ($date <= $now)
{
$key = $date->format('Y-m-d');
$result[] = (object) ['day' => $date->format('M d'), 'total' => $map[$key] ?? 0];
$date->modify('+1 day');
}
return $result;
}
catch (\Throwable $e)
{
return [];
}
}
}