Compare commits

..

7 Commits

Author SHA1 Message Date
gitea-actions[bot] 4df70531e2 chore(version): pre-release bump to 01.39.02-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 7s
2026-06-23 18:13:45 +00:00
gitea-actions[bot] 845b856cda chore(version): auto-bump patch 01.28.03-dev [skip ci] 2026-06-23 18:12:45 +00:00
jmiller 633e9b7f1e chore: remove security-audit.yml -- handled by MokoGitea
Universal: Auto Version Bump / Version Bump (push) Successful in 20s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 43s
2026-06-23 18:05:04 +00:00
gitea-actions[bot] ec0b7eb8a4 chore(version): auto-bump patch 01.28.02-dev [skip ci] 2026-06-23 18:04:50 +00:00
jmiller 7d119565da chore: remove deploy-manual.yml -- no longer needed
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 21s
2026-06-23 17:59:42 +00:00
gitea-actions[bot] 9db7331a72 chore(version): pre-release bump to 01.28.01-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 5s
2026-06-21 23:58:07 +00:00
gitea-actions[bot] 32931c1e37 chore(version): auto-bump patch 01.27.04-dev [skip ci] 2026-06-21 23:57:56 +00:00
54 changed files with 109 additions and 4905 deletions
-9
View File
@@ -30,15 +30,6 @@ on:
types: [opened, closed]
branches:
- main
paths-ignore:
- '.mokogitea/workflows/**'
- '*.md'
- 'wiki/**'
- '.editorconfig'
- '.gitignore'
- '.gitattributes'
- '.gitmessage'
- 'LICENSE'
workflow_dispatch:
inputs:
action:
-126
View File
@@ -1,126 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Deploy
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
# VERSION: 04.07.00
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
name: "Universal: Deploy to Dev (Manual)"
on:
workflow_dispatch:
inputs:
clear_remote:
description: 'Delete all remote files before uploading'
required: false
default: 'false'
type: boolean
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions:
contents: read
jobs:
deploy:
name: SFTP Deploy to Dev
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP
run: |
php -v && composer --version
- name: Setup MokoStandards tools
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
run: |
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
/tmp/mokostandards-api 2>/dev/null || true
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
fi
- name: Check FTP configuration
id: check
env:
HOST: ${{ vars.DEV_FTP_HOST }}
PATH_VAR: ${{ vars.DEV_FTP_PATH }}
PORT: ${{ vars.DEV_FTP_PORT }}
run: |
if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "host=$HOST" >> "$GITHUB_OUTPUT"
REMOTE="${PATH_VAR%/}"
echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
[ -z "$PORT" ] && PORT="22"
echo "port=$PORT" >> "$GITHUB_OUTPUT"
- name: Deploy via SFTP
if: steps.check.outputs.skip != 'true'
env:
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
"${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
> /tmp/sftp-config.json
if [ -n "$SFTP_KEY" ]; then
echo "$SFTP_KEY" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
else
printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json
fi
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
[ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
else
php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
fi
rm -f /tmp/deploy_key /tmp/sftp-config.json
- name: Summary
if: always()
run: |
if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
else
echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY
fi
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 01.36.00
# VERSION: 01.39.02
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
-82
View File
@@ -1,82 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/security-audit.yml
# VERSION: 01.00.00
# BRIEF: Dependency vulnerability scanning for composer and npm packages
name: "Universal: Security Audit"
on:
schedule:
- cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC
pull_request:
branches:
- main
paths:
- 'composer.json'
- 'composer.lock'
- 'package.json'
- 'package-lock.json'
workflow_dispatch:
permissions:
contents: read
env:
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
jobs:
audit:
name: Dependency Audit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Composer audit
if: hashFiles('composer.lock') != ''
run: |
echo "=== Composer Security Audit ==="
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1
fi
composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt
RESULT=$?
if [ $RESULT -ne 0 ]; then
echo "::warning::Composer vulnerabilities found"
echo "composer_vulnerable=true" >> "$GITHUB_ENV"
else
echo "No known vulnerabilities in composer dependencies"
fi
- name: NPM audit
if: hashFiles('package-lock.json') != ''
run: |
echo "=== NPM Security Audit ==="
npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true
if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then
echo "No known vulnerabilities in npm dependencies"
else
echo "::warning::NPM vulnerabilities found"
echo "npm_vulnerable=true" >> "$GITHUB_ENV"
fi
- name: Notify on vulnerabilities
if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true'
run: |
REPO="${{ github.event.repository.name }}"
curl -sS \
-H "Title: ${REPO} has vulnerable dependencies" \
-H "Tags: lock,warning" \
-H "Priority: high" \
-d "Security audit found vulnerabilities. Review dependency updates." \
"${NTFY_URL}/${NTFY_TOPIC}" || true
+6 -6
View File
@@ -1,14 +1,14 @@
# Changelog
## [Unreleased]
## [01.36.00] --- 2026-06-23
## [01.27.03] --- 2026-06-21
## [01.36.00] --- 2026-06-23
## [01.27.03] --- 2026-06-21
## [01.35.04] --- 2026-06-23
## [01.27.00] --- 2026-06-21
## [01.35.04] --- 2026-06-23
## [01.27.00] --- 2026-06-21
## [01.35.03] --- 2026-06-23
## [01.27.00] --- 2026-06-21
## [01.35.03] --- 2026-06-23
## [01.27.00] --- 2026-06-21
+1 -1
View File
@@ -1,6 +1,6 @@
# MokoSuiteBackup
<!-- VERSION: 01.36.00 -->
<!-- VERSION: 01.39.02 -->
Full-site backup and restore for Joomla — database, files, and configuration.
@@ -124,7 +124,6 @@ class BackupsController extends ApiController
// Strip sensitive credentials before serialization
$sensitiveFields = [
'ftp_password', 'ftp_username',
'sftp_password', 'sftp_key_data', 'sftp_passphrase',
's3_access_key', 's3_secret_key',
'gdrive_client_secret', 'gdrive_refresh_token',
'encryption_password', 'ntfy_token',
@@ -1,307 +0,0 @@
<?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
*
* REST API controller for content snapshot operations.
*
* Endpoints:
* GET /api/index.php/v1/mokosuitebackup/snapshots — List snapshots
* POST /api/index.php/v1/mokosuitebackup/snapshot — Create snapshot
* POST /api/index.php/v1/mokosuitebackup/snapshot/:id/restore — Restore snapshot
* DELETE /api/index.php/v1/mokosuitebackup/snapshot/:id — Delete snapshot
* GET /api/index.php/v1/mokosuitebackup/snapshot/:id/download — Download snapshot JSON
*/
namespace Joomla\Component\MokoSuiteBackup\Api\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Controller\ApiController;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotEngine;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotRestoreEngine;
class SnapshotsController extends ApiController
{
protected $contentType = 'snapshots';
protected $default_view = 'snapshots';
/**
* List all snapshots with pagination (GET /api/index.php/v1/mokosuitebackup/snapshots)
*/
public function displayList(): static
{
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
$this->app->setHeader('status', 403);
echo json_encode(['errors' => [['title' => 'Access denied']]]);
$this->app->close();
return $this;
}
$db = Factory::getDbo();
$limit = $this->input->getInt('limit', 20);
$offset = $this->input->getInt('offset', 0);
// Clamp limits
$limit = max(1, min($limit, 100));
$offset = max(0, $offset);
// Get total count
$countQuery = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_snapshots'));
$db->setQuery($countQuery);
$total = (int) $db->loadResult();
// Get paginated results
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_snapshots'))
->order($db->quoteName('created') . ' DESC');
$db->setQuery($query, $offset, $limit);
$items = $db->loadObjectList() ?: [];
$data = [];
foreach ($items as $item) {
$data[] = [
'type' => 'snapshots',
'id' => $item->id,
'attributes' => $item,
];
}
$this->app->setHeader('status', 200);
echo json_encode([
'data' => $data,
'meta' => [
'total' => $total,
'limit' => $limit,
'offset' => $offset,
],
]);
$this->app->close();
return $this;
}
/**
* Create a new content snapshot (POST /api/index.php/v1/mokosuitebackup/snapshot)
*/
public function create(): static
{
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
$this->app->setHeader('status', 403);
echo json_encode(['errors' => [['title' => 'Access denied']]]);
$this->app->close();
return $this;
}
$data = json_decode($this->input->json->getRaw(), true) ?: [];
$contentTypes = $data['content_types'] ?? [];
$description = $data['description'] ?? '';
if (empty($contentTypes) || !is_array($contentTypes)) {
$this->app->setHeader('status', 400);
echo json_encode(['errors' => [['title' => 'content_types array is required']]]);
$this->app->close();
return $this;
}
$engine = new SnapshotEngine();
$result = $engine->create($contentTypes, $description);
if ($result['success']) {
$this->app->setHeader('status', 200);
echo json_encode(['data' => $result]);
} else {
$this->app->setHeader('status', 500);
echo json_encode(['errors' => [['title' => $result['message']]]]);
}
$this->app->close();
return $this;
}
/**
* Restore from a snapshot (POST /api/index.php/v1/mokosuitebackup/snapshot/:id/restore)
*/
public function restore(): static
{
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
$this->app->setHeader('status', 403);
echo json_encode(['errors' => [['title' => 'Access denied']]]);
$this->app->close();
return $this;
}
$id = $this->input->getInt('id', 0);
if (!$id) {
$this->app->setHeader('status', 400);
echo json_encode(['errors' => [['title' => 'Snapshot ID is required']]]);
$this->app->close();
return $this;
}
$data = json_decode($this->input->json->getRaw(), true) ?: [];
$mode = $data['mode'] ?? 'replace';
$contentTypes = $data['content_types'] ?? [];
// Enforce valid restore mode
if (!in_array($mode, ['replace', 'merge'], true)) {
$mode = 'replace';
}
$engine = new SnapshotRestoreEngine();
$result = $engine->restore($id, $mode, $contentTypes);
if ($result['success']) {
$this->app->setHeader('status', 200);
echo json_encode(['data' => $result]);
} else {
$this->app->setHeader('status', 500);
echo json_encode(['errors' => [['title' => $result['message']]]]);
}
$this->app->close();
return $this;
}
/**
* Delete a snapshot record and its data file (DELETE /api/index.php/v1/mokosuitebackup/snapshot/:id)
*/
public function delete(): static
{
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
$this->app->setHeader('status', 403);
echo json_encode(['errors' => [['title' => 'Access denied']]]);
$this->app->close();
return $this;
}
$id = $this->input->getInt('id', 0);
if (!$id) {
$this->app->setHeader('status', 400);
echo json_encode(['errors' => [['title' => 'Snapshot ID is required']]]);
$this->app->close();
return $this;
}
$db = Factory::getDbo();
// Load record to get file path
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_snapshots'))
->where($db->quoteName('id') . ' = ' . $id);
$db->setQuery($query);
$record = $db->loadObject();
if (!$record) {
$this->app->setHeader('status', 404);
echo json_encode(['errors' => [['title' => 'Snapshot not found']]]);
$this->app->close();
return $this;
}
// Delete data file
if ($record->data_file && is_file($record->data_file)) {
if (!unlink($record->data_file)) {
error_log('MokoSuiteBackup: Failed to delete snapshot file: ' . $record->data_file);
}
}
// Delete record
$query = $db->getQuery(true)
->delete($db->quoteName('#__mokosuitebackup_snapshots'))
->where($db->quoteName('id') . ' = ' . $id);
$db->setQuery($query);
$db->execute();
$this->app->setHeader('status', 200);
echo json_encode(['data' => ['success' => true, 'message' => 'Snapshot deleted']]);
$this->app->close();
return $this;
}
/**
* Stream the JSON snapshot file (GET /api/index.php/v1/mokosuitebackup/snapshot/:id/download)
*/
public function download(): static
{
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
$this->app->setHeader('status', 403);
echo json_encode(['errors' => [['title' => 'Access denied']]]);
$this->app->close();
return $this;
}
$id = $this->input->getInt('id', 0);
if (!$id) {
$this->app->setHeader('status', 400);
echo json_encode(['errors' => [['title' => 'Snapshot ID is required']]]);
$this->app->close();
return $this;
}
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_snapshots'))
->where($db->quoteName('id') . ' = ' . $id);
$db->setQuery($query);
$record = $db->loadObject();
if (!$record || !is_file($record->data_file) || !is_readable($record->data_file)) {
$this->app->setHeader('status', 404);
echo json_encode(['errors' => [['title' => 'Snapshot file not found']]]);
$this->app->close();
return $this;
}
// Stream as download
while (@ob_end_clean()) {
// clear all buffers
}
$filename = basename($record->data_file);
$filesize = filesize($record->data_file);
header('Content-Type: application/json');
header("Content-Disposition: attachment; filename*=UTF-8''" . rawurlencode($filename));
header('Content-Length: ' . $filesize);
header('Cache-Control: no-cache, must-revalidate');
readfile($record->data_file);
$this->app->close();
return $this;
}
}
@@ -118,27 +118,6 @@
/>
</fieldset>
<fieldset name="snapshot_cleanup" label="COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_RETENTION">
<field
name="snapshot_retention_count"
type="number"
label="COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_MAX_COUNT"
description="COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_MAX_COUNT_DESC"
default="20"
min="0"
max="100"
/>
<field
name="snapshot_retention_days"
type="number"
label="COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_MAX_AGE"
description="COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_MAX_AGE_DESC"
default="30"
min="0"
max="365"
/>
</fieldset>
<fieldset name="notifications" label="COM_MOKOJOOMBACKUP_CONFIG_NOTIFICATIONS">
<field
name="notify_email"
@@ -72,14 +72,12 @@
/>
<field
name="archive_name_format"
type="PlaceholderText"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT"
description="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC"
default="[host]_[datetime]_profile[profile_id]"
maxlength="512"
hint="[host]_[datetime]_profile[profile_id]"
placeholders="[host],[datetime],[date],[time],[year],[month],[day],[hour],[minute],[second],[profile_id],[profile_name],[site_name],[type],[random]"
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
/>
<field
name="include_mokorestore"
@@ -161,7 +159,7 @@
default="none"
>
<option value="none">COM_MOKOJOOMBACKUP_REMOTE_NONE</option>
<option value="sftp">COM_MOKOJOOMBACKUP_REMOTE_SFTP</option>
<option value="ftp">COM_MOKOJOOMBACKUP_REMOTE_FTP</option>
<option value="google_drive">COM_MOKOJOOMBACKUP_REMOTE_GDRIVE</option>
<option value="s3">COM_MOKOJOOMBACKUP_REMOTE_S3</option>
</field>
@@ -176,80 +174,6 @@
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<!-- SFTP fields (shown when remote_storage = sftp) -->
<field
name="sftp_host"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST_DESC"
maxlength="255"
showon="remote_storage:sftp"
/>
<field
name="sftp_port"
type="number"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT_DESC"
default="22"
min="1"
max="65535"
showon="remote_storage:sftp"
/>
<field
name="sftp_username"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME_DESC"
maxlength="255"
showon="remote_storage:sftp"
/>
<field
name="sftp_auth_type"
type="list"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE_DESC"
default="key"
showon="remote_storage:sftp"
>
<option value="password">COM_MOKOJOOMBACKUP_SFTP_AUTH_PASSWORD</option>
<option value="key">COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY</option>
<option value="key_passphrase">COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY_PASSPHRASE</option>
</field>
<field
name="sftp_password"
type="password"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD_DESC"
maxlength="255"
showon="remote_storage:sftp[AND]sftp_auth_type:password"
/>
<field
name="sftp_key_data"
type="SshKey"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_DESC"
filter="raw"
showon="remote_storage:sftp[AND]sftp_auth_type:key,key_passphrase"
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
/>
<field
name="sftp_passphrase"
type="password"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE_DESC"
maxlength="255"
showon="remote_storage:sftp[AND]sftp_auth_type:key_passphrase"
/>
<field
name="sftp_path"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH_DESC"
default="/backups"
maxlength="512"
showon="remote_storage:sftp"
/>
</fieldset>
<fieldset name="retention" label="COM_MOKOJOOMBACKUP_FIELDSET_RETENTION">
@@ -33,12 +33,6 @@ COM_MOKOJOOMBACKUP_DASHBOARD_QUICK_ACTIONS="Quick Actions"
COM_MOKOJOOMBACKUP_DASHBOARD_SCHEDULED_TASKS="Scheduled Tasks"
COM_MOKOJOOMBACKUP_DASHBOARD_UPDATE_SITE="Update Site"
COM_MOKOJOOMBACKUP_DASHBOARD_SYSTEM_HEALTH="System Health"
COM_MOKOJOOMBACKUP_DASHBOARD_SNAPSHOTS="Content Snapshots"
COM_MOKOJOOMBACKUP_DASHBOARD_VIEW_ALL="View All"
COM_MOKOJOOMBACKUP_DASHBOARD_LATEST_SNAPSHOT="Latest"
COM_MOKOJOOMBACKUP_DASHBOARD_NO_SNAPSHOTS="No snapshots yet. Create one from the Content Snapshots view."
COM_MOKOJOOMBACKUP_DASHBOARD_STORAGE_BREAKDOWN="Storage by Profile"
COM_MOKOJOOMBACKUP_DASHBOARD_BACKUP_TREND="Backup Trend (30 days)"
; Backups view
COM_MOKOJOOMBACKUP_BACKUPS_TITLE="Backup Records"
@@ -50,22 +44,6 @@ COM_MOKOJOOMBACKUP_DOWNLOAD="Download"
; Backup detail view
COM_MOKOJOOMBACKUP_BACKUP_DETAIL="Backup Detail"
COM_MOKOJOOMBACKUP_VIEW_LOG="Backup Log"
COM_MOKOJOOMBACKUP_BROWSE_ARCHIVE="Browse Archive Contents"
COM_MOKOJOOMBACKUP_BROWSE_COL_NAME="Name"
COM_MOKOJOOMBACKUP_BROWSE_COL_SIZE="Size"
COM_MOKOJOOMBACKUP_BROWSE_COL_COMPRESSED="Compressed"
; Backup comparison
COM_MOKOJOOMBACKUP_TOOLBAR_COMPARE="Compare"
COM_MOKOJOOMBACKUP_COMPARE_TITLE="Backup Comparison"
COM_MOKOJOOMBACKUP_COMPARE_LOADING="Loading comparison..."
COM_MOKOJOOMBACKUP_COMPARE_FIELD="Field"
COM_MOKOJOOMBACKUP_COMPARE_BACKUP="Backup"
COM_MOKOJOOMBACKUP_COMPARE_DELTA="Delta"
COM_MOKOJOOMBACKUP_COMPARE_DB_SIZE="DB Size"
COM_MOKOJOOMBACKUP_COMPARE_FILES_COUNT="Files Count"
COM_MOKOJOOMBACKUP_COMPARE_TABLES_COUNT="Tables Count"
COM_MOKOJOOMBACKUP_COMPARE_DURATION="Duration"
COM_MOKOJOOMBACKUP_COMPARE_SELECT_TWO="Please select exactly two backup records to compare."
COM_MOKOJOOMBACKUP_FIELD_CHECKSUM="SHA-256 Checksum"
COM_MOKOJOOMBACKUP_FIELD_PATH="File Path"
COM_MOKOJOOMBACKUP_FIELD_DB_SIZE="DB Size"
@@ -242,35 +220,7 @@ COM_MOKOJOOMBACKUP_VERIFY_FAILED="INTEGRITY CHECK FAILED — archive has been mo
COM_MOKOJOOMBACKUP_VERIFY_NO_CHECKSUM="No checksum stored for this backup. Only backups created after this update can be verified."
; S3 storage
COM_MOKOJOOMBACKUP_REMOTE_SFTP="SFTP (SSH File Transfer)"
COM_MOKOJOOMBACKUP_REMOTE_S3="Amazon S3 / S3-Compatible"
; SFTP fields
COM_MOKOJOOMBACKUP_FIELDSET_SFTP="SFTP Settings"
COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST="SFTP Host"
COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST_DESC="SFTP server hostname or IP address"
COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT="SFTP Port"
COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT_DESC="SSH port (default: 22)"
COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME="SSH Username"
COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME_DESC="Username for SSH authentication"
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD="SSH Password"
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD_DESC="Password for SSH authentication. Leave blank if using a key file."
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY="SSH Private Key"
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_DESC="Upload or paste your SSH private key (e.g. id_rsa or id_ed25519). The key is stored securely in the database and written to a temp file with 0600 permissions only during upload, then deleted. Leave blank to use password authentication."
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_UPLOAD="Upload Key File"
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_REPLACE="Replace Key"
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_LOADED="Key loaded"
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_NONE="No key file"
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_CLEAR="Remove Key"
COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE="Authentication Type"
COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE_DESC="Choose how to authenticate with the SFTP server."
COM_MOKOJOOMBACKUP_SFTP_AUTH_PASSWORD="Password"
COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY="Key File"
COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY_PASSPHRASE="Key File + Passphrase"
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE="Key Passphrase"
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE_DESC="Passphrase for the private key, if encrypted. Leave blank for unencrypted keys."
COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH="Remote Path"
COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH_DESC="Directory on the remote server to upload backups to"
COM_MOKOJOOMBACKUP_FIELDSET_S3="S3 Storage Settings"
COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT="S3 Endpoint"
COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT_DESC="S3 API endpoint URL. Leave blank for AWS S3. For Wasabi, MinIO, Backblaze B2, enter their endpoint URL."
@@ -319,13 +269,6 @@ COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_SUCCESS_DESC="Send email when any backup comple
COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_FAILURE="Notify on Failure"
COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_FAILURE_DESC="Send email when any backup fails (unless overridden by profile)."
; Snapshot Retention
COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_RETENTION="Snapshot Retention"
COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_MAX_COUNT="Max Snapshot Count"
COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_MAX_COUNT_DESC="Maximum number of content snapshots to keep. Oldest are removed first. Set to 0 for unlimited."
COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_MAX_AGE="Max Snapshot Age (days)"
COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_MAX_AGE_DESC="Delete snapshots older than this many days. Set to 0 for unlimited."
; Web Cron
COM_MOKOJOOMBACKUP_CONFIG_WEBCRON="Web Cron"
COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_ENABLED="Enable Web Cron"
@@ -77,22 +77,6 @@ COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DATA="Data"
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_STRUCTURE="Structure"
COM_MOKOJOOMBACKUP_FIELD_TABLE_NAME="Table Name"
COM_MOKOJOOMBACKUP_VIEW_LOG="Backup Log"
COM_MOKOJOOMBACKUP_BROWSE_ARCHIVE="Browse Archive Contents"
COM_MOKOJOOMBACKUP_BROWSE_COL_NAME="Name"
COM_MOKOJOOMBACKUP_BROWSE_COL_SIZE="Size"
COM_MOKOJOOMBACKUP_BROWSE_COL_COMPRESSED="Compressed"
; Backup comparison
COM_MOKOJOOMBACKUP_TOOLBAR_COMPARE="Compare"
COM_MOKOJOOMBACKUP_COMPARE_TITLE="Backup Comparison"
COM_MOKOJOOMBACKUP_COMPARE_LOADING="Loading comparison..."
COM_MOKOJOOMBACKUP_COMPARE_FIELD="Field"
COM_MOKOJOOMBACKUP_COMPARE_BACKUP="Backup"
COM_MOKOJOOMBACKUP_COMPARE_DELTA="Delta"
COM_MOKOJOOMBACKUP_COMPARE_DB_SIZE="DB Size"
COM_MOKOJOOMBACKUP_COMPARE_FILES_COUNT="Files Count"
COM_MOKOJOOMBACKUP_COMPARE_TABLES_COUNT="Tables Count"
COM_MOKOJOOMBACKUP_COMPARE_DURATION="Duration"
COM_MOKOJOOMBACKUP_COMPARE_SELECT_TWO="Please select exactly two backup records to compare."
COM_MOKOJOOMBACKUP_FIELD_CHECKSUM="SHA-256 Checksum"
COM_MOKOJOOMBACKUP_FIELD_PATH="File Path"
COM_MOKOJOOMBACKUP_FIELD_DB_SIZE="DB Size"
@@ -7,7 +7,7 @@
-->
<extension type="component" method="upgrade">
<name>MokoSuiteBackup</name>
<version>01.36.00</version>
<version>01.39.02</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -19,14 +19,6 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
`ftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
`ftp_passive` TINYINT(1) NOT NULL DEFAULT 1,
`ftp_ssl` TINYINT(1) NOT NULL DEFAULT 0,
`sftp_host` VARCHAR(255) NOT NULL DEFAULT '',
`sftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 22,
`sftp_username` VARCHAR(255) NOT NULL DEFAULT '',
`sftp_auth_type` VARCHAR(20) NOT NULL DEFAULT 'key',
`sftp_password` VARCHAR(255) NOT NULL DEFAULT '',
`sftp_key_data` MEDIUMTEXT,
`sftp_passphrase` VARCHAR(255) NOT NULL DEFAULT '',
`sftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
`gdrive_client_id` VARCHAR(255) NOT NULL DEFAULT '',
`gdrive_client_secret` VARCHAR(255) NOT NULL DEFAULT '',
`gdrive_refresh_token` VARCHAR(512) NOT NULL DEFAULT '',
@@ -1,10 +0,0 @@
-- MokoSuiteBackup 01.35.00 — SFTP support with key file storage
ALTER TABLE `#__mokosuitebackup_profiles`
ADD COLUMN `sftp_host` VARCHAR(255) NOT NULL DEFAULT '' AFTER `ftp_ssl`,
ADD COLUMN `sftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 22 AFTER `sftp_host`,
ADD COLUMN `sftp_username` VARCHAR(255) NOT NULL DEFAULT '' AFTER `sftp_port`,
ADD COLUMN `sftp_password` VARCHAR(255) NOT NULL DEFAULT '' AFTER `sftp_username`,
ADD COLUMN `sftp_key_data` MEDIUMTEXT AFTER `sftp_password`,
ADD COLUMN `sftp_passphrase` VARCHAR(255) NOT NULL DEFAULT '' AFTER `sftp_key_data`,
ADD COLUMN `sftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups' AFTER `sftp_passphrase`;
@@ -1,4 +0,0 @@
-- MokoSuiteBackup 01.36.00 — SFTP auth type column
ALTER TABLE `#__mokosuitebackup_profiles`
ADD COLUMN `sftp_auth_type` VARCHAR(20) NOT NULL DEFAULT 'key' AFTER `sftp_username`;
@@ -18,7 +18,6 @@ defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Session\Session;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedBackupEngine;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedRestoreEngine;
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
class AjaxController extends BaseController
@@ -309,459 +308,6 @@ class AjaxController extends BaseController
]);
}
/**
* Initialize a new stepped restore.
* POST: task=ajax.restoreInit&id=123&restore_files=1&restore_db=1&preserve_config=1&encryption_password=
*/
public function restoreInit(): void
{
if (!Session::checkToken('get') && !Session::checkToken('post')) {
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
return;
}
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return;
}
$recordId = $this->input->getInt('id', 0);
$restoreFiles = (bool) $this->input->getInt('restore_files', 1);
$restoreDb = (bool) $this->input->getInt('restore_db', 1);
$preserveConfig = (bool) $this->input->getInt('preserve_config', 1);
$password = $this->input->getString('encryption_password', '');
if (!$recordId) {
$this->sendJson(['error' => true, 'message' => 'Missing record ID']);
return;
}
$engine = new SteppedRestoreEngine();
$result = $engine->init($recordId, $restoreFiles, $restoreDb, $preserveConfig, $password);
$this->sendJson($result);
}
/**
* Run the next step of a restore session.
* POST: task=ajax.restoreStep&session_id=mb_...
*/
public function restoreStep(): void
{
if (!Session::checkToken('get') && !Session::checkToken('post')) {
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
return;
}
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return;
}
$sessionId = $this->input->getString('session_id', '');
if (empty($sessionId)) {
$this->sendJson(['error' => true, 'message' => 'Missing session_id']);
return;
}
$engine = new SteppedRestoreEngine();
$result = $engine->runStep($sessionId);
$this->sendJson($result);
}
/**
* Browse archive contents without extracting.
* POST: task=ajax.browseArchive&id=123
*/
public function browseArchive(): void
{
if (!Session::checkToken('get') && !Session::checkToken('post')) {
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
return;
}
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return;
}
$id = $this->input->getInt('id', 0);
if (!$id) {
$this->sendJson(['error' => true, 'message' => 'Missing record ID']);
return;
}
try {
$db = \Joomla\CMS\Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName(['absolute_path', 'status', 'filesexist']))
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('id') . ' = ' . (int) $id);
$db->setQuery($query);
$record = $db->loadObject();
} catch (\Exception $e) {
error_log('MokoSuiteBackup: browseArchive() DB error for record ' . $id . ': ' . $e->getMessage());
$this->sendJson(['error' => true, 'message' => 'Failed to load backup record'], 500);
return;
}
if (!$record) {
$this->sendJson(['error' => true, 'message' => 'Record not found'], 404);
return;
}
if ($record->status !== 'complete' || !$record->filesexist) {
$this->sendJson(['error' => true, 'message' => 'Archive not available']);
return;
}
$archivePath = $record->absolute_path;
if (!is_file($archivePath)) {
$this->sendJson(['error' => true, 'message' => 'Archive file not found on disk']);
return;
}
$maxEntries = 500;
try {
$files = [];
$totalFiles = 0;
$totalSize = 0;
$truncated = false;
$lower = strtolower($archivePath);
if (substr($lower, -4) === '.zip') {
$files = $this->browseZipArchive($archivePath, $maxEntries, $totalFiles, $totalSize, $truncated);
} elseif (substr($lower, -7) === '.tar.gz' || substr($lower, -4) === '.tgz') {
$files = $this->browseTarArchive($archivePath, $maxEntries, $totalFiles, $totalSize, $truncated);
} else {
$this->sendJson(['error' => true, 'message' => 'Unsupported archive format']);
return;
}
} catch (\Exception $e) {
error_log('MokoSuiteBackup: browseArchive() error for record ' . $id . ': ' . $e->getMessage());
$this->sendJson(['error' => true, 'message' => 'Failed to read archive: ' . $e->getMessage()]);
return;
}
$this->sendJson([
'error' => false,
'files' => $files,
'total_files' => $totalFiles,
'total_size' => $totalSize,
'truncated' => $truncated,
]);
}
/**
* Browse a ZIP archive and return file entries.
*
* @param string $path Absolute path to the ZIP file
* @param int $maxEntries Maximum entries to return
* @param int &$totalFiles Total number of files (by reference)
* @param int &$totalSize Total uncompressed size (by reference)
* @param bool &$truncated Whether results were truncated (by reference)
*
* @return array List of file entry arrays
*/
private function browseZipArchive(string $path, int $maxEntries, int &$totalFiles, int &$totalSize, bool &$truncated): array
{
$zip = new \ZipArchive();
if ($zip->open($path, \ZipArchive::RDONLY) !== true) {
throw new \RuntimeException('Cannot open ZIP archive');
}
$files = [];
$totalFiles = $zip->numFiles;
for ($i = 0; $i < $totalFiles; $i++) {
$stat = $zip->statIndex($i);
if ($stat === false) {
continue;
}
$totalSize += $stat['size'];
if (\count($files) < $maxEntries) {
$files[] = [
'name' => $stat['name'],
'size' => $stat['size'],
'compressed_size' => $stat['comp_size'],
];
}
}
$truncated = $totalFiles > $maxEntries;
$zip->close();
return $files;
}
/**
* Browse a tar.gz archive and return file entries.
*
* @param string $path Absolute path to the tar.gz file
* @param int $maxEntries Maximum entries to return
* @param int &$totalFiles Total number of files (by reference)
* @param int &$totalSize Total uncompressed size (by reference)
* @param bool &$truncated Whether results were truncated (by reference)
*
* @return array List of file entry arrays
*/
private function browseTarArchive(string $path, int $maxEntries, int &$totalFiles, int &$totalSize, bool &$truncated): array
{
$phar = new \PharData($path);
$files = [];
foreach (new \RecursiveIteratorIterator($phar) as $entry) {
$totalFiles++;
$entrySize = $entry->getSize();
$totalSize += $entrySize;
if (\count($files) < $maxEntries) {
// Strip the phar:// prefix and archive path to get relative name
$fullPath = str_replace('\\', '/', $entry->getPathname());
$relativeName = preg_replace('#^phar://.+?\.tar\.gz/#i', '', $fullPath)
?: preg_replace('#^phar://.+?\.tgz/#i', '', $fullPath)
?: $fullPath;
$files[] = [
'name' => $relativeName,
'size' => $entrySize,
'compressed_size' => $entrySize,
];
}
}
$truncated = $totalFiles > $maxEntries;
return $files;
}
/**
* Browse articles inside a snapshot — returns JSON list for the browse modal.
* POST: task=ajax.browseSnapshot&id=123
*/
public function browseSnapshot(): void
{
if (!Session::checkToken('get') && !Session::checkToken('post')) {
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
return;
}
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return;
}
$id = $this->input->getInt('id', 0);
if (!$id) {
$this->sendJson(['error' => true, 'message' => 'Missing snapshot ID']);
return;
}
$db = \Joomla\CMS\Factory::getDbo();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_snapshots'))
->where($db->quoteName('id') . ' = ' . (int) $id);
$db->setQuery($query);
$record = $db->loadObject();
if (!$record) {
$this->sendJson(['error' => true, 'message' => 'Snapshot not found'], 404);
return;
}
if ($record->status !== 'complete') {
$this->sendJson(['error' => true, 'message' => 'Cannot browse a failed snapshot']);
return;
}
if (!is_file($record->data_file) || !is_readable($record->data_file)) {
$this->sendJson(['error' => true, 'message' => 'Snapshot data file not found']);
return;
}
$json = file_get_contents($record->data_file);
if ($json === false) {
$this->sendJson(['error' => true, 'message' => 'Cannot read snapshot file']);
return;
}
$data = json_decode($json, true);
if (json_last_error() !== JSON_ERROR_NONE || empty($data['tables']['#__content'])) {
$this->sendJson(['error' => true, 'message' => 'Snapshot does not contain articles']);
return;
}
$articles = [];
foreach ($data['tables']['#__content'] as $row) {
$articles[] = [
'id' => (int) ($row['id'] ?? 0),
'title' => $row['title'] ?? '',
'catid' => (int) ($row['catid'] ?? 0),
'state' => (int) ($row['state'] ?? 0),
'created' => $row['created'] ?? '',
];
}
$this->sendJson([
'error' => false,
'articles' => $articles,
'total' => count($articles),
]);
}
/**
* Compare two backup records side-by-side.
* POST: task=ajax.compareBackups&id1=123&id2=456
*/
public function compareBackups(): void
{
if (!Session::checkToken('get') && !Session::checkToken('post')) {
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
return;
}
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return;
}
$id1 = $this->input->getInt('id1', 0);
$id2 = $this->input->getInt('id2', 0);
if (!$id1 || !$id2) {
$this->sendJson(['error' => true, 'message' => 'Two backup record IDs are required']);
return;
}
if ($id1 === $id2) {
$this->sendJson(['error' => true, 'message' => 'Please select two different backup records']);
return;
}
$fields = [
'r.id', 'r.description', 'r.status', 'r.backup_type',
'r.total_size', 'r.db_size', 'r.files_count', 'r.tables_count',
'r.backupstart', 'r.backupend',
];
try {
$db = \Joomla\CMS\Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName($fields))
->select($db->quoteName('p.title', 'profile_title'))
->from($db->quoteName('#__mokosuitebackup_records', 'r'))
->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p')
. ' ON ' . $db->quoteName('p.id') . ' = ' . $db->quoteName('r.profile_id'))
->where($db->quoteName('r.id') . ' IN (' . (int) $id1 . ', ' . (int) $id2 . ')');
$db->setQuery($query);
$rows = $db->loadObjectList('id');
} catch (\Exception $e) {
error_log('MokoSuiteBackup: compareBackups() DB error: ' . $e->getMessage());
$this->sendJson(['error' => true, 'message' => 'Failed to load backup records'], 500);
return;
}
if (!isset($rows[$id1])) {
$this->sendJson(['error' => true, 'message' => 'Backup record #' . $id1 . ' not found'], 404);
return;
}
if (!isset($rows[$id2])) {
$this->sendJson(['error' => true, 'message' => 'Backup record #' . $id2 . ' not found'], 404);
return;
}
$b1 = $rows[$id1];
$b2 = $rows[$id2];
// Calculate durations in seconds
$duration1 = 0;
$duration2 = 0;
if ($b1->backupstart !== '0000-00-00 00:00:00' && $b1->backupend !== '0000-00-00 00:00:00') {
$duration1 = strtotime($b1->backupend) - strtotime($b1->backupstart);
}
if ($b2->backupstart !== '0000-00-00 00:00:00' && $b2->backupend !== '0000-00-00 00:00:00') {
$duration2 = strtotime($b2->backupend) - strtotime($b2->backupstart);
}
$formatRecord = function ($row) {
return [
'id' => (int) $row->id,
'description' => $row->description,
'status' => $row->status,
'backup_type' => $row->backup_type,
'total_size' => (int) $row->total_size,
'db_size' => (int) $row->db_size,
'files_count' => (int) $row->files_count,
'tables_count' => (int) $row->tables_count,
'backupstart' => $row->backupstart,
'backupend' => $row->backupend,
'profile_title' => $row->profile_title ?? '',
];
};
$this->sendJson([
'error' => false,
'backup1' => $formatRecord($b1),
'backup2' => $formatRecord($b2),
'delta' => [
'size_diff' => (int) $b2->total_size - (int) $b1->total_size,
'files_diff' => (int) $b2->files_count - (int) $b1->files_count,
'tables_diff' => (int) $b2->tables_count - (int) $b1->tables_count,
'duration_diff_seconds' => $duration2 - $duration1,
],
]);
}
/**
* Send a JSON response and close the application.
*/
@@ -16,7 +16,6 @@ use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\AdminController;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotEngine;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotRestoreEngine;
@@ -107,151 +106,6 @@ class SnapshotsController extends AdminController
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
}
/**
* Browse articles inside a snapshot — returns JSON for AJAX modal.
*/
public function browse(): void
{
if (!Session::checkToken('get') && !Session::checkToken('post')) {
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
return;
}
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return;
}
$id = $this->input->getInt('id', 0);
if (!$id) {
$this->sendJson(['error' => true, 'message' => 'Missing snapshot ID']);
return;
}
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_snapshots'))
->where($db->quoteName('id') . ' = ' . $id);
$db->setQuery($query);
$record = $db->loadObject();
if (!$record) {
$this->sendJson(['error' => true, 'message' => 'Snapshot not found'], 404);
return;
}
if ($record->status !== 'complete') {
$this->sendJson(['error' => true, 'message' => 'Cannot browse a failed snapshot']);
return;
}
if (!is_file($record->data_file) || !is_readable($record->data_file)) {
$this->sendJson(['error' => true, 'message' => 'Snapshot data file not found']);
return;
}
$json = file_get_contents($record->data_file);
if ($json === false) {
$this->sendJson(['error' => true, 'message' => 'Cannot read snapshot file']);
return;
}
$data = json_decode($json, true);
if (json_last_error() !== JSON_ERROR_NONE || empty($data['tables']['#__content'])) {
$this->sendJson(['error' => true, 'message' => 'Snapshot does not contain articles']);
return;
}
$articles = [];
foreach ($data['tables']['#__content'] as $row) {
$articles[] = [
'id' => (int) ($row['id'] ?? 0),
'title' => $row['title'] ?? '',
'catid' => (int) ($row['catid'] ?? 0),
'state' => (int) ($row['state'] ?? 0),
'created' => $row['created'] ?? '',
];
}
$this->sendJson([
'error' => false,
'articles' => $articles,
'total' => count($articles),
]);
}
/**
* Restore selected articles from a snapshot.
*/
public function restoreSelected(): void
{
$this->checkToken();
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
return;
}
$id = $this->input->getInt('id', 0);
$articleIds = $this->input->get('article_ids', [], 'array');
if (!$id) {
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NO_RECORD'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
return;
}
if (empty($articleIds)) {
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NO_ARTICLES_SELECTED'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
return;
}
$engine = new SnapshotRestoreEngine();
$result = $engine->restoreSelectedArticles($id, $articleIds);
if ($result['success']) {
$this->setMessage($result['message']);
} else {
$this->setMessage($result['message'], 'error');
}
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
}
/**
* Send a JSON response and close the application.
*/
private function sendJson(array $data, int $status = 200): void
{
$app = $this->app;
$app->setHeader('status', $status);
$app->setHeader('Content-Type', 'application/json; charset=utf-8');
$app->setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
$app->sendHeaders();
echo json_encode($data);
$app->close();
}
/**
* Delete snapshot records and their data files.
*/
@@ -232,11 +232,6 @@ class BackupEngine
$this->log('Archive created: ' . $sizeHuman);
$this->log('SHA-256: ' . ($checksum ?: 'N/A'));
// Verify archive integrity
$this->log('Verifying archive integrity...');
$this->verifyArchive($archivePath, $profile->backup_type);
$this->log('Archive integrity verified');
// Step 2.5: Wrap with MokoRestore script (if enabled)
$includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
@@ -260,36 +255,26 @@ class BackupEngine
}
$remoteFilename = '';
$uploadFailed = false;
// Step 3: Remote upload (if configured)
// Wrapped in its own try-catch so a remote failure does not mark
// the entire backup as failed — the local archive is preserved.
$remoteStorage = $profile->remote_storage ?? 'none';
if ($remoteStorage !== 'none') {
try {
$this->log('Starting remote upload (' . $remoteStorage . ')...');
$uploader = $this->createUploader($remoteStorage, $profile);
$uploadResult = $uploader->upload($archivePath, $archiveName);
$this->log('Starting remote upload (' . $remoteStorage . ')...');
$uploader = $this->createUploader($remoteStorage, $profile);
$uploadResult = $uploader->upload($archivePath, $archiveName);
if ($uploadResult['success']) {
$remoteFilename = $uploadResult['remote_path'] ?? $archiveName;
$this->log('Remote upload complete: ' . $uploadResult['message']);
if ($uploadResult['success']) {
$remoteFilename = $uploadResult['remote_path'] ?? $archiveName;
$this->log('Remote upload complete: ' . $uploadResult['message']);
// Delete local copy if configured
if (empty($profile->remote_keep_local) && is_file($archivePath)) {
@unlink($archivePath);
$this->log('Local copy removed (remote_keep_local = off)');
}
} else {
$uploadFailed = true;
$this->log('WARNING: Remote upload failed: ' . $uploadResult['message']);
$this->log('Local backup is preserved.');
// Delete local copy if configured
if (empty($profile->remote_keep_local) && is_file($archivePath)) {
@unlink($archivePath);
$this->log('Local copy removed (remote_keep_local = off)');
}
} catch (\Throwable $e) {
$uploadFailed = true;
$this->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
} else {
$this->log('WARNING: Remote upload failed: ' . $uploadResult['message']);
$this->log('Local backup is preserved.');
}
}
@@ -324,14 +309,9 @@ class BackupEngine
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
// Send success notification (backup completed, even if upload failed)
// Send success notification
NotificationSender::send($profile, $update, true, implode("\n", $this->log));
// If remote upload failed, also send a failure notification for the upload
if ($uploadFailed) {
NotificationSender::send($profile, $update, false, "Remote upload failed — see backup log for details.\n\n" . implode("\n", $this->log));
}
// Dispatch event for actionlog and other listeners
$this->dispatchAfterRun(true, $recordId, $description, $profileId, $origin);
@@ -453,7 +433,6 @@ class BackupEngine
{
return match ($type) {
'ftp' => new FtpUploader($profile),
'sftp' => new SftpUploader($profile),
'google_drive' => new GoogleDriveUploader($profile),
's3' => new S3Uploader($profile),
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
@@ -524,90 +503,6 @@ class BackupEngine
$zip->close();
}
/**
* Verify that a backup archive can be opened and contains expected entries.
*
* @param string $archivePath Absolute path to the archive file
* @param string $backupType Backup type: full, database, files, differential
*
* @throws \RuntimeException If the archive fails verification
*/
private function verifyArchive(string $archivePath, string $backupType): void
{
if (!is_file($archivePath)) {
throw new \RuntimeException('Archive file does not exist: ' . $archivePath);
}
$extension = strtolower(pathinfo($archivePath, PATHINFO_EXTENSION));
// Detect tar.gz (pathinfo only returns 'gz')
if ($extension === 'gz' && str_ends_with(strtolower($archivePath), '.tar.gz')) {
$this->verifyTarGzArchive($archivePath);
return;
}
// ZIP verification
$zip = new \ZipArchive();
if ($zip->open($archivePath, \ZipArchive::RDONLY) !== true) {
throw new \RuntimeException('Archive integrity check failed: cannot open ZIP file');
}
if ($zip->numFiles < 1) {
$zip->close();
throw new \RuntimeException('Archive integrity check failed: archive contains no files');
}
// Verify database.sql exists when backup includes database
if ($backupType !== 'files') {
if ($zip->locateName('database.sql') === false) {
$zip->close();
throw new \RuntimeException('Archive integrity check failed: database.sql missing from archive');
}
}
// Spot-check: verify the first entry is readable
$firstName = $zip->getNameIndex(0);
if ($firstName === false) {
$zip->close();
throw new \RuntimeException('Archive integrity check failed: cannot read first entry');
}
$zip->close();
}
/**
* Verify a tar.gz archive can be opened and iterated.
*
* @param string $archivePath Absolute path to the .tar.gz file
*
* @throws \RuntimeException If the archive fails verification
*/
private function verifyTarGzArchive(string $archivePath): void
{
try {
$phar = new \PharData($archivePath);
$count = 0;
foreach ($phar as $entry) {
// Spot-check: verify at least the first entry is accessible
$entry->getFilename();
$count++;
break;
}
if ($count === 0) {
throw new \RuntimeException('Archive integrity check failed: tar.gz archive contains no entries');
}
} catch (\RuntimeException $e) {
throw $e;
} catch (\Throwable $e) {
throw new \RuntimeException('Archive integrity check failed: ' . $e->getMessage());
}
}
/**
* Dispatch the onMokoSuiteBackupAfterRun event so plugins (actionlog, etc.) can react.
*/
@@ -236,297 +236,6 @@ class NotificationSender
}
}
/**
* Send a restore/snapshot notification via email and ntfy.
*
* @param object $profile Profile object with notification settings
* @param string $type One of: site_restore, snapshot_create, snapshot_restore
* @param array $details Context: record_id, content_types, row_count, mode, user, etc.
* @param string $log Operation log text
*
* @return bool True if at least one notification was sent
*/
public static function sendRestoreNotification(object $profile, string $type, array $details, string $log = ''): bool
{
$emailSent = self::sendRestoreEmail($profile, $type, $details, $log);
$ntfySent = self::sendRestoreNtfy($profile, $type, $details);
return $emailSent || $ntfySent;
}
/**
* Load the default profile (ID 1) for notification settings.
*
* @return object|null Profile object or null if not found
*/
public static function getDefaultProfile(): ?object
{
try {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('id') . ' = 1');
$db->setQuery($query);
return $db->loadObject() ?: null;
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: Cannot load default profile: ' . $e->getMessage());
return null;
}
}
/**
* Build subject and body for a restore/snapshot notification email.
*/
private static function buildRestoreMessage(string $type, array $details, string $siteName, string $siteUrl): array
{
$user = $details['user'] ?? 'Unknown';
switch ($type) {
case 'site_restore':
$subject = "[MokoSuiteBackup] RESTORE: Site restored — {$siteName}";
$options = [];
if (!empty($details['restore_files'])) {
$options[] = 'Files';
}
if (!empty($details['restore_db'])) {
$options[] = 'Database';
}
if (!empty($details['preserve_config'])) {
$options[] = 'Config preserved';
}
$body = "MokoSuiteBackup — Site Restore Notification\n"
. "=============================================\n\n"
. "Site: {$siteName}\n"
. "URL: {$siteUrl}\n"
. "Action: Full site restore\n"
. "Record ID: " . ($details['record_id'] ?? 'N/A') . "\n"
. "Options: " . (empty($options) ? 'N/A' : implode(', ', $options)) . "\n"
. "Triggered by: {$user}\n";
break;
case 'snapshot_create':
$types = $details['content_types'] ?? [];
$typesStr = !empty($types) ? implode(', ', $types) : 'N/A';
$subject = "[MokoSuiteBackup] SNAPSHOT: Content snapshot created — {$siteName}";
$body = "MokoSuiteBackup — Snapshot Created\n"
. "===================================\n\n"
. "Site: {$siteName}\n"
. "URL: {$siteUrl}\n"
. "Action: Snapshot created\n"
. "Content types: {$typesStr}\n"
. "Articles: " . ($details['articles_count'] ?? 0) . "\n"
. "Categories: " . ($details['categories_count'] ?? 0) . "\n"
. "Modules: " . ($details['modules_count'] ?? 0) . "\n"
. "Triggered by: {$user}\n";
break;
case 'snapshot_restore':
$types = $details['content_types'] ?? [];
$typesStr = !empty($types) ? implode(', ', $types) : 'N/A';
$subject = "[MokoSuiteBackup] RESTORE: Snapshot restored — {$siteName}";
$body = "MokoSuiteBackup — Snapshot Restore Notification\n"
. "================================================\n\n"
. "Site: {$siteName}\n"
. "URL: {$siteUrl}\n"
. "Action: Snapshot restore\n"
. "Mode: " . ($details['mode'] ?? 'N/A') . "\n"
. "Content types: {$typesStr}\n"
. "Rows restored: " . ($details['row_count'] ?? 0) . "\n"
. "Triggered by: {$user}\n";
break;
default:
$subject = "[MokoSuiteBackup] NOTIFICATION: {$type}{$siteName}";
$body = "MokoSuiteBackup Notification\n"
. "============================\n\n"
. "Site: {$siteName}\n"
. "URL: {$siteUrl}\n"
. "Type: {$type}\n"
. "Details: " . json_encode($details) . "\n";
break;
}
$body .= "\n--\n"
. "MokoSuiteBackup — https://mokoconsulting.tech\n";
return ['subject' => $subject, 'body' => $body];
}
/**
* Send a restore/snapshot notification email.
*/
private static function sendRestoreEmail(object $profile, string $type, array $details, string $log = ''): bool
{
$notifyEmail = trim($profile->notify_email ?? '');
$notifyUserGroups = $profile->notify_user_groups ?? '';
$groupEmails = self::resolveUserGroupEmails($notifyUserGroups);
if (empty($notifyEmail) && empty($groupEmails)) {
return false;
}
// Restore notifications are always "success" events — use notify_on_success preference
if (empty($profile->notify_on_success)) {
return false;
}
try {
$mailer = Factory::getMailer();
$config = Factory::getApplication()->getConfig();
$siteName = $config->get('sitename', 'Joomla Site');
$siteUrl = Uri::root();
$recipients = array_map('trim', explode(',', $notifyEmail));
$recipients = array_merge($recipients, $groupEmails);
$recipients = array_unique(array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL)));
if (empty($recipients)) {
return false;
}
foreach ($recipients as $recipient) {
$mailer->addRecipient($recipient);
}
$message = self::buildRestoreMessage($type, $details, $siteName, $siteUrl);
$mailer->setSubject($message['subject']);
$body = $message['body'];
// Append log excerpt if provided (last 30 lines)
if (!empty($log)) {
$logLines = explode("\n", $log);
$excerpt = array_slice($logLines, -30);
$body .= "\n--- Log (last 30 lines) ---\n"
. implode("\n", $excerpt) . "\n";
}
$mailer->setBody($body);
$mailer->isHtml(false);
return $mailer->Send();
} catch (\Throwable $e) {
error_log('MokoSuiteBackup restore notification error: ' . $e->getMessage());
return false;
}
}
/**
* Send a restore/snapshot push notification via ntfy.
*/
private static function sendRestoreNtfy(object $profile, string $type, array $details): bool
{
$topic = trim($profile->ntfy_topic ?? '');
$server = trim($profile->ntfy_server ?? 'https://ntfy.sh');
$token = trim($profile->ntfy_token ?? '');
if ($topic === '') {
return false;
}
// Restore notifications are always "success" events — use notify_on_success preference
if (empty($profile->notify_on_success)) {
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');
switch ($type) {
case 'site_restore':
$emoji = "\xF0\x9F\x94\x84"; // 🔄
$title = "{$emoji} Site Restored: {$siteName}";
$body = "Record ID: " . ($details['record_id'] ?? 'N/A') . "\n"
. "Triggered by: " . ($details['user'] ?? 'Unknown');
break;
case 'snapshot_create':
$emoji = "\xF0\x9F\x93\xB8"; // 📸
$types = $details['content_types'] ?? [];
$title = "{$emoji} Snapshot Created: {$siteName}";
$body = "Types: " . implode(', ', $types) . "\n"
. "Articles: " . ($details['articles_count'] ?? 0) . "\n"
. "Categories: " . ($details['categories_count'] ?? 0) . "\n"
. "Modules: " . ($details['modules_count'] ?? 0);
break;
case 'snapshot_restore':
$emoji = "\xF0\x9F\x94\x84"; // 🔄
$types = $details['content_types'] ?? [];
$title = "{$emoji} Snapshot Restored: {$siteName}";
$body = "Mode: " . ($details['mode'] ?? 'N/A') . "\n"
. "Types: " . implode(', ', $types) . "\n"
. "Rows: " . ($details['row_count'] ?? 0);
break;
default:
$title = "MokoSuiteBackup: {$type}{$siteName}";
$body = json_encode($details);
break;
}
$url = rtrim($server, '/') . '/' . rawurlencode($topic);
$headers = [
'Title: ' . $title,
'Priority: 3',
'Tags: arrows_counterclockwise',
];
if ($token !== '') {
$headers[] = 'Authorization: Bearer ' . $token;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error !== '') {
error_log('MokoSuiteBackup: ntfy error: ' . $error);
return false;
}
if ($httpCode < 200 || $httpCode >= 300) {
error_log('MokoSuiteBackup: ntfy returned HTTP ' . $httpCode . ': ' . substr((string) $response, 0, 200));
return false;
}
return true;
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: ntfy restore notification error: ' . $e->getMessage());
return false;
}
}
/**
* Resolve user group IDs to email addresses of group members.
*
@@ -278,21 +278,6 @@ class PreflightCheck
break;
case 'sftp':
if (empty($profile->sftp_host)) {
$this->warnings[] = 'SFTP host is not configured — remote upload will fail';
}
if (empty($profile->sftp_username)) {
$this->warnings[] = 'SFTP username is not configured — remote upload will fail';
}
if (empty($profile->sftp_key_data) && empty($profile->sftp_password)) {
$this->warnings[] = 'SFTP requires either a private key or password — remote upload will fail';
}
break;
case 'google_drive':
if (empty($profile->gdrive_client_id) || empty($profile->gdrive_client_secret)) {
$this->warnings[] = 'Google Drive OAuth credentials are not configured — remote upload will fail';
@@ -146,26 +146,6 @@ class RestoreEngine
$this->log('Restore complete');
// Send restore notification
try {
$profile = NotificationSender::getDefaultProfile();
if ($profile) {
$userId = Factory::getApplication()->getIdentity()->id ?? 0;
$userName = Factory::getApplication()->getIdentity()->username ?? 'Unknown';
NotificationSender::sendRestoreNotification($profile, 'site_restore', [
'record_id' => $recordId,
'restore_files' => $restoreFiles,
'restore_db' => $restoreDb,
'preserve_config' => $preserveConfig,
'user' => $userName . ' (ID: ' . $userId . ')',
], implode("\n", $this->log));
}
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: Restore notification failed: ' . $e->getMessage());
}
return [
'success' => true,
'message' => 'Restore complete from: ' . basename($archivePath),
@@ -1,255 +0,0 @@
<?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
*
* SFTP uploader using the system sftp/scp binary with SSH key authentication.
*
* The private key is stored in the database (profile column) and written
* to a temp file with 0600 permissions at upload time, then deleted.
* This avoids leaving key files on the filesystem permanently.
*/
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
class SftpUploader implements RemoteUploaderInterface
{
private string $host;
private int $port;
private string $username;
private string $keyData;
private string $passphrase;
private string $password;
private string $remotePath;
public function __construct(object $profile)
{
$this->host = $profile->sftp_host ?? '';
$this->port = (int) ($profile->sftp_port ?? 22);
$this->username = $profile->sftp_username ?? '';
$this->keyData = $profile->sftp_key_data ?? '';
$this->passphrase = $profile->sftp_passphrase ?? '';
$this->password = $profile->sftp_password ?? '';
$this->remotePath = rtrim($profile->sftp_path ?? '/backups', '/');
}
public function upload(string $localPath, string $remoteName): array
{
if (empty($this->host)) {
return ['success' => false, 'message' => 'SFTP host is not configured'];
}
if (empty($this->username)) {
return ['success' => false, 'message' => 'SFTP username is not configured'];
}
if (empty($this->keyData) && empty($this->password)) {
return ['success' => false, 'message' => 'SFTP requires either a private key or password'];
}
$keyFile = null;
try {
/* Write key to temp file if using key auth */
if (!empty($this->keyData)) {
$keyFile = $this->writeTempKey();
}
/* Ensure remote directory exists */
$this->ensureRemoteDir($keyFile);
/* Upload via scp */
$remoteTarget = $this->username . '@' . $this->host . ':' . $this->remotePath . '/' . $remoteName;
$cmd = $this->buildScpCommand($localPath, $remoteTarget, $keyFile);
$output = [];
$exitCode = 0;
exec($cmd . ' 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
$errorMsg = implode("\n", $output);
throw new \RuntimeException('scp failed (exit ' . $exitCode . '): ' . $errorMsg);
}
/* Verify upload by checking remote file size */
$remoteFile = $this->remotePath . '/' . $remoteName;
$remoteSize = $this->getRemoteFileSize($remoteFile, $keyFile);
$localSize = filesize($localPath);
if ($remoteSize > 0 && $remoteSize !== $localSize) {
throw new \RuntimeException(
'Size mismatch after upload: local=' . $localSize . ' remote=' . $remoteSize
);
}
return [
'success' => true,
'message' => 'Uploaded via SFTP: ' . $remoteFile,
'remote_path' => $remoteFile,
];
} catch (\Throwable $e) {
return ['success' => false, 'message' => 'SFTP upload failed: ' . $e->getMessage()];
} finally {
$this->cleanupTempKey($keyFile);
}
}
public function testConnection(): array
{
if (empty($this->host)) {
return ['success' => false, 'message' => 'SFTP host is not configured'];
}
$keyFile = null;
try {
if (!empty($this->keyData)) {
$keyFile = $this->writeTempKey();
}
$cmd = $this->buildSshCommand('echo "MokoSuiteBackup connection test OK" && hostname', $keyFile);
$output = [];
$exitCode = 0;
exec($cmd . ' 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'message' => 'SSH connection failed: ' . implode(' ', $output)];
}
return [
'success' => true,
'message' => 'Connected to ' . $this->host . ' — ' . implode(' ', $output),
];
} catch (\Throwable $e) {
return ['success' => false, 'message' => 'Connection test failed: ' . $e->getMessage()];
} finally {
$this->cleanupTempKey($keyFile);
}
}
/**
* Write the private key from the database to a temp file with 0600 permissions.
*/
private function writeTempKey(): string
{
$tmpDir = sys_get_temp_dir();
$keyFile = $tmpDir . '/mokobackup-sftp-' . bin2hex(random_bytes(8)) . '.key';
/* Key is stored base64-encoded in the database — decode before writing */
$keyContent = base64_decode($this->keyData, true);
if ($keyContent === false) {
/* Fallback: might be raw PEM (legacy or paste) */
$keyContent = $this->keyData;
}
if (file_put_contents($keyFile, $keyContent) === false) {
throw new \RuntimeException('Cannot write temporary SSH key file');
}
chmod($keyFile, 0600);
return $keyFile;
}
/**
* Delete the temp key file.
*/
private function cleanupTempKey(?string $keyFile): void
{
if ($keyFile !== null && is_file($keyFile)) {
unlink($keyFile);
}
}
/**
* Ensure the remote directory exists via ssh mkdir -p.
*/
private function ensureRemoteDir(?string $keyFile): void
{
$escapedPath = escapeshellarg($this->remotePath);
$cmd = $this->buildSshCommand('mkdir -p ' . $escapedPath, $keyFile);
$output = [];
$exitCode = 0;
exec($cmd . ' 2>&1', $output, $exitCode);
/* mkdir -p exits 0 even if dir already exists, so only fail on non-zero */
if ($exitCode !== 0) {
throw new \RuntimeException('Cannot create remote directory: ' . implode(' ', $output));
}
}
/**
* Get remote file size via ssh stat.
*/
private function getRemoteFileSize(string $remotePath, ?string $keyFile): int
{
$escapedPath = escapeshellarg($remotePath);
$cmd = $this->buildSshCommand('stat -c %s ' . $escapedPath . ' 2>/dev/null || echo -1', $keyFile);
$output = [];
exec($cmd . ' 2>&1', $output);
$size = (int) trim(implode('', $output));
return $size > 0 ? $size : 0;
}
/**
* Build an scp command string with proper SSH options.
*/
private function buildScpCommand(string $localPath, string $remoteTarget, ?string $keyFile): string
{
$parts = ['scp', '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes'];
if ($this->port !== 22) {
$parts[] = '-P';
$parts[] = (string) $this->port;
}
if ($keyFile !== null) {
$parts[] = '-i';
$parts[] = escapeshellarg($keyFile);
}
if (!empty($this->passphrase)) {
/* scp doesn't natively support passphrase via CLI — requires ssh-agent or expect.
For now, key files should be unencrypted or use ssh-agent. */
}
$parts[] = escapeshellarg($localPath);
$parts[] = escapeshellarg($remoteTarget);
return implode(' ', $parts);
}
/**
* Build an ssh command string for remote commands.
*/
private function buildSshCommand(string $remoteCmd, ?string $keyFile): string
{
$parts = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes'];
if ($this->port !== 22) {
$parts[] = '-p';
$parts[] = (string) $this->port;
}
if ($keyFile !== null) {
$parts[] = '-i';
$parts[] = escapeshellarg($keyFile);
}
$parts[] = escapeshellarg($this->username . '@' . $this->host);
$parts[] = escapeshellarg($remoteCmd);
return implode(' ', $parts);
}
}
@@ -41,10 +41,6 @@ class SnapshotEngine
private const ARTICLE_RELATED = [
'#__workflow_associations',
'#__contentitem_tag_map',
'#__tags',
'#__fields',
'#__fields_values',
'#__fields_categories',
];
/**
@@ -111,32 +107,6 @@ class SnapshotEngine
$rows = $this->dumpTagMap($db, $prefix);
$data['tables']['#__contentitem_tag_map'] = $rows;
$this->log(' #__contentitem_tag_map: ' . count($rows) . ' rows');
// Tags — dump all (shared, small table)
$rows = $this->dumpTable($db, str_replace('#__', $prefix, '#__tags'), '#__tags', 'articles');
$data['tables']['#__tags'] = $rows;
$this->log(' #__tags: ' . count($rows) . ' rows');
// Custom fields — only com_content.article context
$rows = $this->dumpFilteredTable(
$db,
str_replace('#__', $prefix, '#__fields'),
'#__fields',
'context',
'com_content.article'
);
$data['tables']['#__fields'] = $rows;
$this->log(' #__fields: ' . count($rows) . ' rows');
// Field values — only for com_content.article fields (table is shared across extensions)
$rows = $this->dumpFieldValues($db, $prefix);
$data['tables']['#__fields_values'] = $rows;
$this->log(' #__fields_values: ' . count($rows) . ' rows');
// Field-category mappings — only for com_content.article fields
$rows = $this->dumpFieldCategories($db, $prefix);
$data['tables']['#__fields_categories'] = $rows;
$this->log(' #__fields_categories: ' . count($rows) . ' rows');
}
// Count items
@@ -194,26 +164,6 @@ class SnapshotEngine
$this->log('Snapshot record created: ID ' . $record->id);
// Send snapshot creation notification
try {
$profile = NotificationSender::getDefaultProfile();
if ($profile) {
$userName = Factory::getApplication()->getIdentity()->username ?? 'Unknown';
$userIdVal = Factory::getApplication()->getIdentity()->id ?? 0;
NotificationSender::sendRestoreNotification($profile, 'snapshot_create', [
'content_types' => array_values($validTypes),
'articles_count' => $counts['articles'],
'categories_count' => $counts['categories'],
'modules_count' => $counts['modules'],
'user' => $userName . ' (ID: ' . $userIdVal . ')',
], implode("\n", $this->log));
}
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: Snapshot creation notification failed: ' . $e->getMessage());
}
return [
'success' => true,
'message' => sprintf(
@@ -281,52 +231,6 @@ class SnapshotEngine
return $db->loadAssocList() ?: [];
}
/**
* Dump field-category mappings for com_content.article fields.
*
* Uses a subquery: field_id IN (SELECT id FROM #__fields WHERE context = 'com_content.article')
*/
/**
* Dump field values only for com_content.article fields.
*/
private function dumpFieldValues(object $db, string $prefix): array
{
$fvTable = $prefix . 'fields_values';
$fTable = $prefix . 'fields';
$subQuery = $db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName($fTable))
->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article'));
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName($fvTable))
->where($db->quoteName('field_id') . ' IN (' . $subQuery . ')');
$db->setQuery($query);
return $db->loadAssocList() ?: [];
}
private function dumpFieldCategories(object $db, string $prefix): array
{
$fcTable = $prefix . 'fields_categories';
$fTable = $prefix . 'fields';
$subQuery = $db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName($fTable))
->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article'));
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName($fcTable))
->where($db->quoteName('field_id') . ' IN (' . $subQuery . ')');
$db->setQuery($query);
return $db->loadAssocList() ?: [];
}
private function log(string $message): void
{
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
@@ -33,10 +33,6 @@ class SnapshotRestoreEngine
'#__contentitem_tag_map' => null, // composite key, handled specially
'#__modules' => 'id',
'#__modules_menu' => null, // composite key, handled specially
'#__tags' => 'id',
'#__fields' => 'id',
'#__fields_values' => null, // composite key, handled specially
'#__fields_categories' => null, // composite key, handled specially
];
/**
@@ -151,25 +147,6 @@ class SnapshotRestoreEngine
$this->log('Restore complete: ' . $totalRows . ' total rows');
// Send snapshot restore notification
try {
$profile = NotificationSender::getDefaultProfile();
if ($profile) {
$userName = Factory::getApplication()->getIdentity()->username ?? 'Unknown';
$userIdVal = Factory::getApplication()->getIdentity()->id ?? 0;
NotificationSender::sendRestoreNotification($profile, 'snapshot_restore', [
'mode' => $mode,
'content_types' => $restoreTypes,
'row_count' => $totalRows,
'user' => $userName . ' (ID: ' . $userIdVal . ')',
], implode("\n", $this->log));
}
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: Snapshot restore notification failed: ' . $e->getMessage());
}
return [
'success' => true,
'message' => sprintf('Snapshot restored (%s mode): %d rows across %d tables', $mode, $totalRows, count($tablesToRestore)),
@@ -305,48 +282,6 @@ class SnapshotRestoreEngine
$query->where($db->quoteName('moduleid') . ' IN (' . implode(',', $moduleIds) . ')');
break;
case '#__tags':
// Only delete tags that exist in the snapshot — never wipe all tags
$ids = array_filter(array_column($rows, 'id'));
if (empty($ids)) {
return;
}
$ids = array_map('intval', $ids);
$query->where($db->quoteName('id') . ' IN (' . implode(',', $ids) . ')');
break;
case '#__fields':
// Only delete custom fields scoped to com_content.article
$query->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article'));
break;
case '#__fields_values':
// Only delete field values for com_content.article fields
$prefix = $db->getPrefix();
$fTable = $prefix . 'fields';
$subQuery = $db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName($fTable))
->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article'));
$query->where($db->quoteName('field_id') . ' IN (' . $subQuery . ')');
break;
case '#__fields_categories':
// Delete field-category mappings for com_content.article fields only
$prefix = $db->getPrefix();
$fTable = $prefix . 'fields';
$subQuery = $db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName($fTable))
->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article'));
$query->where($db->quoteName('field_id') . ' IN (' . $subQuery . ')');
break;
// #__content and #__content_frontpage are fully owned by com_content
default:
break;
@@ -368,10 +303,6 @@ class SnapshotRestoreEngine
$tables[] = '#__content_frontpage';
$tables[] = '#__workflow_associations';
$tables[] = '#__contentitem_tag_map';
$tables[] = '#__tags';
$tables[] = '#__fields';
$tables[] = '#__fields_values';
$tables[] = '#__fields_categories';
}
if (in_array('categories', $types)) {
@@ -386,181 +317,6 @@ class SnapshotRestoreEngine
return array_unique($tables);
}
/**
* Restore only selected articles (and their related rows) from a snapshot.
*
* Uses merge/upsert mode: updates existing rows by ID, inserts missing ones.
*
* @param int $snapshotId Snapshot record ID
* @param array $articleIds Article IDs to restore
*
* @return array{success: bool, message: string, restored?: int, log?: string}
*/
public function restoreSelectedArticles(int $snapshotId, array $articleIds): array
{
if (empty($articleIds)) {
return ['success' => false, 'message' => 'No article IDs provided'];
}
$articleIds = array_map('intval', $articleIds);
$articleIds = array_filter($articleIds, fn($id) => $id > 0);
if (empty($articleIds)) {
return ['success' => false, 'message' => 'No valid article IDs provided'];
}
$db = Factory::getDbo();
// Load snapshot record
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_snapshots'))
->where($db->quoteName('id') . ' = ' . $snapshotId);
$db->setQuery($query);
$record = $db->loadObject();
if (!$record) {
return ['success' => false, 'message' => 'Snapshot not found: ' . $snapshotId];
}
if ($record->status !== 'complete') {
return ['success' => false, 'message' => 'Cannot restore from failed snapshot'];
}
if (!is_file($record->data_file) || !is_readable($record->data_file)) {
return ['success' => false, 'message' => 'Snapshot file not found: ' . $record->data_file];
}
$this->log('Loading snapshot file: ' . basename($record->data_file));
$json = file_get_contents($record->data_file);
if ($json === false) {
return ['success' => false, 'message' => 'Cannot read snapshot file'];
}
$data = json_decode($json, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return ['success' => false, 'message' => 'Snapshot file contains invalid JSON: ' . json_last_error_msg()];
}
if (!is_array($data) || empty($data['tables'])) {
return ['success' => false, 'message' => 'Invalid snapshot data format: missing tables key'];
}
$contentTable = $data['tables']['#__content'] ?? [];
if (empty($contentTable)) {
return ['success' => false, 'message' => 'Snapshot does not contain articles'];
}
// Filter #__content rows to only selected article IDs
$selectedRows = array_filter($contentTable, fn($row) => in_array((int) ($row['id'] ?? 0), $articleIds, true));
if (empty($selectedRows)) {
return ['success' => false, 'message' => 'None of the selected article IDs exist in this snapshot'];
}
$foundIds = array_map(fn($row) => (int) $row['id'], $selectedRows);
$this->log('Restoring ' . count($selectedRows) . ' articles: IDs ' . implode(', ', $foundIds));
// Filter workflow_associations for selected articles
$workflowRows = [];
if (!empty($data['tables']['#__workflow_associations'])) {
$workflowRows = array_filter(
$data['tables']['#__workflow_associations'],
fn($row) => in_array((int) ($row['item_id'] ?? 0), $foundIds, true)
);
}
// Filter tag_map entries for selected articles
$tagMapRows = [];
if (!empty($data['tables']['#__contentitem_tag_map'])) {
$tagMapRows = array_filter(
$data['tables']['#__contentitem_tag_map'],
fn($row) => in_array((int) ($row['content_item_id'] ?? 0), $foundIds, true)
&& str_starts_with($row['type_alias'] ?? '', 'com_content.')
);
}
$prefix = $db->getPrefix();
$totalRows = 0;
try {
$db->transactionStart();
// Restore articles using merge/upsert
$realTable = str_replace('#__', $prefix, '#__content');
$rowCount = $this->restoreMerge($db, $realTable, '#__content', array_values($selectedRows));
$totalRows += $rowCount;
$this->log(' #__content: ' . $rowCount . ' rows restored');
// Restore workflow associations
if (!empty($workflowRows)) {
$realTable = str_replace('#__', $prefix, '#__workflow_associations');
$rowCount = $this->restoreMerge($db, $realTable, '#__workflow_associations', array_values($workflowRows));
$totalRows += $rowCount;
$this->log(' #__workflow_associations: ' . $rowCount . ' rows restored');
}
// Restore tag map entries
if (!empty($tagMapRows)) {
$realTable = str_replace('#__', $prefix, '#__contentitem_tag_map');
$rowCount = $this->restoreMerge($db, $realTable, '#__contentitem_tag_map', array_values($tagMapRows));
$totalRows += $rowCount;
$this->log(' #__contentitem_tag_map: ' . $rowCount . ' rows restored');
}
$db->transactionCommit();
$this->log('Selective restore complete: ' . $totalRows . ' total rows');
// Send notification
try {
$profile = NotificationSender::getDefaultProfile();
if ($profile) {
$userName = Factory::getApplication()->getIdentity()->username ?? 'Unknown';
$userIdVal = Factory::getApplication()->getIdentity()->id ?? 0;
NotificationSender::sendRestoreNotification($profile, 'snapshot_selective_restore', [
'mode' => 'selective',
'article_ids' => $foundIds,
'row_count' => $totalRows,
'user' => $userName . ' (ID: ' . $userIdVal . ')',
], implode("\n", $this->log));
}
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: Selective restore notification failed: ' . $e->getMessage());
}
return [
'success' => true,
'message' => sprintf('Restored %d articles (%d total rows)', count($selectedRows), $totalRows),
'restored' => count($selectedRows),
'log' => implode("\n", $this->log),
];
} catch (\Throwable $e) {
try {
$db->transactionRollback();
$this->log('Transaction rolled back');
} catch (\Exception $rollbackEx) {
$this->log('Rollback failed: ' . $rollbackEx->getMessage());
}
$this->log('FATAL: ' . $e->getMessage());
return [
'success' => false,
'message' => 'Selective restore failed: ' . $e->getMessage(),
'log' => implode("\n", $this->log),
];
}
}
private function log(string $message): void
{
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
@@ -347,11 +347,6 @@ class SteppedBackupEngine
$totalSize = file_exists($session->archivePath) ? filesize($session->archivePath) : 0;
// Verify archive integrity
$session->log('Verifying archive integrity...');
$this->verifyArchive($session->archivePath, $session->backupType);
$session->log('Archive integrity verified');
// MokoRestore wrapper
if ($session->includeMokoRestore) {
$session->log('Wrapping with MokoRestore script...');
@@ -394,48 +389,37 @@ class SteppedBackupEngine
private function stepUpload(SteppedSession $session): void
{
$db = Factory::getDbo();
// Reload profile for remote settings
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('id') . ' = ' . $session->profileId);
$db->setQuery($query);
$profile = $db->loadObject();
$uploader = match ($session->remoteStorage) {
'ftp' => new FtpUploader($profile),
'google_drive' => new GoogleDriveUploader($profile),
's3' => new S3Uploader($profile),
default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage),
};
$session->log('Starting remote upload (' . $session->remoteStorage . ')...');
$result = $uploader->upload($session->archivePath, $session->archiveName);
$remoteFilename = '';
$uploadFailed = false;
// Wrapped in its own try-catch so a remote failure does not mark
// the entire backup as failed — the local archive is preserved.
try {
// Reload profile for remote settings
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('id') . ' = ' . $session->profileId);
$db->setQuery($query);
$profile = $db->loadObject();
if ($result['success']) {
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
$session->log('Remote upload complete: ' . $result['message']);
$uploader = match ($session->remoteStorage) {
'ftp' => new FtpUploader($profile),
'sftp' => new SftpUploader($profile),
'google_drive' => new GoogleDriveUploader($profile),
's3' => new S3Uploader($profile),
default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage),
};
$session->log('Starting remote upload (' . $session->remoteStorage . ')...');
$result = $uploader->upload($session->archivePath, $session->archiveName);
if ($result['success']) {
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
$session->log('Remote upload complete: ' . $result['message']);
if (!$session->remoteKeepLocal && is_file($session->archivePath)) {
@unlink($session->archivePath);
$session->log('Local copy removed');
}
} else {
$uploadFailed = true;
$session->log('WARNING: Remote upload failed: ' . $result['message']);
$session->log('Local backup is preserved.');
if (!$session->remoteKeepLocal && is_file($session->archivePath)) {
@unlink($session->archivePath);
$session->log('Local copy removed');
}
} catch (\Throwable $e) {
$uploadFailed = true;
$session->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
$session->log('Local backup is preserved.');
} else {
$session->log('WARNING: Remote upload failed: ' . $result['message']);
}
// Update record with remote filename
@@ -449,60 +433,14 @@ class SteppedBackupEngine
$session->currentStep++;
$session->phase = 'complete';
$session->statusMessage = $uploadFailed
? 'Backup complete (remote upload failed — local archive preserved)'
: 'Backup complete';
$this->completeRecord($session, $uploadFailed);
}
/**
* Verify that a backup archive can be opened and contains expected entries.
*
* @param string $archivePath Absolute path to the archive file
* @param string $backupType Backup type: full, database, files, differential
*
* @throws \RuntimeException If the archive fails verification
*/
private function verifyArchive(string $archivePath, string $backupType): void
{
if (!is_file($archivePath)) {
throw new \RuntimeException('Archive file does not exist: ' . $archivePath);
}
$zip = new \ZipArchive();
if ($zip->open($archivePath, \ZipArchive::RDONLY) !== true) {
throw new \RuntimeException('Archive integrity check failed: cannot open ZIP file');
}
if ($zip->numFiles < 1) {
$zip->close();
throw new \RuntimeException('Archive integrity check failed: archive contains no files');
}
// Verify database.sql exists when backup includes database
if ($backupType !== 'files') {
if ($zip->locateName('database.sql') === false) {
$zip->close();
throw new \RuntimeException('Archive integrity check failed: database.sql missing from archive');
}
}
// Spot-check: verify the first entry is readable
$firstName = $zip->getNameIndex(0);
if ($firstName === false) {
$zip->close();
throw new \RuntimeException('Archive integrity check failed: cannot read first entry');
}
$zip->close();
$session->statusMessage = 'Backup complete';
$this->completeRecord($session);
}
/**
* Mark the backup record as complete.
*/
private function completeRecord(SteppedSession $session, bool $uploadFailed = false): void
private function completeRecord(SteppedSession $session): void
{
$db = Factory::getDbo();
$logContent = implode("\n", $session->log);
@@ -552,11 +490,6 @@ class SteppedBackupEngine
];
NotificationSender::send($profile, $record, true, $logContent);
// If remote upload failed, also send a failure notification for the upload
if ($uploadFailed) {
NotificationSender::send($profile, $record, false, "Remote upload failed — see backup log for details.\n\n" . $logContent);
}
}
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: SteppedBackupEngine notification failed: ' . $e->getMessage());
@@ -1,753 +0,0 @@
<?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
*
* AJAX step-based restore engine for shared hosting.
*
* Each call to runStep() performs one unit of work within the PHP time
* limit, saves state, and returns. The browser JS fires the next step.
*
* Phases: extract -> files -> database -> config -> cleanup -> complete
*/
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
class SteppedRestoreEngine
{
/**
* Number of files to copy per step during the files phase.
*/
private const FILE_BATCH_SIZE = 200;
/**
* Number of SQL statements to execute per step during the database phase.
*/
private const SQL_BATCH_SIZE = 500;
/**
* Initialize a new stepped restore session.
*
* @param int $recordId Backup record ID to restore from
* @param bool $restoreFiles Whether to restore files
* @param bool $restoreDb Whether to restore the database
* @param bool $preserveConfig Keep current configuration.php
* @param string $password Decryption password (for encrypted archives)
*
* @return array{session_id: string, phase: string, progress: int, message: string}
*/
public function init(int $recordId, bool $restoreFiles = true, bool $restoreDb = true, bool $preserveConfig = true, string $password = ''): array
{
if (!extension_loaded('zip')) {
return ['error' => true, 'message' => 'PHP ext-zip is required for restore operations'];
}
$db = Factory::getDbo();
// Load backup record
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('id') . ' = ' . $recordId);
$db->setQuery($query);
$record = $db->loadObject();
if (!$record) {
return ['error' => true, 'message' => 'Backup record not found: ' . $recordId];
}
if ($record->status !== 'complete') {
return ['error' => true, 'message' => 'Cannot restore from incomplete backup (status: ' . $record->status . ')'];
}
$archivePath = $record->absolute_path;
if (!is_file($archivePath) || !is_readable($archivePath)) {
return ['error' => true, 'message' => 'Backup archive not found: ' . $archivePath];
}
// Create session
$session = SteppedSession::create();
$session->recordId = $recordId;
$session->archivePath = $archivePath;
$session->archiveName = basename($archivePath);
$session->description = 'Restore from: ' . ($record->description ?: basename($archivePath));
// Store restore-specific settings as dynamic properties via the session's
// generic save/load (SteppedSession serialises all public properties).
// We repurpose some existing fields and add restore-specific ones to the
// session data stored on disk.
$session->phase = 'extract';
// Build staging directory path
$safeTag = preg_replace('/[^a-zA-Z0-9_-]/', '', $record->tag ?: 'restore');
$stagingDir = JPATH_ROOT . '/tmp/mokosuitebackup-restore-' . $safeTag . '-' . substr($session->sessionId, 3);
// Estimate total steps
$totalSteps = 1; // extract step
if ($restoreFiles) {
$totalSteps += 1; // at least one files step (will adjust after extraction)
}
if ($restoreDb) {
$totalSteps += 1; // at least one database step (will adjust after extraction)
}
$totalSteps += 1; // config step
$totalSteps += 1; // cleanup step
$session->totalSteps = $totalSteps;
$session->currentStep = 0;
$session->statusMessage = 'Initializing restore...';
// Store restore-specific data in session log metadata
// We'll use a JSON file alongside the session for restore state
$restoreState = [
'staging_dir' => $stagingDir,
'restore_files' => $restoreFiles,
'restore_db' => $restoreDb,
'preserve_config' => $preserveConfig,
'password' => $password,
'config_backup' => '',
'file_list' => [],
'file_index' => 0,
'sql_file' => '',
'sql_offset' => 0,
'sql_done' => false,
'sql_executed' => 0,
];
$this->saveRestoreState($session->sessionId, $restoreState);
$session->log('Restore initialized for record #' . $recordId . ': ' . $record->description);
$session->log('Archive: ' . $archivePath);
$session->log('Options: files=' . ($restoreFiles ? 'yes' : 'no')
. ', database=' . ($restoreDb ? 'yes' : 'no')
. ', preserve_config=' . ($preserveConfig ? 'yes' : 'no'));
$session->save();
return [
'session_id' => $session->sessionId,
'phase' => $session->phase,
'progress' => $session->getProgress(),
'message' => $session->statusMessage,
];
}
/**
* Run the next step of a restore session.
*
* @return array{session_id: string, phase: string, progress: int, message: string, done?: bool}
*/
public function runStep(string $sessionId): array
{
$session = SteppedSession::load($sessionId);
if (!$session) {
return ['error' => true, 'message' => 'Session not found: ' . $sessionId];
}
$restoreState = $this->loadRestoreState($sessionId);
if (!$restoreState) {
return ['error' => true, 'message' => 'Restore state not found for session: ' . $sessionId];
}
try {
switch ($session->phase) {
case 'extract':
$this->stepExtract($session, $restoreState);
break;
case 'files':
$this->stepFiles($session, $restoreState);
break;
case 'database':
$this->stepDatabase($session, $restoreState);
break;
case 'config':
$this->stepConfig($session, $restoreState);
break;
case 'cleanup':
$this->stepCleanup($session, $restoreState);
break;
case 'complete':
$this->destroyRestoreState($sessionId);
$session->destroy();
return [
'session_id' => $sessionId,
'phase' => 'complete',
'progress' => 100,
'message' => 'Restore complete: ' . $session->archiveName,
'done' => true,
];
}
$this->saveRestoreState($sessionId, $restoreState);
$session->save();
return [
'session_id' => $sessionId,
'phase' => $session->phase,
'progress' => $session->getProgress(),
'message' => $session->statusMessage,
'done' => $session->phase === 'complete',
];
} catch (\Throwable $e) {
$session->log('FATAL: ' . $e->getMessage());
// Restore config on failure if we preserved it
if (!empty($restoreState['config_backup']) && $restoreState['preserve_config']) {
@file_put_contents(JPATH_ROOT . '/configuration.php', $restoreState['config_backup']);
$session->log('Configuration.php restored after failure');
}
// Clean up staging on failure
$stagingDir = $restoreState['staging_dir'] ?? '';
if (!empty($stagingDir) && is_dir($stagingDir)) {
$this->recursiveDelete($stagingDir);
}
$this->destroyRestoreState($sessionId);
$session->destroy();
return ['error' => true, 'message' => 'Restore failed: ' . $e->getMessage()];
}
}
/**
* Extract phase: extract archive to staging directory.
*/
private function stepExtract(SteppedSession $session, array &$state): void
{
$stagingDir = $state['staging_dir'];
$archivePath = $session->archivePath;
$password = $state['password'];
// Clean existing staging dir
if (is_dir($stagingDir)) {
$this->recursiveDelete($stagingDir);
}
if (!mkdir($stagingDir, 0755, true)) {
throw new \RuntimeException('Cannot create staging directory: ' . $stagingDir);
}
$session->log('Extracting archive: ' . basename($archivePath));
// Detect format and extract
if (JpaUnarchiver::isJpaFile($archivePath)) {
$session->log('Detected JPA format (Akeeba Backup archive)');
$jpa = new JpaUnarchiver($archivePath, $stagingDir);
$count = $jpa->extract();
$session->log('Extracted ' . $count . ' files from JPA');
} elseif (str_ends_with($archivePath, '.tar.gz') || str_ends_with($archivePath, '.tgz')) {
$session->log('Detected tar.gz format');
$phar = new \PharData($archivePath);
// Validate entries for path traversal
foreach (new \RecursiveIteratorIterator($phar) as $entry) {
$entryName = $entry->getPathname();
$relative = substr($entryName, strlen('phar://' . $archivePath) + 1);
if (str_contains($relative, '../') || str_contains($relative, '..\\')
|| str_starts_with($relative, '/') || str_starts_with($relative, '\\')) {
throw new \RuntimeException('Archive contains unsafe path: ' . $relative);
}
}
$phar->extractTo($stagingDir, null, true);
$session->log('Extracted tar.gz archive');
} else {
$this->extractZipArchive($archivePath, $stagingDir, $password, $session);
}
$session->log('Extraction complete');
// Preserve configuration.php before any files are copied
if ($state['preserve_config'] && is_file(JPATH_ROOT . '/configuration.php')) {
$state['config_backup'] = file_get_contents(JPATH_ROOT . '/configuration.php');
$session->log('Current configuration.php preserved');
}
// Build file list for the files phase
if ($state['restore_files']) {
$fileList = $this->scanStagingFiles($stagingDir);
$state['file_list'] = $fileList;
$state['file_index'] = 0;
$fileBatches = (int) ceil(count($fileList) / self::FILE_BATCH_SIZE);
$session->log('Files to restore: ' . count($fileList) . ' (' . $fileBatches . ' batches)');
}
// Check for SQL file
$sqlFile = $stagingDir . '/database.sql';
if ($state['restore_db'] && is_file($sqlFile)) {
$state['sql_file'] = $sqlFile;
$state['sql_offset'] = 0;
$state['sql_done'] = false;
// Estimate SQL batches by counting lines
$lineCount = 0;
$fh = fopen($sqlFile, 'r');
if ($fh) {
while (fgets($fh) !== false) {
$lineCount++;
}
fclose($fh);
}
// Rough estimate: each statement ~2 lines on average
$estimatedStatements = max(1, (int) ($lineCount / 2));
$sqlBatches = (int) ceil($estimatedStatements / self::SQL_BATCH_SIZE);
$session->log('SQL file found: ~' . $estimatedStatements . ' statements (' . $sqlBatches . ' batches)');
} elseif ($state['restore_db']) {
$session->log('No database.sql found in archive — skipping database restore');
$state['restore_db'] = false;
}
// Recalculate total steps now that we know the actual counts
$totalSteps = 1; // extract (done)
if ($state['restore_files']) {
$totalSteps += max(1, (int) ceil(count($state['file_list']) / self::FILE_BATCH_SIZE));
}
if ($state['restore_db'] && !empty($state['sql_file'])) {
$totalSteps += max(1, $sqlBatches ?? 1);
}
$totalSteps += 1; // config
$totalSteps += 1; // cleanup
$session->totalSteps = $totalSteps;
$session->currentStep = 1;
// Move to next phase
if ($state['restore_files']) {
$session->phase = 'files';
} elseif ($state['restore_db'] && !empty($state['sql_file'])) {
$session->phase = 'database';
} else {
$session->phase = 'config';
}
$session->statusMessage = 'Archive extracted — starting restore...';
}
/**
* Files phase: copy a batch of files from staging to JPATH_ROOT.
*/
private function stepFiles(SteppedSession $session, array &$state): void
{
$fileList = $state['file_list'];
$fileIndex = $state['file_index'];
$stagingDir = $state['staging_dir'];
$totalFiles = count($fileList);
if ($fileIndex >= $totalFiles) {
// Files phase complete
$session->log('Files phase complete: ' . $totalFiles . ' files restored');
if ($state['restore_db'] && !empty($state['sql_file'])) {
$session->phase = 'database';
} else {
$session->phase = 'config';
}
return;
}
$batchEnd = min($fileIndex + self::FILE_BATCH_SIZE, $totalFiles);
$copied = 0;
$sourceBase = rtrim($stagingDir, '/\\');
$targetBase = rtrim(JPATH_ROOT, '/\\');
// Files that should never be overwritten during restore
$skipFiles = ['configuration.php', 'configuration.php.bak', '.htaccess', 'web.config'];
$excludeFiles = ['database.sql'];
for ($i = $fileIndex; $i < $batchEnd; $i++) {
$relativePath = $fileList[$i];
$sourcePath = $sourceBase . '/' . $relativePath;
$targetPath = $targetBase . '/' . $relativePath;
$basename = basename($relativePath);
$dirPart = dirname($relativePath);
// Skip excluded files
if (in_array($basename, $excludeFiles, true)) {
continue;
}
// Skip protected files at root level
if (($dirPart === '' || $dirPart === '.') && in_array($basename, $skipFiles, true)) {
continue;
}
if (!is_file($sourcePath)) {
continue;
}
// Ensure parent directory exists
$parentDir = dirname($targetPath);
if (!is_dir($parentDir)) {
mkdir($parentDir, 0755, true);
}
if (copy($sourcePath, $targetPath)) {
$perms = fileperms($sourcePath);
if ($perms !== false) {
@chmod($targetPath, $perms);
}
$copied++;
}
}
$state['file_index'] = $batchEnd;
$session->currentStep++;
$batchNum = (int) ceil($batchEnd / self::FILE_BATCH_SIZE);
$totalBatch = (int) ceil($totalFiles / self::FILE_BATCH_SIZE);
$session->statusMessage = "Restoring files batch {$batchNum}/{$totalBatch} ({$copied} files copied)";
$session->log("Files batch {$batchNum}: {$copied} files copied ({$batchEnd}/{$totalFiles})");
// Check if we're done with files
if ($batchEnd >= $totalFiles) {
$session->log('Files phase complete: ' . $totalFiles . ' files processed');
if ($state['restore_db'] && !empty($state['sql_file'])) {
$session->phase = 'database';
} else {
$session->phase = 'config';
}
}
}
/**
* Database phase: import SQL statements in batches.
*/
private function stepDatabase(SteppedSession $session, array &$state): void
{
if ($state['sql_done'] || empty($state['sql_file'])) {
$session->log('Database phase complete: ' . $state['sql_executed'] . ' statements executed');
$session->phase = 'config';
return;
}
$sqlFile = $state['sql_file'];
$offset = $state['sql_offset'];
$db = Factory::getDbo();
$prefix = $db->getPrefix();
$handle = fopen($sqlFile, 'r');
if ($handle === false) {
throw new \RuntimeException('Cannot open SQL file: ' . $sqlFile);
}
// Seek to the byte offset where we left off
if ($offset > 0) {
fseek($handle, $offset);
}
$statementsExecuted = 0;
$currentStatement = '';
$inMultiLineComment = false;
while (($line = fgets($handle)) !== false) {
$trimmed = trim($line);
// Skip empty lines
if ($trimmed === '') {
continue;
}
// Skip single-line comments
if (str_starts_with($trimmed, '--') || str_starts_with($trimmed, '#')) {
continue;
}
// Handle multi-line comments
if (str_starts_with($trimmed, '/*')) {
$inMultiLineComment = true;
}
if ($inMultiLineComment) {
if (str_contains($trimmed, '*/')) {
$inMultiLineComment = false;
}
continue;
}
// Accumulate the statement
$currentStatement .= $line;
// Check if statement is complete (ends with semicolon)
if (str_ends_with($trimmed, ';')) {
$statement = trim($currentStatement);
$currentStatement = '';
if (empty($statement)) {
continue;
}
// Replace abstract #__ prefix with the current site's prefix
$statement = str_replace('#__', $prefix, $statement);
try {
$db->setQuery($statement);
$db->execute();
} catch (\Exception $e) {
error_log('MokoSuiteBackup SQL import warning: ' . $e->getMessage());
}
$statementsExecuted++;
$state['sql_executed']++;
// Check if we've hit the batch limit
if ($statementsExecuted >= self::SQL_BATCH_SIZE) {
$state['sql_offset'] = ftell($handle);
fclose($handle);
$session->currentStep++;
$session->statusMessage = 'Importing database... (' . $state['sql_executed'] . ' statements executed)';
$session->log('Database batch: ' . $statementsExecuted . ' statements (total: ' . $state['sql_executed'] . ')');
return;
}
}
}
// Handle any remaining statement without trailing semicolon
$remaining = trim($currentStatement);
if (!empty($remaining)) {
$remaining = str_replace('#__', $prefix, $remaining);
try {
$db->setQuery($remaining);
$db->execute();
$state['sql_executed']++;
} catch (\Exception $e) {
error_log('MokoSuiteBackup SQL import warning (final): ' . $e->getMessage());
}
}
fclose($handle);
$state['sql_done'] = true;
$session->currentStep++;
$session->phase = 'config';
$session->statusMessage = 'Database import complete: ' . $state['sql_executed'] . ' statements';
$session->log('Database import complete: ' . $state['sql_executed'] . ' statements executed');
}
/**
* Config phase: restore preserved configuration.php.
*/
private function stepConfig(SteppedSession $session, array &$state): void
{
if ($state['preserve_config'] && !empty($state['config_backup'])) {
file_put_contents(JPATH_ROOT . '/configuration.php', $state['config_backup']);
$session->log('Configuration.php restored to pre-restore state');
}
$session->currentStep++;
$session->phase = 'cleanup';
$session->statusMessage = 'Configuration restored — cleaning up...';
}
/**
* Cleanup phase: remove staging directory.
*/
private function stepCleanup(SteppedSession $session, array &$state): void
{
$stagingDir = $state['staging_dir'];
if (!empty($stagingDir) && is_dir($stagingDir)) {
$this->recursiveDelete($stagingDir);
$session->log('Staging directory cleaned up');
}
$session->currentStep++;
$session->phase = 'complete';
$session->statusMessage = 'Restore complete: ' . $session->archiveName;
$session->log('Restore complete');
}
/**
* Extract a ZIP archive to the staging directory with path traversal protection.
*/
private function extractZipArchive(string $archivePath, string $stagingDir, string $password, SteppedSession $session): void
{
$zip = new \ZipArchive();
$result = $zip->open($archivePath);
if ($result !== true) {
throw new \RuntimeException('Cannot open archive (error code: ' . $result . ')');
}
if (!empty($password)) {
$zip->setPassword($password);
$session->log('Decryption password set');
}
// Validate all entries before extraction (path traversal protection)
for ($i = 0; $i < $zip->numFiles; $i++) {
$entryName = $zip->getNameIndex($i);
if ($entryName === false) {
continue;
}
if (str_contains($entryName, '../') || str_contains($entryName, '..\\')
|| str_starts_with($entryName, '/') || str_starts_with($entryName, '\\')) {
$zip->close();
throw new \RuntimeException('Archive contains unsafe path: ' . $entryName);
}
}
if (!$zip->extractTo($stagingDir)) {
$zip->close();
throw new \RuntimeException(
'Failed to extract archive. '
. (!empty($password) ? 'Check that the decryption password is correct.' : 'The archive may be encrypted — provide a password.')
);
}
$session->log('Extracted ' . $zip->numFiles . ' entries');
$zip->close();
}
/**
* Scan the staging directory and return a flat list of relative file paths.
*/
private function scanStagingFiles(string $stagingDir): array
{
$files = [];
$baseLen = strlen(rtrim($stagingDir, '/\\')) + 1;
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($stagingDir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $item) {
if ($item->isFile()) {
$relativePath = substr($item->getPathname(), $baseLen);
// Normalise directory separators
$relativePath = str_replace('\\', '/', $relativePath);
$files[] = $relativePath;
}
}
return $files;
}
/**
* Recursively delete a directory and all its contents.
*/
private function recursiveDelete(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$items = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($items as $item) {
if ($item->isDir()) {
@rmdir($item->getPathname());
} else {
@unlink($item->getPathname());
}
}
@rmdir($dir);
}
/**
* Save restore-specific state to a JSON file alongside the session.
*/
private function saveRestoreState(string $sessionId, array $state): void
{
$path = $this->getRestoreStatePath($sessionId);
if (file_put_contents($path, json_encode($state, JSON_PRETTY_PRINT)) === false) {
throw new \RuntimeException('Cannot save restore state: ' . $path);
}
}
/**
* Load restore-specific state from disk.
*/
private function loadRestoreState(string $sessionId): ?array
{
$path = $this->getRestoreStatePath($sessionId);
if (!is_file($path)) {
return null;
}
$data = json_decode(file_get_contents($path), true);
return is_array($data) ? $data : null;
}
/**
* Delete restore state file.
*/
private function destroyRestoreState(string $sessionId): void
{
$path = $this->getRestoreStatePath($sessionId);
if (is_file($path)) {
@unlink($path);
}
}
/**
* Get the file path for restore-specific state.
*/
private function getRestoreStatePath(string $sessionId): string
{
$safe = preg_replace('/[^a-zA-Z0-9_-]/', '', $sessionId);
$dir = JPATH_ROOT . '/tmp/mokosuitebackup-sessions';
if (!is_dir($dir)) {
if (!mkdir($dir, 0755, true)) {
throw new \RuntimeException('Cannot create session directory: ' . $dir);
}
}
return $dir . '/' . $safe . '.restore.json';
}
}
@@ -100,17 +100,6 @@ class FolderPickerField extends FormField
<span class="icon-question-circle" aria-hidden="true"></span>
</button>
</div>
<div class="mt-1 mb-1" id="{$id}_placeholders" style="display:flex; flex-wrap:wrap; gap:4px;">
<span class="text-muted small me-1" style="line-height:24px;">Insert:</span>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[HOME]" title="Home directory">[HOME]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[DEFAULT_DIR]" title="Default backup dir">[DEFAULT_DIR]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[host]" title="Server hostname">[host]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[site_name]" title="Joomla site name">[site_name]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[date]" title="Date (Ymd)">[date]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[profile_id]" title="Profile ID">[profile_id]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[profile_name]" title="Profile name">[profile_name]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[type]" title="Backup type">[type]</button>
</div>
<div class="mt-1" id="{$id}_status">
<small class="{$statusClass}">
<span class="{$statusIcon}" aria-hidden="true"></span>
@@ -166,26 +155,6 @@ class FolderPickerField extends FormField
</div>
<script>
(function() {
/* Clickable placeholder insertion at cursor position */
document.querySelectorAll('.moko-ph-insert[data-field="{$id}"]').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
var target = document.getElementById(this.getAttribute('data-field'));
var ph = this.getAttribute('data-ph');
if (!target) return;
var start = target.selectionStart || 0;
var end = target.selectionEnd || 0;
var val = target.value;
target.value = val.substring(0, start) + ph + val.substring(end);
/* Move cursor to after the inserted placeholder */
var newPos = start + ph.length;
target.setSelectionRange(newPos, newPos);
target.focus();
/* Trigger input event so status updates */
target.dispatchEvent(new Event('input', { bubbles: true }));
});
});
var fieldId = '{$id}';
var btn = document.getElementById(fieldId + '_btn');
var browser = document.getElementById(fieldId + '_browser');
@@ -1,78 +0,0 @@
<?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
*
* Text field with clickable placeholder pills that insert at cursor position.
* Used for backup directory and archive name format fields.
*/
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Form\FormField;
class PlaceholderTextField extends FormField
{
protected $type = 'PlaceholderText';
protected function getInput(): string
{
$value = htmlspecialchars($this->value ?? $this->default ?? '', ENT_QUOTES, 'UTF-8');
$id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
$name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8');
$hint = htmlspecialchars($this->element['hint'] ?? '', ENT_QUOTES, 'UTF-8');
$max = (int) ($this->element['maxlength'] ?? 512);
$placeholderAttr = (string) ($this->element['placeholders'] ?? '');
$placeholders = array_filter(array_map('trim', explode(',', $placeholderAttr)));
if (empty($placeholders)) {
$placeholders = ['[host]', '[date]', '[datetime]', '[time]', '[year]', '[month]', '[day]',
'[hour]', '[minute]', '[second]', '[profile_id]', '[profile_name]', '[site_name]', '[type]', '[random]'];
}
$html = '<input type="text" name="' . $name . '" id="' . $id . '" value="' . $value . '"'
. ' class="form-control" maxlength="' . $max . '"'
. ($hint ? ' placeholder="' . $hint . '"' : '') . '>';
$html .= '<div class="mt-1" style="display:flex; flex-wrap:wrap; gap:4px;">';
$html .= '<span class="text-muted small me-1" style="line-height:24px;">Insert:</span>';
foreach ($placeholders as $ph) {
$html .= '<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert"'
. ' data-field="' . $id . '" data-ph="' . htmlspecialchars($ph) . '">'
. htmlspecialchars($ph) . '</button>';
}
$html .= '</div>';
$html .= <<<JS
<script>
document.querySelectorAll('.moko-ph-insert[data-field="{$id}"]').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
var target = document.getElementById(this.getAttribute('data-field'));
var ph = this.getAttribute('data-ph');
if (!target) return;
var start = target.selectionStart || 0;
var end = target.selectionEnd || 0;
var val = target.value;
target.value = val.substring(0, start) + ph + val.substring(end);
var newPos = start + ph.length;
target.setSelectionRange(newPos, newPos);
target.focus();
target.dispatchEvent(new Event('input', { bubbles: true }));
});
});
</script>
JS;
return $html;
}
}
@@ -1,109 +0,0 @@
<?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
*
* Custom field for SSH private key input.
* Supports both file upload (via FileReader JS) and paste-in textarea.
* The key content is stored in the database, not as a file on disk.
*/
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Form\FormField;
use Joomla\CMS\Language\Text;
class SshKeyField extends FormField
{
protected $type = 'SshKey';
protected function getInput(): string
{
$value = $this->value ?? '';
$id = $this->id;
$name = $this->name;
$hasKey = !empty($value) && str_contains($value, 'PRIVATE KEY');
$html = '<div id="' . htmlspecialchars($id) . '-wrapper">';
/* Status badge */
if ($hasKey) {
$html .= '<span class="badge bg-success me-2">'
. '<span class="icon-lock" aria-hidden="true"></span> '
. Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_LOADED')
. '</span>';
}
/* File upload button */
$html .= '<label class="btn btn-outline-secondary btn-sm" for="' . htmlspecialchars($id) . '-file">';
$html .= '<span class="icon-upload" aria-hidden="true"></span> ';
$html .= $hasKey ? Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_REPLACE') : Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_UPLOAD');
$html .= '</label>';
$html .= '<input type="file" id="' . htmlspecialchars($id) . '-file"'
. ' accept=".pem,.key,.openssh,.ppk,*" style="display:none;"'
. ' onchange="mokoSshKeyFileSelected(\'' . htmlspecialchars($id) . '\', this)">';
$html .= '<span id="' . htmlspecialchars($id) . '-status" class="ms-2 text-muted small"></span>';
if ($hasKey) {
$html .= ' <button type="button" class="btn btn-sm btn-outline-danger ms-2"'
. ' onclick="mokoSshKeyClear(\'' . htmlspecialchars($id) . '\')">'
. '<span class="icon-times" aria-hidden="true"></span> '
. Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_CLEAR')
. '</button>';
}
/* Hidden field key data is NEVER rendered as visible text.
On existing keys, we submit a sentinel value to preserve the DB value
unless a new file is uploaded or clear is clicked. */
if ($hasKey) {
$html .= '<input type="hidden" name="' . htmlspecialchars($name) . '" id="' . htmlspecialchars($id) . '"'
. ' value="__KEEP_EXISTING__">';
} else {
$html .= '<input type="hidden" name="' . htmlspecialchars($name) . '" id="' . htmlspecialchars($id) . '"'
. ' value="">';
}
$html .= '</div>';
$html .= $this->getScript();
return $html;
}
private function getScript(): string
{
return <<<'JS'
<script>
function mokoSshKeyFileSelected(fieldId, input) {
if (!input.files || !input.files[0]) return;
var file = input.files[0];
var reader = new FileReader();
reader.onload = function(e) {
/* Base64 encode the key before storing in the hidden field */
var content = e.target.result;
var encoded = btoa(content);
document.getElementById(fieldId).value = encoded;
var status = document.getElementById(fieldId + '-status');
if (status) status.textContent = file.name + ' uploaded';
};
reader.readAsText(file);
}
function mokoSshKeyClear(fieldId) {
document.getElementById(fieldId).value = '';
var status = document.getElementById(fieldId + '-status');
if (status) status.textContent = 'Key removed';
var fileInput = document.getElementById(fieldId + '-file');
if (fileInput) fileInput.value = '';
}
</script>
JS;
}
}
@@ -198,90 +198,6 @@ class DashboardModel extends BaseDatabaseModel
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.
*
@@ -25,23 +25,6 @@ class ProfileTable extends Table
public function store($updateNulls = true): bool
{
/* Handle SSH key sentinel when __KEEP_EXISTING__ is submitted,
preserve the current DB value instead of overwriting with the sentinel.
This prevents the key from being exposed in the form HTML. */
if (isset($this->sftp_key_data) && $this->sftp_key_data === '__KEEP_EXISTING__') {
if ($this->id) {
$db = $this->getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('sftp_key_data'))
->from($db->quoteName($this->_tbl))
->where($db->quoteName('id') . ' = ' . (int) $this->id);
$db->setQuery($query);
$this->sftp_key_data = $db->loadResult() ?: '';
} else {
$this->sftp_key_data = '';
}
}
$result = parent::store($updateNulls);
if ($result && !empty($this->backup_dir)) {
@@ -122,10 +122,6 @@ class HtmlView extends BaseHtmlView
ToolbarHelper::custom('backups.verify', 'shield', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_VERIFY', true);
if ($user->authorise('core.manage', 'com_mokosuitebackup')) {
ToolbarHelper::custom('backups.compare', 'copy', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_COMPARE', true);
}
if ($user->authorise('core.delete', 'com_mokosuitebackup')) {
ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'backups.delete');
}
@@ -24,26 +24,18 @@ class HtmlView extends BaseHtmlView
public array $systemHealth = [];
public array $profiles = [];
public bool $defaultDirWarning = false;
public ?object $latestSnapshot = null;
public int $snapshotCount = 0;
public array $backupTrend = [];
public array $storageByProfile = [];
public function display($tpl = null): void
{
/** @var \Joomla\Component\MokoSuiteBackup\Administrator\Model\DashboardModel $model */
$model = $this->getModel();
$this->lastBackup = $model->getLastBackup();
$this->nextScheduled = $model->getNextScheduled();
$this->stats = $model->getStats();
$this->systemHealth = $model->getSystemHealth();
$this->profiles = $model->getProfiles();
$this->lastBackup = $model->getLastBackup();
$this->nextScheduled = $model->getNextScheduled();
$this->stats = $model->getStats();
$this->systemHealth = $model->getSystemHealth();
$this->profiles = $model->getProfiles();
$this->defaultDirWarning = $model->isUsingDefaultBackupDir();
$this->latestSnapshot = $model->getLatestSnapshot();
$this->snapshotCount = $model->getSnapshotCount();
$this->backupTrend = $model->getBackupTrend();
$this->storageByProfile = $model->getStorageByProfile();
$this->addToolbar();
@@ -94,28 +94,6 @@ $ajaxUrl = Route::_('index.php?option=com_mokosuitebackup&format=json', false)
</tbody>
</table>
<?php if ($this->item->status === 'complete' && !empty($this->item->filesexist)) : ?>
<!-- Archive Browser -->
<h4 class="mt-4">
<span class="icon-folder-open" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_ARCHIVE'); ?>
</h4>
<div id="mb-detail-browse" class="bg-light rounded" style="max-height:400px; overflow-y:auto;">
<div id="mb-detail-browse-summary" class="p-2 text-muted" style="font-size:0.85rem;"></div>
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_COL_NAME'); ?></th>
<th class="text-end" style="width:100px;"><?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_COL_SIZE'); ?></th>
<th class="text-end" style="width:120px;"><?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_COL_COMPRESSED'); ?></th>
</tr>
</thead>
<tbody id="mb-detail-browse-tbody">
</tbody>
</table>
</div>
<?php endif; ?>
<!-- Backup Log -->
<h4 class="mt-4"><?php echo Text::_('COM_MOKOJOOMBACKUP_VIEW_LOG'); ?></h4>
<div id="mb-detail-log" class="bg-light p-3 rounded" style="max-height:400px; overflow-y:auto;">
@@ -126,105 +104,22 @@ $ajaxUrl = Route::_('index.php?option=com_mokosuitebackup&format=json', false)
<script>
(function() {
var AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
var TOKEN_NAME = <?php echo json_encode($ajaxToken); ?>;
var form = new URLSearchParams();
form.append('task', 'ajax.viewLog');
form.append('id', <?php echo (int) $this->item->id; ?>);
form.append(<?php echo json_encode($ajaxToken); ?>, '1');
function postAjax(params) {
var form = new URLSearchParams();
form.append(TOKEN_NAME, '1');
for (var k in params) {
if (params.hasOwnProperty(k)) {
form.append(k, params[k]);
}
}
return fetch(AJAX_URL, {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function(r) { return r.json(); });
}
// Load log
postAjax({ task: 'ajax.viewLog', id: <?php echo (int) $this->item->id; ?> })
fetch(<?php echo json_encode($ajaxUrl); ?>, {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { return r.json(); })
.then(function(data) {
document.getElementById('mb-detail-log-body').textContent = data.error ? data.message : data.log;
})
.catch(function(err) {
document.getElementById('mb-detail-log-body').textContent = 'Error: ' + err.message;
});
<?php if ($this->item->status === 'complete' && !empty($this->item->filesexist)) : ?>
// Load archive contents
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
var units = ['B', 'KB', 'MB', 'GB'];
var i = Math.floor(Math.log(bytes) / Math.log(1024));
if (i >= units.length) i = units.length - 1;
return (bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
}
function browseSetMessage(tbody, message, cssClass) {
tbody.textContent = '';
var tr = document.createElement('tr');
var td = document.createElement('td');
td.setAttribute('colspan', '3');
td.className = cssClass || 'text-center';
td.textContent = message;
tr.appendChild(td);
tbody.appendChild(tr);
}
function browseAddFileRow(tbody, file) {
var tr = document.createElement('tr');
var tdName = document.createElement('td');
tdName.style.wordBreak = 'break-all';
tdName.style.fontSize = '0.85rem';
var code = document.createElement('code');
code.textContent = file.name;
tdName.appendChild(code);
tr.appendChild(tdName);
var tdSize = document.createElement('td');
tdSize.className = 'text-end text-nowrap';
tdSize.textContent = formatFileSize(file.size);
tr.appendChild(tdSize);
var tdComp = document.createElement('td');
tdComp.className = 'text-end text-nowrap';
tdComp.textContent = formatFileSize(file.compressed_size);
tr.appendChild(tdComp);
tbody.appendChild(tr);
}
var browseTbody = document.getElementById('mb-detail-browse-tbody');
var browseSummary = document.getElementById('mb-detail-browse-summary');
browseSetMessage(browseTbody, 'Loading...');
postAjax({ task: 'ajax.browseArchive', id: <?php echo (int) $this->item->id; ?> })
.then(function(data) {
if (data.error) {
browseSetMessage(browseTbody, data.message || 'Error', 'text-danger');
return;
}
browseTbody.textContent = '';
if (data.files.length === 0) {
browseSetMessage(browseTbody, 'Archive is empty', 'text-center text-muted');
} else {
for (var i = 0; i < data.files.length; i++) {
browseAddFileRow(browseTbody, data.files[i]);
}
}
var text = data.total_files + ' files, ' + formatFileSize(data.total_size) + ' uncompressed';
if (data.truncated) {
text += ' (showing first ' + data.files.length + ')';
}
browseSummary.textContent = text;
})
.catch(function(err) {
browseSetMessage(browseTbody, 'Error: ' + err.message, 'text-danger');
});
<?php endif; ?>
})();
</script>
@@ -155,13 +155,6 @@ $listDirn = $this->escape($this->state->get('list.direction'));
</span>
<?php endif; ?>
<?php endif; ?>
<?php if ($item->status === 'complete' && $item->filesexist) : ?>
<button type="button" class="btn btn-sm btn-outline-info mb-browse-archive"
data-id="<?php echo (int) $item->id; ?>"
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_ARCHIVE'); ?>">
<span class="icon-folder-open"></span>
</button>
<?php endif; ?>
<button type="button" class="btn btn-sm btn-outline-secondary mb-view-log"
data-id="<?php echo (int) $item->id; ?>"
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_VIEW_LOG'); ?>">
@@ -353,106 +346,6 @@ $listDirn = $this->escape($this->state->get('list.direction'));
}
});
// AJAX stepped restore
var restoreRunning = false;
function showRestoreProgress() {
restoreRunning = true;
document.getElementById('mb-restore-modal').style.display = 'none';
document.getElementById('mb-restore-progress-modal').style.display = 'block';
}
function hideRestoreProgress() {
restoreRunning = false;
document.getElementById('mb-restore-progress-modal').style.display = 'none';
}
function updateRestoreProgress(progress, message, phase) {
var bar = document.getElementById('mb-restore-progress-bar');
bar.style.width = progress + '%';
bar.textContent = progress + '%';
document.getElementById('mb-restore-status').textContent = message;
document.getElementById('mb-restore-phase').textContent = 'Phase: ' + phase;
}
window.addEventListener('beforeunload', function(e) {
if (restoreRunning) {
e.preventDefault();
e.returnValue = '';
}
});
async function startSteppedRestore(e) {
e.preventDefault();
var recordId = document.getElementById('mb-restore-record-id').value;
var restoreFiles = document.getElementById('mb-restore-files').checked ? 1 : 0;
var restoreDb = document.getElementById('mb-restore-db').checked ? 1 : 0;
var preserveConfig = document.getElementById('mb-restore-config').checked ? 1 : 0;
var password = document.getElementById('mb-restore-password').value;
showRestoreProgress();
updateRestoreProgress(0, 'Initializing restore...', 'init');
try {
var initResult = await postAjax({
task: 'ajax.restoreInit',
id: recordId,
restore_files: restoreFiles,
restore_db: restoreDb,
preserve_config: preserveConfig,
encryption_password: password
});
if (initResult.error) {
updateRestoreProgress(0, 'ERROR: ' + initResult.message, 'failed');
document.getElementById('mb-restore-title').textContent = 'Restore Failed';
setTimeout(hideRestoreProgress, 5000);
return;
}
var sessionId = initResult.session_id;
updateRestoreProgress(initResult.progress, initResult.message, initResult.phase);
var done = false;
while (!done) {
var stepResult = await postAjax({
task: 'ajax.restoreStep',
session_id: sessionId
});
if (stepResult.error) {
updateRestoreProgress(0, 'ERROR: ' + stepResult.message, 'failed');
document.getElementById('mb-restore-title').textContent = 'Restore Failed';
setTimeout(hideRestoreProgress, 5000);
return;
}
updateRestoreProgress(stepResult.progress, stepResult.message, stepResult.phase);
done = stepResult.done || false;
}
document.getElementById('mb-restore-title').textContent = 'Restore Complete';
setTimeout(function() {
hideRestoreProgress();
location.reload();
}, 2000);
} catch (err) {
updateRestoreProgress(0, 'ERROR: ' + err.message, 'failed');
document.getElementById('mb-restore-title').textContent = 'Restore Failed';
setTimeout(hideRestoreProgress, 5000);
}
}
// Attach the AJAX restore handler to the restore form
document.addEventListener('DOMContentLoaded', function() {
var restoreForm = document.getElementById('mb-restore-form');
if (restoreForm) {
restoreForm.addEventListener('submit', startSteppedRestore);
}
});
// View Log modal handler
document.addEventListener('click', function(e) {
var btn = e.target.closest('.mb-view-log');
@@ -492,93 +385,6 @@ $listDirn = $this->escape($this->state->get('list.direction'));
document.getElementById('mb-log-modal').style.display = 'none';
}
});
// Browse Archive modal handler
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
var units = ['B', 'KB', 'MB', 'GB'];
var i = Math.floor(Math.log(bytes) / Math.log(1024));
if (i >= units.length) i = units.length - 1;
return (bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
}
function browseSetMessage(tbody, message, cssClass) {
tbody.textContent = '';
var tr = document.createElement('tr');
var td = document.createElement('td');
td.setAttribute('colspan', '3');
td.className = cssClass || 'text-center';
td.textContent = message;
tr.appendChild(td);
tbody.appendChild(tr);
}
function browseAddFileRow(tbody, file) {
var tr = document.createElement('tr');
var tdName = document.createElement('td');
tdName.style.wordBreak = 'break-all';
tdName.style.fontSize = '0.85rem';
var code = document.createElement('code');
code.textContent = file.name;
tdName.appendChild(code);
tr.appendChild(tdName);
var tdSize = document.createElement('td');
tdSize.className = 'text-end text-nowrap';
tdSize.textContent = formatFileSize(file.size);
tr.appendChild(tdSize);
var tdComp = document.createElement('td');
tdComp.className = 'text-end text-nowrap';
tdComp.textContent = formatFileSize(file.compressed_size);
tr.appendChild(tdComp);
tbody.appendChild(tr);
}
document.addEventListener('click', function(e) {
var btn = e.target.closest('.mb-browse-archive');
if (!btn) return;
e.preventDefault();
var recordId = btn.getAttribute('data-id');
var modal = document.getElementById('mb-browse-modal');
var tbody = document.getElementById('mb-browse-tbody');
var summary = document.getElementById('mb-browse-summary');
browseSetMessage(tbody, 'Loading...');
summary.textContent = '';
modal.style.display = 'block';
postAjax({ task: 'ajax.browseArchive', id: recordId })
.then(function(data) {
if (data.error) {
browseSetMessage(tbody, data.message || 'Error', 'text-danger');
return;
}
tbody.textContent = '';
if (data.files.length === 0) {
browseSetMessage(tbody, 'Archive is empty', 'text-center text-muted');
} else {
for (var i = 0; i < data.files.length; i++) {
browseAddFileRow(tbody, data.files[i]);
}
}
var text = data.total_files + ' files, ' + formatFileSize(data.total_size) + ' uncompressed';
if (data.truncated) {
text += ' (showing first ' + data.files.length + ')';
}
summary.textContent = text;
})
.catch(function(err) {
browseSetMessage(tbody, 'Error: ' + err.message, 'text-danger');
});
});
document.addEventListener('click', function(e) {
if (e.target.id === 'mb-browse-modal' || e.target.classList.contains('mb-browse-close')) {
document.getElementById('mb-browse-modal').style.display = 'none';
}
});
})();
</script>
@@ -637,18 +443,6 @@ $listDirn = $this->escape($this->state->get('list.direction'));
</div>
</div>
<!-- Restore Progress Modal -->
<div id="mb-restore-progress-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
<div style="max-width:500px; margin:10% auto; background:#fff; border-radius:8px; padding:2rem; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
<h3 id="mb-restore-title" style="margin:0 0 1rem;">Restore in Progress</h3>
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
<div id="mb-restore-progress-bar" style="height:100%; background:#dc3545; transition:width 0.3s; width:0%; display:flex; align-items:center; justify-content:center; color:#fff; font-size:0.8rem; font-weight:bold;">0%</div>
</div>
<p id="mb-restore-status" style="color:#666; font-size:0.9rem; margin:0.5rem 0;">Initializing...</p>
<p id="mb-restore-phase" style="color:#999; font-size:0.8rem; margin:0;">Phase: init</p>
</div>
</div>
<!-- Log Viewer Modal -->
<div id="mb-log-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
<div style="max-width:700px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); display:flex; flex-direction:column; max-height:80vh;">
@@ -661,201 +455,3 @@ $listDirn = $this->escape($this->state->get('list.direction'));
</div>
</div>
</div>
<!-- Archive Browser Modal -->
<div id="mb-browse-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
<div style="max-width:800px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); display:flex; flex-direction:column; max-height:80vh;">
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
<h4 style="margin:0;">
<span class="icon-folder-open" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_ARCHIVE'); ?>
</h4>
<button type="button" class="btn-close mb-browse-close" aria-label="Close"></button>
</div>
<div style="padding:0.75rem 1.5rem; border-bottom:1px solid #dee2e6; background:#f8f9fa;">
<small id="mb-browse-summary" class="text-muted"></small>
</div>
<div style="padding:0; overflow-y:auto; flex:1;">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_COL_NAME'); ?></th>
<th class="text-end" style="width:100px;"><?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_COL_SIZE'); ?></th>
<th class="text-end" style="width:120px;"><?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_COL_COMPRESSED'); ?></th>
</tr>
</thead>
<tbody id="mb-browse-tbody">
</tbody>
</table>
</div>
</div>
</div>
<!-- Backup Comparison Modal -->
<div id="mb-compare-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
<div style="max-width:800px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); display:flex; flex-direction:column; max-height:85vh;">
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
<h4 style="margin:0;">
<span class="icon-copy" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_TITLE'); ?>
</h4>
<button type="button" class="btn-close mb-compare-close" aria-label="Close"></button>
</div>
<div style="padding:1rem 1.5rem; overflow-y:auto; flex:1;">
<div id="mb-compare-loading" style="text-align:center; padding:2rem;">
<span class="icon-spinner icon-spin" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_LOADING'); ?>
</div>
<div id="mb-compare-error" style="display:none;" class="alert alert-danger"></div>
<table id="mb-compare-table" class="table table-striped" style="display:none;">
<thead>
<tr>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_FIELD'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_BACKUP'); ?> 1</th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_BACKUP'); ?> 2</th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_DELTA'); ?></th>
</tr>
</thead>
<tbody id="mb-compare-body"></tbody>
</table>
</div>
</div>
</div>
<script>
(function() {
var COMPARE_AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
var COMPARE_TOKEN = <?php echo json_encode($ajaxToken); ?>;
function mbCmpFormatBytes(bytes) {
if (bytes === 0) return '0 B';
var units = ['B', 'KB', 'MB', 'GB'];
var i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024));
if (i >= units.length) i = units.length - 1;
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + units[i];
}
function mbCmpFormatDuration(seconds) {
if (seconds <= 0) return '0s';
var m = Math.floor(seconds / 60);
var s = seconds % 60;
return m > 0 ? m + 'm ' + s + 's' : s + 's';
}
function mbCmpDeltaCell(value, unit) {
if (value === 0) return '<span class="text-muted">&mdash;</span>';
var isPositive = value > 0;
var colorClass = isPositive ? 'text-danger' : 'text-success';
var display;
if (unit === 'bytes') {
display = (isPositive ? '+' : '') + mbCmpFormatBytes(value);
} else if (unit === 'duration') {
display = (isPositive ? '+' : '-') + mbCmpFormatDuration(Math.abs(value));
} else {
display = (isPositive ? '+' : '') + value.toLocaleString();
}
return '<span class="fw-bold ' + colorClass + '">' + display + '</span>';
}
function mbShowCompareModal(id1, id2) {
var modal = document.getElementById('mb-compare-modal');
var loading = document.getElementById('mb-compare-loading');
var errorEl = document.getElementById('mb-compare-error');
var table = document.getElementById('mb-compare-table');
var body = document.getElementById('mb-compare-body');
modal.style.display = 'block';
loading.style.display = 'block';
errorEl.style.display = 'none';
table.style.display = 'none';
body.innerHTML = '';
var form = new URLSearchParams();
form.append('task', 'ajax.compareBackups');
form.append('id1', id1);
form.append('id2', id2);
form.append(COMPARE_TOKEN, '1');
fetch(COMPARE_AJAX_URL, {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { return r.json(); })
.then(function(data) {
loading.style.display = 'none';
if (data.error) {
errorEl.textContent = data.message || 'Error loading comparison';
errorEl.style.display = 'block';
return;
}
var b1 = data.backup1;
var b2 = data.backup2;
var d = data.delta;
var dur1 = 0, dur2 = 0;
if (b1.backupstart !== '0000-00-00 00:00:00' && b1.backupend !== '0000-00-00 00:00:00') {
dur1 = (new Date(b1.backupend).getTime() - new Date(b1.backupstart).getTime()) / 1000;
}
if (b2.backupstart !== '0000-00-00 00:00:00' && b2.backupend !== '0000-00-00 00:00:00') {
dur2 = (new Date(b2.backupend).getTime() - new Date(b2.backupstart).getTime()) / 1000;
}
var rows = [
['<?php echo Text::_('JGRID_HEADING_ID', true); ?>', '#' + b1.id, '#' + b2.id, ''],
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION', true); ?>', b1.description || '&mdash;', b2.description || '&mdash;', ''],
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_PROFILE', true); ?>', b1.profile_title || '&mdash;', b2.profile_title || '&mdash;', ''],
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATUS', true); ?>', b1.status, b2.status, ''],
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TYPE', true); ?>', b1.backup_type, b2.backup_type, ''],
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_SIZE', true); ?>', mbCmpFormatBytes(b1.total_size), mbCmpFormatBytes(b2.total_size), mbCmpDeltaCell(d.size_diff, 'bytes')],
['<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_DB_SIZE', true); ?>', mbCmpFormatBytes(b1.db_size), mbCmpFormatBytes(b2.db_size), mbCmpDeltaCell(b2.db_size - b1.db_size, 'bytes')],
['<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_FILES_COUNT', true); ?>', b1.files_count.toLocaleString(), b2.files_count.toLocaleString(), mbCmpDeltaCell(d.files_diff, 'number')],
['<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_TABLES_COUNT', true); ?>', b1.tables_count.toLocaleString(), b2.tables_count.toLocaleString(), mbCmpDeltaCell(d.tables_diff, 'number')],
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DATE', true); ?>', b1.backupstart, b2.backupstart, ''],
['<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_DURATION', true); ?>', mbCmpFormatDuration(dur1), mbCmpFormatDuration(dur2), mbCmpDeltaCell(d.duration_diff_seconds, 'duration')],
];
var html = '';
for (var i = 0; i < rows.length; i++) {
html += '<tr><td class="fw-bold">' + rows[i][0] + '</td><td>' + rows[i][1] + '</td><td>' + rows[i][2] + '</td><td>' + rows[i][3] + '</td></tr>';
}
body.innerHTML = html;
table.style.display = 'table';
})
.catch(function(err) {
loading.style.display = 'none';
errorEl.textContent = 'Error: ' + err.message;
errorEl.style.display = 'block';
});
}
// Close compare modal
document.addEventListener('click', function(e) {
if (e.target.id === 'mb-compare-modal' || e.target.classList.contains('mb-compare-close')) {
document.getElementById('mb-compare-modal').style.display = 'none';
}
});
// Intercept Compare toolbar button
document.addEventListener('DOMContentLoaded', function() {
var compareBtn = document.querySelector('[onclick*="backups.compare"], .button-copy');
if (compareBtn) {
compareBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
var checked = document.querySelectorAll('input[name="cid[]"]:checked');
if (checked.length !== 2) {
alert('<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_SELECT_TWO', true); ?>');
return false;
}
mbShowCompareModal(checked[0].value, checked[1].value);
return false;
}, true);
}
});
})();
</script>
@@ -109,122 +109,6 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
});
</script>
<!-- Row 1b: Snapshot Widget -->
<div class="row mb-3">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">
<span class="icon-camera" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_SNAPSHOTS'); ?>
</h5>
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=snapshots'); ?>" class="btn btn-sm btn-outline-secondary">
<?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_VIEW_ALL'); ?>
</a>
</div>
<div class="card-body">
<?php if ($this->latestSnapshot) : ?>
<?php $types = json_decode($this->latestSnapshot->content_types, true) ?: []; ?>
<p class="mb-1">
<strong><?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_LATEST_SNAPSHOT'); ?>:</strong>
<?php echo $this->escape($this->latestSnapshot->description); ?>
</p>
<p class="mb-1 text-muted">
<?php echo HTMLHelper::_('date', $this->latestSnapshot->created, Text::_('DATE_FORMAT_LC4')); ?>
&mdash;
<?php foreach ($types as $type) : ?>
<span class="badge bg-secondary"><?php echo $this->escape($type); ?></span>
<?php endforeach; ?>
</p>
<p class="mb-0">
<small class="text-muted">
<?php echo (int) $this->latestSnapshot->articles_count; ?> articles,
<?php echo (int) $this->latestSnapshot->categories_count; ?> categories,
<?php echo (int) $this->latestSnapshot->modules_count; ?> modules
&mdash; <?php echo $this->snapshotCount; ?> total snapshots
</small>
</p>
<?php else : ?>
<p class="text-muted mb-0"><?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_NO_SNAPSHOTS'); ?></p>
<?php endif; ?>
</div>
</div>
</div>
<!-- Storage Breakdown by Profile -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">
<span class="icon-folder-open" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_STORAGE_BREAKDOWN'); ?>
</h5>
</div>
<div class="card-body">
<?php if (!empty($this->storageByProfile)) : ?>
<?php
$maxSize = max(array_column($this->storageByProfile, 'total_size')) ?: 1;
$colors = ['#0d6efd', '#198754', '#ffc107', '#dc3545', '#6f42c1', '#0dcaf0'];
?>
<?php foreach ($this->storageByProfile as $i => $profile) : ?>
<?php $pct = round(($profile->total_size / $maxSize) * 100); ?>
<div class="mb-2">
<div class="d-flex justify-content-between small">
<span><?php echo $this->escape($profile->profile_title ?: 'Unknown'); ?> (<?php echo (int) $profile->backup_count; ?>)</span>
<span><?php echo HTMLHelper::_('number.bytes', $profile->total_size); ?></span>
</div>
<div style="background:#e9ecef; border-radius:3px; height:8px; overflow:hidden;">
<div style="width:<?php echo $pct; ?>%; height:100%; background:<?php echo $colors[$i % count($colors)]; ?>; border-radius:3px;"></div>
</div>
</div>
<?php endforeach; ?>
<?php else : ?>
<p class="text-muted mb-0"><?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_NO_BACKUPS'); ?></p>
<?php endif; ?>
</div>
</div>
</div>
</div>
<!-- Backup Trend (30 days) -->
<?php if (!empty($this->backupTrend)) : ?>
<div class="row mb-3">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<span class="icon-chart" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_BACKUP_TREND'); ?>
</h5>
</div>
<div class="card-body">
<?php
$maxDaySize = max(array_column($this->backupTrend, 'day_size')) ?: 1;
?>
<div style="display:flex; align-items:flex-end; gap:2px; height:120px; overflow-x:auto;">
<?php foreach ($this->backupTrend as $day) : ?>
<?php
$barHeight = max(4, round(($day->day_size / $maxDaySize) * 100));
$barColor = $day->fail_count > 0 ? '#dc3545' : '#198754';
$tooltip = date('M j', strtotime($day->backup_date))
. ' — ' . $day->day_count . ' backup(s), '
. number_format($day->day_size / 1048576, 1) . ' MB'
. ($day->fail_count > 0 ? ', ' . $day->fail_count . ' failed' : '');
?>
<div style="flex:1; min-width:8px; max-width:24px; height:<?php echo $barHeight; ?>%; background:<?php echo $barColor; ?>; border-radius:2px 2px 0 0; cursor:default;"
title="<?php echo htmlspecialchars($tooltip); ?>"></div>
<?php endforeach; ?>
</div>
<div class="d-flex justify-content-between mt-1">
<small class="text-muted"><?php echo date('M j', strtotime('-30 days')); ?></small>
<small class="text-muted"><?php echo date('M j'); ?></small>
</div>
</div>
</div>
</div>
</div>
<?php endif; ?>
<!-- Row 2: Quick Actions -->
<div class="row mb-3">
<div class="col-md-6">
@@ -99,14 +99,6 @@ $listDirn = $this->escape($this->state->get('list.direction'));
</td>
<td>
<?php if ($item->status === 'complete' && $canManage) : ?>
<?php if (in_array('articles', $types)) : ?>
<button type="button" class="btn btn-sm btn-outline-primary mb-snapshot-browse"
data-id="<?php echo (int) $item->id; ?>"
data-desc="<?php echo $this->escape($item->description); ?>"
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE'); ?>">
<span class="icon-search"></span>
</button>
<?php endif; ?>
<button type="button" class="btn btn-sm btn-outline-success mb-snapshot-restore"
data-id="<?php echo (int) $item->id; ?>"
data-types="<?php echo $this->escape($item->content_types); ?>"
@@ -235,55 +227,6 @@ $listDirn = $this->escape($this->state->get('list.direction'));
</div>
</div>
<!-- Browse Snapshot Articles Modal -->
<div id="mb-snapshot-browse-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
<div style="max-width:700px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); max-height:80vh; display:flex; flex-direction:column;">
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
<h4 style="margin:0;" id="mb-browse-title"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE'); ?></h4>
<button type="button" class="btn-close mb-modal-close" aria-label="Close"></button>
</div>
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=snapshots.restoreSelected'); ?>" method="post" id="mb-snapshot-browse-form">
<input type="hidden" name="id" id="mb-browse-id" value="">
<div style="padding:1rem 1.5rem; overflow-y:auto; flex:1;">
<div id="mb-browse-loading" class="text-center py-4">
<span class="spinner-border spinner-border-sm" role="status"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_LOADING'); ?>
</div>
<div id="mb-browse-error" class="alert alert-danger" style="display:none;"></div>
<div id="mb-browse-content" style="display:none;">
<div class="mb-2">
<label class="form-check form-check-inline">
<input type="checkbox" class="form-check-input" id="mb-browse-select-all">
<span class="form-check-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SELECT_ALL'); ?></span>
</label>
<span class="text-muted ms-2" id="mb-browse-count"></span>
</div>
<table class="table table-sm table-striped" id="mb-browse-table">
<thead>
<tr>
<th class="w-1"></th>
<th>ID</th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DATE'); ?></th>
</tr>
</thead>
<tbody id="mb-browse-tbody"></tbody>
</table>
</div>
</div>
<div style="padding:0.75rem 1.5rem; border-top:1px solid #dee2e6; text-align:right;">
<button type="button" class="btn btn-secondary mb-modal-close"><?php echo Text::_('JCANCEL'); ?></button>
<button type="submit" class="btn btn-success" id="mb-browse-restore-btn" disabled>
<span class="icon-upload" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED'); ?>
</button>
</div>
<?php echo HTMLHelper::_('form.token'); ?>
</form>
</div>
</div>
<script>
(function() {
// Create Snapshot — intercept toolbar button
@@ -369,124 +312,13 @@ $listDirn = $this->escape($this->state->get('list.direction'));
document.getElementById('mb-replace-warning').style.display = isReplace ? 'block' : 'none';
}
// Browse Snapshot — click handler
document.addEventListener('click', function(e) {
var btn = e.target.closest('.mb-snapshot-browse');
if (!btn) return;
e.preventDefault();
var id = btn.getAttribute('data-id');
var desc = btn.getAttribute('data-desc');
document.getElementById('mb-browse-id').value = id;
document.getElementById('mb-browse-title').textContent = 'Browse: ' + desc;
// Reset modal state
document.getElementById('mb-browse-loading').style.display = 'block';
document.getElementById('mb-browse-error').style.display = 'none';
document.getElementById('mb-browse-content').style.display = 'none';
document.getElementById('mb-browse-restore-btn').disabled = true;
document.getElementById('mb-browse-select-all').checked = false;
document.getElementById('mb-snapshot-browse-modal').style.display = 'block';
// Fetch articles via AJAX
var token = <?php echo json_encode(Session::getFormToken()); ?>;
var url = 'index.php?option=com_mokosuitebackup&task=ajax.browseSnapshot&id=' + encodeURIComponent(id) + '&' + token + '=1';
fetch(url, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function(response) { return response.json(); })
.then(function(data) {
document.getElementById('mb-browse-loading').style.display = 'none';
if (data.error) {
document.getElementById('mb-browse-error').textContent = data.message;
document.getElementById('mb-browse-error').style.display = 'block';
return;
}
var tbody = document.getElementById('mb-browse-tbody');
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
var stateLabels = { '1': 'Published', '0': 'Unpublished', '-1': 'Trashed', '-2': 'Archived' };
var stateBadges = { '1': 'bg-success', '0': 'bg-secondary', '-1': 'bg-danger', '-2': 'bg-info' };
data.articles.forEach(function(article) {
var tr = document.createElement('tr');
var tdCheck = document.createElement('td');
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'form-check-input mb-browse-article-cb';
cb.name = 'article_ids[]';
cb.value = article.id;
tdCheck.appendChild(cb);
tr.appendChild(tdCheck);
var tdId = document.createElement('td');
tdId.textContent = article.id;
tr.appendChild(tdId);
var tdTitle = document.createElement('td');
tdTitle.textContent = article.title;
tr.appendChild(tdTitle);
var tdState = document.createElement('td');
var badge = document.createElement('span');
badge.className = 'badge ' + (stateBadges[String(article.state)] || 'bg-secondary');
badge.textContent = stateLabels[String(article.state)] || 'Unknown';
tdState.appendChild(badge);
tr.appendChild(tdState);
var tdDate = document.createElement('td');
tdDate.textContent = article.created ? article.created.substring(0, 10) : '';
tr.appendChild(tdDate);
tbody.appendChild(tr);
});
document.getElementById('mb-browse-count').textContent = data.total + ' article(s)';
document.getElementById('mb-browse-content').style.display = 'block';
})
.catch(function(err) {
document.getElementById('mb-browse-loading').style.display = 'none';
document.getElementById('mb-browse-error').textContent = 'Failed to load articles: ' + err.message;
document.getElementById('mb-browse-error').style.display = 'block';
});
});
// Browse — select all toggle
document.addEventListener('change', function(e) {
if (e.target.id === 'mb-browse-select-all') {
var checked = e.target.checked;
var checkboxes = document.querySelectorAll('.mb-browse-article-cb');
checkboxes.forEach(function(cb) { cb.checked = checked; });
updateBrowseRestoreBtn();
}
if (e.target.classList.contains('mb-browse-article-cb')) {
updateBrowseRestoreBtn();
}
});
function updateBrowseRestoreBtn() {
var checked = document.querySelectorAll('.mb-browse-article-cb:checked').length;
var btn = document.getElementById('mb-browse-restore-btn');
btn.disabled = checked === 0;
btn.textContent = checked > 0
? <?php echo json_encode(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED')); ?> + ' (' + checked + ')'
: <?php echo json_encode(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED')); ?>;
}
// Close modals
document.addEventListener('click', function(e) {
if (e.target.classList.contains('mb-modal-close') ||
e.target.id === 'mb-snapshot-create-modal' ||
e.target.id === 'mb-snapshot-restore-modal' ||
e.target.id === 'mb-snapshot-browse-modal') {
e.target.id === 'mb-snapshot-restore-modal') {
document.getElementById('mb-snapshot-create-modal').style.display = 'none';
document.getElementById('mb-snapshot-restore-modal').style.display = 'none';
document.getElementById('mb-snapshot-browse-modal').style.display = 'none';
}
});
})();
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="actionlog" method="upgrade">
<name>Action Log - MokoSuiteBackup</name>
<version>01.36.00</version>
<version>01.39.02</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.36.00</version>
<version>01.39.02</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -17,7 +17,6 @@ use Joomla\Component\MokoSuiteBackup\Administrator\Engine\RestoreEngine;
use Joomla\Console\Command\AbstractCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
@@ -29,10 +28,6 @@ class RestoreCommand extends AbstractCommand
{
$this->setDescription('Restore a backup by record ID');
$this->addArgument('id', InputArgument::REQUIRED, 'Backup record ID to restore');
$this->addOption('files-only', null, InputOption::VALUE_NONE, 'Restore files only (skip database)');
$this->addOption('db-only', null, InputOption::VALUE_NONE, 'Restore database only (skip files)');
$this->addOption('no-preserve-config', null, InputOption::VALUE_NONE, 'Do not preserve current configuration.php');
$this->addOption('password', 'p', InputOption::VALUE_REQUIRED, 'Decryption password for encrypted archives', '');
}
protected function doExecute(InputInterface $input, OutputInterface $output): int
@@ -90,22 +85,8 @@ class RestoreCommand extends AbstractCommand
require_once $engineFile;
}
$filesOnly = $input->getOption('files-only');
$dbOnly = $input->getOption('db-only');
$preserveConfig = !$input->getOption('no-preserve-config');
$password = $input->getOption('password') ?: '';
$restoreFiles = !$dbOnly;
$restoreDb = !$filesOnly;
if ($filesOnly) {
$io->note('Restoring files only (database will not be touched)');
} elseif ($dbOnly) {
$io->note('Restoring database only (files will not be touched)');
}
$engine = new RestoreEngine();
$result = $engine->restore($recordId, $restoreFiles, $restoreDb, $preserveConfig, $password);
$result = $engine->restore($recordId);
if ($result['success']) {
$io->success($result['message']);
@@ -1,268 +0,0 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage plg_console_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\Plugin\Console\MokoSuiteBackup\Command;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotEngine;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotRestoreEngine;
use Joomla\Console\Command\AbstractCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class SnapshotCommand extends AbstractCommand
{
protected static $defaultName = 'mokosuitebackup:snapshot';
protected function configure(): void
{
$this->setDescription('Create, restore, list, or delete content snapshots');
$this->addArgument('action', InputArgument::REQUIRED, 'Action to perform: create, restore, list, delete');
$this->addOption('id', null, InputOption::VALUE_REQUIRED, 'Snapshot ID (required for restore and delete)');
$this->addOption('types', null, InputOption::VALUE_REQUIRED, 'Comma-separated content types: articles,categories,modules', 'articles,categories,modules');
$this->addOption('description', 'd', InputOption::VALUE_OPTIONAL, 'Snapshot description', '');
$this->addOption('mode', null, InputOption::VALUE_REQUIRED, 'Restore mode: replace or merge', 'replace');
}
protected function doExecute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$action = $input->getArgument('action');
$io->title('MokoSuiteBackup — Content Snapshot');
return match ($action) {
'create' => $this->actionCreate($input, $io),
'restore' => $this->actionRestore($input, $io),
'list' => $this->actionList($io),
'delete' => $this->actionDelete($input, $io),
default => $this->actionUnknown($action, $io),
};
}
private function actionCreate(InputInterface $input, SymfonyStyle $io): int
{
$types = array_map('trim', explode(',', $input->getOption('types')));
$description = $input->getOption('description') ?: '';
$io->text('Types: ' . implode(', ', $types));
$engineFile = JPATH_ADMINISTRATOR . '/components/com_mokosuitebackup/src/Engine/SnapshotEngine.php';
if (!file_exists($engineFile)) {
$io->error('MokoSuiteBackup component not installed.');
return 1;
}
if (!class_exists(SnapshotEngine::class)) {
require_once $engineFile;
}
$engine = new SnapshotEngine();
$result = $engine->create($types, $description ?: 'CLI snapshot');
if ($result['success']) {
$io->success($result['message']);
if (isset($result['id'])) {
$io->text('Snapshot ID: ' . $result['id']);
}
return 0;
}
$io->error($result['message']);
return 1;
}
private function actionRestore(InputInterface $input, SymfonyStyle $io): int
{
$id = $input->getOption('id');
if (!$id) {
$io->error('The --id option is required for restore.');
return 1;
}
$id = (int) $id;
$mode = $input->getOption('mode');
if (!\in_array($mode, ['replace', 'merge'], true)) {
$io->error('Invalid restore mode. Use "replace" or "merge".');
return 1;
}
$typesRaw = $input->getOption('types');
$contentTypes = ($typesRaw === 'articles,categories,modules')
? []
: array_map('trim', explode(',', $typesRaw));
$io->text('Snapshot ID: ' . $id);
$io->text('Mode: ' . $mode);
if (!empty($contentTypes)) {
$io->text('Types: ' . implode(', ', $contentTypes));
} else {
$io->text('Types: all from snapshot');
}
$io->warning('This will modify your site content.');
if (!$io->confirm('Are you sure you want to continue?', false)) {
$io->info('Restore cancelled.');
return 0;
}
$engineFile = JPATH_ADMINISTRATOR . '/components/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php';
if (!file_exists($engineFile)) {
$io->error('SnapshotRestoreEngine not found. Is the component fully installed?');
return 1;
}
if (!class_exists(SnapshotRestoreEngine::class)) {
require_once $engineFile;
}
$engine = new SnapshotRestoreEngine();
$result = $engine->restore($id, $mode, $contentTypes);
if ($result['success']) {
$io->success($result['message']);
return 0;
}
$io->error($result['message']);
return 1;
}
private function actionList(SymfonyStyle $io): int
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select([
$db->quoteName('id'),
$db->quoteName('description'),
$db->quoteName('content_types'),
$db->quoteName('created'),
$db->quoteName('file_size'),
])
->from($db->quoteName('#__mokosuitebackup_snapshots'))
->order($db->quoteName('id') . ' DESC');
$db->setQuery($query);
$rows = $db->loadObjectList();
if (empty($rows)) {
$io->info('No snapshots found.');
return 0;
}
$tableRows = [];
foreach ($rows as $row) {
$size = isset($row->file_size) ? $this->formatBytes((int) $row->file_size) : '-';
$tableRows[] = [
$row->id,
$row->description ?: '-',
$row->content_types ?: '-',
$row->created,
$size,
];
}
$io->table(
['ID', 'Description', 'Content Types', 'Created', 'Size'],
$tableRows
);
return 0;
}
private function actionDelete(InputInterface $input, SymfonyStyle $io): int
{
$id = $input->getOption('id');
if (!$id) {
$io->error('The --id option is required for delete.');
return 1;
}
$id = (int) $id;
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_snapshots'))
->where($db->quoteName('id') . ' = ' . $id);
$db->setQuery($query);
$record = $db->loadObject();
if (!$record) {
$io->error('Snapshot not found: ' . $id);
return 1;
}
// Delete the snapshot file if it exists
if (!empty($record->file_path) && is_file($record->file_path)) {
if (!@unlink($record->file_path)) {
$io->warning('Could not delete snapshot file: ' . $record->file_path);
} else {
$io->text('Deleted file: ' . $record->file_path);
}
}
// Delete the DB record
$query = $db->getQuery(true)
->delete($db->quoteName('#__mokosuitebackup_snapshots'))
->where($db->quoteName('id') . ' = ' . $id);
$db->setQuery($query);
$db->execute();
$io->success('Snapshot #' . $id . ' deleted.');
return 0;
}
private function actionUnknown(string $action, SymfonyStyle $io): int
{
$io->error('Unknown action: ' . $action . '. Valid actions: create, restore, list, delete.');
return 1;
}
private function formatBytes(int $bytes): string
{
if ($bytes === 0) {
return '0 B';
}
$units = ['B', 'KB', 'MB', 'GB'];
$i = (int) floor(log($bytes, 1024));
return round($bytes / (1024 ** $i), 2) . ' ' . $units[$i];
}
}
@@ -20,7 +20,6 @@ use Joomla\Plugin\Console\MokoSuiteBackup\Command\ListCommand;
use Joomla\Plugin\Console\MokoSuiteBackup\Command\ProfilesCommand;
use Joomla\Plugin\Console\MokoSuiteBackup\Command\RestoreCommand;
use Joomla\Plugin\Console\MokoSuiteBackup\Command\RunCommand;
use Joomla\Plugin\Console\MokoSuiteBackup\Command\SnapshotCommand;
final class MokoSuiteBackupConsole extends CMSPlugin implements SubscriberInterface
{
@@ -42,6 +41,5 @@ final class MokoSuiteBackupConsole extends CMSPlugin implements SubscriberInterf
$app->addCommand(new ProfilesCommand());
$app->addCommand(new RestoreCommand());
$app->addCommand(new CleanupCommand());
$app->addCommand(new SnapshotCommand());
}
}
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="content" method="upgrade">
<name>Content - MokoSuiteBackup</name>
<version>01.36.00</version>
<version>01.39.02</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.36.00</version>
<version>01.39.02</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.36.00</version>
<version>01.39.02</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -136,7 +136,6 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
$session->set('mokosuitebackup.last_cleanup', time());
$this->cleanupOldBackups();
$this->cleanupOldSnapshots();
}
/**
@@ -153,93 +152,6 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
}
}
/**
* Remove old content snapshots per component retention settings.
*
* Respects snapshot_retention_days (max age) and snapshot_retention_count
* (max number to keep). A value of 0 means unlimited for that setting.
*/
private function cleanupOldSnapshots(): void
{
try {
$this->doSnapshotCleanup();
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: cleanupOldSnapshots() failed: ' . $e->getMessage());
}
}
private function doSnapshotCleanup(): void
{
$db = Factory::getDbo();
$params = ComponentHelper::getParams('com_mokosuitebackup');
$retentionDays = (int) $params->get('snapshot_retention_days', 30);
$retentionCount = (int) $params->get('snapshot_retention_count', 20);
// Delete snapshots older than retention_days
if ($retentionDays > 0) {
$cutoff = date('Y-m-d H:i:s', strtotime("-{$retentionDays} days"));
$query = $db->getQuery(true)
->select([$db->quoteName('id'), $db->quoteName('data_file')])
->from($db->quoteName('#__mokosuitebackup_snapshots'))
->where($db->quoteName('created') . ' < ' . $db->quote($cutoff))
->order($db->quoteName('created') . ' DESC');
$db->setQuery($query);
$expired = $db->loadObjectList();
foreach ($expired as $snapshot) {
$this->deleteSnapshotRecord($db, $snapshot);
}
}
// Enforce max count (keep newest)
if ($retentionCount > 0) {
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_snapshots'));
$db->setQuery($query);
$totalCount = (int) $db->loadResult();
if ($totalCount > $retentionCount) {
$excess = $totalCount - $retentionCount;
$query = $db->getQuery(true)
->select([$db->quoteName('id'), $db->quoteName('data_file')])
->from($db->quoteName('#__mokosuitebackup_snapshots'))
->order($db->quoteName('created') . ' ASC');
$db->setQuery($query, 0, $excess);
$oldest = $db->loadObjectList();
foreach ($oldest as $snapshot) {
$this->deleteSnapshotRecord($db, $snapshot);
}
}
}
}
/**
* Delete a snapshot record and its JSON data file.
*/
private function deleteSnapshotRecord(object $db, object $snapshot): void
{
if (!empty($snapshot->data_file) && is_file($snapshot->data_file)) {
if (!@unlink($snapshot->data_file)) {
error_log('MokoSuiteBackup: Could not delete snapshot file (id=' . $snapshot->id . '): ' . $snapshot->data_file);
return;
}
}
try {
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokosuitebackup_snapshots'))
->where($db->quoteName('id') . ' = ' . (int) $snapshot->id)
);
$db->execute();
} catch (\Exception $e) {
error_log('MokoSuiteBackup: Could not delete snapshot record ' . $snapshot->id . ': ' . $e->getMessage());
}
}
private function doCleanup(): void
{
$db = Factory::getDbo();
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
* Task form: configure content snapshot parameters.
* This form appears in System > Scheduled Tasks when creating a
* "MokoSuiteBackup: Run Content Snapshot" task.
-->
<form>
<fieldset name="run_snapshot">
<field name="content_types" type="checkboxes" label="Content Types" default="articles,categories,modules">
<option value="articles">Articles</option>
<option value="categories">Categories</option>
<option value="modules">Modules</option>
</field>
<field name="description_format" type="text" label="Description Format" default="[date] Scheduled snapshot" hint="Use [date], [datetime] placeholders" />
</fieldset>
</form>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteBackup</name>
<version>01.36.00</version>
<version>01.39.02</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -43,11 +43,6 @@ final class MokoSuiteBackupTask extends CMSPlugin implements SubscriberInterface
'method' => 'runBackupProfile',
'form' => 'run_profile',
],
'mokosuitebackup.snapshot' => [
'langConstPrefix' => 'PLG_TASK_MOKOJOOMBACKUP_TASK_RUN_SNAPSHOT',
'method' => 'runContentSnapshot',
'form' => 'run_snapshot',
],
];
public static function getSubscribedEvents(): array
@@ -98,51 +93,4 @@ final class MokoSuiteBackupTask extends CMSPlugin implements SubscriberInterface
return Status::KNOCKOUT;
}
/**
* Create a content snapshot using the configured content types.
*
* @param ExecuteTaskEvent $event The task execution event
*
* @return int Status::OK on success, Status::KNOCKOUT on failure
*/
private function runContentSnapshot(ExecuteTaskEvent $event): int
{
$params = $event->getArgument('params');
$contentTypes = (array) ($params->content_types ?? ['articles', 'categories', 'modules']);
$descFormat = (string) ($params->description_format ?? '[date] Scheduled snapshot');
// Resolve placeholders in the description
$description = str_replace(
['[date]', '[datetime]'],
[date('Y-m-d'), date('Y-m-d H:i:s')],
$descFormat
);
// Load the snapshot engine from the component
$engineFile = JPATH_ADMINISTRATOR . '/components/com_mokosuitebackup/src/Engine/SnapshotEngine.php';
if (!file_exists($engineFile)) {
$this->logTask('MokoSuiteBackup component not installed — cannot create snapshot.');
return Status::KNOCKOUT;
}
if (!class_exists('\\Joomla\\Component\\MokoSuiteBackup\\Administrator\\Engine\\SnapshotEngine')) {
require_once $engineFile;
}
$engine = new \Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotEngine();
$result = $engine->create($contentTypes, $description);
if ($result['success']) {
$this->logTask('Snapshot complete: ' . $result['message']);
return Status::OK;
}
$this->logTask('Snapshot failed: ' . $result['message']);
return Status::KNOCKOUT;
}
}
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoSuiteBackup</name>
<version>01.36.00</version>
<version>01.39.02</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -9,19 +9,12 @@
*
* REST API endpoints wire-compatible with the mcp_mokosuitebackup MCP server.
*
* Backup routes:
* POST /api/index.php/v1/mokosuitebackup/backup Start backup
* GET /api/index.php/v1/mokosuitebackup/backups List records
* DELETE /api/index.php/v1/mokosuitebackup/backup/:id Delete record
* GET /api/index.php/v1/mokosuitebackup/backup/:id/download Download archive
* GET /api/index.php/v1/mokosuitebackup/profiles List profiles
*
* Snapshot routes:
* GET /api/index.php/v1/mokosuitebackup/snapshots List snapshots
* POST /api/index.php/v1/mokosuitebackup/snapshot Create snapshot
* POST /api/index.php/v1/mokosuitebackup/snapshot/:id/restore Restore snapshot
* DELETE /api/index.php/v1/mokosuitebackup/snapshot/:id Delete snapshot
* GET /api/index.php/v1/mokosuitebackup/snapshot/:id/download Download snapshot JSON
* Akeeba-compatible routes:
* POST /api/index.php/v1/mokosuitebackup/backup Start backup
* GET /api/index.php/v1/mokosuitebackup/backups List records
* DELETE /api/index.php/v1/mokosuitebackup/backup/:id Delete record
* GET /api/index.php/v1/mokosuitebackup/backup/:id/download Download archive
* GET /api/index.php/v1/mokosuitebackup/profiles List profiles
*/
namespace Joomla\Plugin\WebServices\MokoSuiteBackup\Extension;
@@ -101,62 +94,5 @@ final class MokoSuiteBackupWebServices extends CMSPlugin implements SubscriberIn
$defaults
)
);
// --- Snapshot routes ---
// List snapshots (GET)
$router->addRoute(
new Route(
['GET'],
'v1/mokosuitebackup/snapshots',
'snapshots.displayList',
[],
$defaults
)
);
// Create a snapshot (POST)
$router->addRoute(
new Route(
['POST'],
'v1/mokosuitebackup/snapshot',
'snapshots.create',
[],
$defaults
)
);
// Restore a snapshot (POST)
$router->addRoute(
new Route(
['POST'],
'v1/mokosuitebackup/snapshot/:id/restore',
'snapshots.restore',
['id' => '(\d+)'],
$defaults
)
);
// Delete a snapshot (DELETE)
$router->addRoute(
new Route(
['DELETE'],
'v1/mokosuitebackup/snapshot/:id',
'snapshots.delete',
['id' => '(\d+)'],
$defaults
)
);
// Download a snapshot JSON file (GET)
$router->addRoute(
new Route(
['GET'],
'v1/mokosuitebackup/snapshot/:id/download',
'snapshots.download',
['id' => '(\d+)'],
$defaults
)
);
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
<extension type="package" method="upgrade">
<name>Package - MokoSuiteBackup</name>
<packagename>mokosuitebackup</packagename>
<version>01.36.00</version>
<version>01.39.02</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+24 -24
View File
@@ -58,7 +58,7 @@ class Pkg_MokoSuiteBackupInstallerScript
return false;
}
/* Check required PHP extensions (warn but don't block install) */
// Check required PHP extensions (warn but don't block install)
$requiredExts = ['zip', 'pdo', 'pdo_mysql', 'mbstring', 'curl'];
$missingExts = array_filter($requiredExts, fn($ext) => !extension_loaded($ext));
@@ -71,7 +71,7 @@ class Pkg_MokoSuiteBackupInstallerScript
);
}
/* Save download key before Joomla re-registers the update site */
// Save download key before Joomla re-registers the update site
if ($type === 'update') {
$this->preflight_saveKey();
}
@@ -138,43 +138,43 @@ class Pkg_MokoSuiteBackupInstallerScript
return;
}
/* Restore download key if it was saved before update */
// Restore download key if it was saved before update
if ($this->savedDownloadKey !== null) {
$this->restoreDownloadKey();
}
if ($type === 'install') {
/* Enable all bundled plugins on fresh install */
// Enable all bundled plugins on fresh install
$this->enableBundledPlugins();
/* Create default backup directory in site root */
// Create default backup directory in site root
$this->createBackupDirectory();
/* Generate a random webcron secret word */
// Generate a random webcron secret word
$this->generateWebcronSecret();
/* Create default scheduled task for backup automation */
// Create default scheduled task for backup automation
$this->createDefaultScheduledTask();
}
/* Ensure submenu items exist and are up to date */
/* (Joomla may not add new submenu entries or update params on upgrades) */
// Ensure submenu items exist and are up to date
// (Joomla may not add new submenu entries or update params on upgrades)
$this->ensureSubmenuItems();
/* Fix package client_id — packages must be client_id=0 (site) for */
/* Joomla's updater to match the <client>site</client> in updates.xml */
// Fix package client_id — packages must be client_id=0 (site) for
// Joomla's updater to match the <client>site</client> in updates.xml
$this->fixPackageClientId();
/* Sync submenu icons in #__menu (Joomla doesn't update icons on upgrades) */
// Sync submenu icons in #__menu (Joomla doesn't update icons on upgrades)
$this->syncMenuIcons();
/* Warn if no license key configured */
// Warn if no license key configured
$this->warnMissingLicenseKey();
/* Migrate profiles with old default backup_dir values to [DEFAULT_DIR] placeholder */
// Migrate profiles with old default backup_dir values to [DEFAULT_DIR] placeholder
$this->migrateDefaultBackupDir();
/* Remind user to review backup profile settings */
// Remind user to review backup profile settings
if ($type === 'install') {
$profileUrl = Route::_('index.php?option=com_mokosuitebackup&view=profiles');
@@ -196,7 +196,7 @@ class Pkg_MokoSuiteBackupInstallerScript
try {
$db = Factory::getDbo();
/* Load current component params */
// Load current component params
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
@@ -208,7 +208,7 @@ class Pkg_MokoSuiteBackupInstallerScript
$params = json_decode($rawParams ?: '{}', true) ?: [];
/* Only generate if not already set */
// Only generate if not already set
if (!empty($params['webcron_secret'])) {
return;
}
@@ -286,7 +286,7 @@ class Pkg_MokoSuiteBackupInstallerScript
return;
}
/* Protect directory from direct web access */
// Protect directory from direct web access
$htaccess = $backupDir . '/.htaccess';
if (!file_exists($htaccess)) {
@@ -361,7 +361,7 @@ class Pkg_MokoSuiteBackupInstallerScript
try {
$db = Factory::getDbo();
/* Check if a MokoSuiteBackup task already exists */
// Check if a MokoSuiteBackup task already exists
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__scheduler_tasks'))
@@ -460,7 +460,7 @@ class Pkg_MokoSuiteBackupInstallerScript
try {
$db = Factory::getDbo();
/* Find the parent menu item for our component */
// Find the parent menu item for our component
$query = $db->getQuery(true)
->select([$db->quoteName('id'), $db->quoteName('menutype')])
->from($db->quoteName('#__menu'))
@@ -476,7 +476,7 @@ class Pkg_MokoSuiteBackupInstallerScript
return;
}
/* Get the component extension_id */
// Get the component extension_id
$query = $db->getQuery(true)
->select($db->quoteName('extension_id'))
->from($db->quoteName('#__extensions'))
@@ -492,7 +492,7 @@ class Pkg_MokoSuiteBackupInstallerScript
}
foreach ($submenus as $submenu) {
/* Check if this submenu item already exists */
// Check if this submenu item already exists
$query = $db->getQuery(true)
->select([$db->quoteName('id'), $db->quoteName('params')])
->from($db->quoteName('#__menu'))
@@ -503,7 +503,7 @@ class Pkg_MokoSuiteBackupInstallerScript
$existing = $db->loadObject();
if ($existing) {
/* Merge menu_icon into existing params to preserve other settings */
// Merge menu_icon into existing params to preserve other settings
$existingParams = json_decode($existing->params ?? '{}', true) ?: [];
$existingParams['menu_icon'] = $submenu['menu_icon'];
$mergedParams = json_encode($existingParams);
@@ -517,7 +517,7 @@ class Pkg_MokoSuiteBackupInstallerScript
continue;
}
/* Use Joomla's MenuTable to create the item properly */
// Use Joomla's MenuTable to create the item properly
$table = Factory::getApplication()
->bootComponent('com_menus')
->getMVCFactory()