feat: dashboard widget (#18), differential backups (#19), JPA import (#20)
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
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
Generic: Repo Health / Release configuration (push) Has been cancelled
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
#18 — Dashboard Quickicon Widget: - plg_quickicon_mokobackup: shows backup status on admin dashboard - Displays: last backup time, total count, storage used - Warning states: no backups, recent failures, stale (>7 days) - Links to backup records view #19 — Differential Backups: - New backup_type: "differential" — only backs up changed/new files - DifferentialScanner: builds manifest (path+size+mtime) on full backups - Compares current filesystem against base manifest on differential runs - Manifest stored in backup record (LONGTEXT column) - Falls back to full backup if no base manifest exists - Database is always fully dumped (no incremental DB) #20 — JPA Format Import: - JpaUnarchiver: parses Akeeba JPA binary format - Handles: archive header, file entities, gzip decompression, permissions - RestoreEngine auto-detects JPA vs ZIP by reading signature bytes - Enables restoring from existing Akeeba .jpa backup files Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,14 @@
|
||||
- "Include Restore Script" toggle per profile — wraps backup with restore.php + site-backup.zip
|
||||
- FileRestorer class with protected file handling (preserves configuration.php, .htaccess)
|
||||
- DatabaseImporter with streaming line-by-line SQL execution and error tolerance
|
||||
- Admin dashboard quickicon widget — backup status at a glance with warnings (#18)
|
||||
- Differential backups — only back up files changed since last full backup (#19)
|
||||
- DifferentialScanner: builds file manifests (path/size/mtime) and compares against base
|
||||
- File manifest stored in backup record for future differential comparisons
|
||||
- Automatic full-backup fallback when no base manifest exists
|
||||
- JPA archive format import for Akeeba Backup migration (#20)
|
||||
- JpaUnarchiver: parses Akeeba JPA binary format (headers, gzip, permissions)
|
||||
- RestoreEngine auto-detects JPA vs ZIP format
|
||||
- AES-256 archive encryption with per-profile password (#17)
|
||||
- Encrypted archive support in RestoreEngine (password parameter)
|
||||
- Encrypted archive support in Kickstart restore.php (password field in UI)
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<option value="full">COM_MOKOBACKUP_TYPE_FULL</option>
|
||||
<option value="database">COM_MOKOBACKUP_TYPE_DATABASE</option>
|
||||
<option value="files">COM_MOKOBACKUP_TYPE_FILES</option>
|
||||
<option value="differential">COM_MOKOBACKUP_TYPE_DIFFERENTIAL</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ COM_MOKOBACKUP_FIELD_GDRIVE_FOLDER_ID_DESC="Google Drive folder ID where backups
|
||||
COM_MOKOBACKUP_TYPE_FULL="Full Site (Database + Files)"
|
||||
COM_MOKOBACKUP_TYPE_DATABASE="Database Only"
|
||||
COM_MOKOBACKUP_TYPE_FILES="Files Only"
|
||||
COM_MOKOBACKUP_TYPE_DIFFERENTIAL="Differential (changed files + full DB)"
|
||||
|
||||
; Status labels
|
||||
COM_MOKOBACKUP_STATUS_COMPLETE="Complete"
|
||||
|
||||
@@ -62,6 +62,8 @@ CREATE TABLE IF NOT EXISTS `#__mokobackup_records` (
|
||||
`filesexist` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`remote_filename` VARCHAR(512) NOT NULL DEFAULT '',
|
||||
`checksum` VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'SHA-256 hash of archive',
|
||||
`base_record_id` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Base full backup ID for differential',
|
||||
`manifest` LONGTEXT NOT NULL COMMENT 'JSON file manifest for differential comparison',
|
||||
`log` MEDIUMTEXT NOT NULL COMMENT 'Step-by-step backup log',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_profile` (`profile_id`),
|
||||
|
||||
@@ -127,14 +127,32 @@ class BackupEngine
|
||||
}
|
||||
|
||||
// Step 2: Files (unless database-only)
|
||||
$manifest = [];
|
||||
|
||||
if ($profile->backup_type !== 'database') {
|
||||
$this->log('Starting file scan...');
|
||||
$scanner = new FileScanner(JPATH_ROOT, $excludeDirs, $excludeFiles);
|
||||
$files = $scanner->scan();
|
||||
$filesCount = count($files);
|
||||
$this->log('Found ' . $filesCount . ' files to back up');
|
||||
$scanner = new FileScanner(JPATH_ROOT, $excludeDirs, $excludeFiles);
|
||||
$allFiles = $scanner->scan();
|
||||
|
||||
foreach ($files as $relativePath) {
|
||||
// Differential: only include changed files
|
||||
if ($profile->backup_type === 'differential') {
|
||||
$baseManifest = $this->loadBaseManifest($db, $profileId);
|
||||
|
||||
if (empty($baseManifest)) {
|
||||
$this->log('No base full backup found — running full backup instead');
|
||||
$filesToBackup = $allFiles;
|
||||
} else {
|
||||
$filesToBackup = DifferentialScanner::getChangedFiles($allFiles, $baseManifest, JPATH_ROOT);
|
||||
$this->log('Differential: ' . count($filesToBackup) . ' changed files out of ' . count($allFiles) . ' total');
|
||||
}
|
||||
} else {
|
||||
$filesToBackup = $allFiles;
|
||||
}
|
||||
|
||||
$filesCount = count($filesToBackup);
|
||||
$this->log('Backing up ' . $filesCount . ' files');
|
||||
|
||||
foreach ($filesToBackup as $relativePath) {
|
||||
$fullPath = JPATH_ROOT . '/' . $relativePath;
|
||||
|
||||
if (is_file($fullPath) && is_readable($fullPath)) {
|
||||
@@ -143,6 +161,12 @@ class BackupEngine
|
||||
}
|
||||
|
||||
$this->log('Files added to archive');
|
||||
|
||||
// Build manifest for full/differential backups (used by future differentials)
|
||||
if ($profile->backup_type === 'full' || ($profile->backup_type === 'differential' && empty($baseManifest))) {
|
||||
$manifest = DifferentialScanner::buildManifest($allFiles, JPATH_ROOT);
|
||||
$this->log('File manifest built: ' . count($manifest) . ' entries');
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
@@ -217,6 +241,7 @@ class BackupEngine
|
||||
'filesexist' => is_file($archivePath) ? 1 : 0,
|
||||
'remote_filename' => $remoteFilename,
|
||||
'checksum' => $checksum,
|
||||
'manifest' => !empty($manifest) ? json_encode($manifest) : '',
|
||||
'log' => implode("\n", $this->log),
|
||||
];
|
||||
|
||||
@@ -342,6 +367,30 @@ class BackupEngine
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the file manifest from the most recent full backup for this profile.
|
||||
* Used by differential backups to determine which files changed.
|
||||
*/
|
||||
private function loadBaseManifest(object $db, int $profileId): array
|
||||
{
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('manifest'))
|
||||
->from($db->quoteName('#__mokobackup_records'))
|
||||
->where($db->quoteName('profile_id') . ' = ' . $profileId)
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
|
||||
->where($db->quoteName('manifest') . ' != ' . $db->quote(''))
|
||||
->where($db->quoteName('backup_type') . ' = ' . $db->quote('full'))
|
||||
->order($db->quoteName('backupstart') . ' DESC');
|
||||
$db->setQuery($query, 0, 1);
|
||||
$manifestJson = $db->loadResult();
|
||||
|
||||
if (empty($manifestJson)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return json_decode($manifestJson, true) ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a ZIP archive using AES-256.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomBackup
|
||||
* @subpackage com_mokobackup
|
||||
* @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
|
||||
*
|
||||
* Differential file scanner — compares current filesystem against a
|
||||
* stored manifest from the last full backup. Only returns files that
|
||||
* are new or modified since the base backup.
|
||||
*
|
||||
* Manifest format (JSON):
|
||||
* {"path/to/file": {"size": 1234, "mtime": 1717350000}, ...}
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoBackup\Administrator\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
class DifferentialScanner
|
||||
{
|
||||
/**
|
||||
* Build a file manifest for the current state of the site.
|
||||
*
|
||||
* @param string[] $filePaths Array of relative file paths (from FileScanner)
|
||||
* @param string $rootDir Joomla root directory
|
||||
*
|
||||
* @return array<string, array{size: int, mtime: int}>
|
||||
*/
|
||||
public static function buildManifest(array $filePaths, string $rootDir): array
|
||||
{
|
||||
$manifest = [];
|
||||
|
||||
foreach ($filePaths as $relativePath) {
|
||||
$fullPath = rtrim($rootDir, '/') . '/' . $relativePath;
|
||||
|
||||
if (!is_file($fullPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$manifest[$relativePath] = [
|
||||
'size' => (int) filesize($fullPath),
|
||||
'mtime' => (int) filemtime($fullPath),
|
||||
];
|
||||
}
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare current files against a base manifest and return only changed/new files.
|
||||
*
|
||||
* @param array $currentFiles Array of relative file paths from FileScanner
|
||||
* @param array $baseManifest Manifest from the base full backup
|
||||
* @param string $rootDir Joomla root directory
|
||||
*
|
||||
* @return string[] Array of relative paths that are new or modified
|
||||
*/
|
||||
public static function getChangedFiles(array $currentFiles, array $baseManifest, string $rootDir): array
|
||||
{
|
||||
$changed = [];
|
||||
|
||||
foreach ($currentFiles as $relativePath) {
|
||||
$fullPath = rtrim($rootDir, '/') . '/' . $relativePath;
|
||||
|
||||
if (!is_file($fullPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// New file — not in base manifest
|
||||
if (!isset($baseManifest[$relativePath])) {
|
||||
$changed[] = $relativePath;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if modified (size or mtime changed)
|
||||
$currentSize = (int) filesize($fullPath);
|
||||
$currentMtime = (int) filemtime($fullPath);
|
||||
$baseEntry = $baseManifest[$relativePath];
|
||||
|
||||
if ($currentSize !== $baseEntry['size'] || $currentMtime !== $baseEntry['mtime']) {
|
||||
$changed[] = $relativePath;
|
||||
}
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomBackup
|
||||
* @subpackage com_mokobackup
|
||||
* @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
|
||||
*
|
||||
* JPA (Joomla Pack Archive) unarchiver for importing Akeeba Backup files.
|
||||
*
|
||||
* JPA Format Structure:
|
||||
* - Header: signature (3 bytes "JPA"), header length, major/minor version
|
||||
* - Entity headers: signature (3 bytes), header length, path length, path,
|
||||
* compression type (0=none, 1=gzip), compressed size, uncompressed size,
|
||||
* permissions, then compressed data
|
||||
*
|
||||
* Read-only: extracts JPA archives to a staging directory.
|
||||
* The RestoreEngine can then restore from the extracted files.
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoBackup\Administrator\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
class JpaUnarchiver
|
||||
{
|
||||
private const JPA_SIGNATURE = "\x4a\x50\x41"; // "JPA"
|
||||
private const ENTITY_SIGNATURE = "\x4a\x50\x46"; // "JPF" — file entity
|
||||
|
||||
private string $archivePath;
|
||||
private string $outputDir;
|
||||
private int $filesExtracted = 0;
|
||||
|
||||
public function __construct(string $archivePath, string $outputDir)
|
||||
{
|
||||
$this->archivePath = $archivePath;
|
||||
$this->outputDir = rtrim($outputDir, '/\\');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a JPA archive to the output directory.
|
||||
*
|
||||
* @return int Number of files extracted
|
||||
*
|
||||
* @throws \RuntimeException On format errors or extraction failure
|
||||
*/
|
||||
public function extract(): int
|
||||
{
|
||||
if (!is_file($this->archivePath) || !is_readable($this->archivePath)) {
|
||||
throw new \RuntimeException('JPA file not readable: ' . $this->archivePath);
|
||||
}
|
||||
|
||||
$handle = fopen($this->archivePath, 'rb');
|
||||
|
||||
if ($handle === false) {
|
||||
throw new \RuntimeException('Cannot open JPA file: ' . $this->archivePath);
|
||||
}
|
||||
|
||||
try {
|
||||
// Read and validate archive header
|
||||
$this->readArchiveHeader($handle);
|
||||
|
||||
// Read entities until EOF
|
||||
while (!feof($handle)) {
|
||||
$pos = ftell($handle);
|
||||
|
||||
// Try to read entity signature
|
||||
$sig = fread($handle, 3);
|
||||
|
||||
if ($sig === false || strlen($sig) < 3) {
|
||||
break; // End of archive
|
||||
}
|
||||
|
||||
if ($sig === self::ENTITY_SIGNATURE) {
|
||||
$this->readFileEntity($handle);
|
||||
} else {
|
||||
// Unknown entity — try to skip by reading header length
|
||||
fseek($handle, $pos + 3);
|
||||
$headerLenData = fread($handle, 2);
|
||||
|
||||
if ($headerLenData === false || strlen($headerLenData) < 2) {
|
||||
break;
|
||||
}
|
||||
|
||||
$headerLen = unpack('v', $headerLenData)[1];
|
||||
// Skip remaining header + data
|
||||
fseek($handle, $pos + 3 + $headerLen);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->filesExtracted;
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and validate the JPA archive header.
|
||||
*/
|
||||
private function readArchiveHeader($handle): void
|
||||
{
|
||||
$signature = fread($handle, 3);
|
||||
|
||||
if ($signature !== self::JPA_SIGNATURE) {
|
||||
throw new \RuntimeException('Not a valid JPA archive — invalid signature');
|
||||
}
|
||||
|
||||
// Header length (2 bytes, little-endian)
|
||||
$headerLenData = fread($handle, 2);
|
||||
|
||||
if ($headerLenData === false || strlen($headerLenData) < 2) {
|
||||
throw new \RuntimeException('Truncated JPA header');
|
||||
}
|
||||
|
||||
$headerLen = unpack('v', $headerLenData)[1];
|
||||
|
||||
// Version: major (1 byte), minor (1 byte)
|
||||
$versionData = fread($handle, 2);
|
||||
|
||||
if ($versionData === false || strlen($versionData) < 2) {
|
||||
throw new \RuntimeException('Cannot read JPA version');
|
||||
}
|
||||
|
||||
// File count (4 bytes, little-endian)
|
||||
$countData = fread($handle, 4);
|
||||
|
||||
if ($countData === false || strlen($countData) < 4) {
|
||||
throw new \RuntimeException('Cannot read file count');
|
||||
}
|
||||
|
||||
// Skip any remaining header bytes
|
||||
$bytesRead = 3 + 2 + 2 + 4; // sig + headerLen + version + count
|
||||
|
||||
if ($headerLen > ($bytesRead - 3)) {
|
||||
$remaining = $headerLen - ($bytesRead - 3);
|
||||
|
||||
if ($remaining > 0) {
|
||||
fseek($handle, ftell($handle) + $remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a single file entity and extract it.
|
||||
*/
|
||||
private function readFileEntity($handle): void
|
||||
{
|
||||
// Entity header length (2 bytes)
|
||||
$headerLenData = fread($handle, 2);
|
||||
|
||||
if ($headerLenData === false || strlen($headerLenData) < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
$headerLen = unpack('v', $headerLenData)[1];
|
||||
|
||||
// Path length (2 bytes)
|
||||
$pathLenData = fread($handle, 2);
|
||||
|
||||
if ($pathLenData === false || strlen($pathLenData) < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pathLen = unpack('v', $pathLenData)[1];
|
||||
|
||||
// Path (variable)
|
||||
$path = fread($handle, $pathLen);
|
||||
|
||||
if ($path === false || strlen($path) < $pathLen) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compression type (1 byte): 0 = none, 1 = gzip
|
||||
$compTypeData = fread($handle, 1);
|
||||
$compType = ord($compTypeData);
|
||||
|
||||
// Compressed size (4 bytes)
|
||||
$compSizeData = fread($handle, 4);
|
||||
$compSize = unpack('V', $compSizeData)[1];
|
||||
|
||||
// Uncompressed size (4 bytes)
|
||||
$uncompSizeData = fread($handle, 4);
|
||||
$uncompSize = unpack('V', $uncompSizeData)[1];
|
||||
|
||||
// Permissions (4 bytes)
|
||||
$permsData = fread($handle, 4);
|
||||
$perms = unpack('V', $permsData)[1];
|
||||
|
||||
// Skip any remaining header bytes
|
||||
$entityHeaderRead = 2 + 2 + $pathLen + 1 + 4 + 4 + 4;
|
||||
$entityHeaderTotal = $headerLen;
|
||||
|
||||
if ($entityHeaderTotal > $entityHeaderRead) {
|
||||
fseek($handle, ftell($handle) + ($entityHeaderTotal - $entityHeaderRead));
|
||||
}
|
||||
|
||||
// Read compressed data
|
||||
$data = '';
|
||||
|
||||
if ($compSize > 0) {
|
||||
$data = fread($handle, $compSize);
|
||||
|
||||
if ($data === false || strlen($data) < $compSize) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Is this a directory?
|
||||
if (substr($path, -1) === '/' || $uncompSize === 0 && $compSize === 0) {
|
||||
$dirPath = $this->outputDir . '/' . $path;
|
||||
|
||||
if (!is_dir($dirPath)) {
|
||||
mkdir($dirPath, 0755, true);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Decompress if needed
|
||||
if ($compType === 1 && !empty($data)) {
|
||||
$data = @gzinflate($data);
|
||||
|
||||
if ($data === false) {
|
||||
throw new \RuntimeException('Failed to decompress file: ' . $path);
|
||||
}
|
||||
}
|
||||
|
||||
// Write file
|
||||
$fullPath = $this->outputDir . '/' . $path;
|
||||
$parentDir = dirname($fullPath);
|
||||
|
||||
if (!is_dir($parentDir)) {
|
||||
mkdir($parentDir, 0755, true);
|
||||
}
|
||||
|
||||
file_put_contents($fullPath, $data);
|
||||
|
||||
// Set permissions (only if reasonable)
|
||||
if ($perms > 0 && $perms <= 0777) {
|
||||
@chmod($fullPath, $perms);
|
||||
}
|
||||
|
||||
$this->filesExtracted++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file appears to be a JPA archive.
|
||||
*/
|
||||
public static function isJpaFile(string $path): bool
|
||||
{
|
||||
if (!is_file($path) || !is_readable($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$handle = fopen($path, 'rb');
|
||||
|
||||
if ($handle === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$sig = fread($handle, 3);
|
||||
fclose($handle);
|
||||
|
||||
return $sig === self::JPA_SIGNATURE;
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,16 @@ class RestoreEngine
|
||||
try {
|
||||
// Step 1: Extract archive to staging
|
||||
$this->log('Extracting archive: ' . basename($archivePath));
|
||||
$this->extractArchive($archivePath, $password);
|
||||
|
||||
// Detect format: JPA or ZIP
|
||||
if (JpaUnarchiver::isJpaFile($archivePath)) {
|
||||
$this->log('Detected JPA format (Akeeba Backup archive)');
|
||||
$jpa = new JpaUnarchiver($archivePath, $this->stagingDir);
|
||||
$count = $jpa->extract();
|
||||
$this->log('Extracted ' . $count . ' files from JPA');
|
||||
} else {
|
||||
$this->extractArchive($archivePath, $password);
|
||||
}
|
||||
$this->log('Extraction complete');
|
||||
|
||||
// Step 2: Preserve configuration.php
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -0,0 +1 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -0,0 +1,6 @@
|
||||
PLG_QUICKICON_MOKOBACKUP="Quick Icon - MokoJoomBackup"
|
||||
PLG_QUICKICON_MOKOBACKUP_DESCRIPTION="Shows backup status on the administrator dashboard."
|
||||
PLG_QUICKICON_MOKOBACKUP_OK="Backups: OK"
|
||||
PLG_QUICKICON_MOKOBACKUP_NO_BACKUPS="Backups: No backups yet!"
|
||||
PLG_QUICKICON_MOKOBACKUP_FAILURES="Backups: Recent failures!"
|
||||
PLG_QUICKICON_MOKOBACKUP_STALE="Backups: Last backup > 7 days ago"
|
||||
@@ -0,0 +1,2 @@
|
||||
PLG_QUICKICON_MOKOBACKUP="Quick Icon - MokoJoomBackup"
|
||||
PLG_QUICKICON_MOKOBACKUP_DESCRIPTION="Shows backup status on the administrator dashboard."
|
||||
@@ -0,0 +1 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -0,0 +1,6 @@
|
||||
PLG_QUICKICON_MOKOBACKUP="Quick Icon - MokoJoomBackup"
|
||||
PLG_QUICKICON_MOKOBACKUP_DESCRIPTION="Shows backup status on the administrator dashboard."
|
||||
PLG_QUICKICON_MOKOBACKUP_OK="Backups: OK"
|
||||
PLG_QUICKICON_MOKOBACKUP_NO_BACKUPS="Backups: No backups yet!"
|
||||
PLG_QUICKICON_MOKOBACKUP_FAILURES="Backups: Recent failures!"
|
||||
PLG_QUICKICON_MOKOBACKUP_STALE="Backups: Last backup > 7 days ago"
|
||||
@@ -0,0 +1,2 @@
|
||||
PLG_QUICKICON_MOKOBACKUP="Quick Icon - MokoJoomBackup"
|
||||
PLG_QUICKICON_MOKOBACKUP_DESCRIPTION="Shows backup status on the administrator dashboard."
|
||||
@@ -0,0 +1 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="quickicon" method="upgrade">
|
||||
<name>plg_quickicon_mokobackup</name>
|
||||
<version>01.00.00-dev</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<description>PLG_QUICKICON_MOKOBACKUP_DESCRIPTION</description>
|
||||
|
||||
<namespace path="src">Joomla\Plugin\Quickicon\MokoBackup</namespace>
|
||||
|
||||
<files>
|
||||
<filename plugin="mokobackup">mokobackup.php</filename>
|
||||
<folder>services</folder>
|
||||
<folder>src</folder>
|
||||
</files>
|
||||
|
||||
<languages>
|
||||
<language tag="en-GB">language/en-GB/plg_quickicon_mokobackup.ini</language>
|
||||
<language tag="en-GB">language/en-GB/plg_quickicon_mokobackup.sys.ini</language>
|
||||
</languages>
|
||||
</extension>
|
||||
@@ -0,0 +1 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Extension\PluginInterface;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Plugin\PluginHelper;
|
||||
use Joomla\DI\Container;
|
||||
use Joomla\DI\ServiceProviderInterface;
|
||||
use Joomla\Event\DispatcherInterface;
|
||||
use Joomla\Plugin\Quickicon\MokoBackup\Extension\MokoBackupQuickicon;
|
||||
|
||||
return new class () implements ServiceProviderInterface {
|
||||
public function register(Container $container): void
|
||||
{
|
||||
$container->set(
|
||||
PluginInterface::class,
|
||||
function (Container $container) {
|
||||
$plugin = new MokoBackupQuickicon(
|
||||
$container->get(DispatcherInterface::class),
|
||||
(array) PluginHelper::getPlugin('quickicon', 'mokobackup')
|
||||
);
|
||||
$plugin->setApplication(Factory::getApplication());
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomBackup
|
||||
* @subpackage plg_quickicon_mokobackup
|
||||
* @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
|
||||
*
|
||||
* Dashboard quickicon widget showing backup status at a glance.
|
||||
*/
|
||||
|
||||
namespace Joomla\Plugin\Quickicon\MokoBackup\Extension;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\Event\Event;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
|
||||
final class MokoBackupQuickicon extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
protected $autoloadLanguage = true;
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
'onGetIcons' => 'onGetIcons',
|
||||
];
|
||||
}
|
||||
|
||||
public function onGetIcons(Event $event): void
|
||||
{
|
||||
$context = $event->getArgument('context', $event->getArgument(0, ''));
|
||||
|
||||
if ($context !== 'mod_quickicon' && $context !== 'update_quickicon') {
|
||||
return;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Get last completed backup
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokobackup_records'))
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
|
||||
->order($db->quoteName('backupstart') . ' DESC');
|
||||
$db->setQuery($query, 0, 1);
|
||||
$lastBackup = $db->loadObject();
|
||||
|
||||
// Get total count and storage
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*) AS total, COALESCE(SUM(total_size), 0) AS total_size')
|
||||
->from($db->quoteName('#__mokobackup_records'))
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
|
||||
$db->setQuery($query);
|
||||
$stats = $db->loadObject();
|
||||
|
||||
// Check for recent failures
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokobackup_records'))
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('fail'))
|
||||
->where($db->quoteName('backupstart') . ' > DATE_SUB(NOW(), INTERVAL 7 DAY)');
|
||||
$db->setQuery($query);
|
||||
$recentFailures = (int) $db->loadResult();
|
||||
|
||||
// Determine icon state
|
||||
$warning = false;
|
||||
$text = 'PLG_QUICKICON_MOKOBACKUP_OK';
|
||||
|
||||
if (!$lastBackup) {
|
||||
$warning = true;
|
||||
$text = 'PLG_QUICKICON_MOKOBACKUP_NO_BACKUPS';
|
||||
} elseif ($recentFailures > 0) {
|
||||
$warning = true;
|
||||
$text = 'PLG_QUICKICON_MOKOBACKUP_FAILURES';
|
||||
} elseif (strtotime($lastBackup->backupstart) < strtotime('-7 days')) {
|
||||
$warning = true;
|
||||
$text = 'PLG_QUICKICON_MOKOBACKUP_STALE';
|
||||
}
|
||||
|
||||
// Build subtitle
|
||||
$subtitle = '';
|
||||
|
||||
if ($lastBackup) {
|
||||
$ago = $this->timeAgo($lastBackup->backupstart);
|
||||
$sizeTotal = number_format(($stats->total_size ?? 0) / 1048576, 1);
|
||||
$subtitle = $ago . ' | ' . ($stats->total ?? 0) . ' backups | ' . $sizeTotal . ' MB';
|
||||
}
|
||||
|
||||
$result = $event->getArgument('result', []);
|
||||
$result[] = [
|
||||
[
|
||||
'link' => 'index.php?option=com_mokobackup&view=backups',
|
||||
'image' => $warning ? 'icon-warning' : 'icon-database',
|
||||
'icon' => $warning ? 'icon-warning' : 'icon-database',
|
||||
'text' => $text,
|
||||
'linkadd' => $subtitle ? '<br><small>' . htmlspecialchars($subtitle) . '</small>' : '',
|
||||
'id' => 'plg_quickicon_mokobackup',
|
||||
'group' => 'MOD_QUICKICON_MAINTENANCE',
|
||||
],
|
||||
];
|
||||
|
||||
$event->setArgument('result', $result);
|
||||
}
|
||||
|
||||
private function timeAgo(string $datetime): string
|
||||
{
|
||||
$diff = time() - strtotime($datetime);
|
||||
|
||||
if ($diff < 60) {
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
if ($diff < 3600) {
|
||||
$m = (int) ($diff / 60);
|
||||
|
||||
return $m . ' min ago';
|
||||
}
|
||||
|
||||
if ($diff < 86400) {
|
||||
$h = (int) ($diff / 3600);
|
||||
|
||||
return $h . 'h ago';
|
||||
}
|
||||
|
||||
$d = (int) ($diff / 86400);
|
||||
|
||||
return $d . 'd ago';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -0,0 +1 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -23,6 +23,7 @@
|
||||
<file type="component" id="com_mokobackup">com_mokobackup.zip</file>
|
||||
<file type="plugin" id="mokobackup" group="system">plg_system_mokobackup.zip</file>
|
||||
<file type="plugin" id="mokobackup" group="task">plg_task_mokobackup.zip</file>
|
||||
<file type="plugin" id="mokobackup" group="quickicon">plg_quickicon_mokobackup.zip</file>
|
||||
<file type="plugin" id="mokobackup" group="webservices">plg_webservices_mokobackup.zip</file>
|
||||
</files>
|
||||
|
||||
|
||||
@@ -74,6 +74,17 @@ class Pkg_MokoBackupInstallerScript
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
|
||||
// Enable the quickicon plugin automatically
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('enabled') . ' = 1')
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('quickicon'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokobackup'));
|
||||
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
|
||||
// Enable the task plugin automatically
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
|
||||
Reference in New Issue
Block a user