Compare commits
150 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29da9776cd | |||
| 09bac755a9 | |||
| f830dc2ddf | |||
| 5698c074da | |||
| aaf189b87a | |||
| 61023821e6 | |||
| 02a6e30db1 | |||
| 5a0cd51df6 | |||
| 12c832d7fe | |||
| 65c8820db4 | |||
| 0f914c3061 | |||
| 4191f44c1b | |||
| fb99afbeba | |||
| de632e9c5c | |||
| 53ff99148c | |||
| c2ff3b272a | |||
| 747b68c179 | |||
| cbff40d04c | |||
| e415e701cd | |||
| d184ed9de0 | |||
| 297f27c807 | |||
| 30e8d7baa9 | |||
| efc5754bef | |||
| e3e422d29e | |||
| 9f5c8c0b5e | |||
| 044e57adf3 | |||
| e7f165ac96 | |||
| fc41e1801a | |||
| 1aa35dd041 | |||
| 6a1f4a8797 | |||
| 6f6a6c705b | |||
| e8d7d1d421 | |||
| cd31617e21 | |||
| 6d9d96d7cd | |||
| df7c07bec4 | |||
| 5b4717bf6f | |||
| 65d30613b2 | |||
| d5bbab7e72 | |||
| 18b65d30ac | |||
| f55b032cc9 | |||
| e62dba8f40 | |||
| 0619825f38 | |||
| 70d7da34b3 | |||
| 13c251196b | |||
| 4841f24eab | |||
| 64ffbb9d61 | |||
| 83e91c6fa6 | |||
| b1833825e7 | |||
| bde20e82ad | |||
| 8348d23fe4 | |||
| d9557489d5 | |||
| 089ec69595 | |||
| 7427cbb043 | |||
| 456e744d81 | |||
| 6d5ef50727 | |||
| 00e7963988 | |||
| bc06657317 | |||
| bda4b0a23d | |||
| e327f9cf5c | |||
| 5b9351e5f0 | |||
| 5785e9fd1e | |||
| 1e9c8d54f4 | |||
| 7515274712 | |||
| 0be459fe34 | |||
| 11ccdbfde4 | |||
| fd517c16f3 | |||
| fe76f81b47 | |||
| 18127454b5 | |||
| 7826c315b1 | |||
| e329dbd99b | |||
| d6b3e8cff0 | |||
| 80c97620a5 | |||
| 33d852bacf | |||
| 8be0500913 | |||
| 27dded6c62 | |||
| e465dfa6ee | |||
| 3ac0318ba3 | |||
| 17e4625448 | |||
| eb748323f7 | |||
| bc3085f74b | |||
| f66100f74f | |||
| be8b1f73bf | |||
| 0f2c4fc238 | |||
| d0fe641d5c | |||
| 4a2520a43b | |||
| 54c3a6e2e9 | |||
| a27ec0f0b9 | |||
| a7c30ad67c | |||
| ee21f7a373 | |||
| 5c0ff72d27 | |||
| 50c016d707 | |||
| e4de103a00 | |||
| 8c66fd3260 | |||
| 4213def0ad | |||
| 8a4ebe1bde | |||
| 8ea09ee0d1 | |||
| d562e0dc10 | |||
| 29c7e974b5 | |||
| 6d47b70aaf | |||
| 01bed8942c | |||
| 391047d8e5 | |||
| 5a672454ad | |||
| ed799217bf | |||
| 5f0f958aca | |||
| 7bf42f1a89 | |||
| a919d52cf7 | |||
| a7e94467ee | |||
| 01335ac70f | |||
| 35b7e2a0b8 | |||
| c72e950a25 | |||
| 5dcba6d8cb | |||
| 0638c2cef6 | |||
| fc0c1b05a6 | |||
| 3547667158 | |||
| b882e8ba90 | |||
| db2beef189 | |||
| b0629f9f30 | |||
| b3d955e1a8 | |||
| f5e8d0fe03 | |||
| 5815a65a39 | |||
| ad1c0cf349 | |||
| 8b6e260b28 | |||
| eb7f48d3a2 | |||
| 974b971340 | |||
| c299798542 | |||
| 612dc4acd5 | |||
| cdb54f6a3e | |||
| 6fbc91527e | |||
| 57bfb37be1 | |||
| 3328d7cf19 | |||
| c410c02487 | |||
| 93879c8118 | |||
| e4329c9fc6 | |||
| 0fa58daa12 | |||
| f8591ed15c | |||
| cbc7004d18 | |||
| a33a585b98 | |||
| 2573ba8599 | |||
| f0d506bbb1 | |||
| a26343a76e | |||
| 9990240d2d | |||
| 418db394a4 | |||
| d939d8c9d7 | |||
| 6383e9b111 | |||
| 2395a4eabc | |||
| 1ec8ec8f6d | |||
| 8df630c529 | |||
| 5c8503e79e | |||
| 3a087d7859 | |||
| 58d3b812a7 |
@@ -30,6 +30,15 @@ on:
|
||||
types: [opened, closed]
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '.mokogitea/workflows/**'
|
||||
- '*.md'
|
||||
- 'wiki/**'
|
||||
- '.editorconfig'
|
||||
- '.gitignore'
|
||||
- '.gitattributes'
|
||||
- '.gitmessage'
|
||||
- 'LICENSE'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
action:
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# VERSION: 01.27.00
|
||||
# INGROUP: mokocli.Automation
|
||||
# VERSION: 01.39.00
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
+6
-6
@@ -1,14 +1,14 @@
|
||||
# Changelog
|
||||
## [Unreleased]
|
||||
|
||||
## [01.27.00] --- 2026-06-21
|
||||
## [01.39.00] --- 2026-06-23
|
||||
|
||||
## [01.27.00] --- 2026-06-21
|
||||
## [01.39.00] --- 2026-06-23
|
||||
|
||||
## [01.26.00] --- 2026-06-21
|
||||
## [01.38.05] --- 2026-06-23
|
||||
|
||||
## [01.26.00] --- 2026-06-21
|
||||
## [01.38.05] --- 2026-06-23
|
||||
|
||||
## [01.25.00] --- 2026-06-20
|
||||
## [01.38.04] --- 2026-06-23
|
||||
|
||||
## [01.25.00] --- 2026-06-20
|
||||
## [01.38.04] --- 2026-06-23
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
# 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,6 +1,6 @@
|
||||
# MokoSuiteBackup
|
||||
|
||||
<!-- VERSION: 01.27.00 -->
|
||||
<!-- VERSION: 01.39.00 -->
|
||||
|
||||
Full-site backup and restore for Joomla — database, files, and configuration.
|
||||
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,11 +0,0 @@
|
||||
<?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;
|
||||
@@ -1,31 +0,0 @@
|
||||
<?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.27.00</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,11 +121,28 @@ class BackupsController extends ApiController
|
||||
|
||||
$data = [];
|
||||
|
||||
// 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',
|
||||
];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$safe = clone $item;
|
||||
|
||||
foreach ($sensitiveFields as $field) {
|
||||
if (isset($safe->$field) && $safe->$field !== '') {
|
||||
$safe->$field = '***';
|
||||
}
|
||||
}
|
||||
|
||||
$data[] = [
|
||||
'type' => 'profiles',
|
||||
'id' => $item->id,
|
||||
'attributes' => $item,
|
||||
'id' => $safe->id,
|
||||
'attributes' => $safe,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
<?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,6 +118,27 @@
|
||||
/>
|
||||
</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,6 +19,18 @@
|
||||
<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">
|
||||
|
||||
@@ -72,23 +72,25 @@
|
||||
/>
|
||||
<field
|
||||
name="archive_name_format"
|
||||
type="text"
|
||||
type="PlaceholderText"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC"
|
||||
default="[host]_[datetime]_profile[profile_id]"
|
||||
default="[HOST]_[DATETIME]_profile[PROFILE_ID]"
|
||||
maxlength="512"
|
||||
hint="[host]_[datetime]_profile[profile_id]"
|
||||
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"
|
||||
type="radio"
|
||||
type="list"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
<option value="0">COM_MOKOJOOMBACKUP_MOKORESTORE_NONE</option>
|
||||
<option value="1">COM_MOKOJOOMBACKUP_MOKORESTORE_WRAPPED</option>
|
||||
<option value="standalone">COM_MOKOJOOMBACKUP_MOKORESTORE_STANDALONE</option>
|
||||
</field>
|
||||
<field
|
||||
name="encryption_password"
|
||||
@@ -99,6 +101,54 @@
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="sanitization" label="COM_MOKOJOOMBACKUP_FIELDSET_SANITIZATION">
|
||||
<field
|
||||
name="sanitize_passwords"
|
||||
type="radio"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS_DESC"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="preserve_super_admin"
|
||||
type="radio"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
showon="sanitize_passwords:1"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="sanitize_emails"
|
||||
type="radio"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS_DESC"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="sanitize_sessions"
|
||||
type="radio"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="sidebar" label="COM_MOKOJOOMBACKUP_FIELDSET_STATUS">
|
||||
<field
|
||||
name="id"
|
||||
@@ -159,7 +209,7 @@
|
||||
default="none"
|
||||
>
|
||||
<option value="none">COM_MOKOJOOMBACKUP_REMOTE_NONE</option>
|
||||
<option value="ftp">COM_MOKOJOOMBACKUP_REMOTE_FTP</option>
|
||||
<option value="sftp">COM_MOKOJOOMBACKUP_REMOTE_SFTP</option>
|
||||
<option value="google_drive">COM_MOKOJOOMBACKUP_REMOTE_GDRIVE</option>
|
||||
<option value="s3">COM_MOKOJOOMBACKUP_REMOTE_S3</option>
|
||||
</field>
|
||||
@@ -174,6 +224,80 @@
|
||||
<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,6 +33,12 @@ 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"
|
||||
@@ -44,6 +50,22 @@ 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"
|
||||
@@ -56,6 +78,12 @@ COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found."
|
||||
COM_MOKOJOOMBACKUP_PROFILE_NEW="New Profile"
|
||||
COM_MOKOJOOMBACKUP_PROFILE_EDIT="Edit Profile"
|
||||
|
||||
; Profile actions
|
||||
COM_MOKOJOOMBACKUP_RUN_BACKUP="Run"
|
||||
COM_MOKOJOOMBACKUP_RUN_BACKUP_NOW="Run Backup Now"
|
||||
COM_MOKOJOOMBACKUP_VIEW_BACKUPS="View Backups"
|
||||
COM_MOKOJOOMBACKUP_HEADING_BACKUPS="Backups"
|
||||
|
||||
; Table headings
|
||||
COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION="Description"
|
||||
COM_MOKOJOOMBACKUP_HEADING_PROFILE="Profile"
|
||||
@@ -102,11 +130,25 @@ COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD_DESC="Set a password to encrypt the
|
||||
COM_MOKOJOOMBACKUP_FIELD_SPLIT_SIZE="Split Size (MB)"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SPLIT_SIZE_DESC="Split archive into parts of this size in MB. 0 = no splitting."
|
||||
COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR="Backup Directory"
|
||||
COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR_DESC="Directory where backup archives are stored. Supports placeholders: [HOME] (user home directory), [host], [date], [year], [month], [day], [profile_name], [site_name], [type]. Use [HOME]/backups to store outside the web root. Absolute paths (starting with /) are used as-is; relative paths resolve from the Joomla root."
|
||||
COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR_DESC="Directory where backup archives are stored. Supports placeholders: [HOME] (user home directory), [HOST], [DATE], [YEAR], [MONTH], [DAY], [PROFILE_NAME], [SITE_NAME], [TYPE]. Use [HOME]/backups to store outside the web root. Absolute paths (starting with /) are used as-is; relative paths resolve from the Joomla root."
|
||||
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT="Archive Name Format"
|
||||
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC="Filename template for backup archives (without extension). Placeholders: [host] hostname, [date] Ymd, [time] His, [datetime] Ymd_His, [year] [month] [day] [hour] [minute] [second], [profile_id], [profile_name], [site_name], [type], [random]."
|
||||
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE="Include Restore Script"
|
||||
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="Include MokoRestore (standalone restore.php) inside the backup archive. Creates a self-contained package that can restore the site on a blank server without Joomla installed."
|
||||
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC="Filename template for backup archives (without extension). Placeholders: [HOST] hostname, [DATE] Ymd, [TIME] His, [DATETIME] Ymd_His, [YEAR] [MONTH] [DAY] [HOUR] [MINUTE] [SECOND], [PROFILE_ID], [PROFILE_NAME], [SITE_NAME], [TYPE], [RANDOM]."
|
||||
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE="MokoRestore Script"
|
||||
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="Include the MokoRestore standalone restore wizard. 'Wrapped' bundles it inside the backup ZIP. 'Standalone' generates a separate restore.php that scans for backup ZIPs in its directory — ideal for remote servers."
|
||||
COM_MOKOJOOMBACKUP_MOKORESTORE_NONE="None"
|
||||
COM_MOKOJOOMBACKUP_MOKORESTORE_WRAPPED="Wrapped (inside backup ZIP)"
|
||||
COM_MOKOJOOMBACKUP_MOKORESTORE_STANDALONE="Standalone (separate restore.php)"
|
||||
|
||||
; Data Sanitization
|
||||
COM_MOKOJOOMBACKUP_FIELDSET_SANITIZATION="Data Sanitization"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS="Sanitize User Passwords"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS_DESC="Replace all user password hashes with an invalid value. Users will not be able to log in with the restored backup without resetting their password. Ideal for sharing backups, creating demo/staging sites, or GDPR compliance."
|
||||
COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN="Preserve Super Admin Password"
|
||||
COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN_DESC="Keep the password for Super Users (group ID 8) intact. You will still be able to log in as a Super Admin after restoring."
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS="Sanitize User Emails"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS_DESC="Replace all user email addresses with dummy values (user123@sanitized.example.com). Prevents accidental emails being sent to real users from a cloned/staging site. Super admin emails are preserved if 'Preserve Super Admin' is enabled."
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS="Clear Session Data"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS_DESC="Exclude active session data from the backup. This logs out all users and prevents session hijacking when the backup is restored on another server. Enabled by default."
|
||||
|
||||
; Exclusion filter fields
|
||||
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS="Exclude Directories"
|
||||
@@ -167,6 +209,7 @@ 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"
|
||||
@@ -219,7 +262,35 @@ 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."
|
||||
@@ -268,6 +339,13 @@ 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"
|
||||
@@ -357,6 +435,20 @@ COM_MOKOJOOMBACKUP_WEBCRON_IP_NONE="No IP restrictions — any IP can trigger we
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_IP_PLACEHOLDER="Enter IP address"
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_IP_ADD="Add"
|
||||
|
||||
; Snapshot browse / detail view
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE="Browse Snapshot"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_ARTICLES="Articles"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_CATEGORIES="Categories"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_MODULES="Modules"
|
||||
COM_MOKOJOOMBACKUP_HEADING_STATE="State"
|
||||
COM_MOKOJOOMBACKUP_HEADING_POSITION="Position"
|
||||
COM_MOKOJOOMBACKUP_HEADING_MODULE_TYPE="Module Type"
|
||||
COM_MOKOJOOMBACKUP_HEADING_LEVEL="Level"
|
||||
COM_MOKOJOOMBACKUP_LOADING="Loading..."
|
||||
COM_MOKOJOOMBACKUP_SELECT_ALL="Select All"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED="Restore Selected"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_NO_ARTICLES_SELECTED="No articles selected for restore."
|
||||
|
||||
; Errors
|
||||
COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND="Backup archive file not found or has been deleted."
|
||||
COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED="No backup record selected for restore."
|
||||
|
||||
@@ -35,6 +35,10 @@ COM_MOKOJOOMBACKUP_PROFILES_TITLE="Backup Profiles"
|
||||
COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW="Backup Now"
|
||||
COM_MOKOJOOMBACKUP_NO_BACKUPS="No backups found. Click 'Backup Now' to create your first backup."
|
||||
COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found."
|
||||
COM_MOKOJOOMBACKUP_RUN_BACKUP="Run"
|
||||
COM_MOKOJOOMBACKUP_RUN_BACKUP_NOW="Run Backup Now"
|
||||
COM_MOKOJOOMBACKUP_VIEW_BACKUPS="View Backups"
|
||||
COM_MOKOJOOMBACKUP_HEADING_BACKUPS="Backups"
|
||||
COM_MOKOJOOMBACKUP_UPDATE_SITE_NOTICE="To receive automatic updates, configure your <a href=\"%s\">Update Site</a> with your download key."
|
||||
COM_MOKOJOOMBACKUP_UPDATE_SITE_MISSING="MokoSuiteBackup update site not found. Reinstall the package to register the update server."
|
||||
COM_MOKOJOOMBACKUP_POSTINSTALL_UPDATE_SITE="MokoSuiteBackup installed successfully. Configure your <a href=\"%s\">Update Site</a> to receive automatic updates."
|
||||
@@ -77,6 +81,22 @@ 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.27.00</version>
|
||||
<version>01.39.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
|
||||
`compression_level` TINYINT(1) UNSIGNED NOT NULL DEFAULT 5 COMMENT '0=none, 9=max',
|
||||
`split_size` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=no split, otherwise MB per part',
|
||||
`backup_dir` VARCHAR(512) NOT NULL DEFAULT '[DEFAULT_DIR]',
|
||||
`archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders',
|
||||
`archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[HOST]_[DATETIME]_profile[PROFILE_ID]' COMMENT 'Filename format with placeholders',
|
||||
`exclude_dirs` TEXT NOT NULL COMMENT 'Newline-separated directory paths to exclude',
|
||||
`exclude_files` TEXT NOT NULL COMMENT 'Newline-separated filename patterns to exclude',
|
||||
`exclude_tables` TEXT NOT NULL COMMENT 'Newline-separated table names to exclude',
|
||||
@@ -19,6 +19,14 @@ 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 '',
|
||||
@@ -31,7 +39,11 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
|
||||
`s3_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
|
||||
`remote_keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload',
|
||||
`encryption_password` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'AES-256 archive encryption password (blank = no encryption)',
|
||||
`include_mokorestore` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include MokoRestore standalone restore script in archive',
|
||||
`include_mokorestore` VARCHAR(20) NOT NULL DEFAULT '0' COMMENT 'MokoRestore mode: 0=none, 1=wrapped, standalone',
|
||||
`sanitize_passwords` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Replace user password hashes with invalid value',
|
||||
`preserve_super_admin` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep super admin password when sanitizing',
|
||||
`sanitize_emails` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Replace user emails with dummy values',
|
||||
`sanitize_sessions` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Skip session table data',
|
||||
`notify_email` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Comma-separated notification emails',
|
||||
`notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs',
|
||||
`notify_on_success` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
|
||||
@@ -9,4 +9,4 @@ ALTER TABLE `#__mokosuitebackup_records` MODIFY `log` MEDIUMTEXT DEFAULT NULL;
|
||||
ALTER TABLE `#__mokosuitebackup_profiles` ADD COLUMN `notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs' AFTER `notify_email`;
|
||||
|
||||
-- Add archive_name_format column with placeholder support
|
||||
ALTER TABLE `#__mokosuitebackup_profiles` ADD COLUMN `archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders' AFTER `backup_dir`;
|
||||
ALTER TABLE `#__mokosuitebackup_profiles` ADD COLUMN `archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[HOST]_[DATETIME]_profile[PROFILE_ID]' COMMENT 'Filename format with placeholders' AFTER `backup_dir`;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 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`;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 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`;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- MokoSuiteBackup 01.39.00 — Change include_mokorestore from TINYINT to VARCHAR
|
||||
-- Needed to support 'standalone' value alongside 0/1
|
||||
|
||||
ALTER TABLE `#__mokosuitebackup_profiles`
|
||||
MODIFY COLUMN `include_mokorestore` VARCHAR(20) NOT NULL DEFAULT '0';
|
||||
@@ -0,0 +1,34 @@
|
||||
-- MokoSuiteBackup 01.39.01 — Uppercase all placeholders in profile data
|
||||
|
||||
UPDATE `#__mokosuitebackup_profiles` SET
|
||||
`archive_name_format` = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
|
||||
`archive_name_format`,
|
||||
'[host]', '[HOST]'),
|
||||
'[site_name]', '[SITE_NAME]'),
|
||||
'[datetime]', '[DATETIME]'),
|
||||
'[date]', '[DATE]'),
|
||||
'[time]', '[TIME]'),
|
||||
'[year]', '[YEAR]'),
|
||||
'[month]', '[MONTH]'),
|
||||
'[day]', '[DAY]'),
|
||||
'[hour]', '[HOUR]'),
|
||||
'[minute]', '[MINUTE]'),
|
||||
'[second]', '[SECOND]'),
|
||||
'[profile_id]', '[PROFILE_ID]'),
|
||||
'[profile_name]', '[PROFILE_NAME]'),
|
||||
'[type]', '[TYPE]'),
|
||||
'[random]', '[RANDOM]')
|
||||
WHERE `archive_name_format` REGEXP '\\[[a-z]';
|
||||
|
||||
UPDATE `#__mokosuitebackup_profiles` SET
|
||||
`backup_dir` = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
|
||||
`backup_dir`,
|
||||
'[host]', '[HOST]'),
|
||||
'[site_name]', '[SITE_NAME]'),
|
||||
'[date]', '[DATE]'),
|
||||
'[year]', '[YEAR]'),
|
||||
'[month]', '[MONTH]'),
|
||||
'[day]', '[DAY]'),
|
||||
'[profile_id]', '[PROFILE_ID]'),
|
||||
'[profile_name]', '[PROFILE_NAME]')
|
||||
WHERE `backup_dir` REGEXP '\\[[a-z]';
|
||||
@@ -0,0 +1,7 @@
|
||||
-- MokoSuiteBackup 01.39.02 — Data sanitization columns
|
||||
|
||||
ALTER TABLE `#__mokosuitebackup_profiles`
|
||||
ADD COLUMN `sanitize_passwords` TINYINT(1) NOT NULL DEFAULT 0 AFTER `include_mokorestore`,
|
||||
ADD COLUMN `preserve_super_admin` TINYINT(1) NOT NULL DEFAULT 1 AFTER `sanitize_passwords`,
|
||||
ADD COLUMN `sanitize_emails` TINYINT(1) NOT NULL DEFAULT 0 AFTER `preserve_super_admin`,
|
||||
ADD COLUMN `sanitize_sessions` TINYINT(1) NOT NULL DEFAULT 1 AFTER `sanitize_emails`;
|
||||
@@ -15,9 +15,12 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\PlaceholderResolver;
|
||||
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
|
||||
@@ -282,7 +285,32 @@ class AjaxController extends BaseController
|
||||
return;
|
||||
}
|
||||
|
||||
$resolved = BackupDirectory::resolve($rawPath);
|
||||
/* Resolve all placeholders — both directory ([HOME], [DEFAULT_DIR])
|
||||
and name-level ([SITE_NAME], [HOST], [PROFILE_ID], etc.) */
|
||||
$profileId = $this->input->getInt('profile_id', 0);
|
||||
|
||||
if ($profileId > 0) {
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
||||
->where($db->quoteName('id') . ' = ' . $profileId);
|
||||
$db->setQuery($query);
|
||||
$profile = $db->loadObject();
|
||||
}
|
||||
|
||||
if (empty($profile)) {
|
||||
/* No profile context — create a minimal dummy for PlaceholderResolver */
|
||||
$profile = (object) [
|
||||
'id' => 1,
|
||||
'title' => 'default',
|
||||
'backup_type' => 'full',
|
||||
];
|
||||
}
|
||||
|
||||
$resolver = new PlaceholderResolver($profile);
|
||||
$withNamePlaceholders = $resolver->resolve($rawPath);
|
||||
$resolved = BackupDirectory::resolve($withNamePlaceholders);
|
||||
|
||||
if (BackupDirectory::hasPlaceholders($resolved)) {
|
||||
$this->sendJson([
|
||||
@@ -308,6 +336,498 @@ 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) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid snapshot data']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tables = $data['tables'] ?? [];
|
||||
|
||||
// Articles
|
||||
$articles = [];
|
||||
|
||||
if (!empty($tables['#__content'])) {
|
||||
foreach ($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'] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Categories
|
||||
$categories = [];
|
||||
|
||||
if (!empty($tables['#__categories'])) {
|
||||
foreach ($tables['#__categories'] as $row) {
|
||||
$categories[] = [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'title' => $row['title'] ?? '',
|
||||
'extension' => $row['extension'] ?? '',
|
||||
'published' => (int) ($row['published'] ?? 0),
|
||||
'level' => (int) ($row['level'] ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Modules
|
||||
$modules = [];
|
||||
|
||||
if (!empty($tables['#__modules'])) {
|
||||
foreach ($tables['#__modules'] as $row) {
|
||||
$modules[] = [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'title' => $row['title'] ?? '',
|
||||
'module' => $row['module'] ?? '',
|
||||
'position' => $row['position'] ?? '',
|
||||
'published' => (int) ($row['published'] ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$this->sendJson([
|
||||
'error' => false,
|
||||
'articles' => $articles,
|
||||
'categories' => $categories,
|
||||
'modules' => $modules,
|
||||
'total_articles' => \count($articles),
|
||||
'total_categories' => \count($categories),
|
||||
'total_modules' => \count($modules),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -15,6 +15,7 @@ defined('_JEXEC') or die;
|
||||
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\BackupEngine;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\RestoreEngine;
|
||||
|
||||
@@ -34,7 +35,14 @@ class BackupsController extends AdminController
|
||||
*/
|
||||
public function start(): void
|
||||
{
|
||||
$this->checkToken();
|
||||
/* Accept token from both GET (profile Run button) and POST (backup form).
|
||||
Joomla's checkToken() throws on failure, so try GET first. */
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$this->setMessage(Text::_('JINVALID_TOKEN_NOTICE'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
|
||||
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
||||
|
||||
@@ -16,6 +16,7 @@ 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;
|
||||
|
||||
@@ -106,6 +107,151 @@ 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.
|
||||
*/
|
||||
|
||||
@@ -360,16 +360,12 @@ class AkeebaImporter
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Try JSON
|
||||
// Parse as JSON only — unserialize is an object injection risk
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
if (!is_array($data)) {
|
||||
// Try unserialize (older Akeeba versions)
|
||||
$data = @unserialize($raw);
|
||||
|
||||
if (!is_array($data)) {
|
||||
return $result;
|
||||
}
|
||||
// Older Akeeba versions used serialized PHP — skip rather than risk object injection
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Extract directory exclusions
|
||||
|
||||
@@ -85,9 +85,10 @@ class BackupEngine
|
||||
$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]';
|
||||
$nameFormat = $profile->archive_name_format ?? '[HOST]_[DATETIME]_profile[PROFILE_ID]';
|
||||
$archiveName = $resolver->resolve($nameFormat) . '.' . $archiveExt;
|
||||
|
||||
if (empty($description)) {
|
||||
@@ -130,12 +131,27 @@ 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...');
|
||||
$dumper = new DatabaseDumper($excludeTables);
|
||||
$sqlDump = $dumper->dump();
|
||||
$archiver->addFromString('database.sql', $sqlDump);
|
||||
$dbSize = strlen($sqlDump);
|
||||
$sqlTempFile = $this->backupDir . '/.database-' . $tag . '.sql';
|
||||
$sanitizePasswords = (bool) ($profile->sanitize_passwords ?? false);
|
||||
$preserveSuperAdmin = (bool) ($profile->preserve_super_admin ?? false);
|
||||
$sanitizeEmails = (bool) ($profile->sanitize_emails ?? false);
|
||||
$sanitizeSessions = (bool) ($profile->sanitize_sessions ?? true);
|
||||
$dumper = new DatabaseDumper($excludeTables, $sanitizePasswords, $preserveSuperAdmin, $sanitizeEmails, $sanitizeSessions);
|
||||
|
||||
if ($sanitizePasswords) {
|
||||
$this->log('User passwords will be sanitized' . ($preserveSuperAdmin ? ' (super admin preserved)' : ''));
|
||||
}
|
||||
|
||||
if ($sanitizeEmails) {
|
||||
$this->log('User emails will be sanitized');
|
||||
}
|
||||
$dbSize = $dumper->dumpToFile($sqlTempFile);
|
||||
$archiver->addFile($sqlTempFile, 'database.sql');
|
||||
$tablesCount = $dumper->getTablesCount();
|
||||
$this->log('Database dump complete: ' . $tablesCount . ' tables, ' . number_format($dbSize) . ' bytes');
|
||||
}
|
||||
@@ -203,6 +219,11 @@ 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 ?? '';
|
||||
|
||||
@@ -223,49 +244,82 @@ class BackupEngine
|
||||
$this->log('Archive created: ' . $sizeHuman);
|
||||
$this->log('SHA-256: ' . ($checksum ?: 'N/A'));
|
||||
|
||||
// Step 2.5: Wrap with MokoRestore script (if enabled)
|
||||
$includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
|
||||
// Verify archive integrity
|
||||
$this->log('Verifying archive integrity...');
|
||||
$this->verifyArchive($archivePath, $profile->backup_type);
|
||||
$this->log('Archive integrity verified');
|
||||
|
||||
if ($includeMokoRestore) {
|
||||
// Step 2.5: MokoRestore script (if enabled)
|
||||
$mokoRestoreMode = $profile->include_mokorestore ?? '0';
|
||||
$restoreScriptPath = '';
|
||||
|
||||
if ($mokoRestoreMode === '1') {
|
||||
// Wrapped mode: backup ZIP inside an outer ZIP with restore.php
|
||||
$this->log('Wrapping with MokoRestore script...');
|
||||
$mokoRestoreName = str_replace('.zip', '-mokorestore.zip', $archiveName);
|
||||
$mokoRestorePath = $this->backupDir . '/' . $mokoRestoreName;
|
||||
MokoRestore::wrap($archivePath, $mokoRestorePath);
|
||||
|
||||
// Replace the original archive with the wrapped one
|
||||
if (is_file($archivePath) && !unlink($archivePath)) {
|
||||
$this->log('WARNING: Could not remove pre-wrap archive');
|
||||
}
|
||||
rename($mokoRestorePath, $archivePath);
|
||||
$totalSize = filesize($archivePath);
|
||||
$sizeHuman = number_format($totalSize / 1048576, 2) . ' MB';
|
||||
// Recompute checksum for the final wrapped archive
|
||||
$checksum = hash_file('sha256', $archivePath);
|
||||
$this->log('MokoRestore archive created: ' . $sizeHuman);
|
||||
$this->log('SHA-256 (wrapped): ' . $checksum);
|
||||
} elseif ($mokoRestoreMode === 'standalone') {
|
||||
// Standalone mode: restore.php as a separate file next to the backup ZIP
|
||||
$this->log('Generating standalone restore.php...');
|
||||
$restoreScriptPath = $this->backupDir . '/restore.php';
|
||||
MokoRestore::generateStandalone($restoreScriptPath);
|
||||
$this->log('Standalone restore.php generated (' . number_format(filesize($restoreScriptPath)) . ' bytes)');
|
||||
}
|
||||
|
||||
$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') {
|
||||
$this->log('Starting remote upload (' . $remoteStorage . ')...');
|
||||
$uploader = $this->createUploader($remoteStorage, $profile);
|
||||
$uploadResult = $uploader->upload($archivePath, $archiveName);
|
||||
try {
|
||||
$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)');
|
||||
// Upload standalone restore.php alongside the backup if in standalone mode
|
||||
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
|
||||
$this->log('Uploading standalone restore.php...');
|
||||
$restoreUpload = $uploader->upload($restoreScriptPath, 'restore.php');
|
||||
|
||||
if ($restoreUpload['success']) {
|
||||
$this->log('Standalone restore.php uploaded');
|
||||
} else {
|
||||
$this->log('WARNING: restore.php upload failed: ' . $restoreUpload['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.');
|
||||
}
|
||||
} else {
|
||||
$this->log('WARNING: Remote upload failed: ' . $uploadResult['message']);
|
||||
} catch (\Throwable $e) {
|
||||
$uploadFailed = true;
|
||||
$this->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
|
||||
$this->log('Local backup is preserved.');
|
||||
}
|
||||
}
|
||||
@@ -300,9 +354,14 @@ class BackupEngine
|
||||
|
||||
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
|
||||
|
||||
// Send success notification
|
||||
// Send success notification (backup completed, even if upload failed)
|
||||
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);
|
||||
|
||||
@@ -315,6 +374,17 @@ class BackupEngine
|
||||
} 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',
|
||||
@@ -402,7 +472,7 @@ class BackupEngine
|
||||
return match ($format) {
|
||||
'zip' => new ZipArchiver(),
|
||||
'tar.gz' => new TarGzArchiver(),
|
||||
default => new ZipArchiver(),
|
||||
default => throw new \InvalidArgumentException('Unknown archive format: ' . $format),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -413,6 +483,7 @@ 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),
|
||||
@@ -483,6 +554,90 @@ 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.
|
||||
*/
|
||||
|
||||
@@ -27,12 +27,35 @@ class DatabaseDumper
|
||||
|
||||
private int $tablesCount = 0;
|
||||
|
||||
/** @var bool Whether to sanitize user passwords */
|
||||
private bool $sanitizePasswords = false;
|
||||
|
||||
/** @var bool Whether to preserve super admin password when sanitizing */
|
||||
private bool $preserveSuperAdmin = false;
|
||||
|
||||
/** @var bool Whether to sanitize user emails */
|
||||
private bool $sanitizeEmails = false;
|
||||
|
||||
/** @var bool Whether to clear session data */
|
||||
private bool $sanitizeSessions = false;
|
||||
|
||||
/** Known invalid bcrypt hash used for sanitized passwords */
|
||||
private const SANITIZED_HASH = '$2y$10$SANITIZED.MOKOSUITEBACKUP.INVALID.HASH.DO.NOT.USE.000000';
|
||||
|
||||
/**
|
||||
* @param array $excludeTables Table names to exclude (with #__ prefix).
|
||||
* Supports suffixes: :data-only, :structure-only.
|
||||
* No suffix = exclude both (backward compatible).
|
||||
* @param array $excludeTables Table names to exclude (with #__ prefix).
|
||||
* @param bool $sanitizePasswords Replace user password hashes with invalid value
|
||||
* @param bool $preserveSuperAdmin Keep super admin password when sanitizing
|
||||
* @param bool $sanitizeEmails Replace user emails with sanitized placeholders
|
||||
* @param bool $sanitizeSessions Skip session table data entirely
|
||||
*/
|
||||
public function __construct(array $excludeTables = [])
|
||||
public function __construct(
|
||||
array $excludeTables = [],
|
||||
bool $sanitizePasswords = false,
|
||||
bool $preserveSuperAdmin = false,
|
||||
bool $sanitizeEmails = false,
|
||||
bool $sanitizeSessions = false
|
||||
)
|
||||
{
|
||||
foreach ($excludeTables as $entry) {
|
||||
if (str_ends_with($entry, ':data-only')) {
|
||||
@@ -43,6 +66,16 @@ class DatabaseDumper
|
||||
$this->excludeBoth[] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
$this->sanitizePasswords = $sanitizePasswords;
|
||||
$this->preserveSuperAdmin = $preserveSuperAdmin;
|
||||
$this->sanitizeEmails = $sanitizeEmails;
|
||||
$this->sanitizeSessions = $sanitizeSessions;
|
||||
|
||||
/* If session sanitization is on, auto-exclude session table data */
|
||||
if ($sanitizeSessions) {
|
||||
$this->excludeDataOnly[] = '#__session';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,6 +187,7 @@ class DatabaseDumper
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$this->sanitizeRow($row, $abstractName, $db);
|
||||
$values = [];
|
||||
|
||||
foreach ($row as $value) {
|
||||
@@ -219,6 +253,219 @@ 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) {
|
||||
$this->sanitizeRow($row, $abstractName, $db);
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a row if it belongs to the users table and sanitization is enabled.
|
||||
*
|
||||
* Replaces the password column with an invalid hash so the backup
|
||||
* cannot be used to extract user credentials.
|
||||
*/
|
||||
private function sanitizeRow(array &$row, string $abstractTable, object $db): void
|
||||
{
|
||||
if ($abstractTable !== '#__users') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->sanitizePasswords && !$this->sanitizeEmails) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->sanitizeEmails && isset($row['email']) && isset($row['id'])) {
|
||||
$userId = (int) $row['id'];
|
||||
|
||||
/* Preserve super admin emails if preserving super admin */
|
||||
if (!$this->preserveSuperAdmin || !$this->isSuperAdmin($userId, $db)) {
|
||||
$row['email'] = 'user' . $userId . '@sanitized.example.com';
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->sanitizePasswords || !isset($row['password'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->preserveSuperAdmin && isset($row['id'])) {
|
||||
if ($this->isSuperAdmin((int) $row['id'], $db)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$row['password'] = self::SANITIZED_HASH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user ID belongs to the Super Users group (group_id = 8).
|
||||
*/
|
||||
private function isSuperAdmin(int $userId, object $db): bool
|
||||
{
|
||||
static $superAdminIds = null;
|
||||
|
||||
if ($superAdminIds === null) {
|
||||
$prefix = $db->getPrefix();
|
||||
|
||||
try {
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('DISTINCT ' . $db->quoteName('user_id'))
|
||||
->from($db->quoteName($prefix . 'user_usergroup_map'))
|
||||
->where($db->quoteName('group_id') . ' = 8')
|
||||
);
|
||||
$superAdminIds = array_map('intval', $db->loadColumn() ?: []);
|
||||
} catch (\Throwable $e) {
|
||||
$superAdminIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
return in_array($userId, $superAdminIds, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if passwords were sanitized (for use by callers to log the action).
|
||||
*/
|
||||
public function isPasswordSanitizationEnabled(): bool
|
||||
{
|
||||
return $this->sanitizePasswords;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sentinel hash used for sanitized passwords.
|
||||
*/
|
||||
public static function getSanitizedHash(): string
|
||||
{
|
||||
return self::SANITIZED_HASH;
|
||||
}
|
||||
|
||||
public function getTablesCount(): int
|
||||
{
|
||||
return $this->tablesCount;
|
||||
|
||||
@@ -206,6 +206,11 @@ 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;
|
||||
@@ -228,6 +233,24 @@ 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)) {
|
||||
|
||||
@@ -54,6 +54,191 @@ class MokoRestore
|
||||
return $outputPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the standalone restore.php script as a separate file.
|
||||
*
|
||||
* Unlike the wrapped version, this script scans its own directory
|
||||
* for ZIP files and lets the user choose which one to restore from.
|
||||
*
|
||||
* @param string $outputPath Where to write restore.php
|
||||
*
|
||||
* @return string Path to the generated script
|
||||
*/
|
||||
public static function generateStandalone(string $outputPath): string
|
||||
{
|
||||
$script = self::generateStandaloneScript();
|
||||
|
||||
if (file_put_contents($outputPath, $script) === false) {
|
||||
throw new \RuntimeException('Cannot write standalone restore script: ' . $outputPath);
|
||||
}
|
||||
|
||||
return $outputPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the standalone script content that scans for ZIPs.
|
||||
*/
|
||||
private static function generateStandaloneScript(): string
|
||||
{
|
||||
/* Take the normal backend but replace the hardcoded BACKUP_FILE
|
||||
with a directory scanner that finds ZIP files */
|
||||
$php = self::generateBackend();
|
||||
|
||||
/* Replace the fixed BACKUP_FILE constant with dynamic scanner */
|
||||
$php = str_replace(
|
||||
"define('BACKUP_FILE', RESTORE_DIR . '/site-backup.zip');",
|
||||
"/* BACKUP_FILE is set dynamically — see actionSelectBackup() below */\n" .
|
||||
"define('BACKUP_FILE', ''); /* placeholder — overridden per request */",
|
||||
$php
|
||||
);
|
||||
|
||||
/* Inject the backup scanner function after the constants */
|
||||
$scannerCode = <<<'SCANNER'
|
||||
|
||||
/**
|
||||
* Scan the restore directory for ZIP files that look like backups.
|
||||
*/
|
||||
function scanForBackups(): array
|
||||
{
|
||||
$dir = RESTORE_DIR;
|
||||
$files = [];
|
||||
|
||||
foreach (glob($dir . '/*.zip') as $path) {
|
||||
$name = basename($path);
|
||||
|
||||
/* Skip the restore script wrapper if present */
|
||||
if ($name === 'restore.php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files[] = [
|
||||
'name' => $name,
|
||||
'path' => $path,
|
||||
'size' => filesize($path),
|
||||
'date' => date('Y-m-d H:i:s', filemtime($path)),
|
||||
];
|
||||
}
|
||||
|
||||
/* Sort by modification time, newest first */
|
||||
usort($files, fn($a, $b) => filemtime($b['path']) <=> filemtime($a['path']));
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle backup file selection and set the working file.
|
||||
*/
|
||||
function getSelectedBackupFile(): string
|
||||
{
|
||||
if (!empty($_POST['backup_file'])) {
|
||||
$selected = basename($_POST['backup_file']); /* sanitize — basename only */
|
||||
$path = RESTORE_DIR . '/' . $selected;
|
||||
|
||||
if (is_file($path) && str_ends_with(strtolower($selected), '.zip')) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
/* Auto-select if only one ZIP exists */
|
||||
$backups = scanForBackups();
|
||||
|
||||
if (count($backups) === 1) {
|
||||
return $backups[0]['path'];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
SCANNER;
|
||||
|
||||
/* Insert scanner after the opening PHP section but before the action handlers */
|
||||
$php = str_replace(
|
||||
"/* ── Action Handlers",
|
||||
$scannerCode . "\n/* ── Action Handlers",
|
||||
$php
|
||||
);
|
||||
|
||||
/* Modify actionExtract to use getSelectedBackupFile() instead of BACKUP_FILE */
|
||||
$php = str_replace(
|
||||
'$zip->open(BACKUP_FILE)',
|
||||
'$zip->open(getSelectedBackupFile() ?: BACKUP_FILE)',
|
||||
$php
|
||||
);
|
||||
|
||||
/* Modify the pre-checks to use getSelectedBackupFile() */
|
||||
$php = str_replace(
|
||||
"file_exists(BACKUP_FILE)",
|
||||
"(getSelectedBackupFile() !== '' || file_exists(BACKUP_FILE))",
|
||||
$php
|
||||
);
|
||||
|
||||
$html = self::generateFrontend();
|
||||
|
||||
/* Add backup file selector to the frontend before the extract step */
|
||||
$selectorHtml = <<<'SELECTOR'
|
||||
<!-- Backup File Selector (standalone mode) -->
|
||||
<div id="mr-step-select" class="mr-step" style="display:none;">
|
||||
<h2 class="mr-step-title">Select Backup File</h2>
|
||||
<p class="mr-desc">Choose which backup archive to restore from.</p>
|
||||
<div id="mr-backup-list"></div>
|
||||
<input type="hidden" name="backup_file" id="mr-backup-file" value="">
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var backups = <?php echo json_encode(scanForBackups()); ?>;
|
||||
var list = document.getElementById('mr-backup-list');
|
||||
var hiddenInput = document.getElementById('mr-backup-file');
|
||||
|
||||
if (backups.length === 0) {
|
||||
var alert = document.createElement('div');
|
||||
alert.className = 'mr-alert mr-alert-danger';
|
||||
alert.textContent = 'No ZIP files found in this directory. Upload a backup archive first.';
|
||||
list.appendChild(alert);
|
||||
} else if (backups.length === 1) {
|
||||
hiddenInput.value = backups[0].name;
|
||||
var found = document.createElement('div');
|
||||
found.className = 'mr-alert mr-alert-success';
|
||||
var strong = document.createElement('strong');
|
||||
strong.textContent = backups[0].name;
|
||||
found.appendChild(document.createTextNode('Found: '));
|
||||
found.appendChild(strong);
|
||||
found.appendChild(document.createTextNode(' (' + (backups[0].size / 1048576).toFixed(1) + ' MB)'));
|
||||
list.appendChild(found);
|
||||
} else {
|
||||
var group = document.createElement('div');
|
||||
group.className = 'mr-field-group';
|
||||
backups.forEach(function(b) {
|
||||
var label = document.createElement('label');
|
||||
label.style.cssText = 'display:block; padding:8px; margin:4px 0; border:1px solid #ddd; border-radius:4px; cursor:pointer;';
|
||||
var radio = document.createElement('input');
|
||||
radio.type = 'radio';
|
||||
radio.name = 'backup_choice';
|
||||
radio.value = b.name;
|
||||
radio.style.marginRight = '8px';
|
||||
radio.addEventListener('change', function() { hiddenInput.value = this.value; });
|
||||
label.appendChild(radio);
|
||||
var nameStrong = document.createElement('strong');
|
||||
nameStrong.textContent = b.name;
|
||||
label.appendChild(nameStrong);
|
||||
label.appendChild(document.createTextNode(' \u2014 ' + (b.size / 1048576).toFixed(1) + ' MB \u2014 ' + b.date));
|
||||
group.appendChild(label);
|
||||
});
|
||||
list.appendChild(group);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
SELECTOR;
|
||||
|
||||
/* Insert the selector before the extract step in the HTML */
|
||||
$html = str_replace(
|
||||
'<!-- Step: Extract -->',
|
||||
$selectorHtml . "\n<!-- Step: Extract -->",
|
||||
$html
|
||||
);
|
||||
|
||||
return $php . $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the standalone restore.php script.
|
||||
*
|
||||
@@ -303,6 +488,20 @@ 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(
|
||||
|
||||
@@ -236,6 +236,297 @@ 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.
|
||||
*
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*
|
||||
* Resolves placeholders like [host], [date], [profile_name] in backup
|
||||
* Resolves placeholders like [HOST], [DATE], [PROFILE_NAME] in backup
|
||||
* directory paths and archive filename formats.
|
||||
*/
|
||||
|
||||
@@ -24,21 +24,21 @@ class PlaceholderResolver
|
||||
* Supported placeholders and their descriptions (for documentation).
|
||||
*/
|
||||
public const PLACEHOLDERS = [
|
||||
'[host]' => 'Server hostname',
|
||||
'[date]' => 'Date as Ymd (e.g. 20260604)',
|
||||
'[time]' => 'Time as His (e.g. 143025)',
|
||||
'[datetime]' => 'Date and time as Ymd_His',
|
||||
'[year]' => 'Four-digit year',
|
||||
'[month]' => 'Two-digit month',
|
||||
'[day]' => 'Two-digit day',
|
||||
'[hour]' => 'Two-digit hour (24h)',
|
||||
'[minute]' => 'Two-digit minute',
|
||||
'[second]' => 'Two-digit second',
|
||||
'[profile_id]' => 'Backup profile ID',
|
||||
'[profile_name]' => 'Profile title (sanitized)',
|
||||
'[site_name]' => 'Joomla site name (sanitized)',
|
||||
'[type]' => 'Backup type (full, database, files, differential)',
|
||||
'[random]' => 'Random 6-character hex string',
|
||||
'[HOST]' => 'Server hostname',
|
||||
'[DATE]' => 'Date as Ymd (e.g. 20260604)',
|
||||
'[TIME]' => 'Time as His (e.g. 143025)',
|
||||
'[DATETIME]' => 'Date and time as Ymd_His',
|
||||
'[YEAR]' => 'Four-digit year',
|
||||
'[MONTH]' => 'Two-digit month',
|
||||
'[DAY]' => 'Two-digit day',
|
||||
'[HOUR]' => 'Two-digit hour (24h)',
|
||||
'[MINUTE]' => 'Two-digit minute',
|
||||
'[SECOND]' => 'Two-digit second',
|
||||
'[PROFILE_ID]' => 'Backup profile ID',
|
||||
'[PROFILE_NAME]' => 'Profile title (sanitized)',
|
||||
'[SITE_NAME]' => 'Joomla site name (sanitized)',
|
||||
'[TYPE]' => 'Backup type (full, database, files, differential)',
|
||||
'[RANDOM]' => 'Random 6-character hex string',
|
||||
'[DEFAULT_DIR]' => 'Default backup directory',
|
||||
'[HOME]' => 'Home directory of the PHP process owner',
|
||||
];
|
||||
@@ -62,21 +62,21 @@ class PlaceholderResolver
|
||||
}
|
||||
|
||||
$this->replacements = [
|
||||
'[host]' => $hostname,
|
||||
'[date]' => $now->format('Ymd'),
|
||||
'[time]' => $now->format('His'),
|
||||
'[datetime]' => $now->format('Ymd_His'),
|
||||
'[year]' => $now->format('Y'),
|
||||
'[month]' => $now->format('m'),
|
||||
'[day]' => $now->format('d'),
|
||||
'[hour]' => $now->format('H'),
|
||||
'[minute]' => $now->format('i'),
|
||||
'[second]' => $now->format('s'),
|
||||
'[profile_id]' => (string) ($profile->id ?? '0'),
|
||||
'[profile_name]' => $this->sanitize($profile->title ?? 'default'),
|
||||
'[site_name]' => $this->sanitize($siteName ?: 'joomla'),
|
||||
'[type]' => $profile->backup_type ?? 'full',
|
||||
'[random]' => bin2hex(random_bytes(3)),
|
||||
'[HOST]' => $hostname,
|
||||
'[DATE]' => $now->format('Ymd'),
|
||||
'[TIME]' => $now->format('His'),
|
||||
'[DATETIME]' => $now->format('Ymd_His'),
|
||||
'[YEAR]' => $now->format('Y'),
|
||||
'[MONTH]' => $now->format('m'),
|
||||
'[DAY]' => $now->format('d'),
|
||||
'[HOUR]' => $now->format('H'),
|
||||
'[MINUTE]' => $now->format('i'),
|
||||
'[SECOND]' => $now->format('s'),
|
||||
'[PROFILE_ID]' => (string) ($profile->id ?? '0'),
|
||||
'[PROFILE_NAME]' => $this->sanitize($profile->title ?? 'default'),
|
||||
'[SITE_NAME]' => $this->sanitize($siteName ?: 'joomla'),
|
||||
'[TYPE]' => $profile->backup_type ?? 'full',
|
||||
'[RANDOM]' => bin2hex(random_bytes(3)),
|
||||
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
|
||||
'[HOME]' => BackupDirectory::getHomeDirectory(),
|
||||
];
|
||||
@@ -103,7 +103,7 @@ class PlaceholderResolver
|
||||
*/
|
||||
public function getHostname(): string
|
||||
{
|
||||
return $this->replacements['[host]'];
|
||||
return $this->replacements['[HOST]'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,7 +111,7 @@ class PlaceholderResolver
|
||||
*/
|
||||
public function getTag(): string
|
||||
{
|
||||
return $this->replacements['[datetime]'];
|
||||
return $this->replacements['[DATETIME]'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,15 +38,27 @@ class PreflightCheck
|
||||
*/
|
||||
public function run(int $profileId): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
} catch (\Exception $e) {
|
||||
$this->errors[] = 'Cannot connect to database: ' . $e->getMessage();
|
||||
|
||||
return $this->result();
|
||||
}
|
||||
|
||||
// Load profile
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
||||
->where($db->quoteName('id') . ' = ' . $profileId);
|
||||
$db->setQuery($query);
|
||||
$profile = $db->loadObject();
|
||||
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;
|
||||
@@ -111,14 +123,19 @@ class PreflightCheck
|
||||
$resolvedDir = BackupDirectory::resolve($resolver->resolve($configuredDir));
|
||||
|
||||
if (BackupDirectory::hasPlaceholders($resolvedDir)) {
|
||||
// Can't fully validate paths with unresolved placeholders
|
||||
$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)) {
|
||||
$this->errors[] = 'Backup directory does not exist and cannot be created: ' . $resolvedDir;
|
||||
$lastError = error_get_last();
|
||||
$reason = $lastError['message'] ?? 'unknown reason';
|
||||
$this->errors[] = 'Backup directory does not exist and cannot be created: ' . $resolvedDir
|
||||
. ' (' . $reason . ')';
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -261,6 +278,21 @@ 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';
|
||||
|
||||
@@ -23,6 +23,7 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Event\Event;
|
||||
|
||||
class RestoreEngine
|
||||
{
|
||||
@@ -76,8 +77,9 @@ class RestoreEngine
|
||||
return ['success' => false, 'message' => 'Backup archive not found: ' . $archivePath];
|
||||
}
|
||||
|
||||
// Create staging directory
|
||||
$this->stagingDir = JPATH_ROOT . '/tmp/mokosuitebackup-restore-' . $record->tag;
|
||||
// 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;
|
||||
|
||||
if (is_dir($this->stagingDir)) {
|
||||
$this->recursiveDelete($this->stagingDir);
|
||||
@@ -145,6 +147,29 @@ 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());
|
||||
}
|
||||
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterRestore(true, $recordId);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Restore complete from: ' . basename($archivePath),
|
||||
@@ -164,6 +189,9 @@ class RestoreEngine
|
||||
$this->recursiveDelete($this->stagingDir);
|
||||
}
|
||||
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterRestore(false, $recordId);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Restore failed: ' . $e->getMessage(),
|
||||
@@ -190,6 +218,20 @@ 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();
|
||||
|
||||
@@ -209,6 +251,18 @@ 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');
|
||||
}
|
||||
@@ -238,6 +292,26 @@ class RestoreEngine
|
||||
@rmdir($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch the onMokoSuiteBackupAfterRestore event so plugins (actionlog, etc.) can react.
|
||||
*/
|
||||
private function dispatchAfterRestore(bool $success, int $recordId): void
|
||||
{
|
||||
try {
|
||||
$app = Factory::getApplication();
|
||||
|
||||
$event = new Event('onMokoSuiteBackupAfterRestore', [
|
||||
'success' => $success,
|
||||
'record_id' => $recordId,
|
||||
]);
|
||||
|
||||
$app->getDispatcher()->dispatch('onMokoSuiteBackupAfterRestore', $event);
|
||||
} catch (\Throwable $e) {
|
||||
// Never let a listener failure break the restore result, but log it
|
||||
error_log('MokoSuiteBackup: onAfterRestore listener error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||
|
||||
@@ -114,19 +114,28 @@ class S3Uploader implements RemoteUploaderInterface
|
||||
*/
|
||||
private function singleUpload(string $localPath, string $objectKey): void
|
||||
{
|
||||
$url = $this->getObjectUrl($objectKey);
|
||||
$fileContent = file_get_contents($localPath);
|
||||
$contentHash = hash('sha256', $fileContent);
|
||||
$url = $this->getObjectUrl($objectKey);
|
||||
$fileSize = filesize($localPath);
|
||||
|
||||
// Stream file to compute SHA-256 without loading into RAM
|
||||
$contentHash = hash_file('sha256', $localPath);
|
||||
$headers = $this->signRequest('PUT', $url, $contentHash, [
|
||||
'Content-Type' => 'application/zip',
|
||||
'Content-Length' => (string) strlen($fileContent),
|
||||
'Content-Length' => (string) $fileSize,
|
||||
]);
|
||||
|
||||
$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_CUSTOMREQUEST => 'PUT',
|
||||
CURLOPT_POSTFIELDS => $fileContent,
|
||||
CURLOPT_PUT => true,
|
||||
CURLOPT_INFILE => $fp,
|
||||
CURLOPT_INFILESIZE => $fileSize,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 600,
|
||||
@@ -135,6 +144,8 @@ 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);
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
||||
use Joomla\Event\Event;
|
||||
|
||||
class SnapshotEngine
|
||||
{
|
||||
@@ -41,6 +42,10 @@ class SnapshotEngine
|
||||
private const ARTICLE_RELATED = [
|
||||
'#__workflow_associations',
|
||||
'#__contentitem_tag_map',
|
||||
'#__tags',
|
||||
'#__fields',
|
||||
'#__fields_values',
|
||||
'#__fields_categories',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -107,6 +112,32 @@ 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
|
||||
@@ -164,6 +195,29 @@ 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());
|
||||
}
|
||||
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterSnapshot(true, $record->id, array_values($validTypes));
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => sprintf(
|
||||
@@ -177,6 +231,9 @@ class SnapshotEngine
|
||||
} catch (\Exception $e) {
|
||||
$this->log('FATAL: ' . $e->getMessage());
|
||||
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterSnapshot(false, 0, $contentTypes);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Snapshot failed: ' . $e->getMessage(),
|
||||
@@ -231,6 +288,73 @@ 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() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch the onMokoSuiteBackupAfterSnapshot event so plugins (actionlog, etc.) can react.
|
||||
*/
|
||||
private function dispatchAfterSnapshot(bool $success, int $snapshotId, array $contentTypes): void
|
||||
{
|
||||
try {
|
||||
$app = Factory::getApplication();
|
||||
|
||||
$event = new Event('onMokoSuiteBackupAfterSnapshot', [
|
||||
'success' => $success,
|
||||
'snapshot_id' => $snapshotId,
|
||||
'content_types' => $contentTypes,
|
||||
]);
|
||||
|
||||
$app->getDispatcher()->dispatch('onMokoSuiteBackupAfterSnapshot', $event);
|
||||
} catch (\Throwable $e) {
|
||||
// Never let a listener failure break the snapshot result, but log it
|
||||
error_log('MokoSuiteBackup: onAfterSnapshot listener error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Event\Event;
|
||||
|
||||
class SnapshotRestoreEngine
|
||||
{
|
||||
@@ -33,6 +34,10 @@ 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
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -147,6 +152,28 @@ 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());
|
||||
}
|
||||
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterSnapshotRestore(true, $snapshotId, $mode);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => sprintf('Snapshot restored (%s mode): %d rows across %d tables', $mode, $totalRows, count($tablesToRestore)),
|
||||
@@ -162,6 +189,9 @@ class SnapshotRestoreEngine
|
||||
|
||||
$this->log('FATAL: ' . $e->getMessage());
|
||||
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterSnapshotRestore(false, $snapshotId, $mode);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Restore failed: ' . $e->getMessage(),
|
||||
@@ -282,6 +312,48 @@ 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;
|
||||
@@ -303,6 +375,10 @@ 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)) {
|
||||
@@ -317,6 +393,208 @@ 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());
|
||||
}
|
||||
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterSnapshotRestore(true, $snapshotId, 'selective');
|
||||
|
||||
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());
|
||||
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterSnapshotRestore(false, $snapshotId, 'selective');
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Selective restore failed: ' . $e->getMessage(),
|
||||
'log' => implode("\n", $this->log),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch the onMokoSuiteBackupAfterSnapshotRestore event so plugins (actionlog, etc.) can react.
|
||||
*/
|
||||
private function dispatchAfterSnapshotRestore(bool $success, int $snapshotId, string $mode): void
|
||||
{
|
||||
try {
|
||||
$app = Factory::getApplication();
|
||||
|
||||
$event = new Event('onMokoSuiteBackupAfterSnapshotRestore', [
|
||||
'success' => $success,
|
||||
'snapshot_id' => $snapshotId,
|
||||
'mode' => $mode,
|
||||
]);
|
||||
|
||||
$app->getDispatcher()->dispatch('onMokoSuiteBackupAfterSnapshotRestore', $event);
|
||||
} catch (\Throwable $e) {
|
||||
// Never let a listener failure break the restore result, but log it
|
||||
error_log('MokoSuiteBackup: onAfterSnapshotRestore listener error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||
|
||||
@@ -83,7 +83,7 @@ class SteppedBackupEngine
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$tag = $resolver->getTag();
|
||||
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
|
||||
$nameFormat = $profile->archive_name_format ?? '[HOST]_[DATETIME]_profile[PROFILE_ID]';
|
||||
$archiveName = $resolver->resolve($nameFormat) . '.zip';
|
||||
|
||||
$session->archivePath = $backupDir . '/' . $archiveName;
|
||||
@@ -347,6 +347,11 @@ 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...');
|
||||
@@ -389,37 +394,48 @@ 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;
|
||||
|
||||
if ($result['success']) {
|
||||
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
|
||||
$session->log('Remote upload complete: ' . $result['message']);
|
||||
// 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 (!$session->remoteKeepLocal && is_file($session->archivePath)) {
|
||||
@unlink($session->archivePath);
|
||||
$session->log('Local copy removed');
|
||||
$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.');
|
||||
}
|
||||
} else {
|
||||
$session->log('WARNING: Remote upload failed: ' . $result['message']);
|
||||
} catch (\Throwable $e) {
|
||||
$uploadFailed = true;
|
||||
$session->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
|
||||
$session->log('Local backup is preserved.');
|
||||
}
|
||||
|
||||
// Update record with remote filename
|
||||
@@ -433,14 +449,60 @@ class SteppedBackupEngine
|
||||
|
||||
$session->currentStep++;
|
||||
$session->phase = 'complete';
|
||||
$session->statusMessage = 'Backup complete';
|
||||
$this->completeRecord($session);
|
||||
$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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the backup record as complete.
|
||||
*/
|
||||
private function completeRecord(SteppedSession $session): void
|
||||
private function completeRecord(SteppedSession $session, bool $uploadFailed = false): void
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$logContent = implode("\n", $session->log);
|
||||
@@ -490,6 +552,11 @@ 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());
|
||||
|
||||
@@ -0,0 +1,753 @@
|
||||
<?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';
|
||||
}
|
||||
}
|
||||
@@ -47,12 +47,14 @@ class TarGzArchiver implements ArchiverInterface
|
||||
|
||||
public function close(): void
|
||||
{
|
||||
// Compress the .tar to .tar.gz
|
||||
$this->tar->compress(\Phar::GZ);
|
||||
|
||||
// Remove the uncompressed .tar
|
||||
if (is_file($this->tarPath)) {
|
||||
@unlink($this->tarPath);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,15 +52,15 @@ class FolderPickerField extends FormField
|
||||
$placeholders = [
|
||||
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
|
||||
'[HOME]' => BackupDirectory::getHomeDirectory(),
|
||||
'[host]' => $hostname,
|
||||
'[site_name]' => $sanitizedSiteName ?: 'joomla',
|
||||
'[profile_id]' => '1',
|
||||
'[profile_name]' => 'default',
|
||||
'[type]' => 'full',
|
||||
'[year]' => date('Y'),
|
||||
'[month]' => date('m'),
|
||||
'[day]' => date('d'),
|
||||
'[date]' => date('Ymd'),
|
||||
'[HOST]' => $hostname,
|
||||
'[SITE_NAME]' => $sanitizedSiteName ?: 'joomla',
|
||||
'[PROFILE_ID]' => '1',
|
||||
'[PROFILE_NAME]' => 'default',
|
||||
'[TYPE]' => 'full',
|
||||
'[YEAR]' => date('Y'),
|
||||
'[MONTH]' => date('m'),
|
||||
'[DAY]' => date('d'),
|
||||
'[DATE]' => date('Ymd'),
|
||||
];
|
||||
|
||||
$placeholdersJson = json_encode($placeholders);
|
||||
@@ -96,51 +96,140 @@ class FolderPickerField extends FormField
|
||||
<span class="icon-folder-open" aria-hidden="true"></span>
|
||||
Browse
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-info" data-bs-toggle="modal" data-bs-target="#{$id}_helpModal" title="Available placeholders">
|
||||
<button type="button" class="btn btn-outline-info" id="{$id}_helpBtn" title="Help — placeholders, paths, and examples">
|
||||
<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>
|
||||
{$statusDetail}
|
||||
</small>
|
||||
</div>
|
||||
<div class="mt-1" id="{$id}_resolved" style="font-size:0.8rem; line-height:1.6;">
|
||||
</div>
|
||||
<div id="{$id}_defaultwarn" class="alert alert-warning alert-sm mt-1 py-1 px-2" style="display:none; font-size:0.85rem;">
|
||||
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||
The default backup directory is inside the web root. Backup archives may be directly downloadable if <code>.htaccess</code> is not supported. For better security, use a path outside the web root.
|
||||
</div>
|
||||
<div class="modal fade" id="{$id}_helpModal" tabindex="-1" aria-labelledby="{$id}_helpLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="{$id}_helpLabel">Backup Directory Placeholders</h5>
|
||||
<h5 class="modal-title" id="{$id}_helpLabel">Backup Directory — Help</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Use these placeholders in the backup directory path. They are resolved at backup time.</p>
|
||||
|
||||
<h6 class="text-primary">How Path Resolution Works</h6>
|
||||
<p>The backup directory path is resolved at backup time. You can use <strong>absolute paths</strong>, <strong>relative paths</strong>, or <strong>placeholder paths</strong>.</p>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header fw-bold">Absolute Paths</div>
|
||||
<div class="card-body py-2">
|
||||
<p class="mb-1">Start with <code>/</code> (Linux) or a drive letter (Windows). Used as-is.</p>
|
||||
<ul class="mb-0">
|
||||
<li><code>/home/user/backups</code> — Fixed path on the server</li>
|
||||
<li><code>/var/backups/joomla</code> — System backup directory</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header fw-bold">Relative Paths</div>
|
||||
<div class="card-body py-2">
|
||||
<p class="mb-1">Paths that do <strong>not</strong> start with <code>/</code> are resolved relative to the Joomla root directory, using the same conventions as URL paths:</p>
|
||||
<table class="table table-sm mb-2">
|
||||
<thead><tr><th>Path</th><th>Meaning</th><th>Resolves To</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>backups</code></td><td>Subdirectory of Joomla root</td><td><code>{$jRoot}/backups</code></td></tr>
|
||||
<tr><td><code>./backups</code></td><td>Same as above (explicit current dir)</td><td><code>{$jRoot}/backups</code></td></tr>
|
||||
<tr><td><code>../backups</code></td><td>One level <strong>above</strong> Joomla root</td><td>Parent of <code>{$jRoot}</code></td></tr>
|
||||
<tr><td><code>../../backups</code></td><td>Two levels above Joomla root</td><td>Grandparent of <code>{$jRoot}</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="alert alert-warning py-1 px-2 mb-0" style="font-size:0.85rem;">
|
||||
<strong>Warning:</strong> Relative paths that stay inside the web root may expose backup files to direct download if .htaccess is not supported. Use <code>../</code> or <code>[HOME]</code> to store backups outside the web root.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header fw-bold">Placeholder Paths (Recommended)</div>
|
||||
<div class="card-body py-2">
|
||||
<p class="mb-1">Use <code>[PLACEHOLDER]</code> tokens that are replaced with actual values at backup time. This makes paths <strong>portable</strong> across servers.</p>
|
||||
<ul class="mb-0">
|
||||
<li><code>[HOME]/backups</code> — User's home directory + /backups</li>
|
||||
<li><code>[HOME]/[HOST]/backups</code> — Per-site subdirectory under home</li>
|
||||
<li><code>[DEFAULT_DIR]</code> — Joomla's default backup directory</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="text-primary mt-3">Available Placeholders</h6>
|
||||
<table class="table table-sm table-striped">
|
||||
<thead><tr><th>Placeholder</th><th>Description</th><th>Example</th></tr></thead>
|
||||
<thead><tr><th>Placeholder</th><th>Description</th><th>Current Value</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>[HOME]</code></td><td>Home directory of the server user</td><td><code>{$placeholders['[HOME]']}</code></td></tr>
|
||||
<tr><td><code>[DEFAULT_DIR]</code></td><td>Default backup directory (inside web root)</td><td><code>{$placeholders['[DEFAULT_DIR]']}</code></td></tr>
|
||||
<tr><td><code>[host]</code></td><td>Server hostname</td><td><code>{$placeholders['[host]']}</code></td></tr>
|
||||
<tr><td><code>[site_name]</code></td><td>Joomla site name</td><td><code>{$placeholders['[site_name]']}</code></td></tr>
|
||||
<tr><td><code>[date]</code></td><td>Date (Ymd)</td><td><code>{$placeholders['[date]']}</code></td></tr>
|
||||
<tr><td><code>[year]</code></td><td>Four-digit year</td><td><code>{$placeholders['[year]']}</code></td></tr>
|
||||
<tr><td><code>[month]</code></td><td>Two-digit month</td><td><code>{$placeholders['[month]']}</code></td></tr>
|
||||
<tr><td><code>[day]</code></td><td>Two-digit day</td><td><code>{$placeholders['[day]']}</code></td></tr>
|
||||
<tr><td><code>[profile_id]</code></td><td>Backup profile ID</td><td><code>1</code></td></tr>
|
||||
<tr><td><code>[profile_name]</code></td><td>Profile title</td><td><code>default</code></td></tr>
|
||||
<tr><td><code>[type]</code></td><td>Backup type</td><td><code>full</code></td></tr>
|
||||
<tr><td><code>[HOME]</code></td><td>Home directory of the PHP process owner. Detected from environment, POSIX, or JPATH_ROOT.</td><td><code>{$placeholders['[HOME]']}</code></td></tr>
|
||||
<tr><td><code>[DEFAULT_DIR]</code></td><td>Default backup directory inside the Joomla web root. Protected by .htaccess but not recommended for production.</td><td><code>{$placeholders['[DEFAULT_DIR]']}</code></td></tr>
|
||||
<tr><td><code>[HOST]</code></td><td>Server hostname from HTTP_HOST. Sanitized to alphanumeric, dots, and hyphens.</td><td><code>{$placeholders['[HOST]']}</code></td></tr>
|
||||
<tr><td><code>[SITE_NAME]</code></td><td>Joomla site name from Global Configuration. Spaces become hyphens, special characters stripped.</td><td><code>{$placeholders['[SITE_NAME]']}</code></td></tr>
|
||||
<tr><td><code>[DATE]</code></td><td>Current date in Ymd format (e.g. 20260623).</td><td><code>{$placeholders['[DATE]']}</code></td></tr>
|
||||
<tr><td><code>[YEAR]</code></td><td>Four-digit year.</td><td><code>{$placeholders['[YEAR]']}</code></td></tr>
|
||||
<tr><td><code>[MONTH]</code></td><td>Two-digit month (01-12).</td><td><code>{$placeholders['[MONTH]']}</code></td></tr>
|
||||
<tr><td><code>[DAY]</code></td><td>Two-digit day (01-31).</td><td><code>{$placeholders['[DAY]']}</code></td></tr>
|
||||
<tr><td><code>[PROFILE_ID]</code></td><td>Numeric ID of the backup profile being used.</td><td><code>1</code></td></tr>
|
||||
<tr><td><code>[PROFILE_NAME]</code></td><td>Title of the backup profile, sanitized for filesystem use.</td><td><code>default</code></td></tr>
|
||||
<tr><td><code>[TYPE]</code></td><td>Backup type: full, database, files, or differential.</td><td><code>full</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h6>Recommended Paths</h6>
|
||||
<ul class="list-unstyled">
|
||||
<li><code>[HOME]/backups</code> — Outside web root (recommended)</li>
|
||||
<li><code>[HOME]/backups/[host]</code> — Per-site subdirectory</li>
|
||||
<li><code>[DEFAULT_DIR]</code> — Inside web root (protected by .htaccess)</li>
|
||||
</ul>
|
||||
|
||||
<h6 class="text-primary mt-3">Recommended Configurations</h6>
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>Use Case</th><th>Path</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Single site, secure</strong></td>
|
||||
<td><code>[HOME]/backups</code></td>
|
||||
<td>Outside web root. Best for most sites.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Multiple sites on one server</strong></td>
|
||||
<td><code>[HOME]/backups/[HOST]</code></td>
|
||||
<td>Each site gets its own subdirectory.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Date-organized</strong></td>
|
||||
<td><code>[HOME]/backups/[YEAR]/[MONTH]</code></td>
|
||||
<td>Backups sorted by year and month.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Per-profile</strong></td>
|
||||
<td><code>[HOME]/backups/[PROFILE_NAME]</code></td>
|
||||
<td>Separate directory for each backup profile.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Shared hosting (default)</strong></td>
|
||||
<td><code>[DEFAULT_DIR]</code></td>
|
||||
<td>Inside web root, protected by .htaccess. Use only if you cannot write outside web root.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="alert alert-info py-2 mt-3 mb-0">
|
||||
<strong>Tip:</strong> The directory is created automatically if it doesn't exist. Placeholders are resolved fresh each time a backup runs, so date-based paths create new directories over time.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
@@ -155,6 +244,56 @@ 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 }));
|
||||
});
|
||||
});
|
||||
|
||||
/* Help button — open modal with Bootstrap 5 or fallback */
|
||||
var helpBtn = document.getElementById('{$id}_helpBtn');
|
||||
var helpModal = document.getElementById('{$id}_helpModal');
|
||||
if (helpBtn && helpModal) {
|
||||
helpBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
if (typeof bootstrap !== 'undefined' && bootstrap.Modal) {
|
||||
var modal = bootstrap.Modal.getOrCreateInstance(helpModal);
|
||||
modal.show();
|
||||
} else {
|
||||
helpModal.classList.add('show');
|
||||
helpModal.style.display = 'block';
|
||||
helpModal.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('modal-open');
|
||||
var backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop fade show';
|
||||
backdrop.id = '{$id}_backdrop';
|
||||
document.body.appendChild(backdrop);
|
||||
helpModal.querySelector('.btn-close, [data-bs-dismiss]').addEventListener('click', function() {
|
||||
helpModal.classList.remove('show');
|
||||
helpModal.style.display = 'none';
|
||||
helpModal.setAttribute('aria-hidden', 'true');
|
||||
document.body.classList.remove('modal-open');
|
||||
var bd = document.getElementById('{$id}_backdrop');
|
||||
if (bd) bd.remove();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var fieldId = '{$id}';
|
||||
var btn = document.getElementById(fieldId + '_btn');
|
||||
var browser = document.getElementById(fieldId + '_browser');
|
||||
@@ -162,7 +301,7 @@ class FolderPickerField extends FormField
|
||||
var input = document.getElementById(fieldId);
|
||||
var placeholders = {$placeholdersJson};
|
||||
|
||||
// Resolve placeholders in a path (forward: [site_name] -> actual value)
|
||||
// Resolve placeholders in a path (forward: [SITE_NAME] -> actual value)
|
||||
function resolve(path) {
|
||||
for (var key in placeholders) {
|
||||
path = path.split(key).join(placeholders[key]);
|
||||
@@ -253,8 +392,54 @@ class FolderPickerField extends FormField
|
||||
});
|
||||
}
|
||||
|
||||
/* Show which placeholders are in use and their resolved values */
|
||||
var resolvedDiv = document.getElementById(fieldId + '_resolved');
|
||||
|
||||
function updateResolvedDisplay() {
|
||||
while (resolvedDiv.firstChild) resolvedDiv.removeChild(resolvedDiv.firstChild);
|
||||
var val = input.value || '';
|
||||
var found = false;
|
||||
|
||||
for (var key in placeholders) {
|
||||
if (val.indexOf(key) !== -1 && placeholders[key]) {
|
||||
found = true;
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'badge bg-light text-dark border me-1 mb-1';
|
||||
badge.style.fontSize = '0.75rem';
|
||||
badge.style.fontFamily = 'monospace';
|
||||
|
||||
var keySpan = document.createElement('strong');
|
||||
keySpan.textContent = key;
|
||||
badge.appendChild(keySpan);
|
||||
|
||||
badge.appendChild(document.createTextNode(' = '));
|
||||
|
||||
var valSpan = document.createElement('span');
|
||||
valSpan.className = 'text-primary';
|
||||
valSpan.textContent = placeholders[key];
|
||||
badge.appendChild(valSpan);
|
||||
|
||||
resolvedDiv.appendChild(badge);
|
||||
}
|
||||
}
|
||||
|
||||
if (found) {
|
||||
var fullResolved = document.createElement('div');
|
||||
fullResolved.className = 'mt-1';
|
||||
var arrow = document.createElement('span');
|
||||
arrow.className = 'text-muted';
|
||||
arrow.textContent = 'EXAMPLE: ';
|
||||
fullResolved.appendChild(arrow);
|
||||
var code = document.createElement('code');
|
||||
code.textContent = resolve(val);
|
||||
fullResolved.appendChild(code);
|
||||
resolvedDiv.appendChild(fullResolved);
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener('input', function() {
|
||||
clearTimeout(checkTimer);
|
||||
updateResolvedDisplay();
|
||||
checkTimer = setTimeout(checkDirPermissions, 400);
|
||||
});
|
||||
|
||||
@@ -368,6 +553,7 @@ class FolderPickerField extends FormField
|
||||
|
||||
// Run initial check on page load
|
||||
setDefaultDirWarning();
|
||||
updateResolvedDisplay();
|
||||
checkDirPermissions();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ class BackupModel extends AdminModel
|
||||
$data = $this->getItem();
|
||||
}
|
||||
|
||||
return $data;
|
||||
return is_array($data) ? (object) $data : $data;
|
||||
}
|
||||
|
||||
public function getTable($name = 'Backup', $prefix = 'Administrator', $options = [])
|
||||
|
||||
@@ -61,6 +61,13 @@ 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');
|
||||
|
||||
|
||||
@@ -198,6 +198,90 @@ 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.
|
||||
*
|
||||
|
||||
@@ -36,7 +36,7 @@ class ProfileModel extends AdminModel
|
||||
$data = $this->getItem();
|
||||
}
|
||||
|
||||
return $data;
|
||||
return is_array($data) ? (object) $data : $data;
|
||||
}
|
||||
|
||||
public function getTable($name = 'Profile', $prefix = 'Administrator', $options = [])
|
||||
|
||||
@@ -40,6 +40,13 @@ class ProfilesModel extends ListModel
|
||||
$query->select('a.*')
|
||||
->from($db->quoteName('#__mokosuitebackup_profiles', 'a'));
|
||||
|
||||
// Subquery: count of backup records per profile
|
||||
$subQuery = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokosuitebackup_records', 'r'))
|
||||
->where($db->quoteName('r.profile_id') . ' = ' . $db->quoteName('a.id'));
|
||||
$query->select('(' . $subQuery . ') AS ' . $db->quoteName('backup_count'));
|
||||
|
||||
$published = $this->getState('filter.published');
|
||||
|
||||
if (is_numeric($published)) {
|
||||
|
||||
@@ -39,11 +39,22 @@ class BackupTable extends Table
|
||||
|
||||
public function delete($pk = null): bool
|
||||
{
|
||||
// Delete the archive file if it exists
|
||||
if (!empty($this->absolute_path) && is_file($this->absolute_path)) {
|
||||
@unlink($this->absolute_path);
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
return parent::delete($pk);
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,23 @@ 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,6 +122,10 @@ 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,18 +24,26 @@ 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();
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ defined('_JEXEC') or die;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\CMS\Toolbar\Toolbar;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
@@ -48,6 +51,27 @@ class HtmlView extends BaseHtmlView
|
||||
ToolbarHelper::save('profile.save');
|
||||
}
|
||||
|
||||
if (!$isNew) {
|
||||
$toolbar = Toolbar::getInstance();
|
||||
$profileId = (int) $this->item->id;
|
||||
|
||||
// "Run Backup Now" button — links to backup start with CSRF token
|
||||
if ($user->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
|
||||
$runUrl = Route::_('index.php?option=com_mokosuitebackup&view=backups&task=backups.start&profile_id=' . $profileId . '&' . Session::getFormToken() . '=1');
|
||||
$toolbar->linkButton('run-backup', 'COM_MOKOJOOMBACKUP_RUN_BACKUP_NOW')
|
||||
->url($runUrl)
|
||||
->icon('icon-play')
|
||||
->buttonClass('btn btn-success');
|
||||
}
|
||||
|
||||
// "View Backups" link button
|
||||
$backupsUrl = Route::_('index.php?option=com_mokosuitebackup&view=backups&filter[PROFILE_ID]=' . $profileId);
|
||||
$toolbar->linkButton('view-backups', 'COM_MOKOJOOMBACKUP_VIEW_BACKUPS')
|
||||
->url($backupsUrl)
|
||||
->icon('icon-database')
|
||||
->buttonClass('btn btn-info');
|
||||
}
|
||||
|
||||
ToolbarHelper::cancel('profile.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,28 @@ $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;">
|
||||
@@ -104,22 +126,105 @@ $ajaxUrl = Route::_('index.php?option=com_mokosuitebackup&format=json', false)
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
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');
|
||||
var AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
|
||||
var TOKEN_NAME = <?php echo json_encode($ajaxToken); ?>;
|
||||
|
||||
fetch(<?php echo json_encode($ajaxUrl); ?>, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
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; ?> })
|
||||
.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,6 +155,13 @@ $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'); ?>">
|
||||
@@ -184,6 +191,10 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
<div id="mokosuitebackup-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-modal-title" style="margin:0 0 1rem;">Backup in Progress</h3>
|
||||
<div class="alert alert-warning py-1 px-2 mb-2" style="font-size:0.85rem;">
|
||||
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||
<strong>Do not navigate away or close this window</strong> while the backup is running.
|
||||
</div>
|
||||
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
|
||||
<div id="mb-progress-bar" style="height:100%; background:#0d6efd; 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>
|
||||
@@ -346,6 +357,106 @@ $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');
|
||||
@@ -385,6 +496,93 @@ $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>
|
||||
|
||||
@@ -443,6 +641,18 @@ $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;">
|
||||
@@ -455,3 +665,201 @@ $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">—</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 || '—', b2.description || '—', ''],
|
||||
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_PROFILE', true); ?>', b1.profile_title || '—', b2.profile_title || '—', ''],
|
||||
['<?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,6 +109,122 @@ 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')); ?>
|
||||
—
|
||||
<?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
|
||||
— <?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">
|
||||
@@ -189,6 +305,10 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
|
||||
<div id="mokosuitebackup-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-modal-title" style="margin:0 0 1rem;">Backup in Progress</h3>
|
||||
<div class="alert alert-warning py-1 px-2 mb-2" style="font-size:0.85rem;">
|
||||
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||
<strong>Do not navigate away or close this window</strong> while the backup is running.
|
||||
</div>
|
||||
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
|
||||
<div id="mb-progress-bar" style="height:100%; background:#0d6efd; 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>
|
||||
|
||||
@@ -14,6 +14,7 @@ use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Layout\LayoutHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
HTMLHelper::_('behavior.multiselect');
|
||||
|
||||
@@ -45,9 +46,15 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
<th scope="col" class="w-10">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_TYPE', 'a.backup_type', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-5 text-center">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_BACKUPS'); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'JSTATUS', 'a.published', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10 text-center">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_ACTIONS'); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-5">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'JGRID_HEADING_ID', 'a.id', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
@@ -70,9 +77,26 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
<td>
|
||||
<?php echo $this->escape($item->backup_type); ?>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backups&filter[PROFILE_ID]=' . $item->id); ?>">
|
||||
<span class="badge bg-<?php echo ($item->backup_count > 0) ? 'info' : 'secondary'; ?>">
|
||||
<?php echo (int) $item->backup_count; ?>
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo HTMLHelper::_('jgrid.published', $item->published, $i, 'profiles.'); ?>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<?php if ($item->published == 1) : ?>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backups&task=backups.start&profile_id=' . $item->id . '&' . Session::getFormToken() . '=1'); ?>"
|
||||
class="btn btn-sm btn-outline-success"
|
||||
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_RUN_BACKUP'); ?>">
|
||||
<span class="icon-play" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_RUN_BACKUP'); ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo (int) $item->id; ?>
|
||||
</td>
|
||||
|
||||
@@ -99,6 +99,12 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($item->status === 'complete' && $canManage) : ?>
|
||||
<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>
|
||||
<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); ?>"
|
||||
@@ -227,6 +233,116 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Browse Snapshot Detail 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:800px; 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;">
|
||||
|
||||
<!-- Bootstrap tabs -->
|
||||
<ul class="nav nav-tabs" id="mb-browse-tabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="mb-tab-articles-btn" data-bs-toggle="tab" data-bs-target="#mb-tab-articles" type="button" role="tab" aria-controls="mb-tab-articles" aria-selected="true">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_ARTICLES'); ?>
|
||||
<span class="badge bg-secondary ms-1" id="mb-tab-articles-count">0</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="mb-tab-categories-btn" data-bs-toggle="tab" data-bs-target="#mb-tab-categories" type="button" role="tab" aria-controls="mb-tab-categories" aria-selected="false">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_CATEGORIES'); ?>
|
||||
<span class="badge bg-secondary ms-1" id="mb-tab-categories-count">0</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="mb-tab-modules-btn" data-bs-toggle="tab" data-bs-target="#mb-tab-modules" type="button" role="tab" aria-controls="mb-tab-modules" aria-selected="false">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_MODULES'); ?>
|
||||
<span class="badge bg-secondary ms-1" id="mb-tab-modules-count">0</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content pt-3" id="mb-browse-tabs-content">
|
||||
|
||||
<!-- Articles tab -->
|
||||
<div class="tab-pane fade show active" id="mb-tab-articles" role="tabpanel" aria-labelledby="mb-tab-articles-btn">
|
||||
<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>
|
||||
|
||||
<!-- Categories tab -->
|
||||
<div class="tab-pane fade" id="mb-tab-categories" role="tabpanel" aria-labelledby="mb-tab-categories-btn">
|
||||
<table class="table table-sm table-striped" id="mb-browse-categories-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TYPE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_LEVEL'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="mb-browse-categories-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Modules tab -->
|
||||
<div class="tab-pane fade" id="mb-tab-modules" role="tabpanel" aria-labelledby="mb-tab-modules-btn">
|
||||
<table class="table table-sm table-striped" id="mb-browse-modules-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_MODULE_TYPE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_POSITION'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="mb-browse-modules-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</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
|
||||
@@ -287,7 +403,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
var label = document.createElement('label');
|
||||
label.className = 'form-check-label';
|
||||
label.setAttribute('for', 'mb-rtype-' + type);
|
||||
label.textContent = typeLabels[type] || type;
|
||||
label.textContent = typeLabels[TYPE] || type;
|
||||
|
||||
div.appendChild(input);
|
||||
div.appendChild(label);
|
||||
@@ -312,13 +428,204 @@ $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;
|
||||
|
||||
// Reset to Articles tab
|
||||
var firstTab = document.querySelector('#mb-tab-articles-btn');
|
||||
if (firstTab && typeof bootstrap !== 'undefined') {
|
||||
var tab = new bootstrap.Tab(firstTab);
|
||||
tab.show();
|
||||
}
|
||||
|
||||
document.getElementById('mb-snapshot-browse-modal').style.display = 'block';
|
||||
|
||||
// Fetch snapshot content 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 stateLabels = { '1': 'Published', '0': 'Unpublished', '-1': 'Trashed', '-2': 'Archived' };
|
||||
var stateBadges = { '1': 'bg-success', '0': 'bg-secondary', '-1': 'bg-danger', '-2': 'bg-info' };
|
||||
|
||||
// --- Articles ---
|
||||
var tbody = document.getElementById('mb-browse-tbody');
|
||||
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
|
||||
|
||||
(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_articles + ' article(s)';
|
||||
document.getElementById('mb-tab-articles-count').textContent = data.total_articles;
|
||||
|
||||
// --- Categories ---
|
||||
var catTbody = document.getElementById('mb-browse-categories-tbody');
|
||||
while (catTbody.firstChild) catTbody.removeChild(catTbody.firstChild);
|
||||
|
||||
(data.categories || []).forEach(function(cat) {
|
||||
var tr = document.createElement('tr');
|
||||
|
||||
var tdId = document.createElement('td');
|
||||
tdId.textContent = cat.id;
|
||||
tr.appendChild(tdId);
|
||||
|
||||
var tdTitle = document.createElement('td');
|
||||
tdTitle.textContent = '\u2003'.repeat(Math.max(0, cat.level - 1)) + cat.title;
|
||||
tr.appendChild(tdTitle);
|
||||
|
||||
var tdExt = document.createElement('td');
|
||||
tdExt.textContent = cat.extension;
|
||||
tr.appendChild(tdExt);
|
||||
|
||||
var tdState = document.createElement('td');
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'badge ' + (stateBadges[String(cat.published)] || 'bg-secondary');
|
||||
badge.textContent = stateLabels[String(cat.published)] || 'Unknown';
|
||||
tdState.appendChild(badge);
|
||||
tr.appendChild(tdState);
|
||||
|
||||
var tdLevel = document.createElement('td');
|
||||
tdLevel.textContent = cat.level;
|
||||
tr.appendChild(tdLevel);
|
||||
|
||||
catTbody.appendChild(tr);
|
||||
});
|
||||
|
||||
document.getElementById('mb-tab-categories-count').textContent = data.total_categories;
|
||||
|
||||
// --- Modules ---
|
||||
var modTbody = document.getElementById('mb-browse-modules-tbody');
|
||||
while (modTbody.firstChild) modTbody.removeChild(modTbody.firstChild);
|
||||
|
||||
(data.modules || []).forEach(function(mod) {
|
||||
var tr = document.createElement('tr');
|
||||
|
||||
var tdId = document.createElement('td');
|
||||
tdId.textContent = mod.id;
|
||||
tr.appendChild(tdId);
|
||||
|
||||
var tdTitle = document.createElement('td');
|
||||
tdTitle.textContent = mod.title;
|
||||
tr.appendChild(tdTitle);
|
||||
|
||||
var tdType = document.createElement('td');
|
||||
tdType.textContent = mod.module;
|
||||
tr.appendChild(tdType);
|
||||
|
||||
var tdPos = document.createElement('td');
|
||||
tdPos.textContent = mod.position;
|
||||
tr.appendChild(tdPos);
|
||||
|
||||
var tdState = document.createElement('td');
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'badge ' + (stateBadges[String(mod.published)] || 'bg-secondary');
|
||||
badge.textContent = stateLabels[String(mod.published)] || 'Unknown';
|
||||
tdState.appendChild(badge);
|
||||
tr.appendChild(tdState);
|
||||
|
||||
modTbody.appendChild(tr);
|
||||
});
|
||||
|
||||
document.getElementById('mb-tab-modules-count').textContent = data.total_modules;
|
||||
|
||||
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 snapshot content: ' + 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-restore-modal' ||
|
||||
e.target.id === 'mb-snapshot-browse-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';
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
+6
@@ -7,3 +7,9 @@ PLG_ACTIONLOG_MOKOJOOMBACKUP_PROFILE_DELETED="User {username} deleted backup pro
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_RECORD_DELETED="User {username} deleted backup record "{title}" (ID: {id})"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_COMPLETE="Backup completed: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_FAILED="Backup FAILED: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_COMPLETE="User {username} restored backup #{id}"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_FAILED="User {username} attempted to restore backup #{id} but it FAILED"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_CREATED="User {username} created content snapshot (ID: {id}, types: {content_types})"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_FAILED="User {username} attempted to create content snapshot but it FAILED"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_COMPLETE="User {username} restored snapshot #{id} ({mode} mode)"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_FAILED="User {username} attempted to restore snapshot #{id} ({mode} mode) but it FAILED"
|
||||
|
||||
+6
@@ -7,3 +7,9 @@ PLG_ACTIONLOG_MOKOJOOMBACKUP_PROFILE_DELETED="User {username} deleted backup pro
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_RECORD_DELETED="User {username} deleted backup record "{title}" (ID: {id})"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_COMPLETE="Backup completed: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_FAILED="Backup FAILED: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_COMPLETE="User {username} restored backup #{id}"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_FAILED="User {username} attempted to restore backup #{id} but it FAILED"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_CREATED="User {username} created content snapshot (ID: {id}, types: {content_types})"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_FAILED="User {username} attempted to create content snapshot but it FAILED"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_COMPLETE="User {username} restored snapshot #{id} ({mode} mode)"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_FAILED="User {username} attempted to restore snapshot #{id} ({mode} mode) but it FAILED"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="actionlog" method="upgrade">
|
||||
<name>Action Log - MokoSuiteBackup</name>
|
||||
<version>01.27.00</version>
|
||||
<version>01.39.00</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
+92
-1
@@ -27,7 +27,10 @@ final class MokoSuiteBackupActionlog extends CMSPlugin implements SubscriberInte
|
||||
return [
|
||||
'onContentAfterSave' => 'onContentAfterSave',
|
||||
'onContentAfterDelete' => 'onContentAfterDelete',
|
||||
'onMokoSuiteBackupAfterRun' => 'onMokoSuiteBackupAfterRun',
|
||||
'onMokoSuiteBackupAfterRun' => 'onMokoSuiteBackupAfterRun',
|
||||
'onMokoSuiteBackupAfterRestore' => 'onMokoSuiteBackupAfterRestore',
|
||||
'onMokoSuiteBackupAfterSnapshot' => 'onMokoSuiteBackupAfterSnapshot',
|
||||
'onMokoSuiteBackupAfterSnapshotRestore' => 'onMokoSuiteBackupAfterSnapshotRestore',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -130,6 +133,94 @@ final class MokoSuiteBackupActionlog extends CMSPlugin implements SubscriberInte
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log when a backup is restored.
|
||||
*/
|
||||
public function onMokoSuiteBackupAfterRestore(Event $event): void
|
||||
{
|
||||
$args = $event->getArguments();
|
||||
|
||||
$success = $args['success'] ?? false;
|
||||
$recordId = $args['record_id'] ?? 0;
|
||||
|
||||
$messageKey = $success
|
||||
? 'PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_COMPLETE'
|
||||
: 'PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_FAILED';
|
||||
|
||||
$this->addLog(
|
||||
[
|
||||
$messageKey,
|
||||
'id' => $recordId,
|
||||
'title' => 'Backup #' . $recordId,
|
||||
'userid' => $this->getCurrentUserId(),
|
||||
'username' => $this->getCurrentUserName(),
|
||||
],
|
||||
$messageKey,
|
||||
'com_mokosuitebackup.backup',
|
||||
$this->getCurrentUserId()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log when a content snapshot is created.
|
||||
*/
|
||||
public function onMokoSuiteBackupAfterSnapshot(Event $event): void
|
||||
{
|
||||
$args = $event->getArguments();
|
||||
|
||||
$success = $args['success'] ?? false;
|
||||
$snapshotId = $args['snapshot_id'] ?? 0;
|
||||
$contentTypes = $args['content_types'] ?? [];
|
||||
|
||||
$messageKey = $success
|
||||
? 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_CREATED'
|
||||
: 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_FAILED';
|
||||
|
||||
$this->addLog(
|
||||
[
|
||||
$messageKey,
|
||||
'id' => $snapshotId,
|
||||
'title' => 'Snapshot #' . $snapshotId,
|
||||
'content_types' => implode(', ', $contentTypes),
|
||||
'userid' => $this->getCurrentUserId(),
|
||||
'username' => $this->getCurrentUserName(),
|
||||
],
|
||||
$messageKey,
|
||||
'com_mokosuitebackup.snapshot',
|
||||
$this->getCurrentUserId()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log when a snapshot is restored.
|
||||
*/
|
||||
public function onMokoSuiteBackupAfterSnapshotRestore(Event $event): void
|
||||
{
|
||||
$args = $event->getArguments();
|
||||
|
||||
$success = $args['success'] ?? false;
|
||||
$snapshotId = $args['snapshot_id'] ?? 0;
|
||||
$mode = $args['mode'] ?? 'replace';
|
||||
|
||||
$messageKey = $success
|
||||
? 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_COMPLETE'
|
||||
: 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_FAILED';
|
||||
|
||||
$this->addLog(
|
||||
[
|
||||
$messageKey,
|
||||
'id' => $snapshotId,
|
||||
'title' => 'Snapshot #' . $snapshotId,
|
||||
'mode' => $mode,
|
||||
'userid' => $this->getCurrentUserId(),
|
||||
'username' => $this->getCurrentUserName(),
|
||||
],
|
||||
$messageKey,
|
||||
'com_mokosuitebackup.snapshot',
|
||||
$this->getCurrentUserId()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an action log entry.
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="console" method="upgrade">
|
||||
<name>Console - MokoSuiteBackup</name>
|
||||
<version>01.27.00</version>
|
||||
<version>01.39.00</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -17,6 +17,7 @@ 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;
|
||||
|
||||
@@ -28,6 +29,10 @@ 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
|
||||
@@ -85,8 +90,22 @@ 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($record->absolute_path, $record->backup_type);
|
||||
$result = $engine->restore($recordId, $restoreFiles, $restoreDb, $preserveConfig, $password);
|
||||
|
||||
if ($result['success']) {
|
||||
$io->success($result['message']);
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
<?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,6 +20,7 @@ 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
|
||||
{
|
||||
@@ -41,5 +42,6 @@ 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.27.00</version>
|
||||
<version>01.39.00</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.27.00</version>
|
||||
<version>01.39.00</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.27.00</version>
|
||||
<version>01.39.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -59,11 +59,15 @@ 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)
|
||||
@@ -73,6 +77,8 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
|
||||
|
||||
if (!in_array($clientIp, $allowedIps, true)) {
|
||||
$this->sendJsonResponse(false, 'IP not allowed', 403);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +136,7 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
|
||||
$session->set('mokosuitebackup.last_cleanup', time());
|
||||
|
||||
$this->cleanupOldBackups();
|
||||
$this->cleanupOldSnapshots();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,6 +153,93 @@ 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();
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?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.27.00</version>
|
||||
<version>01.39.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -43,6 +43,11 @@ 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
|
||||
@@ -93,4 +98,51 @@ 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.27.00</version>
|
||||
<version>01.39.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
+70
-6
@@ -9,12 +9,19 @@
|
||||
*
|
||||
* REST API endpoints — wire-compatible with the mcp_mokosuitebackup MCP server.
|
||||
*
|
||||
* 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
|
||||
* 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
|
||||
*/
|
||||
|
||||
namespace Joomla\Plugin\WebServices\MokoSuiteBackup\Extension;
|
||||
@@ -94,5 +101,62 @@ 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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<extension type="package" method="upgrade">
|
||||
<name>Package - MokoSuiteBackup</name>
|
||||
<packagename>mokosuitebackup</packagename>
|
||||
<version>01.27.00</version>
|
||||
<version>01.39.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
+24
-24
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user