* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE */ namespace Joomla\Component\MokoSuiteBackup\Administrator\Model; defined('_JEXEC') or die; use Joomla\CMS\Factory; use Joomla\CMS\MVC\Model\BaseDatabaseModel; use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory; class DashboardModel extends BaseDatabaseModel { /** * Get the most recent completed backup record. * * @return object|null */ public function getLastBackup(): ?object { $db = $this->getDatabase(); $query = $db->getQuery(true) ->select('r.*, p.title AS profile_title') ->from($db->quoteName('#__mokosuitebackup_records', 'r')) ->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p') . ' ON p.id = r.profile_id') ->where($db->quoteName('r.status') . ' = ' . $db->quote('complete')) ->order($db->quoteName('r.backupend') . ' DESC'); $db->setQuery($query, 0, 1); return $db->loadObject() ?: null; } /** * Query com_scheduler for the next scheduled MokoSuiteBackup task. * * @return object|null Object with next_execution and title, or null */ public function getNextScheduled(): ?object { $db = $this->getDatabase(); try { $query = $db->getQuery(true) ->select($db->quoteName(['t.next_execution', 't.title'])) ->from($db->quoteName('#__scheduler_tasks', 't')) ->where($db->quoteName('t.type') . ' = ' . $db->quote('mokosuitebackup.run_profile')) ->where($db->quoteName('t.state') . ' = 1') ->order($db->quoteName('t.next_execution') . ' ASC'); $db->setQuery($query, 0, 1); return $db->loadObject() ?: null; } catch (\Throwable $e) { return null; } } /** * Get backup statistics. * * @return object Object with total_count, total_size, fail_count_7d */ public function getStats(): object { $db = $this->getDatabase(); // Total completed backups and storage $query = $db->getQuery(true) ->select('COUNT(*) AS total_count') ->select('COALESCE(SUM(' . $db->quoteName('total_size') . '), 0) AS total_size') ->from($db->quoteName('#__mokosuitebackup_records')) ->where($db->quoteName('status') . ' = ' . $db->quote('complete')); $db->setQuery($query); $stats = $db->loadObject(); // Failures in last 7 days $cutoff = date('Y-m-d H:i:s', strtotime('-7 days')); $query = $db->getQuery(true) ->select('COUNT(*) AS fail_count') ->from($db->quoteName('#__mokosuitebackup_records')) ->where($db->quoteName('status') . ' = ' . $db->quote('fail')) ->where($db->quoteName('backupstart') . ' >= ' . $db->quote($cutoff)); $db->setQuery($query); $stats->fail_count_7d = (int) $db->loadResult(); return $stats; } /** * Check system health for backup readiness. * * @return array Array of check results [{label, status, detail}] */ public function getSystemHealth(): array { $checks = []; // PHP version $checks[] = (object) [ 'label' => 'PHP Version', 'status' => version_compare(PHP_VERSION, '8.1.0', '>='), 'detail' => PHP_VERSION, ]; // ZipArchive extension $checks[] = (object) [ 'label' => 'ZipArchive', 'status' => extension_loaded('zip'), 'detail' => extension_loaded('zip') ? 'Loaded' : 'Not loaded', ]; // AES-256 encryption support $aesSupport = defined('ZipArchive::EM_AES_256'); $checks[] = (object) [ 'label' => 'AES-256 Encryption', 'status' => $aesSupport, 'detail' => $aesSupport ? 'Available' : 'Requires libzip 1.2.0+', ]; // Backup directory writable — check the first published profile's dir $backupDir = BackupDirectory::getDefaultAbsolute(); $db2 = $this->getDatabase(); $qDir = $db2->getQuery(true) ->select($db2->quoteName('backup_dir')) ->from($db2->quoteName('#__mokosuitebackup_profiles')) ->where($db2->quoteName('published') . ' = 1') ->where($db2->quoteName('backup_dir') . ' != ' . $db2->quote('')) ->where($db2->quoteName('backup_dir') . ' IS NOT NULL'); $db2->setQuery($qDir, 0, 1); $profileDir = $db2->loadResult(); if ($profileDir) { $backupDir = BackupDirectory::resolve($profileDir); } if (BackupDirectory::hasPlaceholders($backupDir)) { $checks[] = (object) [ 'label' => 'Backup Directory', 'status' => true, 'detail' => 'Uses placeholders (resolved at backup time) — ' . $backupDir, ]; } else { $writable = is_dir($backupDir) && is_writable($backupDir); $checks[] = (object) [ 'label' => 'Backup Directory', 'status' => $writable, 'detail' => ($writable ? 'Writable' : 'Not writable or missing') . ' — ' . $backupDir, ]; } // Disk space $freeSpace = @disk_free_space($backupDir ?: JPATH_ROOT); $freeGB = $freeSpace ? round($freeSpace / 1073741824, 1) : 0; $checks[] = (object) [ 'label' => 'Free Disk Space', 'status' => $freeGB >= 1.0, 'detail' => $freeGB . ' GB free', ]; return $checks; } /** * Check if any profiles use the default (web-root) backup directory. * * @return bool */ public function isUsingDefaultBackupDir(): bool { $db = $this->getDatabase(); $query = $db->getQuery(true) ->select($db->quoteName('backup_dir')) ->from($db->quoteName('#__mokosuitebackup_profiles')) ->where($db->quoteName('published') . ' = 1'); $db->setQuery($query); $dirs = $db->loadColumn(); // Warn only if any profile's resolved path is inside the web root foreach ($dirs as $dir) { $resolved = BackupDirectory::resolve($dir ?: BackupDirectory::DEFAULT_RELATIVE); if (BackupDirectory::hasPlaceholders($resolved)) { continue; } if (BackupDirectory::isWebAccessible($resolved)) { return true; } } return false; } /** * Get latest snapshot info for the dashboard widget. */ public function getLatestSnapshot(): ?object { $db = $this->getDatabase(); try { $query = $db->getQuery(true) ->select('*') ->from($db->quoteName('#__mokosuitebackup_snapshots')) ->where($db->quoteName('status') . ' = ' . $db->quote('complete')) ->order($db->quoteName('created') . ' DESC'); $db->setQuery($query, 0, 1); return $db->loadObject() ?: null; } catch (\Throwable $e) { return null; } } /** * Get snapshot count. */ public function getSnapshotCount(): int { $db = $this->getDatabase(); try { $query = $db->getQuery(true) ->select('COUNT(*)') ->from($db->quoteName('#__mokosuitebackup_snapshots')); $db->setQuery($query); return (int) $db->loadResult(); } catch (\Throwable $e) { return 0; } } /** * Get backup size trend data for the last 30 days. * Returns array of {date, total_size, count, status} grouped by day. */ public function getBackupTrend(): array { $db = $this->getDatabase(); $cutoff = date('Y-m-d', strtotime('-30 days')); $query = $db->getQuery(true) ->select('DATE(' . $db->quoteName('backupstart') . ') AS backup_date') ->select('SUM(' . $db->quoteName('total_size') . ') AS day_size') ->select('COUNT(*) AS day_count') ->select('SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('fail') . ' THEN 1 ELSE 0 END) AS fail_count') ->from($db->quoteName('#__mokosuitebackup_records')) ->where('DATE(' . $db->quoteName('backupstart') . ') >= ' . $db->quote($cutoff)) ->group('DATE(' . $db->quoteName('backupstart') . ')') ->order('backup_date ASC'); $db->setQuery($query); return $db->loadObjectList() ?: []; } /** * Get storage breakdown by profile. */ public function getStorageByProfile(): array { $db = $this->getDatabase(); $query = $db->getQuery(true) ->select('p.title AS profile_title') ->select('COUNT(*) AS backup_count') ->select('COALESCE(SUM(r.total_size), 0) AS total_size') ->from($db->quoteName('#__mokosuitebackup_records', 'r')) ->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p') . ' ON p.id = r.profile_id') ->where($db->quoteName('r.status') . ' = ' . $db->quote('complete')) ->group($db->quoteName('r.profile_id')) ->order('total_size DESC'); $db->setQuery($query); return $db->loadObjectList() ?: []; } /** * Get published backup profiles for the quick-action selector. * * @return array */ public function getProfiles(): array { $db = $this->getDatabase(); $query = $db->getQuery(true) ->select($db->quoteName(['id', 'title', 'backup_type'])) ->from($db->quoteName('#__mokosuitebackup_profiles')) ->where($db->quoteName('published') . ' = 1') ->order($db->quoteName('id') . ' ASC'); $db->setQuery($query); return $db->loadObjectList() ?: []; } }