608aeb3641
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
- Add Dashboard as first submenu entry in component manifest - Add [DEFAULT_DIR] placeholder to PlaceholderResolver for portable profiles - Add live AJAX directory permission checking on backup_dir field changes - Add web-accessible warning badge on backup download buttons - Auto-create .htaccess protection in web-accessible backup dirs on profile save - Auto-create .htaccess protection at backup time in both engines - Add checkDir AJAX endpoint for real-time directory validation - Fix script.php warnMissingLicenseKey running on uninstall
218 lines
6.5 KiB
PHP
218 lines
6.5 KiB
PHP
<?php
|
|
|
|
/**
|
|
* @package MokoJoomBackup
|
|
* @subpackage com_mokojoombackup
|
|
* @author Moko Consulting <hello@mokoconsulting.tech>
|
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
* @license GNU General Public License version 3 or later; see LICENSE
|
|
*/
|
|
|
|
namespace Joomla\Component\MokoJoomBackup\Administrator\Model;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\Factory;
|
|
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
|
|
|
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('#__mokojoombackup_records', 'r'))
|
|
->join('LEFT', $db->quoteName('#__mokojoombackup_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 MokoJoomBackup 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('mokojoombackup.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('#__mokojoombackup_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('#__mokojoombackup_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 default path
|
|
$defaultDir = JPATH_ADMINISTRATOR . '/components/com_mokojoombackup/backups';
|
|
$backupDir = $defaultDir;
|
|
|
|
// If profiles use a custom directory, check that instead
|
|
$db2 = $this->getDatabase();
|
|
$qDir = $db2->getQuery(true)
|
|
->select($db2->quoteName('backup_dir'))
|
|
->from($db2->quoteName('#__mokojoombackup_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) {
|
|
// Absolute paths used as-is, relative resolved from JPATH_ROOT
|
|
if ($profileDir[0] === '/' || preg_match('#^[A-Za-z]:[/\\\\]#', $profileDir)) {
|
|
$backupDir = rtrim($profileDir, '/\\');
|
|
} else {
|
|
$backupDir = JPATH_ROOT . '/' . $profileDir;
|
|
}
|
|
}
|
|
|
|
// Skip filesystem check if path contains placeholders (resolved at backup time)
|
|
if (preg_match('/\[.+\]/', $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();
|
|
$default = 'administrator/components/com_mokojoombackup/backups';
|
|
|
|
$query = $db->getQuery(true)
|
|
->select('COUNT(*)')
|
|
->from($db->quoteName('#__mokojoombackup_profiles'))
|
|
->where($db->quoteName('published') . ' = 1')
|
|
->where('(' . $db->quoteName('backup_dir') . ' = ' . $db->quote($default)
|
|
. ' OR ' . $db->quoteName('backup_dir') . ' = ' . $db->quote('[DEFAULT_DIR]')
|
|
. ' OR ' . $db->quoteName('backup_dir') . ' = ' . $db->quote('')
|
|
. ' OR ' . $db->quoteName('backup_dir') . ' IS NULL)');
|
|
$db->setQuery($query);
|
|
|
|
return (int) $db->loadResult() > 0;
|
|
}
|
|
|
|
/**
|
|
* 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('#__mokojoombackup_profiles'))
|
|
->where($db->quoteName('published') . ' = 1')
|
|
->order($db->quoteName('ordering') . ' ASC');
|
|
$db->setQuery($query);
|
|
|
|
return $db->loadObjectList() ?: [];
|
|
}
|
|
}
|