15 Commits

Author SHA1 Message Date
gitea-actions[bot] affe2db36a chore(version): pre-release bump to 01.23.01-dev [skip ci] 2026-06-18 19:02:31 +00:00
jmiller 0c4ba5756b Merge pull request 'feat: add BackupStatusHelper for external plugin integration (#47)' (#48) from feature/47-backup-status-helper into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 6s
Generic: Project CI / Lint & Validate (push) Successful in 8s
Generic: Project CI / Tests (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
2026-06-18 19:02:25 +00:00
Jonathan Miller 896c4c597e ci: retrigger after runner restart
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Generic: Project CI / Lint & Validate (pull_request) Successful in 23s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
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: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
2026-06-18 12:46:53 -05:00
Jonathan Miller a77458a24a fix(status): use staleDays param for cutoff, remove unused profile_id (#47)
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 5s
Universal: Auto Version Bump / Version Bump (push) Successful in 5s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 4s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Project CI / Lint & Validate (pull_request) Successful in 26s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
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: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
- Use $staleDays instead of hardcoded 7-day cutoff for recent/failure
  counts so the parameter is consistently applied
- Remove unused profile_id from SELECT (never included in return array)
2026-06-18 11:13:12 -05:00
gitea-actions[bot] 64c6ef6d7e chore(release): build 01.23.00 [skip ci] 2026-06-18 16:07:30 +00:00
jmiller b2630fbc87 Merge pull request 'feat: per-profile retention, ntfy notifications, extension checks' (#46) from dev into main
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Project CI / Lint & Validate (push) Successful in 32s
Generic: Project CI / Tests (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
2026-06-18 16:07:20 +00:00
gitea-actions[bot] 2e7e49fa60 chore(version): pre-release bump to 01.22.10-dev [skip ci]
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 11s
2026-06-18 15:59:59 +00:00
Jonathan Miller 55954ba081 fix: remaining review items — prefix in trailing SQL, dead code, indent
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Generic: Repo Health / Site Health (push) Has been skipped
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 4s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 7s
Generic: Project CI / Lint & Validate (push) Successful in 42s
Generic: Project CI / Lint & Validate (pull_request) Successful in 42s
Universal: PR Check / Validate PR (pull_request) Failing after 38s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 44s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) 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
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
- DatabaseImporter: apply #__ prefix replacement on trailing statement
  (was missing for SQL not terminated by semicolon)
- SteppedBackupEngine: remove unused DatabaseDumper instantiation
- SteppedBackupEngine: fix misaligned indentation in stepDatabase()
2026-06-18 10:59:47 -05:00
gitea-actions[bot] 7ecc855e40 chore(version): pre-release bump to 01.22.09-dev [skip ci] 2026-06-18 15:56:16 +00:00
Jonathan Miller a4c03d0032 fix: critical review — infinite recursion, SQL injection, FK prefix
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Successful in 4s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 7s
Generic: Project CI / Lint & Validate (push) Successful in 31s
Generic: Project CI / Lint & Validate (pull_request) Successful in 31s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 35s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) 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
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Critical:
- Fix infinite recursion in getValidatedPrefix() — was calling itself
  instead of extracting from $data array
- Fix SQL injection in actionResetAdmin() — prefix not validated,
  now uses getValidatedPrefix()

High:
- Fix prefix abstraction to cover FK REFERENCES — str_replace now
  targets backtick+prefix pattern to catch all table references in
  CREATE TABLE output, not just the current table name

Medium:
- Security gate file write check — skip verification gracefully if
  file cannot be written (don't lock user out)
- Stepped notification catch \Throwable instead of \Exception
2026-06-18 10:56:02 -05:00
gitea-actions[bot] 682538e4de chore(version): pre-release bump to 01.22.08-dev [skip ci] 2026-06-18 15:42:29 +00:00
Jonathan Miller b2874f32f2 feat: abstract DB prefix, stepped checksum, restore security gate
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 6s
Generic: Project CI / Lint & Validate (pull_request) Successful in 32s
Generic: Project CI / Lint & Validate (push) Successful in 32s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 35s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) 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
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Database prefix abstraction:
- DatabaseDumper uses #__ placeholder instead of live prefix in all
  SQL output (DROP TABLE, CREATE TABLE, INSERT INTO)
- SteppedBackupEngine::dumpSingleTable() same #__ replacement
- DatabaseImporter replaces #__ with current site prefix on import
- MokoRestore replaces #__ with user-specified prefix on import
- Backups are now portable across sites with different prefixes

Stepped backup checksum:
- completeRecord() now computes and stores SHA-256 checksum

MokoRestore security gate:
- Writes .mokorestore-security.php with random 8-char code to site root
- User must read code from filesystem and enter it in browser
- Proves filesystem access before any restore actions are allowed
- Security file auto-deleted after successful verification
- All AJAX actions blocked until verification completes
2026-06-18 10:42:10 -05:00
Jonathan Miller 937e38bcc6 feat(utility): add BackupStatusHelper for external plugin integration (#47)
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Project CI / Lint & Validate (pull_request) Successful in 22s
Universal: PR Check / Validate PR (pull_request) Failing after 22s
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: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Public API for MokoSuiteClient bridge plugin to query backup status
without depending on internal model structure. Provides getStatus()
and isInstalled() static methods.
2026-06-18 10:40:56 -05:00
gitea-actions[bot] b3e7c8ec72 chore(version): pre-release bump to 01.22.07-dev [skip ci] 2026-06-18 15:27:08 +00:00
Jonathan Miller 9656a2a92b fix: PR #46 review — error handling, failure notifications, cleanup
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 7s
Generic: Project CI / Lint & Validate (push) Successful in 11s
Universal: Auto Version Bump / Version Bump (push) Successful in 4s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 10s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 8s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Generic: Project CI / Lint & Validate (pull_request) Successful in 35s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 38s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) 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
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Critical:
- Wrap cleanupOldBackups() in try-catch to prevent admin panel crash
- Add missing fields (total_size, files_count, etc.) to failure record
  so failure notifications actually send

High:
- Log unlink failures in deleteBackupRecord() instead of silent return
- Wrap DB delete in try-catch so one failed record doesn't abort loop
- Check for ext-curl before calling curl_init() in sendNtfy()

Medium:
- Change runPreActionBackup catch from \Exception to \Throwable
- Log warning for skipped files during archive encryption
- Truncate ntfy response body in error logs (200 chars max)
2026-06-18 10:26:48 -05:00
22 changed files with 381 additions and 77 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<display-name>Package - MokoSuiteBackup</display-name>
<org>MokoConsulting</org>
<description>Full-site backup and restore for Joomla — database, files, and configuration</description>
<version>01.22.06-dev</version>
<version>01.23.01-dev</version>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokoplatform.Automation
# VERSION: 01.22.06
# VERSION: 01.23.01
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
+2 -21
View File
@@ -1,6 +1,8 @@
# Changelog
## [Unreleased]
## [01.23.00] --- 2026-06-18
## [01.21.00] --- 2026-06-16
### Fixed
@@ -15,24 +17,3 @@
## [01.07.00] --- 2026-06-07
## [01.06.00] --- 2026-06-07
## [01.05.00] --- 2026-06-07
### Added
- Dashboard submenu entry as default landing page with `class:home` icon
- `[DEFAULT_DIR]` placeholder for portable backup directory configuration — resolves to `administrator/components/com_mokosuitebackup/backups` at runtime
- Live AJAX directory validation on backup_dir field — checks existence, writability, and placeholder resolution as user types (debounced 400ms)
- `checkDir` AJAX endpoint for real-time directory permission checking
- Web-accessible warning badge on backup download buttons when archive is inside web root
- Inline security warning in FolderPicker when default directory is selected
- Auto `.htaccess` and `index.html` protection for web-accessible backup directories on profile save and at backup time
- Font Awesome 6 submenu icons via CSS injection in `MokoSuiteBackupComponent::boot()`
- `syncMenuIcons()` installer postflight — syncs icon classes to `#__menu` on install and update
- `encryptionPassword` property on `SteppedSession` for upcoming stepped backup encryption support
### Changed
- Profile `backup_dir` default changed from literal path to `[DEFAULT_DIR]` placeholder
- Backup engine fallback directory changed from hardcoded path to `[DEFAULT_DIR]`
- `isUsingDefaultBackupDir()` now matches `[DEFAULT_DIR]` placeholder in addition to literal path and empty values
- Dashboard submenu language key added to `.sys.ini` files (en-GB, en-US)
+1 -1
View File
@@ -1,6 +1,6 @@
# MokoSuiteBackup
<!-- VERSION: 01.22.06 -->
<!-- VERSION: 01.23.01 -->
Full-site backup and restore for Joomla — database, files, and configuration.
+1 -1
View File
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoSuiteBackup</name>
<version>01.22.06-dev</version>
<version>01.23.01-dev</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="component" method="upgrade">
<name>MokoSuiteBackup</name>
<version>01.22.06-dev</version>
<version>01.23.01-dev</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -305,15 +305,19 @@ class BackupEngine
$this->log('FATAL: ' . $e->getMessage());
$update = (object) [
'id' => $recordId,
'status' => 'fail',
'description' => $description ?: '',
'backup_type' => $profile->backup_type ?? 'full',
'origin' => $origin,
'archivename' => $archiveName,
'backupstart' => $now ?? date('Y-m-d H:i:s'),
'backupend' => date('Y-m-d H:i:s'),
'log' => implode("\n", $this->log),
'id' => $recordId,
'status' => 'fail',
'description' => $description ?: '',
'backup_type' => $profile->backup_type ?? 'full',
'origin' => $origin,
'archivename' => $archiveName,
'backupstart' => $now ?? date('Y-m-d H:i:s'),
'backupend' => date('Y-m-d H:i:s'),
'total_size' => 0,
'files_count' => 0,
'tables_count' => 0,
'remote_filename' => '',
'log' => implode("\n", $this->log),
];
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
@@ -487,6 +491,7 @@ class BackupEngine
$name = $zip->getNameIndex($i);
if ($name === false) {
$this->log('WARNING: Could not read file at index ' . $i . ' during encryption — file may remain unencrypted');
continue;
}
@@ -60,7 +60,9 @@ class DatabaseDumper
$output[] = '-- Generated: ' . date('Y-m-d H:i:s');
$output[] = '-- Server: ' . $db->getServerType();
$output[] = '-- Database: ' . $db->getName();
$output[] = '-- Prefix: ' . $prefix;
$output[] = '-- Original Prefix: ' . $prefix;
$output[] = '-- Abstract Prefix: #__';
$output[] = '-- Note: Table names use #__ placeholder. Replace with your prefix on restore.';
$output[] = '';
$output[] = 'SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";';
$output[] = 'SET time_zone = "+00:00";';
@@ -90,7 +92,7 @@ class DatabaseDumper
$this->tablesCount++;
$output[] = '-- --------------------------------------------------------';
$output[] = '-- Table: ' . $table;
$output[] = '-- Table: ' . $abstractName;
if ($skipData) {
$output[] = '-- (data excluded)';
@@ -112,8 +114,11 @@ class DatabaseDumper
continue;
}
$output[] = 'DROP TABLE IF EXISTS ' . $db->quoteName($table) . ';';
$output[] = $createRow[1] . ';';
// Replace all occurrences of the live prefix with #__ in CREATE TABLE
// output — covers the table itself and FK REFERENCES to other tables
$createSql = str_replace('`' . $prefix, '`#__', $createRow[1]);
$output[] = 'DROP TABLE IF EXISTS `' . $abstractName . '`;';
$output[] = $createSql . ';';
$output[] = '';
}
@@ -160,7 +165,7 @@ class DatabaseDumper
}
$columns = array_map([$db, 'quoteName'], array_keys($row));
$output[] = 'INSERT INTO ' . $db->quoteName($table)
$output[] = 'INSERT INTO `' . $abstractName . '`'
. ' (' . implode(', ', $columns) . ')'
. ' VALUES (' . implode(', ', $values) . ');';
}
@@ -87,11 +87,8 @@ class DatabaseImporter
continue;
}
// Replace the prefix from the dump with the current site prefix.
// The dump uses real table names (with the original prefix), but
// if restoring to a site with a different prefix we need to handle it.
// Our DatabaseDumper uses real names, so no replacement needed
// for same-site restores.
// Replace abstract #__ prefix with the current site's prefix
$statement = str_replace('#__', $prefix, $statement);
try {
$db->setQuery($statement);
@@ -110,6 +107,8 @@ class DatabaseImporter
$remaining = trim($currentStatement);
if (!empty($remaining)) {
$remaining = str_replace('#__', $prefix, $remaining);
try {
$db->setQuery($remaining);
$db->execute();
@@ -109,6 +109,56 @@ if (empty($_SESSION['restore_token'])) {
$token = $_SESSION['restore_token'];
// ── Security Verification ───────────────────────────────────────────
// Write a security file to the web root with a random code.
// The user must read the code from the file and enter it in the browser
// to prove they have filesystem access before any restore actions are allowed.
$securityFile = RESTORE_DIR . '/.mokorestore-security.php';
$securityCode = $_SESSION['security_code'] ?? '';
if (empty($securityCode)) {
$securityCode = strtoupper(substr(bin2hex(random_bytes(4)), 0, 8));
$_SESSION['security_code'] = $securityCode;
$_SESSION['security_verified'] = false;
// Write security file with the code
$securityContent = "<?php die('MokoRestore Security Code: " . $securityCode . "'); ?>\n"
. "MokoRestore Security Verification\n"
. "==================================\n"
. "Code: " . $securityCode . "\n"
. "Enter this code in the MokoRestore browser interface to proceed.\n"
. "This file will be deleted automatically after verification.\n";
if (file_put_contents($securityFile, $securityContent) === false) {
// Cannot write security file — skip verification to avoid locking user out
$_SESSION['security_verified'] = true;
error_log('MokoRestore: Cannot write security file — verification skipped (check directory permissions)');
}
}
// Handle security code verification via POST
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'verify_security') {
header('Content-Type: application/json; charset=utf-8');
$inputCode = strtoupper(trim($_POST['security_code'] ?? ''));
if ($inputCode === $securityCode) {
$_SESSION['security_verified'] = true;
// Delete the security file
if (is_file($securityFile)) {
@unlink($securityFile);
}
echo json_encode(['success' => true, 'message' => 'Security verified']);
} else {
echo json_encode(['success' => false, 'message' => 'Incorrect security code. Check the file: .mokorestore-security.php']);
}
exit;
}
// Block all other actions until security is verified
$securityVerified = !empty($_SESSION['security_verified']);
// ── AJAX Handler ────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
header('Content-Type: application/json; charset=utf-8');
@@ -118,6 +168,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
exit;
}
if (!$securityVerified) {
echo json_encode(['success' => false, 'message' => 'Security verification required. Enter the code from .mokorestore-security.php']);
exit;
}
@set_time_limit(0);
@ini_set('max_execution_time', '0');
@ini_set('memory_limit', '512M');
@@ -348,7 +403,12 @@ function actionDatabase(array $data): array
$pdo->exec("SET time_zone = '+00:00'");
$pdo->exec('SET FOREIGN_KEY_CHECKS = 0');
$sql = file_get_contents($sqlFile);
$sql = file_get_contents($sqlFile);
$prefix = getValidatedPrefix($data);
// Replace abstract #__ prefix with the user's target prefix
$sql = str_replace('#__', $prefix, $sql);
$parts = explode(";\n", $sql);
$statements = 0;
$errors = 0;
@@ -675,7 +735,7 @@ HTACCESS;
function getValidatedPrefix(array $data): string
{
$prefix = getValidatedPrefix($data);
$prefix = trim($data['db_prefix'] ?? 'moko_');
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,20}$/', $prefix)) {
throw new RuntimeException('Invalid table prefix format');
@@ -710,7 +770,7 @@ function actionListAdmins(array $data): array
function actionResetAdmin(array $data): array
{
$pdo = getDbConnection($data);
$prefix = $data['db_prefix'] ?? 'moko_';
$prefix = getValidatedPrefix($data);
$userId = (int) ($data['admin_id'] ?? 0);
$password = $data['new_password'] ?? '';
@@ -981,8 +1041,33 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
<div class="mr-step" data-step="7"><span class="mr-num">7</span>Complete</div>
</div>
<!-- Step 0: Security Verification -->
<div class="mr-panel <?php echo $securityVerified ? '' : 'visible'; ?>" id="panel0">
<h2>Security Verification</h2>
<p class="mr-desc">To prevent unauthorized access, enter the security code from the file <code>.mokorestore-security.php</code> in your site root.</p>
<div style="border:1px solid #e2e8f0;border-radius:8px;padding:1.25rem;margin-bottom:1.25rem;background:#f8fafc">
<div style="font-weight:600;font-size:0.9rem;color:#334155;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem">
<span style="font-size:1.1rem">&#128274;</span> How to find the code
</div>
<ol style="margin:0;padding-left:1.25rem;color:#475569;font-size:0.9rem;line-height:1.6">
<li>Connect to your server via FTP, SSH, or file manager</li>
<li>Open <code>.mokorestore-security.php</code> in the site root directory</li>
<li>Copy the 8-character code and enter it below</li>
</ol>
</div>
<div class="mr-field">
<label>Security Code</label>
<input type="text" id="securityCode" placeholder="e.g. A1B2C3D4" maxlength="8" style="text-transform:uppercase;letter-spacing:0.2em;font-family:monospace;font-size:1.1rem;text-align:center">
</div>
<div class="mr-status" id="securityStatus"></div>
<div class="mr-actions">
<span></span>
<button class="mr-btn mr-btn-primary" id="btnVerify" onclick="verifySecurity()">Verify &amp; Continue</button>
</div>
</div>
<!-- Step 1: Pre-flight Checks -->
<div class="mr-panel visible" id="panel1">
<div class="mr-panel <?php echo $securityVerified ? 'visible' : ''; ?>" id="panel1">
<h2>Pre-Installation Checks</h2>
<p class="mr-desc">Verify your server meets the requirements for Joomla and MokoRestore.</p>
<ul class="mr-checks" id="checkList"></ul>
@@ -1223,6 +1308,35 @@ function setBtnLoading(btn, loading) {
}
// Step 1
async function verifySecurity() {
const btn = document.getElementById('btnVerify');
setBtnLoading(btn, true);
const code = document.getElementById('securityCode').value.trim();
if (!code) {
setStatus('securityStatus', 'Please enter the security code', 'error');
setBtnLoading(btn, false);
return;
}
const form = new FormData();
form.append('action', 'verify_security');
form.append('security_code', code);
form.append('token', TOKEN);
const resp = await fetch('', { method: 'POST', body: form });
const r = await resp.json();
setBtnLoading(btn, false);
if (r.success) {
setStatus('securityStatus', 'Verified!', 'success');
document.getElementById('panel0').classList.remove('visible');
document.getElementById('panel1').classList.add('visible');
} else {
setStatus('securityStatus', r.message, 'error');
}
}
async function runPreflight() {
const btn = document.getElementById('btnCheck');
setBtnLoading(btn, true);
@@ -169,6 +169,12 @@ class NotificationSender
return false;
}
if (!function_exists('curl_init')) {
error_log('MokoSuiteBackup: ntfy notifications require ext-curl');
return false;
}
try {
$config = Factory::getApplication()->getConfig();
$siteName = $config->get('sitename', 'Joomla Site');
@@ -219,7 +225,7 @@ class NotificationSender
}
if ($httpCode < 200 || $httpCode >= 300) {
error_log('MokoSuiteBackup: ntfy returned HTTP ' . $httpCode . ': ' . $response);
error_log('MokoSuiteBackup: ntfy returned HTTP ' . $httpCode . ': ' . substr((string) $response, 0, 200));
return false;
}
@@ -220,8 +220,7 @@ class SteppedBackupEngine
$db = Factory::getDbo();
// Dump this single table
$dumper = new DatabaseDumper([]);
$sql = $this->dumpSingleTable($db, $table);
$sql = $this->dumpSingleTable($db, $table);
// Append to a temp SQL file that will be added to ZIP in finalize
$sqlFile = $session->archivePath . '.sql';
@@ -234,8 +233,9 @@ class SteppedBackupEngine
. "SET SQL_MODE = \"NO_AUTO_VALUE_ON_ZERO\";\n"
. "SET time_zone = \"+00:00\";\n\n";
if (file_put_contents($sqlFile, $header) === false) {
throw new \RuntimeException('Cannot write SQL dump: ' . $sqlFile);
}
throw new \RuntimeException('Cannot write SQL dump: ' . $sqlFile);
}
$flags = FILE_APPEND;
}
@@ -434,12 +434,14 @@ class SteppedBackupEngine
}
$totalSize = is_file($session->archivePath) ? filesize($session->archivePath) : 0;
$checksum = is_file($session->archivePath) ? hash_file('sha256', $session->archivePath) : '';
$update = (object) [
'id' => $session->recordId,
'status' => 'complete',
'backupend' => date('Y-m-d H:i:s'),
'total_size' => $totalSize,
'checksum' => $checksum,
'log' => $logContent,
];
@@ -471,7 +473,7 @@ class SteppedBackupEngine
NotificationSender::send($profile, $record, true, $logContent);
}
} catch (\Exception $e) {
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: SteppedBackupEngine notification failed: ' . $e->getMessage());
}
}
@@ -529,13 +531,16 @@ class SteppedBackupEngine
*/
private function dumpSingleTable(object $db, string $table): string
{
$prefix = $db->getPrefix();
$abstractName = '#__' . substr($table, strlen($prefix));
$output = [];
$output[] = '-- --------------------------------------------------------';
$output[] = '-- Table: ' . $table;
$output[] = '-- Table: ' . $abstractName;
$output[] = '-- --------------------------------------------------------';
$output[] = '';
// CREATE TABLE
// CREATE TABLE — replace live prefix with #__
$db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table));
$createRow = $db->loadRow();
@@ -543,8 +548,10 @@ class SteppedBackupEngine
return '';
}
$output[] = 'DROP TABLE IF EXISTS ' . $db->quoteName($table) . ';';
$output[] = $createRow[1] . ';';
// Replace all occurrences of the live prefix — covers FK REFERENCES too
$createSql = str_replace('`' . $prefix, '`#__', $createRow[1]);
$output[] = 'DROP TABLE IF EXISTS `' . $abstractName . '`;';
$output[] = $createSql . ';';
$output[] = '';
// Data in chunks
@@ -580,7 +587,7 @@ class SteppedBackupEngine
}
$columns = array_map([$db, 'quoteName'], array_keys($row));
$output[] = 'INSERT INTO ' . $db->quoteName($table)
$output[] = 'INSERT INTO `' . $abstractName . '`'
. ' (' . implode(', ', $columns) . ')'
. ' VALUES (' . implode(', ', $values) . ');';
}
@@ -0,0 +1,173 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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\MokoSuiteBackup\Administrator\Utility;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Provides a public API for external plugins (e.g. MokoSuiteClient bridge)
* to query backup status without depending on internal model internals.
*
* @since 01.22.07
*/
class BackupStatusHelper
{
/**
* Get a summary of the latest backup status.
*
* Returns an array suitable for inclusion in heartbeat payloads.
*
* @param int $staleDays Days without backup before status is degraded.
*
* @return array
*/
public static function getStatus(int $staleDays = 7): array
{
try
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
}
catch (\Throwable $e)
{
return ['installed' => true, 'status' => 'error', 'message' => 'Database unavailable'];
}
// Most recently inserted backup record (by ID, any status)
$query = $db->getQuery(true)
->select([
$db->quoteName('id'),
$db->quoteName('description'),
$db->quoteName('status'),
$db->quoteName('backup_type'),
$db->quoteName('total_size'),
$db->quoteName('backupstart'),
$db->quoteName('backupend'),
$db->quoteName('origin'),
$db->quoteName('filesexist'),
])
->from($db->quoteName('#__mokosuitebackup_records'))
->order($db->quoteName('id') . ' DESC');
$db->setQuery($query, 0, 1);
$latest = $db->loadObject();
if (!$latest)
{
return [
'installed' => true,
'status' => 'degraded',
'message' => 'No backups found',
];
}
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
);
$totalBackups = (int) $db->loadResult();
$cutoff = date('Y-m-d H:i:s', strtotime("-{$staleDays} days"));
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
->where($db->quoteName('backupstart') . ' >= ' . $db->quote($cutoff))
);
$recentBackups = (int) $db->loadResult();
// Failures in last 7 days
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('status') . ' = ' . $db->quote('fail'))
->where($db->quoteName('backupstart') . ' >= ' . $db->quote($cutoff))
);
$failCount7d = (int) $db->loadResult();
// Determine overall status
$daysSince = 999;
if (!empty($latest->backupstart) && $latest->backupstart !== '0000-00-00 00:00:00')
{
$daysSince = (int) ((time() - strtotime($latest->backupstart)) / 86400);
}
$status = 'ok';
if ($latest->status === 'fail')
{
$status = 'degraded';
}
elseif ($latest->status !== 'complete')
{
$status = ($latest->status === 'running') ? 'ok' : 'degraded';
}
elseif ($daysSince > $staleDays)
{
$status = 'degraded';
}
$sizeMb = $latest->total_size
? round($latest->total_size / 1048576)
: null;
return [
'installed' => true,
'status' => $status,
'last_backup' => $latest->backupstart,
'last_status' => $latest->status,
'last_size_mb' => $sizeMb,
'days_since' => $daysSince,
'backup_type' => $latest->backup_type,
'origin' => $latest->origin,
'total_backups' => $totalBackups,
'recent_7d' => $recentBackups,
'fail_count_7d' => $failCount7d,
'files_exist' => (bool) $latest->filesexist,
'description' => $latest->description,
];
}
/**
* Check if MokoSuiteBackup component is installed.
*
* Useful for external plugins that want to check before calling getStatus().
*
* @return bool
*/
public static function isInstalled(): bool
{
try
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuitebackup'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
$db->setQuery($query);
return (int) $db->loadResult() > 0;
}
catch (\Throwable $e)
{
return false;
}
}
}
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="actionlog" method="upgrade">
<name>Action Log - MokoSuiteBackup</name>
<version>01.22.06-dev</version>
<version>01.23.01-dev</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="console" method="upgrade">
<name>Console - MokoSuiteBackup</name>
<version>01.22.06-dev</version>
<version>01.23.01-dev</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="content" method="upgrade">
<name>Content - MokoSuiteBackup</name>
<version>01.22.06-dev</version>
<version>01.23.01-dev</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="quickicon" method="upgrade">
<name>Quick Icon - MokoSuiteBackup</name>
<version>01.22.06-dev</version>
<version>01.23.01-dev</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteBackup</name>
<version>01.22.06-dev</version>
<version>01.23.01-dev</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -138,6 +138,15 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
* A profile value of 0 means "use the global default".
*/
private function cleanupOldBackups(): void
{
try {
$this->doCleanup();
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: cleanupOldBackups() failed: ' . $e->getMessage());
}
}
private function doCleanup(): void
{
$db = Factory::getDbo();
$globalMaxAge = (int) ComponentHelper::getParams('com_mokosuitebackup')->get('max_age_days', 30);
@@ -219,10 +228,11 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
{
if (!empty($record->absolute_path) && is_file($record->absolute_path)) {
if (!@unlink($record->absolute_path)) {
return; // Don't delete DB record if file can't be removed
error_log('MokoSuiteBackup: Could not delete backup file (id=' . $record->id . '): ' . $record->absolute_path);
return;
}
// Also remove the log file if it exists alongside the archive
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $record->absolute_path);
if (is_file($logPath)) {
@@ -230,12 +240,16 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
}
}
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('id') . ' = ' . (int) $record->id)
);
$db->execute();
try {
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('id') . ' = ' . (int) $record->id)
);
$db->execute();
} catch (\Exception $e) {
error_log('MokoSuiteBackup: Could not delete backup record ' . $record->id . ': ' . $e->getMessage());
}
}
/**
@@ -291,7 +305,7 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
'warning'
);
}
} catch (\Exception $e) {
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: ' . $description . ' failed: ' . $e->getMessage());
Factory::getApplication()->enqueueMessage(
'MokoSuiteBackup: ' . $description . ' failed — ' . $e->getMessage(),
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteBackup</name>
<version>01.22.06-dev</version>
<version>01.23.01-dev</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoSuiteBackup</name>
<version>01.22.06-dev</version>
<version>01.23.01-dev</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+1 -1
View File
@@ -8,7 +8,7 @@
<extension type="package" method="upgrade">
<name>Package - MokoSuiteBackup</name>
<packagename>mokosuitebackup</packagename>
<version>01.22.06-dev</version>
<version>01.23.01-dev</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>