Compare commits

..

1 Commits

Author SHA1 Message Date
gitea-actions[bot] ce99cebf80 chore(version): auto-bump patch 01.25.03-dev [skip ci] 2026-06-21 22:36:12 +00:00
46 changed files with 594 additions and 1821 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:
+2 -2
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 01.31.00
# INGROUP: moko-platform.Automation
# VERSION: 01.25.03
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
+12 -17
View File
@@ -1,26 +1,21 @@
# Changelog
## [Unreleased]
## [01.31.00] --- 2026-06-22
## [01.25.00] --- 2026-06-20
## [01.31.00] --- 2026-06-22
## [01.25.00] --- 2026-06-20
### Added
- REST API endpoints for content snapshots: list, create, restore, delete, download (#54)
- Automatic archive integrity verification after backup creation (#65)
- CLI command `mokosuitebackup:snapshot` for create, restore, list, and delete operations (#55)
## [01.24.00] --- 2026-06-20
## [01.30.00] --- 2026-06-22
## [01.24.00] --- 2026-06-19
## [01.30.00] --- 2026-06-22
## [01.23.00] --- 2026-06-18
### Changed
- Remote upload failure no longer marks the entire backup as failed — local archive is preserved with status 'complete' (#66)
## [01.21.00] --- 2026-06-16
### Added
- Snapshots now capture tags, custom fields, field values, and field-category mappings when articles are included (#57)
- Snapshot retention settings: max count and max age with automatic cleanup (#63)
## [01.27.03] --- 2026-06-21
## [01.27.03] --- 2026-06-21
### Fixed
- Admin submenu items (Dashboard, Backups, Profiles) not created on install — `<submenu>` block in manifest was empty
- Submenu items not created on update — added `ensureSubmenuItems()` using Joomla's `MenuTable` API with proper nested set positioning
- Submenu icons not rendering in Joomla 6 — set `menu_icon` param for level 2+ items (Atum only renders `img` column icons for level 1)
- CSS selector `#menu``.main-nav` for icon injection (Joomla 6 uses dynamic `id="menu{moduleId}"`)
- Use `margin-inline-end` instead of `margin-right` for RTL layout support
+165
View File
@@ -0,0 +1,165 @@
# Makefile for Joomla Extensions
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# MokoSuiteBackup — Full-site backup and restore for Joomla
#
# Builds and releases are handled by CI workflows (pre-release.yml,
# auto-release.yml). This Makefile provides local validation helpers
# and workflow dispatch shortcuts.
# ==============================================================================
# CONFIGURATION
# ==============================================================================
EXTENSION_NAME := mokosuitebackup
EXTENSION_TYPE := package
SRC_DIR := source
# Gitea
GITEA_URL := https://git.mokoconsulting.tech
GITEA_ORG := MokoConsulting
GITEA_REPO := MokoSuiteBackup
# Tools
PHP := php
COMPOSER := composer
PHPCS := vendor/bin/phpcs
# Coding Standards
PHPCS_STANDARD := Joomla
# Colors for output
COLOR_RESET := \033[0m
COLOR_GREEN := \033[32m
COLOR_YELLOW := \033[33m
COLOR_BLUE := \033[34m
COLOR_RED := \033[31m
# ==============================================================================
# TARGETS
# ==============================================================================
.PHONY: help
help: ## Show this help message
@echo "$(COLOR_BLUE)╔════════════════════════════════════════════════════════════╗$(COLOR_RESET)"
@echo "$(COLOR_BLUE)║ MokoSuiteBackup Makefile ║$(COLOR_RESET)"
@echo "$(COLOR_BLUE)╚════════════════════════════════════════════════════════════╝$(COLOR_RESET)"
@echo ""
@echo "$(COLOR_GREEN)Available targets:$(COLOR_RESET)"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " $(COLOR_BLUE)%-20s$(COLOR_RESET) %s\n", $$1, $$2}'
@echo ""
# -- Validation ----------------------------------------------------------------
.PHONY: lint
lint: ## Run PHP syntax check on all source files
@echo "$(COLOR_BLUE)Running PHP linter...$(COLOR_RESET)"
@ERROR=0; \
find $(SRC_DIR) -name "*.php" -exec $(PHP) -l {} \; 2>&1 | grep -v "No syntax errors" || true; \
if find $(SRC_DIR) -name "*.php" -exec $(PHP) -l {} \; 2>&1 | grep -q "Parse error"; then \
echo "$(COLOR_RED)✗ Syntax errors found$(COLOR_RESET)"; exit 1; \
fi
@echo "$(COLOR_GREEN)✓ PHP linting complete$(COLOR_RESET)"
.PHONY: phpcs
phpcs: ## Run PHP CodeSniffer (Joomla standards)
@echo "$(COLOR_BLUE)Running PHP CodeSniffer...$(COLOR_RESET)"
@if [ -f "$(PHPCS)" ]; then \
$(PHPCS) --standard=$(PHPCS_STANDARD) --extensions=php $(SRC_DIR); \
else \
echo "$(COLOR_YELLOW)⚠ PHP CodeSniffer not installed. Run: composer install$(COLOR_RESET)"; \
fi
.PHONY: validate
validate: lint ## Run all local validation checks
@echo "$(COLOR_GREEN)✓ Validation passed$(COLOR_RESET)"
.PHONY: validate-xml
validate-xml: ## Validate all XML manifests are well-formed
@echo "$(COLOR_BLUE)Validating XML manifests...$(COLOR_RESET)"
@ERROR=0; \
for f in $$(find $(SRC_DIR) -name "*.xml"); do \
$(PHP) -r "new SimpleXMLElement(file_get_contents('$$f'));" 2>/dev/null \
|| { echo "$(COLOR_RED)✗ Invalid XML: $$f$(COLOR_RESET)"; ERROR=1; }; \
done; \
[ $$ERROR -eq 0 ] && echo "$(COLOR_GREEN)✓ All XML manifests valid$(COLOR_RESET)" || exit 1
# -- Dependencies --------------------------------------------------------------
.PHONY: install-deps
install-deps: ## Install PHP dependencies via Composer
@echo "$(COLOR_BLUE)Installing dependencies...$(COLOR_RESET)"
@if [ -f "composer.json" ]; then \
$(COMPOSER) install; \
echo "$(COLOR_GREEN)✓ Composer dependencies installed$(COLOR_RESET)"; \
fi
.PHONY: security-check
security-check: ## Run security audit on dependencies
@echo "$(COLOR_BLUE)Running security checks...$(COLOR_RESET)"
@if [ -f "composer.json" ]; then \
$(COMPOSER) audit || echo "$(COLOR_YELLOW)⚠ Vulnerabilities found$(COLOR_RESET)"; \
fi
# -- Minify --------------------------------------------------------------------
MOKO_PLATFORM ?= $(or $(wildcard ../moko-platform),$(wildcard $(HOME)/moko-platform),$(wildcard /opt/moko-platform))
MINIFY_SCRIPT := $(MOKO_PLATFORM)/build/minify.js
.PHONY: minify
minify: ## Minify CSS/JS assets
@echo "$(COLOR_BLUE)Minifying assets...$(COLOR_RESET)"
@if [ -f "$(MINIFY_SCRIPT)" ]; then \
node "$(MINIFY_SCRIPT)" $(SRC_DIR); \
elif [ -f "scripts/minify.js" ]; then \
node scripts/minify.js; \
else \
echo "$(COLOR_YELLOW)⚠ No minify script found$(COLOR_RESET)"; \
fi
# -- Release (CI workflow dispatch) --------------------------------------------
.PHONY: release
release: validate validate-xml ## Trigger pre-release build via CI workflow
@echo "$(COLOR_BLUE)Triggering pre-release workflow...$(COLOR_RESET)"
@if ! command -v curl >/dev/null 2>&1; then \
echo "$(COLOR_RED)✗ curl required$(COLOR_RESET)"; exit 1; \
fi
@if [ -z "$$MOKOGITEA_TOKEN" ]; then \
echo "$(COLOR_RED)✗ MOKOGITEA_TOKEN not set$(COLOR_RESET)"; exit 1; \
fi
@BRANCH=$$(git rev-parse --abbrev-ref HEAD); \
curl -sf -X POST \
-H "Authorization: token $$MOKOGITEA_TOKEN" \
-H "Content-Type: application/json" \
"$(GITEA_URL)/api/v1/repos/$(GITEA_ORG)/$(GITEA_REPO)/actions/workflows/pre-release.yml/dispatches" \
-d "{\"ref\":\"$$BRANCH\",\"inputs\":{\"stability\":\"development\"}}" \
&& echo "$(COLOR_GREEN)✓ Pre-release dispatched on $$BRANCH (development channel)$(COLOR_RESET)" \
|| { echo "$(COLOR_RED)✗ Dispatch failed$(COLOR_RESET)"; exit 1; }
.PHONY: release-rc
release-rc: validate validate-xml ## Trigger release-candidate build via CI workflow
@echo "$(COLOR_BLUE)Triggering RC pre-release workflow...$(COLOR_RESET)"
@if [ -z "$$MOKOGITEA_TOKEN" ]; then \
echo "$(COLOR_RED)✗ MOKOGITEA_TOKEN not set$(COLOR_RESET)"; exit 1; \
fi
@BRANCH=$$(git rev-parse --abbrev-ref HEAD); \
curl -sf -X POST \
-H "Authorization: token $$MOKOGITEA_TOKEN" \
-H "Content-Type: application/json" \
"$(GITEA_URL)/api/v1/repos/$(GITEA_ORG)/$(GITEA_REPO)/actions/workflows/pre-release.yml/dispatches" \
-d "{\"ref\":\"$$BRANCH\",\"inputs\":{\"stability\":\"release-candidate\"}}" \
&& echo "$(COLOR_GREEN)✓ Pre-release dispatched on $$BRANCH (release-candidate channel)$(COLOR_RESET)" \
|| { echo "$(COLOR_RED)✗ Dispatch failed$(COLOR_RESET)"; exit 1; }
# -- Info ----------------------------------------------------------------------
.PHONY: version
version: ## Display version from package manifest
@VERSION=$$(grep '<version>' $(SRC_DIR)/pkg_mokosuitebackup.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/'); \
echo "$(COLOR_BLUE)$(EXTENSION_NAME)$(COLOR_RESET) v$$VERSION ($(EXTENSION_TYPE))"
# Default target
.DEFAULT_GOAL := help
+1 -1
View File
@@ -1,6 +1,6 @@
# MokoSuiteBackup
<!-- VERSION: 01.31.00 -->
<!-- VERSION: 01.25.03 -->
Full-site backup and restore for Joomla — database, files, and configuration.
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env bash
# ============================================================================
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Automation.CI
# INGROUP: moko-platform.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /automation/ci-issue-reporter.sh
# VERSION: 09.23.00
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
# Deduplicates by searching open issues with the "ci-auto" label
# whose title matches the gate. If a matching issue exists, a comment
# is appended instead of opening a duplicate.
# ============================================================================
set -euo pipefail
# ── Defaults ────────────────────────────────────────────────────────────────
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
GITEA_TOKEN="${GITEA_TOKEN:-}"
REPO="${GITHUB_REPOSITORY:-}"
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
LABEL_NAME="ci-auto"
LABEL_COLOR="#e11d48"
GATE=""
DETAILS=""
SEVERITY="error"
WORKFLOW=""
# ── Parse arguments ─────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
Required:
--gate CI gate name (e.g. "Code Quality", "Self-Health")
--details Human-readable failure description
Optional:
--severity "error" (default) or "warning"
--workflow Workflow name for the issue title
--repo owner/repo (default: \$GITHUB_REPOSITORY)
--run-url URL to the CI run (auto-detected from env)
--token Gitea API token (default: \$GITEA_TOKEN)
--url Gitea base URL (default: \$GITEA_URL)
EOF
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--gate) GATE="$2"; shift 2 ;;
--details) DETAILS="$2"; shift 2 ;;
--severity) SEVERITY="$2"; shift 2 ;;
--workflow) WORKFLOW="$2"; shift 2 ;;
--repo) REPO="$2"; shift 2 ;;
--run-url) RUN_URL="$2"; shift 2 ;;
--token) GITEA_TOKEN="$2"; shift 2 ;;
--url) GITEA_URL="$2"; shift 2 ;;
-h|--help) usage ;;
*) echo "Unknown option: $1"; usage ;;
esac
done
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
API="${GITEA_URL}/api/v1/repos/${REPO}"
# ── Build title ─────────────────────────────────────────────────────────────
if [[ -n "$WORKFLOW" ]]; then
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
else
TITLE="[CI] ${GATE} failed"
fi
# ── Ensure label exists ─────────────────────────────────────────────────────
ensure_label() {
local exists
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null || echo "000")
if [[ "$exists" == "200" ]]; then
# Check if label already exists
local found
found=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null \
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
if [[ -z "$found" ]]; then
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/labels" \
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
> /dev/null 2>&1 || true
fi
fi
}
# ── Search for existing open issue ──────────────────────────────────────────
find_existing_issue() {
# URL-encode the gate name for the query
local query
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
local response
response=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
2>/dev/null || echo "[]")
# Extract the first matching issue number
echo "$response" \
| grep -oP '"number":\s*\K[0-9]+' \
| head -1
}
# ── Build issue body ────────────────────────────────────────────────────────
build_body() {
local severity_badge
if [[ "$SEVERITY" == "error" ]]; then
severity_badge="**Severity:** Error"
else
severity_badge="**Severity:** Warning"
fi
cat <<BODY
## CI Gate Failure: ${GATE}
${severity_badge}
**Workflow:** ${WORKFLOW:-unknown}
**Branch:** ${GITHUB_REF_NAME:-unknown}
**Commit:** \`${GITHUB_SHA:0:8}\`
**Run:** [View CI run](${RUN_URL})
### Details
${DETAILS}
### Resolution
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
---
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
BODY
}
# ── Build comment body (for existing issues) ────────────────────────────────
build_comment() {
cat <<COMMENT
### CI failure recurrence
**Branch:** ${GITHUB_REF_NAME:-unknown}
**Commit:** \`${GITHUB_SHA:0:8}\`
**Run:** [View CI run](${RUN_URL})
${DETAILS}
COMMENT
}
# ── Main ────────────────────────────────────────────────────────────────────
ensure_label
EXISTING=$(find_existing_issue)
if [[ -n "$EXISTING" ]]; then
# Append comment to existing issue
COMMENT_BODY=$(build_comment)
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
import sys, json
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${EXISTING}/comments" \
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
if [[ "$HTTP" == "201" ]]; then
echo "Commented on existing issue #${EXISTING}"
else
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
fi
else
# Create new issue
ISSUE_BODY=$(build_body)
ISSUE_JSON=$(python3 -c "
import sys, json
body = sys.stdin.read()
print(json.dumps({
'title': sys.argv[1],
'body': body,
'labels': []
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
# Create the issue
RESPONSE=$(curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues" \
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
if [[ -n "$ISSUE_NUM" ]]; then
# Apply label (separate call — more reliable across Gitea versions)
LABEL_ID=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null \
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
| head -1 || true)
if [[ -n "$LABEL_ID" ]]; then
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${ISSUE_NUM}/labels" \
-d "{\"labels\":[${LABEL_ID}]}" \
> /dev/null 2>&1 || true
fi
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
else
echo "WARNING: Failed to create issue"
echo "Response: ${RESPONSE}"
fi
fi
+11
View File
@@ -0,0 +1,11 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage plg_webservices_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
*/
defined('_JEXEC') or die;
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
* @package 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
-->
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoSuiteBackup</name>
<version>01.25.03</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<description>PLG_WEBSERVICES_MOKOJOOMBACKUP_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\WebServices\MokoSuiteBackup</namespace>
<files>
<filename plugin="mokosuitebackup">mokosuitebackup.php</filename>
<folder>services</folder>
<folder>src</folder>
</files>
<languages>
<language tag="en-GB">language/en-GB/plg_webservices_mokosuitebackup.ini</language>
<language tag="en-GB">language/en-GB/plg_webservices_mokosuitebackup.sys.ini</language>
</languages>
</extension>
@@ -121,27 +121,11 @@ class BackupsController extends ApiController
$data = [];
// Strip sensitive credentials before serialization
$sensitiveFields = [
'ftp_password', 'ftp_username',
's3_access_key', 's3_secret_key',
'gdrive_client_secret', 'gdrive_refresh_token',
'encryption_password', 'ntfy_token',
];
foreach ($items as $item) {
$safe = clone $item;
foreach ($sensitiveFields as $field) {
if (isset($safe->$field) && $safe->$field !== '') {
$safe->$field = '***';
}
}
$data[] = [
'type' => 'profiles',
'id' => $safe->id,
'attributes' => $safe,
'id' => $item->id,
'attributes' => $item,
];
}
@@ -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"
@@ -19,18 +19,6 @@
<option value="fail">COM_MOKOJOOMBACKUP_STATUS_FAIL</option>
<option value="pending">COM_MOKOJOOMBACKUP_STATUS_PENDING</option>
</field>
<field
name="backup_type"
type="list"
label="COM_MOKOJOOMBACKUP_FIELD_BACKUP_TYPE"
onchange="this.form.submit();"
>
<option value="">COM_MOKOJOOMBACKUP_FILTER_TYPE_ALL</option>
<option value="full">COM_MOKOJOOMBACKUP_TYPE_FULL</option>
<option value="database">COM_MOKOJOOMBACKUP_TYPE_DATABASE</option>
<option value="files">COM_MOKOJOOMBACKUP_TYPE_FILES</option>
<option value="differential">COM_MOKOJOOMBACKUP_TYPE_DIFFERENTIAL</option>
</field>
</fields>
<fields name="list">
@@ -167,7 +167,6 @@ COM_MOKOJOOMBACKUP_STATUS_PENDING="Pending"
COM_MOKOJOOMBACKUP_FILTER_SEARCH="Search"
COM_MOKOJOOMBACKUP_FILTER_STATUS="Status"
COM_MOKOJOOMBACKUP_FILTER_STATUS_ALL="- Select Status -"
COM_MOKOJOOMBACKUP_FILTER_TYPE_ALL="- Select Type -"
; Tabs and fieldsets
COM_MOKOJOOMBACKUP_TAB_GENERAL="General"
@@ -269,13 +268,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"
@@ -7,7 +7,7 @@
-->
<extension type="component" method="upgrade">
<name>MokoSuiteBackup</name>
<version>01.31.00</version>
<version>01.25.03</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -49,13 +49,6 @@ class BackupsController extends AdminController
$engine = new BackupEngine();
$result = $engine->run($profileId, $description, 'backend');
// Surface preflight warnings as Joomla messages
if (!empty($result['warnings'])) {
foreach ($result['warnings'] as $warning) {
$this->app->enqueueMessage($warning, 'warning');
}
}
if ($result['success']) {
$this->setMessage($result['message']);
} else {
@@ -360,12 +360,16 @@ class AkeebaImporter
return $result;
}
// Parse as JSON only — unserialize is an object injection risk
// Try JSON
$data = json_decode($raw, true);
if (!is_array($data)) {
// Older Akeeba versions used serialized PHP — skip rather than risk object injection
return $result;
// Try unserialize (older Akeeba versions)
$data = @unserialize($raw);
if (!is_array($data)) {
return $result;
}
}
// Extract directory exclusions
@@ -32,21 +32,16 @@ class BackupEngine
*/
public function run(int $profileId, string $description, string $origin = 'backend'): array
{
// Run pre-flight checks before creating any backup record
$preflight = new PreflightCheck();
$preflightResult = $preflight->run($profileId);
if (!$preflightResult['pass']) {
return [
'success' => false,
'message' => 'Pre-flight failed: ' . implode('; ', $preflightResult['errors']),
'warnings' => $preflightResult['warnings'],
];
}
// Override PHP limits for long-running backup operations
$this->overridePhpLimits();
// Verify required extensions
$extCheck = $this->checkRequiredExtensions();
if ($extCheck !== true) {
return ['success' => false, 'message' => $extCheck];
}
$db = Factory::getDbo();
// Load profile
@@ -58,12 +53,7 @@ class BackupEngine
$profile = $db->loadObject();
if (!$profile) {
return ['success' => false, 'message' => 'Profile not found: ' . $profileId, 'warnings' => []];
}
// Log any preflight warnings
foreach ($preflightResult['warnings'] as $warning) {
$this->log('PREFLIGHT WARNING: ' . $warning);
return ['success' => false, 'message' => 'Profile not found: ' . $profileId];
}
// Read settings directly from profile columns
@@ -78,14 +68,13 @@ class BackupEngine
$this->backupDir = BackupDirectory::resolve($resolver->resolve($configuredDir));
if (!BackupDirectory::ensureReady($this->backupDir)) {
return ['success' => false, 'message' => 'Cannot create backup directory: ' . $this->backupDir, 'record_id' => 0, 'warnings' => $preflightResult['warnings']];
return ['success' => false, 'message' => 'Cannot create backup directory: ' . $this->backupDir, 'record_id' => 0];
}
// Create backup record
$now = date('Y-m-d H:i:s');
$tag = $resolver->getTag();
$archiveFormat = $profile->archive_format ?? 'zip';
$archiveName = '';
$archiver = $this->createArchiver($archiveFormat);
$archiveExt = $archiver->getExtension();
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
@@ -131,15 +120,12 @@ class BackupEngine
$tablesCount = 0;
// Step 1: Database dump (unless files-only)
// Streams to a temp file to avoid loading the entire dump into RAM
$sqlTempFile = '';
if ($profile->backup_type !== 'files') {
$this->log('Starting database dump...');
$sqlTempFile = $this->backupDir . '/.database-' . $tag . '.sql';
$dumper = new DatabaseDumper($excludeTables);
$dbSize = $dumper->dumpToFile($sqlTempFile);
$archiver->addFile($sqlTempFile, 'database.sql');
$dumper = new DatabaseDumper($excludeTables);
$sqlDump = $dumper->dump();
$archiver->addFromString('database.sql', $sqlDump);
$dbSize = strlen($sqlDump);
$tablesCount = $dumper->getTablesCount();
$this->log('Database dump complete: ' . $tablesCount . ' tables, ' . number_format($dbSize) . ' bytes');
}
@@ -207,11 +193,6 @@ class BackupEngine
$archiver->close();
// Clean up temp SQL file (no longer needed after archive is closed)
if (!empty($sqlTempFile) && is_file($sqlTempFile)) {
@unlink($sqlTempFile);
}
// Step 1.5: Apply AES-256 encryption (if configured)
$encryptionPassword = $profile->encryption_password ?? '';
@@ -232,11 +213,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 +236,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 +290,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);
@@ -339,22 +300,10 @@ class BackupEngine
'success' => true,
'message' => 'Backup complete: ' . $archiveName . ' (' . $sizeHuman . ')',
'record_id' => $recordId,
'warnings' => $preflightResult['warnings'],
];
} catch (\Throwable $e) {
$this->log('FATAL: ' . $e->getMessage());
// Clean up temp SQL file on failure
if (!empty($sqlTempFile) && is_file($sqlTempFile)) {
@unlink($sqlTempFile);
}
// If encryption was intended and failed, remove the plaintext archive
if (!empty($encryptionPassword) && !empty($archivePath) && is_file($archivePath)) {
@unlink($archivePath);
$this->log('Plaintext archive removed after encryption failure');
}
$update = (object) [
'id' => $recordId,
'status' => 'fail',
@@ -379,7 +328,7 @@ class BackupEngine
// Dispatch event for actionlog and other listeners
$this->dispatchAfterRun(false, $recordId, $description, $profileId, $origin);
return ['success' => false, 'message' => 'Backup failed: ' . $e->getMessage(), 'record_id' => $recordId, 'warnings' => $preflightResult['warnings'] ?? []];
return ['success' => false, 'message' => 'Backup failed: ' . $e->getMessage(), 'record_id' => $recordId];
}
}
@@ -434,6 +383,35 @@ class BackupEngine
};
}
/**
* Verify required PHP extensions are loaded.
*
* @return true|string True if all ok, or error message string
*/
private function checkRequiredExtensions(): true|string
{
$required = [
'zip' => 'ext-zip (required for archive creation)',
'pdo' => 'ext-pdo (required for database operations)',
'pdo_mysql' => 'ext-pdo_mysql (required for MySQL database dumps)',
'mbstring' => 'ext-mbstring (required for binary-safe operations)',
];
$missing = [];
foreach ($required as $ext => $label) {
if (!extension_loaded($ext)) {
$missing[] = $label;
}
}
if (!empty($missing)) {
return 'Missing PHP extensions: ' . implode(', ', $missing);
}
return true;
}
/**
* Create the appropriate archiver based on the archive format.
*/
@@ -442,7 +420,7 @@ class BackupEngine
return match ($format) {
'zip' => new ZipArchiver(),
'tar.gz' => new TarGzArchiver(),
default => throw new \InvalidArgumentException('Unknown archive format: ' . $format),
default => new ZipArchiver(),
};
}
@@ -523,90 +501,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.
*/
@@ -219,138 +219,6 @@ class DatabaseDumper
return false;
}
/**
* Dump all database tables directly to a file, streaming row by row.
* Avoids loading the entire dump into RAM.
*
* @param string $filePath Absolute path to write the SQL file
*
* @return int Size of the dump file in bytes
*/
public function dumpToFile(string $filePath): int
{
$db = Factory::getDbo();
$prefix = $db->getPrefix();
$fp = fopen($filePath, 'w');
if ($fp === false) {
throw new \RuntimeException('Cannot open dump file for writing: ' . $filePath);
}
fwrite($fp, "-- MokoSuiteBackup Database Dump\n");
fwrite($fp, "-- Generated: " . date('Y-m-d H:i:s') . "\n");
fwrite($fp, "-- Server: " . $db->getServerType() . "\n");
fwrite($fp, "-- Database: " . $db->getName() . "\n");
fwrite($fp, "-- Original Prefix: " . $prefix . "\n");
fwrite($fp, "-- Abstract Prefix: #__\n");
fwrite($fp, "-- Note: Table names use #__ placeholder. Replace with your prefix on restore.\n\n");
fwrite($fp, "SET SQL_MODE = \"NO_AUTO_VALUE_ON_ZERO\";\n");
fwrite($fp, "SET time_zone = \"+00:00\";\n\n");
// Get all tables with the site prefix
$tables = $db->getTableList();
$siteTables = [];
foreach ($tables as $table) {
if (str_starts_with($table, $prefix)) {
$siteTables[] = $table;
}
}
foreach ($siteTables as $table) {
$abstractName = '#__' . substr($table, strlen($prefix));
if ($this->isExcludedBoth($abstractName, $table)) {
continue;
}
$skipData = $this->isExcludedDataOnly($abstractName, $table);
$skipStructure = $this->isExcludedStructureOnly($abstractName, $table);
$this->tablesCount++;
fwrite($fp, "-- --------------------------------------------------------\n");
fwrite($fp, "-- Table: " . $abstractName . "\n");
if ($skipData) {
fwrite($fp, "-- (data excluded)\n");
}
if ($skipStructure) {
fwrite($fp, "-- (structure excluded)\n");
}
fwrite($fp, "-- --------------------------------------------------------\n\n");
if (!$skipStructure) {
$db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table));
$createRow = $db->loadRow();
if (!$createRow || empty($createRow[1])) {
continue;
}
$createSql = str_replace('`' . $prefix, '`#__', $createRow[1]);
fwrite($fp, 'DROP TABLE IF EXISTS `' . $abstractName . "`;\\n");
fwrite($fp, $createSql . ";\n\n");
}
if ($skipData) {
fwrite($fp, "\n");
continue;
}
$db->setQuery('SELECT COUNT(*) FROM ' . $db->quoteName($table));
$rowCount = (int) $db->loadResult();
if ($rowCount === 0) {
fwrite($fp, "-- (empty table)\n\n");
continue;
}
$chunkSize = 500;
for ($offset = 0; $offset < $rowCount; $offset += $chunkSize) {
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName($table)),
$offset,
$chunkSize
);
$rows = $db->loadAssocList();
if (empty($rows)) {
break;
}
foreach ($rows as $row) {
$values = [];
foreach ($row as $value) {
if ($value === null) {
$values[] = 'NULL';
} else {
$values[] = $db->quote($value);
}
}
$columns = array_map([$db, 'quoteName'], array_keys($row));
fwrite($fp, 'INSERT INTO `' . $abstractName . '`'
. ' (' . implode(', ', $columns) . ')'
. ' VALUES (' . implode(', ', $values) . ");\n");
}
}
fwrite($fp, "\n");
}
fclose($fp);
return filesize($filePath) ?: 0;
}
public function getTablesCount(): int
{
return $this->tablesCount;
@@ -206,11 +206,6 @@ class JpaUnarchiver
}
}
// Path traversal protection: reject absolute paths and directory traversal
if (str_starts_with($path, '/') || str_starts_with($path, '\\') || str_contains($path, '..')) {
return; // skip malicious entry
}
// Is this a directory?
if (substr($path, -1) === '/' || $uncompSize === 0 && $compSize === 0) {
$dirPath = $this->outputDir . '/' . $path;
@@ -233,24 +228,6 @@ class JpaUnarchiver
// Write file
$fullPath = $this->outputDir . '/' . $path;
// Verify resolved path stays within output directory
$realOutput = realpath($this->outputDir);
if ($realOutput !== false) {
$parentDir = dirname($fullPath);
if (!is_dir($parentDir)) {
mkdir($parentDir, 0755, true);
}
$realDest = realpath($parentDir);
if ($realDest === false || !str_starts_with($realDest, $realOutput)) {
return; // path escapes staging directory
}
}
$parentDir = dirname($fullPath);
if (!is_dir($parentDir)) {
@@ -303,20 +303,6 @@ function actionExtract(array $data): array
$zip->setPassword($password);
}
// 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(RESTORE_DIR)) {
$zip->close();
throw new RuntimeException(
@@ -1,305 +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
*
* Pre-flight validation for backup operations.
*
* Runs before any backup record is created, catching problems early
* with clear messages instead of failing mid-backup. Returns a result
* with errors (blockers) and warnings (informational).
*/
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
class PreflightCheck
{
/** @var string[] Fatal issues that prevent backup from starting */
private array $errors = [];
/** @var string[] Non-fatal issues the user should know about */
private array $warnings = [];
/**
* Run all pre-flight checks for a backup profile.
*
* @param int $profileId Profile to validate
*
* @return array{pass: bool, errors: string[], warnings: string[]}
*/
public function run(int $profileId): array
{
try {
$db = Factory::getDbo();
} catch (\Exception $e) {
$this->errors[] = 'Cannot connect to database: ' . $e->getMessage();
return $this->result();
}
// Load profile
try {
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('id') . ' = ' . (int) $profileId);
$db->setQuery($query);
$profile = $db->loadObject();
} catch (\Exception $e) {
$this->errors[] = 'Cannot load profile: ' . $e->getMessage();
return $this->result();
}
if (!$profile) {
$this->errors[] = 'Profile not found: #' . $profileId;
return $this->result();
}
if (!$profile->published) {
$this->errors[] = 'Profile is unpublished: ' . $profile->title;
return $this->result();
}
$this->checkPhpExtensions($profile);
$this->checkBackupDirectory($profile);
$this->checkDiskSpace($profile, $db);
$this->checkRunningBackup($profile, $db);
$this->checkExcludedTables($profile, $db);
$this->checkRemoteCredentials($profile);
return $this->result();
}
/**
* Check that required PHP extensions are loaded.
*/
private function checkPhpExtensions(object $profile): void
{
$required = ['pdo', 'pdo_mysql', 'mbstring'];
// ZIP is required unless using tar.gz
$format = $profile->archive_format ?? 'zip';
if ($format === 'zip') {
$required[] = 'zip';
}
foreach ($required as $ext) {
if (!extension_loaded($ext)) {
$this->errors[] = 'Missing required PHP extension: ext-' . $ext;
}
}
// curl is only needed for remote upload and ntfy notifications
$needsCurl = ($profile->remote_storage ?? 'none') !== 'none'
|| !empty($profile->ntfy_topic);
if ($needsCurl && !extension_loaded('curl')) {
$this->warnings[] = 'ext-curl is not loaded — remote upload and ntfy notifications will not work';
}
}
/**
* Check that the backup directory exists and is writable.
*/
private function checkBackupDirectory(object $profile): void
{
$configuredDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER;
// Resolve placeholders using a temporary resolver
$resolver = new PlaceholderResolver($profile);
$resolvedDir = BackupDirectory::resolve($resolver->resolve($configuredDir));
if (BackupDirectory::hasPlaceholders($resolvedDir)) {
$this->warnings[] = 'Backup directory contains unresolved placeholders: ' . $resolvedDir
. ' — directory cannot be validated until backup runs';
return;
}
if (!is_dir($resolvedDir)) {
// Try to create it
if (!@mkdir($resolvedDir, 0755, true)) {
$lastError = error_get_last();
$reason = $lastError['message'] ?? 'unknown reason';
$this->errors[] = 'Backup directory does not exist and cannot be created: ' . $resolvedDir
. ' (' . $reason . ')';
return;
}
}
if (!is_writable($resolvedDir)) {
$this->errors[] = 'Backup directory is not writable: ' . $resolvedDir;
}
}
/**
* Check available disk space against the last backup size + 20% buffer.
* Skipped if no previous backup exists for this profile.
*/
private function checkDiskSpace(object $profile, object $db): void
{
$configuredDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER;
$resolver = new PlaceholderResolver($profile);
$resolvedDir = BackupDirectory::resolve($resolver->resolve($configuredDir));
if (BackupDirectory::hasPlaceholders($resolvedDir) || !is_dir($resolvedDir)) {
return;
}
// Find last successful backup size for this profile
$query = $db->getQuery(true)
->select($db->quoteName('total_size'))
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id)
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
->where($db->quoteName('total_size') . ' > 0')
->order($db->quoteName('backupstart') . ' DESC');
$db->setQuery($query, 0, 1);
$lastSize = (int) $db->loadResult();
if ($lastSize === 0) {
// No previous backup — skip disk space check
return;
}
$requiredBytes = (int) ($lastSize * 1.2); // 20% buffer
$freeBytes = @disk_free_space($resolvedDir);
if ($freeBytes === false) {
$this->warnings[] = 'Could not determine free disk space for: ' . $resolvedDir;
return;
}
if ($freeBytes < $requiredBytes) {
$freeMB = number_format($freeBytes / 1048576, 1);
$neededMB = number_format($requiredBytes / 1048576, 1);
$this->warnings[] = 'Low disk space: ' . $freeMB . ' MB free, estimated ' . $neededMB . ' MB needed'
. ' (based on last backup + 20% buffer)';
}
}
/**
* Check if another backup is already running for this profile.
*/
private function checkRunningBackup(object $profile, object $db): void
{
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id)
->where($db->quoteName('status') . ' = ' . $db->quote('running'));
$db->setQuery($query);
$running = (int) $db->loadResult();
if ($running > 0) {
$this->errors[] = 'Another backup is already running for profile: ' . $profile->title
. ' — wait for it to finish or delete the stale record';
}
}
/**
* Check that excluded tables actually exist in the database.
* Missing tables are warnings, not errors — the profile may have
* been copied from another site or a table may have been removed.
*/
private function checkExcludedTables(object $profile, object $db): void
{
$excludeRaw = BackupDirectory::parseNewlineList($profile->exclude_tables ?? '');
if (empty($excludeRaw)) {
return;
}
$prefix = $db->getPrefix();
$allTables = array_flip($db->getTableList());
foreach ($excludeRaw as $entry) {
// Strip :data-only / :structure-only suffixes
$tableName = preg_replace('/:(?:data-only|structure-only)$/', '', $entry);
// Resolve #__ prefix to real prefix
$realName = str_replace('#__', $prefix, $tableName);
if (!isset($allTables[$realName])) {
$this->warnings[] = 'Excluded table does not exist: ' . $tableName
. ' — it will be silently skipped during backup';
}
}
}
/**
* Check that remote storage credentials are minimally configured.
* Does not test the actual connection (too slow for preflight).
*/
private function checkRemoteCredentials(object $profile): void
{
$remote = $profile->remote_storage ?? 'none';
if ($remote === 'none') {
return;
}
switch ($remote) {
case 'ftp':
if (empty($profile->ftp_host)) {
$this->warnings[] = 'FTP host is not configured — remote upload will fail';
}
if (empty($profile->ftp_username)) {
$this->warnings[] = 'FTP username is not configured — remote upload will fail';
}
break;
case 's3':
if (empty($profile->s3_bucket)) {
$this->warnings[] = 'S3 bucket is not configured — remote upload will fail';
}
if (empty($profile->s3_access_key) || empty($profile->s3_secret_key)) {
$this->warnings[] = 'S3 credentials are not configured — 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';
}
if (empty($profile->gdrive_refresh_token)) {
$this->warnings[] = 'Google Drive refresh token is missing — remote upload will fail';
}
break;
}
}
/**
* Build the result array.
*/
private function result(): array
{
return [
'pass' => empty($this->errors),
'errors' => $this->errors,
'warnings' => $this->warnings,
];
}
}
@@ -76,9 +76,8 @@ class RestoreEngine
return ['success' => false, 'message' => 'Backup archive not found: ' . $archivePath];
}
// Create staging directory (sanitize tag to prevent path traversal)
$safeTag = preg_replace('/[^a-zA-Z0-9_-]/', '', $record->tag ?: 'restore');
$this->stagingDir = JPATH_ROOT . '/tmp/mokosuitebackup-restore-' . $safeTag;
// Create staging directory
$this->stagingDir = JPATH_ROOT . '/tmp/mokosuitebackup-restore-' . $record->tag;
if (is_dir($this->stagingDir)) {
$this->recursiveDelete($this->stagingDir);
@@ -191,20 +190,6 @@ class RestoreEngine
$this->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($this->stagingDir)) {
$zip->close();
@@ -224,18 +209,6 @@ class RestoreEngine
private function extractTarGz(string $archivePath): void
{
$phar = new \PharData($archivePath);
// Validate all entries before extraction (path traversal protection)
foreach (new \RecursiveIteratorIterator($phar) as $entry) {
$entryName = $entry->getPathname();
// PharData paths are prefixed with phar:// — extract the relative part
$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($this->stagingDir, null, true);
$this->log('Extracted tar.gz archive');
}
@@ -114,28 +114,19 @@ class S3Uploader implements RemoteUploaderInterface
*/
private function singleUpload(string $localPath, string $objectKey): void
{
$url = $this->getObjectUrl($objectKey);
$fileSize = filesize($localPath);
// Stream file to compute SHA-256 without loading into RAM
$contentHash = hash_file('sha256', $localPath);
$url = $this->getObjectUrl($objectKey);
$fileContent = file_get_contents($localPath);
$contentHash = hash('sha256', $fileContent);
$headers = $this->signRequest('PUT', $url, $contentHash, [
'Content-Type' => 'application/zip',
'Content-Length' => (string) $fileSize,
'Content-Length' => (string) strlen($fileContent),
]);
$fp = fopen($localPath, 'rb');
if ($fp === false) {
throw new \RuntimeException('Cannot open file for upload: ' . $localPath);
}
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_PUT => true,
CURLOPT_INFILE => $fp,
CURLOPT_INFILESIZE => $fileSize,
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_POSTFIELDS => $fileContent,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 600,
@@ -144,8 +135,6 @@ class S3Uploader implements RemoteUploaderInterface
$response = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
fclose($fp);
if (curl_errno($ch)) {
$error = curl_error($ch);
curl_close($ch);
@@ -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
@@ -261,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
];
/**
@@ -286,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;
@@ -349,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)) {
@@ -32,18 +32,6 @@ class SteppedBackupEngine
*/
public function init(int $profileId, string $description = '', string $origin = 'backend'): array
{
// Run pre-flight checks before creating any backup record
$preflight = new PreflightCheck();
$preflightResult = $preflight->run($profileId);
if (!$preflightResult['pass']) {
return [
'error' => true,
'message' => 'Pre-flight failed: ' . implode('; ', $preflightResult['errors']),
'warnings' => $preflightResult['warnings'],
];
}
$db = Factory::getDbo();
// Load profile
@@ -55,7 +43,7 @@ class SteppedBackupEngine
$profile = $db->loadObject();
if (!$profile) {
return ['error' => true, 'message' => 'Profile not found: ' . $profileId, 'warnings' => []];
return ['error' => true, 'message' => 'Profile not found: ' . $profileId];
}
// Create session
@@ -142,11 +130,6 @@ class SteppedBackupEngine
$session->phase = ($profile->backup_type !== 'files') ? 'database' : 'files';
$session->log('Backup initialized: ' . $session->description);
$session->log('Total steps: ' . $totalSteps . ' (tables: ' . count($session->tables) . ', file batches: ' . count($session->fileBatches) . ')');
// Log any preflight warnings into the session
foreach ($preflightResult['warnings'] as $warning) {
$session->log('PREFLIGHT WARNING: ' . $warning);
}
$session->statusMessage = 'Initialized — starting backup...';
$session->save();
@@ -155,7 +138,6 @@ class SteppedBackupEngine
'phase' => $session->phase,
'progress' => $session->getProgress(),
'message' => $session->statusMessage,
'warnings' => $preflightResult['warnings'],
];
}
@@ -347,11 +329,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,47 +371,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),
'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
@@ -448,60 +415,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);
@@ -551,11 +472,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());
@@ -47,14 +47,12 @@ class TarGzArchiver implements ArchiverInterface
public function close(): void
{
try {
// Compress the .tar to .tar.gz
$this->tar->compress(\Phar::GZ);
} finally {
// Always remove the uncompressed .tar, even if compress() fails
if (is_file($this->tarPath)) {
@unlink($this->tarPath);
}
// Compress the .tar to .tar.gz
$this->tar->compress(\Phar::GZ);
// Remove the uncompressed .tar
if (is_file($this->tarPath)) {
@unlink($this->tarPath);
}
}
@@ -36,7 +36,7 @@ class BackupModel extends AdminModel
$data = $this->getItem();
}
return is_array($data) ? (object) $data : $data;
return $data;
}
public function getTable($name = 'Backup', $prefix = 'Administrator', $options = [])
@@ -61,13 +61,6 @@ class BackupsModel extends ListModel
$query->where($db->quoteName('a.profile_id') . ' = ' . (int) $profileId);
}
// Filter by backup type
$backupType = $this->getState('filter.backup_type');
if (!empty($backupType)) {
$query->where($db->quoteName('a.backup_type') . ' = ' . $db->quote($backupType));
}
// Filter by search
$search = $this->getState('filter.search');
@@ -36,7 +36,7 @@ class ProfileModel extends AdminModel
$data = $this->getItem();
}
return is_array($data) ? (object) $data : $data;
return $data;
}
public function getTable($name = 'Profile', $prefix = 'Administrator', $options = [])
@@ -39,22 +39,11 @@ class BackupTable extends Table
public function delete($pk = null): bool
{
$archivePath = $this->absolute_path;
// Delete DB record first — if this fails, the file is preserved
$result = parent::delete($pk);
if ($result && !empty($archivePath) && is_file($archivePath)) {
@unlink($archivePath);
// Also remove the log file if it exists alongside the archive
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $archivePath);
if (is_file($logPath)) {
@unlink($logPath);
}
// Delete the archive file if it exists
if (!empty($this->absolute_path) && is_file($this->absolute_path)) {
@unlink($this->absolute_path);
}
return $result;
return parent::delete($pk);
}
}
@@ -270,17 +270,10 @@ $listDirn = $this->escape($this->state->get('list.direction'));
if (initResult.error) {
updateProgress(0, 'ERROR: ' + initResult.message, 'failed');
setTimeout(hideModal, 5000);
setTimeout(hideModal, 3000);
return;
}
// Show preflight warnings if any
if (initResult.warnings && initResult.warnings.length > 0) {
var warningEl = document.getElementById('mb-phase');
warningEl.textContent = 'Warnings: ' + initResult.warnings.join('; ');
warningEl.style.color = '#856404';
}
const sessionId = initResult.session_id;
updateProgress(initResult.progress, initResult.message, initResult.phase);
@@ -255,17 +255,10 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
if (initResult.error) {
updateProgress(0, 'ERROR: ' + initResult.message, 'failed');
setTimeout(hideModal, 5000);
setTimeout(hideModal, 3000);
return;
}
// Show preflight warnings if any
if (initResult.warnings && initResult.warnings.length > 0) {
var warningEl = document.getElementById('mb-phase');
warningEl.textContent = 'Warnings: ' + initResult.warnings.join('; ');
warningEl.style.color = '#856404';
}
const sessionId = initResult.session_id;
updateProgress(initResult.progress, initResult.message, initResult.phase);
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="actionlog" method="upgrade">
<name>Action Log - MokoSuiteBackup</name>
<version>01.31.00</version>
<version>01.25.03</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.31.00</version>
<version>01.25.03</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -86,7 +86,7 @@ class RestoreCommand extends AbstractCommand
}
$engine = new RestoreEngine();
$result = $engine->restore($recordId);
$result = $engine->restore($record->absolute_path, $record->backup_type);
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.31.00</version>
<version>01.25.03</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.31.00</version>
<version>01.25.03</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.31.00</version>
<version>01.25.03</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -59,15 +59,11 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
// Reject if disabled or no secret configured
if (!$enabled || $configSecret === '') {
$this->sendJsonResponse(false, 'Web cron is not enabled', 403);
return;
}
// Validate secret (timing-safe comparison)
if (!hash_equals($configSecret, $secret)) {
$this->sendJsonResponse(false, 'Invalid secret', 403);
return;
}
// IP whitelist check (if configured)
@@ -77,8 +73,6 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
if (!in_array($clientIp, $allowedIps, true)) {
$this->sendJsonResponse(false, 'IP not allowed', 403);
return;
}
}
@@ -136,7 +130,6 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
$session->set('mokosuitebackup.last_cleanup', time());
$this->cleanupOldBackups();
$this->cleanupOldSnapshots();
}
/**
@@ -153,93 +146,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();
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteBackup</name>
<version>01.31.00</version>
<version>01.25.03</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoSuiteBackup</name>
<version>01.31.00</version>
<version>01.25.03</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.31.00</version>
<version>01.25.03</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>